diff --git a/src/main/java/com/tiedup/remake/rig/patch/PlayerPatch.java b/src/main/java/com/tiedup/remake/rig/patch/PlayerPatch.java index 0d63336..2bed87d 100644 --- a/src/main/java/com/tiedup/remake/rig/patch/PlayerPatch.java +++ b/src/main/java/com/tiedup/remake/rig/patch/PlayerPatch.java @@ -270,15 +270,47 @@ public abstract class PlayerPatch extends LivingEntityPatch * IDLE sur {@code tiedup:context_stand_idle}, résolue via le registry * data-driven ({@link TiedUpAnimationRegistry#resolveWithFallback}). * - *

Pre-reload window : si le patch est construit avant que - * {@code AnimationManager.apply} n'ait tourné (bootstrap client, race - * entre join et resource-pack reload, etc.), l'ID {@code context_stand_idle} - * n'est pas encore dans {@code animationByName} et - * {@code resolveWithFallback} retourne {@link TiedUpRigRegistry#EMPTY_ANIMATION}. - * C'est idempotent côté animator — une prochaine passe de - * {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler#maybePlayIdle} - * re-bind sur la vraie anim une fois le datapack chargé (self-heal).

+ *

Pre-reload window — race avec AnimationManager.apply (RISK-003)

+ *

Si le patch est construit AVANT que + * {@link com.tiedup.remake.rig.anim.AnimationManager#apply} n'ait tourné + * (bootstrap client, race entre join et resource-pack reload, capability + * attach pré-{@code AddReloadListenerEvent} en Phase 2 du wiring), l'ID + * {@code tiedup:context_stand_idle} n'est pas encore dans + * {@code animationByName} et {@code resolveWithFallback} retourne + * {@link TiedUpRigRegistry#EMPTY_ANIMATION}.

* + *

Pourquoi on bind quand même EMPTY_ANIMATION (au lieu de skip)

+ *

{@link com.tiedup.remake.rig.anim.client.ClientAnimator#postInit} appelle + * immédiatement {@code playAnimationInstantly(livingAnimations.get(IDLE))}. + * Si on skipait le bind ({@code null} dans la map), {@code postInit} NPE + * sur {@code nextAnimation.get()}. EMPTY_ANIMATION a {@code isPresent()==true} + * (cf. {@link com.tiedup.remake.rig.anim.types.DirectStaticAnimation#isPresent}) + * donc passe le {@code AnimationManager.checkNull} guard et bootstrap + * proprement avec une pose identity (no-op visuel).

+ * + *

Self-heal après reload

+ *

Une fois {@code AnimationManager.apply} terminé (datapack chargé, anim + * réelle dans le registry), + * {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler#maybePlayIdle} + * détecte le bind EMPTY au tick suivant via + * {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler#shouldRebindIdle} + * et rebind sur la vraie anim. Idempotent — les + * {@code addLivingAnimation} suivantes écrasent l'entry existante. Aucun + * code "retry hook" post-reload n'est nécessaire : le tick handler observe + * en pull (chaque tick) plutôt qu'en push (callback reload).

+ * + *

Couverture test (RISK-003 verrouillé)

+ *

Voir {@code RigAnimationTickHandlerTest.shouldRebindIdle_*} pour les + * tests unitaires de la logique de self-heal pure (sans MC bootstrap). + * Cas couverts :

+ * + * + *

Pourquoi on se limite à IDLE

*

L'ordre EF ajoute ~25 motions différentes (WALK, RUN, SNEAK, SIT, * SLEEP, etc.). On se limite à IDLE ici — ajouter les autres sans anim * source = pollution registre pour rien. Au fur et à mesure que les JSON @@ -291,6 +323,7 @@ public abstract class PlayerPatch extends LivingEntityPatch // Full data-driven : lookup par ID, EMPTY_ANIMATION fallback si // l'asset n'est pas encore chargé (rare mais possible au bootstrap). // Le tick handler self-heal le bind une fois le datapack loadé. + // Voir javadoc ci-dessus pour le contrat complet (RISK-003). AnimationAccessor idle = TiedUpAnimationRegistry.resolveWithFallback( TiedUpAnimationRegistry.CONTEXT_STAND_IDLE_ID diff --git a/src/main/java/com/tiedup/remake/rig/tick/RigAnimationTickHandler.java b/src/main/java/com/tiedup/remake/rig/tick/RigAnimationTickHandler.java index 68b4b45..ac750f7 100644 --- a/src/main/java/com/tiedup/remake/rig/tick/RigAnimationTickHandler.java +++ b/src/main/java/com/tiedup/remake/rig/tick/RigAnimationTickHandler.java @@ -261,13 +261,62 @@ public final class RigAnimationTickHandler { // EMPTY_ANIMATION. Dans les deux cas on rebind vers le target résolu. AssetAccessor currentIdleBind = clientAnimator.getLivingAnimation(LivingMotions.IDLE, null); - if (currentIdleBind == null || currentIdleBind == TiedUpRigRegistry.EMPTY_ANIMATION) { + if (shouldRebindIdle(currentIdleBind, target)) { clientAnimator.addLivingAnimation(LivingMotions.IDLE, target); } clientAnimator.playAnimation(target, 0.2F); } + /** + * Pure helper — décide si le bind IDLE courant doit être remplacé par + * {@code target}. Extrait pour tests unitaires sans bootstrap MC (cf. + * {@code RigAnimationTickHandlerTest.shouldRebindIdle_*}). + * + *

Contrat : retourne {@code true} ssi {@code target} est une + * vraie anim (pas {@link TiedUpRigRegistry#EMPTY_ANIMATION}) ET que le + * bind courant est soit absent ({@code null}), soit la sentinelle EMPTY. + * Dans tous les autres cas ({@code target} non-EMPTY identique au bind + * courant, ou {@code target} EMPTY) on garde le bind existant.

+ * + *

L'appelant ({@link #maybePlayIdle}) a déjà filtré + * {@code target == EMPTY_ANIMATION} en early-return — la condition + * {@code target != EMPTY} ici est défensive (l'helper reste correct si + * jamais le filtre upstream est modifié).

+ * + *

Race scenario couvert (RISK-003) :

+ *
    + *
  1. {@code PlayerPatch.initAnimator} construit pre-{@code apply()} : + * {@code resolveWithFallback} retourne EMPTY → {@code addLivingAnimation} + * met EMPTY dans la map (car {@code EMPTY_ANIMATION.isPresent()==true} + * — cf. {@link com.tiedup.remake.rig.anim.types.DirectStaticAnimation#isPresent}).
  2. + *
  3. {@code AnimationManager.apply()} tourne plus tard (datapack reload + * wiring Phase 2). Le registry contient maintenant un nouvel accessor + * pour {@code context_stand_idle}.
  4. + *
  5. Tick suivant : {@code maybePlayIdle} résout {@code target = real + * accessor}, voit {@code currentIdleBind == EMPTY}, rebind via cet + * helper.
  6. + *
+ * + * @param currentBind bind IDLE courant (peut être {@code null} si jamais + * bound, ou {@link TiedUpRigRegistry#EMPTY_ANIMATION} si + * bound pré-apply) + * @param target target résolu via {@link TiedUpAnimationRegistry#resolveWithFallback} + * (par contrat upstream, jamais {@code null}) + * @return {@code true} si rebind nécessaire, {@code false} sinon + */ + static boolean shouldRebindIdle( + AssetAccessor currentBind, + AssetAccessor target + ) { + // Garde défensive : un target EMPTY ne doit jamais écraser un bind + // existant (régresserait l'état). En pratique l'appelant filtre déjà. + if (target == TiedUpRigRegistry.EMPTY_ANIMATION) { + return false; + } + return currentBind == null || currentBind == TiedUpRigRegistry.EMPTY_ANIMATION; + } + /** * Reset des erreurs loggées. Appelé : *