P3-05 JALON : ClientRigEquipmentHandler.rebuildBondageAnimations

First visible bondage animation pipeline orchestrator. Consumes the
primitives added in P3-01..P3-04 + P3-09 to rebuild the livingAnimations
map of a player based on currently equipped bondage items.

Design decisions :
- IdentityHashMap dedup (armbinder covers N regions -> 1 unique stack).
  Defensive even though the capability already dedupes (so we don't
  depend on an upstream invariant that might regress).
- Sort by posePriority ASC so highest-priority iterates last -> wins
  conflicts (Map.put last-write-wins semantics in livingAnimations).
- Option B : JSON items don't bind IDLE, let EF defaults flow through
  after resetLivingAnimations() which re-pushes default motions.
- null-check on animations() : 99% of V2 legacy items lack the JSON
  block and return null from the parser, must be skipped silently.
- @OnlyIn(Dist.CLIENT) at class level : ClientAnimator is client-only,
  server class-loader must never touch this handler.
- Extracted testable methods (extractSortedDefinitions + applyDefinitions)
  with functional callbacks (Runnable + LivingAnimationAdder + Function
  resolvers). Generic <T> on extractSortedDefinitions lets tests pass
  Object dummies without MC ItemStack bootstrap.

Tests (15) covering :
- rebuildBondageAnimations null-safety
- extractSortedDefinitions : empty input, null resolver result, identity
  dedup (multi-region), ASC priority sort, null entries skipped
- applyDefinitions : reset-only on empty, null/empty animations skipped,
  multi-binding single item, two-item conflict last-write-wins, reset
  ordering before any adder, end-to-end multi-region dedup, animResolver
  contract (no internal dereference)

Note : this commit provides the HANDLER. The first visible bondage
animation still requires P3-06 (hook it to PacketSyncV2Equipment +
LivingEquipmentChangeEvent) and P3-08 (updateMotion state machine) to
fully light up. Jalon = pipeline viable, not yet wired.

212 tests GREEN (197 baseline + 15 new).
This commit is contained in:
notevil
2026-04-23 17:16:19 +02:00
parent 921a028a53
commit 9a31f21b55
2 changed files with 798 additions and 0 deletions

View File

@@ -0,0 +1,287 @@
/*
* © 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).
*
* <h2>Call sites (P3-06 &amp; co)</h2>
* <ul>
* <li>{@code PacketSyncV2Equipment.handleOnClient} après
* {@code IV2BondageEquipment} deserialize pour une remote entity,</li>
* <li>{@code LivingEquipmentChangeEvent} (filtré sur slots V2, ignorer
* vanilla armor),</li>
* <li>{@code ClientPlayerNetworkEvent.LoggingIn} et
* {@code EntityJoinLevelEvent} (P3-20 rehydrate entrée de world /
* chunk load race).</li>
* </ul>
*
* <h2>Workflow</h2>
* <ol>
* <li>Récupère le {@link PlayerPatch} via capability ; abort si absent
* (player pas encore patché, rare mais arrivable avant la fin du
* {@code onConstructed}).</li>
* <li>Récupère le {@link ClientAnimator} ; abort si null (server side ou
* race avec {@code Animator} stripping durant chunk unload).</li>
* <li>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}.</li>
* <li>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.</li>
* <li>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).</li>
* <li>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).</li>
* </ol>
*
* <h2>Préconditions</h2>
* <ul>
* <li><b>Option B modder convention</b> : 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.</li>
* <li>Appelé depuis le <b>client main thread</b> uniquement. L'animator
* n'est pas thread-safe.</li>
* <li>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.</li>
* </ul>
*
* <h2>Design notes</h2>
*
* <p>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 :</p>
* <ul>
* <li>{@link #extractSortedDefinitions} — dedup identity + filter +
* sort. Pur Java, testable sans aucun bootstrap.</li>
* <li>{@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.</li>
* </ul>
*
* @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.
*
* <p>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}).</p>
*/
@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.
*
* <p>No-op si :</p>
* <ul>
* <li>{@code player == null}</li>
* <li>pas de {@link PlayerPatch} capability attachée</li>
* <li>pas de {@link ClientAnimator} (server side ou anim pipeline pas
* encore bootstrap)</li>
* </ul>
*
* @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<BodyRegionV2, ItemStack> 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<DataDrivenItemDefinition> 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.
*
* <p>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.</p>
*
* <p>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).</p>
*
* <p><b>Note sur la généricité</b> : l'API est paramétrée {@code <T>}
* 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.</p>
*
* @param <T> 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 <T> List<DataDrivenItemDefinition> extractSortedDefinitions(
Iterable<T> stacks,
Function<T, DataDrivenItemDefinition> 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<T> unique = Collections.newSetFromMap(new IdentityHashMap<>());
List<DataDrivenItemDefinition> 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.
*
* <p>Workflow strict :</p>
* <ol>
* <li>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}).</li>
* <li>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}.</li>
* </ol>
*
* <p>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.</p>
*
* @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<DataDrivenItemDefinition> sortedDefinitions,
Function<ResourceLocation, AssetAccessor<? extends StaticAnimation>> animResolver
) {
resetCallback.run();
for (DataDrivenItemDefinition def : sortedDefinitions) {
AnimationBindings bindings = def.animations();
if (bindings == null || bindings.isEmpty()) continue;
for (Map.Entry<LivingMotion, ResourceLocation> entry
: bindings.livingMotions().entrySet()) {
AssetAccessor<? extends StaticAnimation> accessor =
animResolver.apply(entry.getValue());
adder.add(entry.getKey(), accessor);
}
}
}
}