/* * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. */ package com.tiedup.remake.v2.client; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Function; import net.minecraft.resources.ResourceLocation; 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 com.tiedup.remake.rig.TiedUpAnimationRegistry; import com.tiedup.remake.rig.TiedUpRigConstants; import com.tiedup.remake.rig.anim.LivingMotion; import com.tiedup.remake.rig.anim.client.ClientAnimator; import com.tiedup.remake.rig.anim.types.StaticAnimation; import com.tiedup.remake.rig.asset.AssetAccessor; import com.tiedup.remake.rig.patch.PlayerPatch; import com.tiedup.remake.rig.patch.TiedUpCapabilities; import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; import com.tiedup.remake.v2.bondage.datadriven.AnimationBindings; import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; /** * Orchestrateur client-side — rebuild la map {@code livingAnimations} de * l'animator RIG d'un {@link Player} en fonction des items bondage qu'il * porte (P3-05). * *

Call sites (P3-06 & co)

* * *

Workflow

*
    *
  1. Récupère le {@link PlayerPatch} via capability ; abort si absent * (player pas encore patché, rare mais arrivable avant la fin du * {@code onConstructed}).
  2. *
  3. Récupère le {@link ClientAnimator} ; abort si null (server side ou * race avec {@code Animator} stripping durant chunk unload).
  4. *
  5. 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}.
  6. *
  7. 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.
  8. *
  9. 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).
  10. *
  11. 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).
  12. *
* *

Préconditions

* * *

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 :

* * * @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 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 :

* * *

Null-safety : seuls {@code player}, {@code patch} et * {@code animator} sont null-checkés ici. Les autres scénarios null * (bindings null, stack empty dans l'equipment, id null) sont gérés * inline dans {@link #applyDefinitions} et * {@link #extractSortedDefinitions} — chacun skip silencieusement sans * throw.

* *

Contract – ne JAMAIS appeler * {@code animator.setCurrentMotionsAsDefault()} ailleurs dans le * pipeline TiedUp. Cette méthode EF consolide les bindings custom * courants dans la snapshot {@code defaultLivingAnimations} ; un * {@code resetLivingAnimations()} ultérieur les restaurerait alors * comme "defaults" après un unbind, et l'anim custom fuiterait au-delà * de la durée de vie de l'item équipé. En prod EF, * {@code setCurrentMotionsAsDefault} n'est appelé qu'une fois depuis * {@code ClientAnimator.postInit()} pendant le bootstrap de * l'animator, capturant les IDLE/WALK/RUN vanilla comme snapshot * pérenne. Le rebuild TiedUp compte sur cet invariant : tout rebuild * part d'un état EF vanilla stable.

* * @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); // Reset + restore des defaults EF (IDLE, WALK, RUN, etc.). Les bindings // custom qu'on ajoute après prennent le pas sur ces defaults via la // map livingAnimations (dernière put gagne pour une même LivingMotion). // // IMPORTANT : ne jamais appeler animator.setCurrentMotionsAsDefault() // ailleurs dans le pipeline TiedUp — cela consoliderait les custom // bindings dans les defaults, et un unbind ultérieur laisserait les // anims en place (le reset restaurerait les ex-custom comme "defaults"). // Pour un rebuild propre, on compte sur le fait que defaultLivingAnimations // reste la snapshot originale EF (IDLE/WALK/RUN vanilla, capturée une // seule fois dans ClientAnimator.postInit() durant le bootstrap). 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).

* *

Sort déterministe :

*
    *
  1. Primary : {@code posePriority} ASC → highest-priority itère en * dernier → gagne le conflit (last put wins dans * {@code addLivingAnimation} map semantics).
  2. *
  3. Secondary : {@code id.toString()} lexicographique → tiebreaker * stable indépendant de l'ordre de déclaration de * {@link BodyRegionV2}. Deux items avec même {@code posePriority} * résolvent sur leur {@link ResourceLocation} — un dev qui * réordonne l'enum ne change plus silencieusement quelle anim * gagne les conflits priority-tied.
  4. *
* *

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); } // Sort déterministe : // 1. Primary : posePriority ASC → highest-priority itère en dernier → // gagne le conflit (last put wins dans addLivingAnimation map // semantics). // 2. Secondary : id.toString() lexicographique → tiebreaker stable // indépendant de BodyRegionV2 enum declaration order. Deux items // avec même posePriority résolvent sur leur ResourceLocation. defs.sort( Comparator.comparingInt(DataDrivenItemDefinition::posePriority) .thenComparing(d -> d.id().toString()) ); return defs; } /** * Pousse les bindings de chaque définition dans l'animator via les * callbacks fournis. * *

Workflow strict :

*
    *
  1. 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}).
  2. *
  3. 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}.
  4. *
* *

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.

* * @param resetCallback callback pour reset la map animator (en prod: * {@code animator::resetLivingAnimations}) * @param adder callback pour ajouter un binding * {@code (motion, accessor)} * @param sortedDefinitions liste triée ASC par posePriority * @param animResolver résout un {@link ResourceLocation} en * {@link AssetAccessor} — en prod * {@link TiedUpAnimationRegistry#resolveWithFallback} */ static void applyDefinitions( Runnable resetCallback, LivingAnimationAdder adder, List sortedDefinitions, Function> animResolver ) { resetCallback.run(); for (DataDrivenItemDefinition def : sortedDefinitions) { AnimationBindings bindings = def.animations(); if (bindings == null || bindings.isEmpty()) continue; for (Map.Entry entry : bindings.livingMotions().entrySet()) { AssetAccessor accessor = animResolver.apply(entry.getValue()); adder.add(entry.getKey(), accessor); } } } }