{@code ClientPlayerNetworkEvent.LoggingIn} et
+ * {@code EntityJoinLevelEvent} (P3-20 rehydrate entrée de world /
+ * chunk load race).
+ *
+ *
+ *
Workflow
+ *
+ *
Récupère le {@link PlayerPatch} via capability ; abort si absent
+ * (player pas encore patché, rare mais arrivable avant la fin du
+ * {@code onConstructed}).
+ *
Récupère le {@link ClientAnimator} ; abort si null (server side ou
+ * race avec {@code Animator} stripping durant chunk unload).
+ *
Appelle {@link ClientAnimator#resetLivingAnimations()} — les defaults
+ * EF (IDLE, WALK, etc.) sont re-poussés dans la map par le cycle
+ * {@code clear() + defaultLivingAnimations.forEach(addLivingAnimation)}
+ * implémenté dans {@link ClientAnimator}.
+ *
Lit {@link V2EquipmentHelper#getAllEquipped} — déjà dédupliqué au
+ * niveau capability ({@link com.tiedup.remake.v2.bondage.IV2BondageEquipment}
+ * contract), on applique néanmoins une dédup défensive par identity
+ * pour ne pas dépendre d'un invariant en amont qui pourrait changer.
+ *
Filtre les items non data-driven (V2 legacy sans
+ * {@link DataDrivenItemDefinition}) et trie par
+ * {@link DataDrivenItemDefinition#posePriority} ASC — ainsi le plus
+ * prioritaire itère EN DERNIER, et son {@code addLivingAnimation} écrase
+ * les lower-priority pour une même {@link LivingMotion} (sémantique
+ * {@code Map.put()} : dernier écrivain gagne).
+ *
Pour chaque item, si {@link DataDrivenItemDefinition#animations()}
+ * est non-null et non-vide, ajoute chaque binding via
+ * {@link ClientAnimator#addLivingAnimation}. Les IDs inconnus sont
+ * résolus via {@link TiedUpAnimationRegistry#resolveWithFallback}
+ * (fallback {@code EMPTY_ANIMATION} + WARN une fois par ID).
+ *
+ *
+ *
Préconditions
+ *
+ *
Option B modder convention : les items bondage JSON NE bindent
+ * PAS {@link com.tiedup.remake.rig.anim.LivingMotions#IDLE}. Le default
+ * EF IDLE re-injecté par {@code resetLivingAnimations} reste donc
+ * visible. Laisser un item binder IDLE est toléré (l'anim custom
+ * écrase) mais c'est un code smell — voir docs plan P3.
+ *
Appelé depuis le client main thread uniquement. L'animator
+ * n'est pas thread-safe.
+ *
Le package {@code v2.client} est strictement client-only malgré
+ * l'absence d'{@code @OnlyIn} sur {@link BondageStateHelpers} (pas
+ * d'imports client-only dedans). Cette classe-ci référence
+ * {@link ClientAnimator} → {@code @OnlyIn(Dist.CLIENT)} est requis
+ * pour éviter le class-load server-side.
+ *
+ *
+ *
Design notes
+ *
+ *
L'entrée publique {@link #rebuildBondageAnimations(Player)} est
+ * side-gated mais non testable sans MC runtime (player réel, capability
+ * réelle, animator réel). La logique pure est extraite dans deux helpers
+ * package-private :
{@link #applyDefinitions} — itère + appelle reset/add via des
+ * callbacks injectables ({@link Runnable} +
+ * {@link LivingAnimationAdder}). Testable avec un fake adder qui
+ * capture les appels dans une list.
+ *
+ *
+ * @see AnimationBindings
+ * @see TiedUpAnimationRegistry#resolveWithFallback(ResourceLocation)
+ */
+@OnlyIn(Dist.CLIENT)
+public final class ClientRigEquipmentHandler {
+
+ private ClientRigEquipmentHandler() {
+ // utility class
+ }
+
+ /**
+ * Functional adapter autour de {@link ClientAnimator#addLivingAnimation}
+ * pour permettre l'injection en tests sans bootstrap MC.
+ *
+ *
En production, l'implémentation est une méthode-ref
+ * {@code animator::addLivingAnimation}. En test, un fake qui capture les
+ * appels dans une collection (voir {@code ClientRigEquipmentHandlerTest}).
+ */
+ @FunctionalInterface
+ interface LivingAnimationAdder {
+ void add(LivingMotion motion, AssetAccessor extends StaticAnimation> accessor);
+ }
+
+ /**
+ * Entry point publique — rebuild la map {@code livingAnimations} du
+ * {@link ClientAnimator} du player en fonction de ses items bondage V2
+ * équipés.
+ *
+ *
No-op si :
+ *
+ *
{@code player == null}
+ *
pas de {@link PlayerPatch} capability attachée
+ *
pas de {@link ClientAnimator} (server side ou anim pipeline pas
+ * encore bootstrap)
+ *
+ *
+ * @param player le player dont on rebuild les anims ; null tolérée
+ */
+ public static void rebuildBondageAnimations(Player player) {
+ if (player == null) return;
+
+ PlayerPatch> patch = TiedUpCapabilities.getPlayerPatch(player);
+ if (patch == null) return;
+
+ ClientAnimator animator = patch.getClientAnimator();
+ if (animator == null) return;
+
+ Map equipped = V2EquipmentHelper.getAllEquipped(player);
+
+ // La capability dédupe déjà par identity (cf V2BondageEquipment.getAllEquipped,
+ // IdentityHashMap seen+LinkedHashMap result) — on applique néanmoins notre
+ // propre dédup défensive pour ne pas dépendre d'un invariant qu'un refactor
+ // futur pourrait casser silencieusement.
+ List sortedDefs =
+ extractSortedDefinitions(equipped.values(), DataDrivenItemRegistry::get);
+
+ applyDefinitions(
+ animator::resetLivingAnimations,
+ animator::addLivingAnimation,
+ sortedDefs,
+ TiedUpAnimationRegistry::resolveWithFallback
+ );
+
+ TiedUpRigConstants.LOGGER.debug(
+ "[ClientRigEquipmentHandler] Rebuilt livingAnimations for player {} "
+ + "({} data-driven items processed)",
+ player.getName().getString(),
+ sortedDefs.size()
+ );
+ }
+
+ /**
+ * Collecte les {@link DataDrivenItemDefinition} associées aux items
+ * d'entrée, dédupe par identity, et trie par {@code posePriority}
+ * ascendant.
+ *
+ *
Pure fonction sans dépendance MC — testable unit. Le resolver
+ * permet de mocker {@code DataDrivenItemRegistry.get(stack)} en test.
+ * Un item dont le resolver retourne {@code null} (item non data-driven)
+ * est skip silencieusement.
+ *
+ *
Tri ASC volontaire : dans {@link #applyDefinitions} la map
+ * {@code livingAnimations} suit la sémantique "dernier put gagne", donc
+ * pour qu'un item de priorité 20 batte un item de priorité 10 sur une
+ * même {@link LivingMotion}, on doit itérer le priorité 10 d'abord et
+ * le priorité 20 ensuite (il écrase).
+ *
+ *
Note sur la généricité : l'API est paramétrée {@code }
+ * plutôt que fixée à {@link ItemStack} pour permettre un unit test sans
+ * bootstrap MC (le test passe des {@code Object} dummy, la prod passe
+ * des {@code ItemStack}). Aucune méthode d'instance n'est appelée sur
+ * le type — seule l'identité objet est utilisée, donc l'abstraction est
+ * safe.
+ *
+ * @param type des éléments (typiquement {@link ItemStack} en
+ * prod, {@link Object} en test)
+ * @param stacks iterable d'items (peut contenir des duplicats identity
+ * pour les multi-region items)
+ * @param resolver fonction {@code item → DataDrivenItemDefinition}
+ * (null pour items non data-driven)
+ * @return liste dédupliquée + triée ASC par posePriority ; non-null,
+ * possiblement vide
+ */
+ static List extractSortedDefinitions(
+ Iterable stacks,
+ Function resolver
+ ) {
+ // Dedup par identity — Collections.newSetFromMap(IdentityHashMap) est
+ // préféré à HashSet+equals() pour la sémantique "== vs equals()".
+ // Deux ItemStacks avec les mêmes NBT mais instances distinctes sont
+ // traités comme items distincts (convention V2 : un stack = une
+ // occurrence équipée).
+ Set unique = Collections.newSetFromMap(new IdentityHashMap<>());
+ List defs = new ArrayList<>();
+
+ for (T stack : stacks) {
+ if (stack == null) continue;
+ if (!unique.add(stack)) continue; // duplicate identity (multi-region item)
+ DataDrivenItemDefinition def = resolver.apply(stack);
+ if (def == null) continue; // V2 legacy item sans JSON definition
+ defs.add(def);
+ }
+
+ defs.sort(Comparator.comparingInt(DataDrivenItemDefinition::posePriority));
+ return defs;
+ }
+
+ /**
+ * Pousse les bindings de chaque définition dans l'animator via les
+ * callbacks fournis.
+ *
+ *
Workflow strict :
+ *
+ *
Appelle {@code resetCallback} une fois — le contrat attendu est
+ * que cela vide la map {@code livingAnimations} et la repopule avec
+ * les defaults EF (comportement de
+ * {@link ClientAnimator#resetLivingAnimations}).
+ *
Pour chaque définition (déjà triée ASC par posePriority), si elle
+ * porte un {@link AnimationBindings} non-null et non-vide, itère
+ * les entries {@code livingMotions()} et appelle {@code adder} avec
+ * l'{@link AssetAccessor} résolu via {@code animResolver}.
+ *
+ *
+ *
Si {@code animations()} est null (99% des items V2 legacy) ou
+ * {@link AnimationBindings#isEmpty()}, on skip — pas d'appel adder. Pas
+ * de warn : la majorité des items n'ont légitimement pas de binding.
L'entrée publique {@link ClientRigEquipmentHandler#rebuildBondageAnimations}
+ * nécessite un {@code Player} réel + capability MC — non testable sans
+ * bootstrap. On vérifie uniquement son null-safety. La logique métier est
+ * extraite dans deux méthodes package-private pures :
{@link ClientRigEquipmentHandler#applyDefinitions} — itère +
+ * adder pour chaque binding non-null/non-vide, reset d'abord.
+ *
+ *
+ *
Les ItemStack sont remplacés par des {@link Object} dummy : la logique
+ * ne lit aucune méthode d'instance, seule l'identité objet compte pour la
+ * dédup. Les {@link DataDrivenItemDefinition} sont construites via le
+ * {@link #makeDef} helper avec des defaults neutres (seuls posePriority +
+ * animations comptent pour ces tests).
+ */
+class ClientRigEquipmentHandlerTest {
+
+ private static final ResourceLocation ITEM_A =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "item_a");
+ private static final ResourceLocation ITEM_B =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "item_b");
+ private static final ResourceLocation ANIM_IDLE_A =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "anim_idle_a");
+ private static final ResourceLocation ANIM_WALK_A =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "anim_walk_a");
+ private static final ResourceLocation ANIM_WALK_B =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "anim_walk_b");
+
+ /**
+ * Helper : construit une {@link DataDrivenItemDefinition} minimale avec
+ * les seuls champs testés (posePriority + animations). Les autres champs
+ * reçoivent des defaults neutres pour satisfaire le compact ctor.
+ */
+ private static DataDrivenItemDefinition makeDef(
+ ResourceLocation id,
+ int posePriority,
+ AnimationBindings animations
+ ) {
+ return new DataDrivenItemDefinition(
+ id,
+ /* displayName */ id.getPath(),
+ /* translationKey */ null,
+ /* modelLocation */ ResourceLocation.fromNamespaceAndPath("tiedup", "model"),
+ /* slimModelLocation */ null,
+ /* animationSource */ null,
+ /* occupiedRegions */ Set.of(BodyRegionV2.ARMS),
+ /* blockedRegions */ Set.of(BodyRegionV2.ARMS),
+ /* poseType */ null,
+ posePriority,
+ /* escapeDifficulty */ 100,
+ /* lockable */ false,
+ /* canAttachPadlock */ true,
+ /* supportsColor */ false,
+ /* tintChannels */ Map.of(),
+ /* icon */ null,
+ /* movementStyle */ (MovementStyle) null,
+ /* movementModifier */ (MovementModifier) null,
+ /* creator */ null,
+ /* animationBones */ Map.of(),
+ animations,
+ /* componentConfigs */ Map.of()
+ );
+ }
+
+ /**
+ * Fake adder qui capture chaque {@code (motion, accessor)} dans une
+ * liste en ordre chronologique. L'accessor réel n'est pas inspecté —
+ * on matérialise sa {@link AssetAccessor#registryName() registryName}
+ * pour faciliter les assertions.
+ */
+ private static final class CapturingAdder
+ implements ClientRigEquipmentHandler.LivingAnimationAdder {
+
+ final List> calls = new ArrayList<>();
+
+ @Override
+ public void add(LivingMotion motion, AssetAccessor extends StaticAnimation> accessor) {
+ calls.add(Map.entry(motion, accessor.registryName()));
+ }
+ }
+
+ /** Stub {@link AssetAccessor} qui retourne juste un {@link ResourceLocation}. */
+ private static AssetAccessor extends StaticAnimation> stubAccessor(ResourceLocation id) {
+ return new AssetAccessor() {
+ @Override
+ public StaticAnimation get() {
+ return null; // jamais dereferenced dans le handler, juste adder capture
+ }
+
+ @Override
+ public ResourceLocation registryName() {
+ return id;
+ }
+
+ @Override
+ public boolean inRegistry() {
+ return false;
+ }
+ };
+ }
+
+ /** Résolver passthrough : id → stubAccessor(id). */
+ private static final Function> PASSTHROUGH_RESOLVER =
+ ClientRigEquipmentHandlerTest::stubAccessor;
+
+ // ========== null-safety public API ==========
+
+ /**
+ * {@link ClientRigEquipmentHandler#rebuildBondageAnimations(net.minecraft.world.entity.player.Player)}
+ * avec null ne doit rien throw — early return propre.
+ */
+ @Test
+ void rebuildBondageAnimations_nullPlayer_noThrow() {
+ assertDoesNotThrow(() ->
+ ClientRigEquipmentHandler.rebuildBondageAnimations(null));
+ }
+
+ // ========== extractSortedDefinitions ==========
+
+ /** Iterable vide → liste vide (pas de crash, pas de resolver appelé). */
+ @Test
+ void extractSortedDefinitions_emptyInput_returnsEmpty() {
+ List result =
+ ClientRigEquipmentHandler.extractSortedDefinitions(
+ Collections.emptyList(),
+ stack -> { throw new AssertionError("resolver ne doit pas etre appele"); }
+ );
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ /** Items non data-driven (resolver → null) sont skip. */
+ @Test
+ void extractSortedDefinitions_nullResolver_skipsItem() {
+ Object stackA = new Object();
+ Object stackB = new Object();
+
+ List result =
+ ClientRigEquipmentHandler.extractSortedDefinitions(
+ List.of(stackA, stackB),
+ any -> null // aucun item n'est data-driven
+ );
+
+ assertTrue(result.isEmpty());
+ }
+
+ /**
+ * Un même {@link Object} répété N fois dans l'input ne doit produire
+ * qu'une seule def dans la sortie — cas armbinder (3 régions, 1 stack).
+ */
+ @Test
+ void extractSortedDefinitions_duplicateIdentity_dedups() {
+ Object stack = new Object();
+ DataDrivenItemDefinition def = makeDef(ITEM_A, 10, null);
+
+ // Identity resolver — retourne def pour n'importe quel input
+ Function