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,511 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.client;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import net.minecraft.resources.ResourceLocation;
import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.TiedUpLivingMotions;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.datadriven.AnimationBindings;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.movement.MovementModifier;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
/**
* Tests unitaires pour {@link ClientRigEquipmentHandler} (P3-05).
*
* <h2>Stratégie de test</h2>
* <p>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 :</p>
* <ul>
* <li>{@link ClientRigEquipmentHandler#extractSortedDefinitions} —
* dedup identity, filter null, sort by posePriority ASC.</li>
* <li>{@link ClientRigEquipmentHandler#applyDefinitions} — itère +
* adder pour chaque binding non-null/non-vide, reset d'abord.</li>
* </ul>
*
* <p>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).</p>
*/
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<Map.Entry<LivingMotion, ResourceLocation>> 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<StaticAnimation>() {
@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<ResourceLocation,
AssetAccessor<? extends StaticAnimation>> 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<DataDrivenItemDefinition> 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<DataDrivenItemDefinition> 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<Object, DataDrivenItemDefinition> resolver = s -> def;
List<DataDrivenItemDefinition> result =
ClientRigEquipmentHandler.extractSortedDefinitions(
// Input : le MÊME stack 3 fois (multi-region simulation)
List.of(stack, stack, stack),
resolver
);
assertEquals(1, result.size(),
"dédup identity doit retenir un seul exemplaire du stack");
assertSame(def, result.get(0));
}
/**
* Deux items distincts avec des priorités 20 et 10 → triés ASC : 10 en
* premier, 20 en dernier. Garantit que le plus prioritaire itère en
* dernier dans {@link ClientRigEquipmentHandler#applyDefinitions}.
*/
@Test
void extractSortedDefinitions_sortsAscendingByPosePriority() {
Object stackHigh = new Object();
Object stackLow = new Object();
DataDrivenItemDefinition high = makeDef(ITEM_A, 20, null);
DataDrivenItemDefinition low = makeDef(ITEM_B, 10, null);
// Resolver by identity via IdentityHashMap
IdentityHashMap<Object, DataDrivenItemDefinition> map = new IdentityHashMap<>();
map.put(stackHigh, high);
map.put(stackLow, low);
// Input intentionnellement dans l'ordre "high d'abord" pour vérifier
// que le sort le pousse en dernier
List<DataDrivenItemDefinition> result =
ClientRigEquipmentHandler.extractSortedDefinitions(
List.of(stackHigh, stackLow),
map::get
);
assertEquals(2, result.size());
assertSame(low, result.get(0), "posePriority=10 doit sortir en premier (ASC)");
assertSame(high, result.get(1), "posePriority=20 doit sortir en dernier (ASC)");
}
/** Un {@code null} dans l'iterable est silencieusement skip. */
@Test
void extractSortedDefinitions_nullItemInList_skips() {
List<Object> input = Arrays.asList(null, new Object(), null);
DataDrivenItemDefinition def = makeDef(ITEM_A, 5, null);
List<DataDrivenItemDefinition> result =
ClientRigEquipmentHandler.extractSortedDefinitions(
input,
stack -> stack == null ? null : def
);
assertEquals(1, result.size());
}
// ========== applyDefinitions ==========
/** Reset callback doit être appelé exactement une fois, même sur liste vide. */
@Test
void applyDefinitions_emptyList_callsResetOnly() {
AtomicInteger resetCount = new AtomicInteger();
CapturingAdder adder = new CapturingAdder();
ClientRigEquipmentHandler.applyDefinitions(
resetCount::incrementAndGet,
adder,
Collections.emptyList(),
PASSTHROUGH_RESOLVER
);
assertEquals(1, resetCount.get(), "reset doit etre appele exactement une fois");
assertTrue(adder.calls.isEmpty(), "aucune anim ajoutee sur input vide");
}
/**
* Une définition avec {@code animations() == null} (item V2 legacy sans
* bloc JSON) doit être skip silencieusement — pas d'appel adder.
*/
@Test
void applyDefinitions_nullAnimations_skipsSilently() {
AtomicInteger resetCount = new AtomicInteger();
CapturingAdder adder = new CapturingAdder();
DataDrivenItemDefinition def = makeDef(ITEM_A, 10, null); // animations() == null
ClientRigEquipmentHandler.applyDefinitions(
resetCount::incrementAndGet,
adder,
List.of(def),
PASSTHROUGH_RESOLVER
);
assertEquals(1, resetCount.get());
assertTrue(adder.calls.isEmpty(),
"def avec animations()==null doit etre skip sans appel adder");
}
/**
* Une définition avec {@link AnimationBindings#EMPTY} doit aussi être
* skip (isEmpty() == true).
*/
@Test
void applyDefinitions_emptyAnimations_skipsSilently() {
CapturingAdder adder = new CapturingAdder();
DataDrivenItemDefinition def = makeDef(ITEM_A, 10, AnimationBindings.EMPTY);
ClientRigEquipmentHandler.applyDefinitions(
() -> {},
adder,
List.of(def),
PASSTHROUGH_RESOLVER
);
assertTrue(adder.calls.isEmpty(),
"def avec AnimationBindings.EMPTY doit etre skip (isEmpty() true)");
}
/**
* Un item avec plusieurs bindings → adder appelé une fois par binding
* avec la motion + un accessor résolu depuis l'animResolver.
*/
@Test
void applyDefinitions_singleItemWithBindings_addsAllMotions() {
CapturingAdder adder = new CapturingAdder();
// Ordered map to make the motion iteration order deterministic
LinkedHashMap<LivingMotion, ResourceLocation> motions = new LinkedHashMap<>();
motions.put(LivingMotions.WALK, ANIM_WALK_A);
motions.put(TiedUpLivingMotions.WALK_BOUND, ANIM_IDLE_A);
AnimationBindings bindings = new AnimationBindings(motions, null, null);
DataDrivenItemDefinition def = makeDef(ITEM_A, 10, bindings);
ClientRigEquipmentHandler.applyDefinitions(
() -> {},
adder,
List.of(def),
PASSTHROUGH_RESOLVER
);
assertEquals(2, adder.calls.size(),
"2 bindings => 2 appels adder");
// Chaque binding doit etre present — l'ordre est celui de livingMotions().entrySet()
Map<LivingMotion, ResourceLocation> collected = new LinkedHashMap<>();
for (Map.Entry<LivingMotion, ResourceLocation> call : adder.calls) {
collected.put(call.getKey(), call.getValue());
}
assertEquals(ANIM_WALK_A, collected.get(LivingMotions.WALK));
assertEquals(ANIM_IDLE_A, collected.get(TiedUpLivingMotions.WALK_BOUND));
}
/**
* Deux items bindent la même motion avec priorities 20 et 10. Le flux
* applyDefinitions attend une liste déjà triée ASC, donc la prod (via
* extractSortedDefinitions) passe [low, high] — le dernier add gagne.
* On teste ici que si l'ordre respecté est [low, high] l'adder est
* appelé 2 fois, le dernier portant la valeur high.
*/
@Test
void applyDefinitions_twoItemsConflictingMotion_lastCallWins() {
CapturingAdder adder = new CapturingAdder();
// Low priority (itère en premier) bind WALK → ANIM_WALK_A
AnimationBindings lowBindings = new AnimationBindings(
Map.of(LivingMotions.WALK, ANIM_WALK_A), null, null);
DataDrivenItemDefinition low = makeDef(ITEM_A, 10, lowBindings);
// High priority (itère en dernier) bind WALK → ANIM_WALK_B
AnimationBindings highBindings = new AnimationBindings(
Map.of(LivingMotions.WALK, ANIM_WALK_B), null, null);
DataDrivenItemDefinition high = makeDef(ITEM_B, 20, highBindings);
ClientRigEquipmentHandler.applyDefinitions(
() -> {},
adder,
// Ordre ASC par posePriority — contrat de applyDefinitions
List.of(low, high),
PASSTHROUGH_RESOLVER
);
assertEquals(2, adder.calls.size());
assertEquals(LivingMotions.WALK, adder.calls.get(0).getKey());
assertEquals(ANIM_WALK_A, adder.calls.get(0).getValue());
assertEquals(LivingMotions.WALK, adder.calls.get(1).getKey());
assertEquals(ANIM_WALK_B, adder.calls.get(1).getValue());
// La sémantique "last write wins" dans la map livingAnimations est
// une responsabilite de ClientAnimator.addLivingAnimation — ici on
// vérifie juste l'ordre d'émission.
}
/**
* Reset et adds forment un ordre strict : reset est le premier appel
* observable par l'animator. On le vérifie via un compteur externe
* et un wrapper adder qui fail si reset n'a pas encore été appelé.
*/
@Test
void applyDefinitions_resetIsCalledBeforeAnyAdder() {
boolean[] resetDone = { false };
List<LivingMotion> orderedCalls = new ArrayList<>();
ClientRigEquipmentHandler.LivingAnimationAdder adder = (motion, accessor) -> {
assertTrue(resetDone[0],
"adder ne doit jamais etre appele AVANT reset");
orderedCalls.add(motion);
};
AnimationBindings bindings = new AnimationBindings(
Map.of(LivingMotions.WALK, ANIM_WALK_A), null, null);
DataDrivenItemDefinition def = makeDef(ITEM_A, 10, bindings);
ClientRigEquipmentHandler.applyDefinitions(
() -> resetDone[0] = true,
adder,
List.of(def),
PASSTHROUGH_RESOLVER
);
assertTrue(resetDone[0]);
assertEquals(1, orderedCalls.size());
}
/**
* Sanity end-to-end : extractSortedDefinitions feed applyDefinitions
* avec un input multi-région (même stack 3 fois) → dedup + add pour
* le seul item réel.
*/
@Test
void endToEnd_multiRegionStack_dedupsAndApplies() {
Object multiRegionStack = new Object();
AnimationBindings bindings = new AnimationBindings(
Map.of(LivingMotions.WALK, ANIM_WALK_A), null, null);
DataDrivenItemDefinition def = makeDef(ITEM_A, 10, bindings);
// Dedup step
List<DataDrivenItemDefinition> defs =
ClientRigEquipmentHandler.extractSortedDefinitions(
List.of(multiRegionStack, multiRegionStack, multiRegionStack),
any -> def
);
assertEquals(1, defs.size());
// Apply step
CapturingAdder adder = new CapturingAdder();
ClientRigEquipmentHandler.applyDefinitions(
() -> {},
adder,
defs,
PASSTHROUGH_RESOLVER
);
assertEquals(1, adder.calls.size(),
"3 régions du même stack → un seul add (dedup identity)");
assertEquals(LivingMotions.WALK, adder.calls.get(0).getKey());
assertEquals(ANIM_WALK_A, adder.calls.get(0).getValue());
}
/**
* Sanity : si animResolver retourne un accessor pour l'ID fourni, le
* handler ne suppose pas de non-nullité — contract de
* {@link com.tiedup.remake.rig.TiedUpAnimationRegistry#resolveWithFallback}
* garantit non-null en prod, mais on vérifie qu'aucune assertion plus
* stricte n'a été introduite dans le handler.
*/
@Test
void applyDefinitions_respectsAnimResolverContract() {
AtomicInteger resolverCalls = new AtomicInteger();
ClientRigEquipmentHandler.LivingAnimationAdder adder = (motion, accessor) -> {
// accessor vient du resolver, peut être null si resolver est buggy —
// le handler ne doit pas le dereferencer lui-même
assertNull(accessor, "resolver renvoie null ici");
};
AnimationBindings bindings = new AnimationBindings(
Map.of(LivingMotions.WALK, ANIM_WALK_A), null, null);
DataDrivenItemDefinition def = makeDef(ITEM_A, 10, bindings);
ClientRigEquipmentHandler.applyDefinitions(
() -> {},
adder,
List.of(def),
id -> { resolverCalls.incrementAndGet(); return null; }
);
assertEquals(1, resolverCalls.get(),
"animResolver appele une fois (un seul binding)");
}
// ========== guard : makeDef helper sanity ==========
/**
* Sanity on the test-internal {@link #makeDef} helper — garantit qu'on
* construit bien un record valide (le compact ctor pourrait throw en
* cas de champ manquant).
*/
@Test
void makeDef_returnsValidDefinition() {
DataDrivenItemDefinition def = makeDef(ITEM_A, 42, null);
assertEquals(ITEM_A, def.id());
assertEquals(42, def.posePriority());
assertNull(def.animations());
// un seul occupiedRegion par défaut
assertFalse(def.occupiedRegions().isEmpty());
}
}