P3-06 : wire ClientRigEquipmentHandler from 2 event sources

Trigger rebuildBondageAnimations from :
1. PacketSyncV2Equipment.handleOnClient -- after deserializeNBT capability
2. LivingEquipmentChangeEvent -- filtered to bondage items, client only

Third source (LoggingIn / dimension change) scoped to P3-20 with TODO.

body : idempotent si double-fire, dedup via isBondageRelevant filter.
Caveat Forge doc LivingEquipmentChangeEvent = server-side only : le
handler est neanmoins enregistre @Dist.CLIENT comme filet defensif
pour les edge cases creative/admin. Le path canonique reste
PacketSyncV2Equipment qui couvre 100%% des changements V2 via la
capability dediee (V2 n'utilise pas les slots armor vanilla).

isBondageItem pattern : DataDrivenItemRegistry.get(stack) != null
(coherent avec ClientRigEquipmentHandler.extractSortedDefinitions).
Refactor generic <T> pour testabilite sans MC bootstrap (Mockito
ItemStack crash la static init registries).
This commit is contained in:
notevil
2026-04-23 22:41:44 +02:00
parent aebc7f3868
commit 13b0f8f590
3 changed files with 442 additions and 0 deletions

View File

@@ -2,12 +2,14 @@ package com.tiedup.remake.v2.bondage.network;
import com.tiedup.remake.v2.bondage.IV2EquipmentHolder; import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider; import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
import com.tiedup.remake.v2.client.ClientRigEquipmentHandler;
import java.util.function.Supplier; import java.util.function.Supplier;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.api.distmarker.OnlyIn;
@@ -86,6 +88,20 @@ public class PacketSyncV2Equipment {
living living
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT) .getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
.ifPresent(equip -> equip.deserializeNBT(msg.data)); .ifPresent(equip -> equip.deserializeNBT(msg.data));
// P3-06 : l'equipment a changé côté client, on rebuild la map
// livingAnimations du ClientAnimator. Limité aux Player car
// ClientRigEquipmentHandler ne connaît que le PlayerPatch pour
// l'instant (les NPCs bondage sont traités via IV2EquipmentHolder
// branch ci-dessus + leur propre sync path — rebuild NPC est
// scopé à P3-07/P3-08).
//
// Double-fire safe : si LivingEquipmentChangeEvent fire en même
// temps pour le même tick, on rebuild 2× (idempotent, seul le
// filtre isBondageRelevant gaspille un appel — pas critique).
if (living instanceof Player player) {
ClientRigEquipmentHandler.rebuildBondageAnimations(player);
}
} }
} }
} }

View File

@@ -0,0 +1,214 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.client;
import java.util.function.Predicate;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.event.entity.living.LivingEquipmentChangeEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
/**
* Deuxième event source pour {@link ClientRigEquipmentHandler#rebuildBondageAnimations}
* (P3-06). Miroir de {@link com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment}
* pour couvrir les changements d'équipement qui passeraient par les slots
* armor vanilla (creative, commandes admin, mods tiers qui inject des items
* bondage via {@code setItemSlot}).
*
* <h2>Caveat important — Forge sémantique "server-side only"</h2>
* <p>La javadoc Forge de {@link LivingEquipmentChangeEvent} indique explicitement
* "This event is fired on server-side only". L'appel
* {@code ForgeEventFactory.onLivingEquipmentChange} dans {@code LivingEntity#tick()}
* n'a pas de garde {@code level.isClientSide} explicite, mais en pratique
* Forge le documente comme authoritative server. On enregistre néanmoins ce
* handler client-only comme filet de sécurité :</p>
* <ul>
* <li>Dans un environnement <b>client intégré (solo / LAN host)</b>, le
* tick {@code LivingEntity} s'exécute côté client aussi pour les
* entités chargées — l'event peut donc, dans certains paths, fire
* côté client.</li>
* <li>Dans un <b>serveur dédié</b>, le handler ne sera jamais chargé
* (registre gated par {@code value = Dist.CLIENT}).</li>
* <li>Si l'event ne fire <i>jamais</i> côté client, ce handler est dead
* code bénin — pas de régression. Le path canonique P3-06
* {@link com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment}
* couvre 100% des changements V2 puisque V2 utilise la capability
* dédiée et non les slots armor.</li>
* </ul>
*
* <p><b>Décision design</b> : V2 bondage n'utilise PAS les slots armor vanilla
* — toute l'equipment passe par la capability {@code V2_BONDAGE_EQUIPMENT} et
* son packet {@code PacketSyncV2Equipment}. Ce listener est donc défensif :
* il attrape les cas edge où un item bondage atterrirait dans un slot armor
* (ex : creative mode {@code /item replace}, mods tiers qui manipulent
* directement {@code setItemSlot}, future migration partielle).</p>
*
* <h2>Workflow</h2>
* <ol>
* <li>Filtre par {@link #isBondageRelevant} : au moins un des deux stacks
* (from/to) doit être un item bondage data-driven, sinon ignore.</li>
* <li>Filtre par {@code instanceof Player} — le handler ne gère que les
* {@link Player} pour le moment. NPCs bondage (Damsels, etc.) seront
* câblés dans P3-07/P3-08 séparément via leur propre pipeline.</li>
* <li>Guard {@code level().isClientSide()} pour éviter le rebuild
* côté serveur au cas où le tick fire sur les deux côtés.</li>
* <li>Appelle {@link ClientRigEquipmentHandler#rebuildBondageAnimations}.</li>
* </ol>
*
* <h2>Idempotence + double-fire</h2>
* <p>Si {@code PacketSyncV2Equipment} ET ce listener fire pour le même
* changement (cas hypothétique d'un item bondage V2 déplacé vers un slot
* armor et syncé via la capability en parallèle), on rebuild 2×. Le rebuild
* est idempotent côté {@link ClientRigEquipmentHandler} — aucune corruption
* d'état, juste du gaspillage CPU. Pas de dedup tick-level pour l'instant
* (MVP), à mesurer si l'overhead devient visible.</p>
*
* @see ClientRigEquipmentHandler
* @see com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public final class BondageEquipmentChangeListener {
private BondageEquipmentChangeListener() {
// utility subscriber class
}
/**
* Subscriber principal — déclenche un rebuild si au moins un des items
* (from/to) est un bondage item data-driven.
*
* <p>Ce handler est un best-effort pour les changements d'armor slot ;
* le path canonique reste {@code PacketSyncV2Equipment.handleOnClient}
* qui cible la capability V2 dédiée. Cf. class javadoc pour la
* sémantique "server-side only" de l'event.</p>
*
* @param event l'event Forge (from/to ItemStack non-null par contrat)
*/
@SubscribeEvent
public static void onEquipmentChange(LivingEquipmentChangeEvent event) {
if (!(event.getEntity() instanceof Player player)) return;
// Double-guard : en cas de fire côté serveur (comportement Forge
// documenté), on skip. L'event peut fire côté client dans certains
// paths malgré la doc — on gate explicitement.
if (!player.level().isClientSide()) return;
// En prod : emptiness check = ItemStack::isEmpty ; bondage check =
// DataDrivenItemRegistry::get non-null. Factorisés en deux method-refs
// pour permettre leur substitution en test par des lambdas génériques
// (les overloads <T> acceptent n'importe quel type opaque — l'Object
// test-friendly évite le MC bootstrap qu'impose Mockito sur ItemStack).
if (!isBondageRelevant(
event.getFrom(),
event.getTo(),
ItemStack::isEmpty,
DEFAULT_BONDAGE_PREDICATE
)) {
return;
}
ClientRigEquipmentHandler.rebuildBondageAnimations(player);
}
/**
* Predicate production qui utilise {@link DataDrivenItemRegistry#get(ItemStack)}.
* Factoré en constante pour permettre l'injection en test (les overloads
* package-private {@code isBondageRelevant}/{@code isBondageItem} acceptent
* un predicate alternatif, évitant le besoin de MC bootstrap pour les
* ItemStack + registry init).
*/
static final Predicate<ItemStack> DEFAULT_BONDAGE_PREDICATE =
stack -> DataDrivenItemRegistry.get(stack) != null;
/**
* Détermine si un changement d'équipement (from → to) implique au moins
* un item bondage data-driven. Overload générique {@code <T>} pour
* permettre les unit tests sans {@link ItemStack} réel (les tests passent
* des {@code Object} dummy + predicates contrôlés, évitant le crash
* {@code Mockito.mock(ItemStack.class)} qui déclenche la static init MC).
*
* <p><b>Sémantique</b> : vrai ssi au moins un des deux stacks n'est ni
* null ni vide ET matche le {@code bondagePredicate}. On utilise le
* pattern registry (plutôt que {@code instanceof AbstractV2BondageItem})
* car l'animation rebuild n'a d'effet que pour les items qui portent une
* {@code DataDrivenItemDefinition} — un item V2 legacy sans JSON
* definition ne contribuerait pas au rebuild et gaspillerait un appel.
* Même pattern que {@link ClientRigEquipmentHandler#extractSortedDefinitions}
* pour la cohérence.</p>
*
* <p><b>Null-safety</b> : les deux stacks sont contractuellement non-null
* selon {@link LivingEquipmentChangeEvent#getFrom()} et
* {@link LivingEquipmentChangeEvent#getTo()} ({@code @NotNull}), mais on
* double-check null + empty dans {@link #isBondageItem}.</p>
*
* @param <T> type du stack ({@link ItemStack} en prod,
* {@link Object} en test)
* @param from item précédemment équipé (tolère null)
* @param to item nouvellement équipé (tolère null)
* @param emptyPredicate predicate qui retourne {@code true} si le stack
* est "empty" (en prod {@code ItemStack::isEmpty})
* @param bondagePredicate predicate qui retourne {@code true} si un stack
* non-null/non-empty est data-driven (en prod
* {@link #DEFAULT_BONDAGE_PREDICATE})
* @return {@code true} si au moins un item est un bondage data-driven
*/
static <T> boolean isBondageRelevant(
T from,
T to,
Predicate<T> emptyPredicate,
Predicate<T> bondagePredicate
) {
return isBondageItem(from, emptyPredicate, bondagePredicate)
|| isBondageItem(to, emptyPredicate, bondagePredicate);
}
/**
* Vérifie si un stack est un item bondage data-driven (overload générique
* pour testabilité).
*
* <p>Gère null/empty : retourne {@code false} sans appeler
* {@code bondagePredicate}. Ne throw jamais. Le {@code bondagePredicate}
* n'est appelé que pour des stacks non-null et non-empty
* (short-circuit).</p>
*
* @param <T> type du stack
* @param stack item à inspecter (tolère null)
* @param emptyPredicate predicate "est-il empty ?" ({@code ItemStack::isEmpty}
* en prod)
* @param bondagePredicate predicate "est-il bondage data-driven ?"
* (appelé uniquement pour stack non-null/non-empty)
* @return {@code true} ssi stack ≠ null, ≠ empty ET bondagePredicate.test(stack)
*/
static <T> boolean isBondageItem(
T stack,
Predicate<T> emptyPredicate,
Predicate<T> bondagePredicate
) {
if (stack == null) return false;
if (emptyPredicate.test(stack)) return false;
return bondagePredicate.test(stack);
}
// TODO(P3-20) : Ajouter les sources "rehydrate" pour respawn / dimension
// change / login, qui ne sont pas couvertes par PacketSyncV2Equipment
// (race possible entre capability sync et client animator bootstrap) :
// - ClientPlayerNetworkEvent.LoggingIn → rebuild sur le LocalPlayer
// - EntityJoinLevelEvent → rebuild si player (remote /
// après dimension change)
// - PlayerEvent.PlayerRespawnEvent → rebuild post-respawn
// Scopé à P3-20 (task #63) pour garder la surface de ce PR minimale.
}

View File

@@ -0,0 +1,212 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.client;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.IdentityHashMap;
import java.util.function.Predicate;
import org.junit.jupiter.api.Test;
/**
* Tests unitaires pour {@link BondageEquipmentChangeListener} (P3-06).
*
* <h2>Stratégie de test</h2>
* <p>L'entrée publique
* {@link BondageEquipmentChangeListener#onEquipmentChange} nécessite un
* {@code LivingEquipmentChangeEvent} + {@code Player} + MC runtime — non
* testable sans bootstrap. On se limite ici à la logique pure
* {@link BondageEquipmentChangeListener#isBondageItem} et
* {@link BondageEquipmentChangeListener#isBondageRelevant} via leurs overloads
* génériques {@code <T>}.</p>
*
* <p>Les {@link net.minecraft.world.item.ItemStack ItemStack} sont remplacés
* par des {@code Object} dummy (même pattern que
* {@link ClientRigEquipmentHandlerTest#extractSortedDefinitions_nullResolver_skipsItem}).
* Les fonctions {@code isEmpty} et {@code isBondage} sont des lambdas
* contrôlées qui inspectent via {@link IdentityHashMap} les objets dummy —
* évite le crash {@code Mockito.mock(ItemStack.class)} qui déclenche la
* static init MC.</p>
*/
class BondageEquipmentChangeListenerTest {
/** Predicate qui considère chaque stack distinct comme "non-empty". */
private static final Predicate<Object> NEVER_EMPTY = stack -> false;
/** Predicate qui considère chaque stack comme "empty". */
private static final Predicate<Object> ALWAYS_EMPTY = stack -> true;
/** Predicate qui considère chaque stack comme "bondage". */
private static final Predicate<Object> ALWAYS_BONDAGE = stack -> true;
/** Predicate qui ne considère aucun stack comme bondage. */
private static final Predicate<Object> NEVER_BONDAGE = stack -> false;
// ========== isBondageItem (generic overload) ==========
/** null stack → false ; aucun predicate n'est appelé. */
@Test
void isBondageItem_null_returnsFalse_noPredicateInvoked() {
Predicate<Object> emptyTripwire = stack -> {
throw new AssertionError("emptyPredicate ne doit pas etre appele pour null");
};
Predicate<Object> bondageTripwire = stack -> {
throw new AssertionError("bondagePredicate ne doit pas etre appele pour null");
};
assertFalse(
BondageEquipmentChangeListener.isBondageItem(
null, emptyTripwire, bondageTripwire),
"null stack doit retourner false sans invoquer les predicates");
}
/** Empty stack → false ; bondagePredicate n'est PAS appelé (short-circuit). */
@Test
void isBondageItem_empty_returnsFalse_bondageNotInvoked() {
Object stack = new Object();
Predicate<Object> bondageTripwire = s -> {
throw new AssertionError(
"bondagePredicate ne doit pas etre appele apres isEmpty==true");
};
assertFalse(
BondageEquipmentChangeListener.isBondageItem(
stack, ALWAYS_EMPTY, bondageTripwire),
"empty stack doit retourner false sans invoquer bondagePredicate");
}
/** Non-empty stack, bondage=false (vanilla item) → false. */
@Test
void isBondageItem_nonEmptyNonBondage_returnsFalse() {
Object stack = new Object();
assertFalse(
BondageEquipmentChangeListener.isBondageItem(
stack, NEVER_EMPTY, NEVER_BONDAGE),
"item vanilla (bondagePredicate false) doit retourner false");
}
/** Non-empty stack, bondage=true (data-driven) → true. */
@Test
void isBondageItem_nonEmptyBondage_returnsTrue() {
Object stack = new Object();
assertTrue(
BondageEquipmentChangeListener.isBondageItem(
stack, NEVER_EMPTY, ALWAYS_BONDAGE),
"item data-driven (bondagePredicate true) doit retourner true");
}
// ========== isBondageRelevant (OR sur from+to) ==========
/** Les deux stacks empty → false, même avec ALWAYS_BONDAGE. */
@Test
void isBondageRelevant_bothEmpty_returnsFalse() {
Object from = new Object();
Object to = new Object();
assertFalse(
BondageEquipmentChangeListener.isBondageRelevant(
from, to, ALWAYS_EMPTY, ALWAYS_BONDAGE),
"deux stacks empty doivent retourner false meme avec ALWAYS_BONDAGE");
}
/** From=bondage, to=empty → true (cas unequip bondage). */
@Test
void isBondageRelevant_fromBondageToEmpty_returnsTrue() {
Object from = new Object();
Object to = new Object();
// Discriminant : seul 'from' est non-empty
IdentityHashMap<Object, Boolean> emptyMap = new IdentityHashMap<>();
emptyMap.put(from, Boolean.FALSE);
emptyMap.put(to, Boolean.TRUE);
assertTrue(
BondageEquipmentChangeListener.isBondageRelevant(
from, to, emptyMap::get, ALWAYS_BONDAGE),
"unequip bondage (from=bondage non-empty, to=empty) doit retourner true");
}
/** From=empty, to=bondage → true (cas equip bondage). */
@Test
void isBondageRelevant_fromEmptyToBondage_returnsTrue() {
Object from = new Object();
Object to = new Object();
IdentityHashMap<Object, Boolean> emptyMap = new IdentityHashMap<>();
emptyMap.put(from, Boolean.TRUE);
emptyMap.put(to, Boolean.FALSE);
assertTrue(
BondageEquipmentChangeListener.isBondageRelevant(
from, to, emptyMap::get, ALWAYS_BONDAGE),
"equip bondage (from=empty, to=bondage) doit retourner true");
}
/** Both non-empty, neither bondage (swap vanilla armor) → false. */
@Test
void isBondageRelevant_bothNonBondage_returnsFalse() {
Object from = new Object();
Object to = new Object();
assertFalse(
BondageEquipmentChangeListener.isBondageRelevant(
from, to, NEVER_EMPTY, NEVER_BONDAGE),
"swap vanilla armor (aucun bondage) ne doit pas declencher rebuild");
}
/** from=null, to=bondage → true (first equip après spawn). */
@Test
void isBondageRelevant_nullFromBondageTo_returnsTrue() {
Object to = new Object();
assertTrue(
BondageEquipmentChangeListener.isBondageRelevant(
null, to, NEVER_EMPTY, ALWAYS_BONDAGE),
"null from + bondage to doit retourner true (null-safe)");
}
/** both null → false, aucun predicate appelé. */
@Test
void isBondageRelevant_bothNull_returnsFalse() {
Predicate<Object> emptyTripwire = s -> {
throw new AssertionError("ne doit pas etre appele");
};
Predicate<Object> bondageTripwire = s -> {
throw new AssertionError("ne doit pas etre appele");
};
assertFalse(
BondageEquipmentChangeListener.isBondageRelevant(
null, null, emptyTripwire, bondageTripwire),
"two null stacks doivent retourner false sans appel predicates");
}
/**
* Both non-empty, BOTH bondage → true — sanity : on ne short-circuit
* pas sur un state incohérent, et on n'appelle pas nécessairement les
* deux (l'OR court-circuite sur le premier true).
*/
@Test
void isBondageRelevant_bothBondage_returnsTrue() {
Object from = new Object();
Object to = new Object();
assertTrue(
BondageEquipmentChangeListener.isBondageRelevant(
from, to, NEVER_EMPTY, ALWAYS_BONDAGE),
"deux stacks bondage doit retourner true (sanity OR logic)");
}
// ========== DEFAULT_BONDAGE_PREDICATE sanity ==========
/**
* Le {@link BondageEquipmentChangeListener#DEFAULT_BONDAGE_PREDICATE}
* existe et est non-null. On ne peut pas l'invoquer sans MC bootstrap
* (il appelle {@code DataDrivenItemRegistry.get(stack)} qui lit
* {@code stack.getTag()} et accède au registry volatile), mais on
* vérifie qu'il est bien câblé en référence — garde contre un
* refactor qui le nullifierait silencieusement.
*/
@Test
void defaultBondagePredicate_isWired() {
assertNotNull(
BondageEquipmentChangeListener.DEFAULT_BONDAGE_PREDICATE,
"DEFAULT_BONDAGE_PREDICATE doit etre non-null (wiring sanity)");
}
}