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:
@@ -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 & 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user