Files
TiedUp-/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java
notevil e37dad18aa P3-05 review fixes : deterministic tiebreaker + reset contract doc
HIGH RISK-001 : secondary comparator on def.id() prevents UB on
equal-priority items — no longer depends on BodyRegionV2 enum order.
New test asserts lexicographic ordering.

HIGH RISK-002 : resetLivingAnimations restores defaultLivingAnimations
snapshot. Javadoc now warns against calling setCurrentMotionsAsDefault
anywhere in the TiedUp pipeline (would leak custom bindings into
defaults, breaking unbind). Grep confirms no call site exists today.

Polish : rename tautological null test, precise null-safety doc.
2026-04-23 22:11:33 +02:00

342 lines
16 KiB
Java

/*
* © 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>
*
* <p><b>Null-safety</b> : 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.</p>
*
* <p><b>Contract &ndash; ne JAMAIS appeler
* {@code animator.setCurrentMotionsAsDefault()} ailleurs dans le
* pipeline TiedUp.</b> 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.</p>
*
* @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);
// 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.
*
* <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>Sort déterministe</b> :</p>
* <ol>
* <li>Primary : {@code posePriority} ASC → highest-priority itère en
* dernier → gagne le conflit (last put wins dans
* {@code addLivingAnimation} map semantics).</li>
* <li>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.</li>
* </ol>
*
* <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);
}
// 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.
*
* <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);
}
}
}
}