P3-12 review fixes : reorder preconditions + document 400ms decision

HIGH BUG-001 + MEDIUM RISK-001 : spam timestamp was burned BEFORE
preconditions were validated. Null registryName meant server played
but clients got no broadcast (state desync). Null animator meant
400ms lockout for an entity that never played. Reorder : validate
registryName + animator FIRST, record spam only after preconditions
pass, then server play + broadcast.

MEDIUM RISK-002 : document 400ms global guard as INTENTIONAL design
for cinematic one-shots. Edge case hit+fall-damage same-tick drops
the 2nd — acceptable trade-off. Per-category rate limiting is Phase 4
if gameplay needs it.

LOW SMELL-001 : merge duplicate javadoc on LAST_SYNC_PLAY_BY_ENTITY.
This commit is contained in:
notevil
2026-04-24 03:10:23 +02:00
parent d39a9d5ebc
commit e969131ad2
2 changed files with 242 additions and 39 deletions

View File

@@ -165,12 +165,11 @@ public abstract class LivingEntityPatch<T extends LivingEntity> extends EntityPa
* handler appelle {@code playAnimationSync} hors tick. Les * handler appelle {@code playAnimationSync} hors tick. Les
* {@code get}/{@code put} individuels sont atomic via le wrapper ; pas * {@code get}/{@code put} individuels sont atomic via le wrapper ; pas
* d'itération concurrente ici donc pas de sync externe nécessaire.</p> * d'itération concurrente ici donc pas de sync externe nécessaire.</p>
*/ *
/** * <p><b>Key-type = {@link Object}</b> (pas {@link LivingEntity}) pour
* Key-type = {@link Object} (pas {@link LivingEntity}) pour permettre le * permettre le test en isolation sans bootstrap MC. En prod la key
* test en isolation sans bootstrap MC. En prod la key effective est * effective est toujours une {@link LivingEntity} ; {@link WeakHashMap}
* toujours une {@link LivingEntity} ; {@link WeakHashMap} tolère * tolère n'importe quel Object comme clé et GC-cleanup reste valide.</p>
* n'importe quel Object comme clé et GC-cleanup reste valide.
*/ */
static final Map<Object, Long> LAST_SYNC_PLAY_BY_ENTITY = static final Map<Object, Long> LAST_SYNC_PLAY_BY_ENTITY =
Collections.synchronizedMap(new WeakHashMap<>()); Collections.synchronizedMap(new WeakHashMap<>());
@@ -218,20 +217,53 @@ public abstract class LivingEntityPatch<T extends LivingEntity> extends EntityPa
* les animations ambient / motion-driven, préférer {@code addLivingAnimation} * les animations ambient / motion-driven, préférer {@code addLivingAnimation}
* sur l'animator via le pipeline d'équipement data-driven.</p> * sur l'animator via le pipeline d'équipement data-driven.</p>
* *
* <p><b>Anti-spam guard</b> : les appels consécutifs sur la même entity * <h3>Ordre des preconditions (P3-12 review fix)</h3>
* dans une fenêtre courte (400 ms) sont dropped silencieusement. Prévient * <p>L'ordre des vérifications est load-bearing pour éviter deux classes
* l'abus master-click-spam (hit/slap) qui flood-erait les packets.</p> * de bugs :</p>
* <ol>
* <li><b>Null accessor / registryName</b> : validé AVANT le server play.
* Sinon, un accessor qui retourne {@code null} en
* {@code registryName()} (cas {@link com.tiedup.remake.rig.anim.types.LinkAnimation}
* ou {@link com.tiedup.remake.rig.anim.types.LayerOffAnimation}) jouerait
* côté serveur mais les clients ne recevraient rien → state desync.</li>
* <li><b>Animator null</b> : validé AVANT {@code checkAndRecordSpam}.
* Sinon, un appel durant la fenêtre {@code onConstructed} (pre-postInit
* race) burnerait le timestamp spam-guard pour 400ms alors que
* l'animation n'a jamais été jouée — un call légitime suivant
* serait drop silencieusement.</li>
* <li><b>Anti-spam</b> : le timestamp n'est enregistré qu'APRÈS les deux
* preconditions, pour éviter un lockout sur un call qui n'a rien fait.</li>
* </ol>
* *
* <p><b>Server-side play</b> : l'animator côté server est un * <h3>Server-side play</h3>
* {@code ServerAnimator} — il ne rend rien visuellement mais track l'état * <p>L'animator côté server est un {@code ServerAnimator} — il ne rend rien
* d'animation pour {@code StateSpectrum} / {@code EntityState} (utilisé par * visuellement mais track l'état d'animation pour {@code StateSpectrum} /
* la logique AI, collision, etc.). Donc on joue localement aussi, pas * {@code EntityState} (utilisé par la logique AI, collision, etc.). Donc
* seulement broadcast.</p> * on joue localement aussi, pas seulement broadcast.</p>
* *
* <p><b>Priority non-forwardée</b> : le paramètre {@code priority} est * <h3>Anti-spam design (400ms global)</h3>
* encodé dans le packet pour future use / logging client-side, mais * <p>Un guard global de 400 ms par entity drop les calls {@code playAnimationSync}
* {@link Animator#playAnimation} ne l'accepte pas — la priority effective * suivants. C'est <b>INTENTIONAL</b> pour les cinematic one-shots
* vient de {@link StaticAnimation#getPriority()} (déclarée en JSON via * (capture grab, hurt, death) — prévient la cacophonie visuelle quand
* plusieurs events serveur fire dans la même fenêtre (ex: master-click-spam).</p>
*
* <p><b>Edge case acceptée</b> : hit + fall-damage dans le même tick drop
* le 2e call silencieusement. Le server-authored gagne par order of call ;
* la perte visuelle est préférable à l'override brutal de la 1re anim.</p>
*
* <p><b>Pour la locomotion</b> (walk / idle / struggle / sneak), utiliser
* le pipeline {@code addLivingAnimation} map (pas de rate limit) — il est
* driven par {@code updateMotion} tick-by-tick sans broadcast discret.</p>
*
* <p><b>Phase 4</b> : si le gameplay réclame des buckets par catégorie
* (cinematic vs reaction vs env-feedback), introduire un paramètre
* {@code category} + maps séparées. Scope creep Phase 3.</p>
*
* <h3>Priority non-forwardée</h3>
* <p>Le paramètre {@code priority} est encodé dans le packet pour future
* use / logging client-side, mais {@link Animator#playAnimation} ne
* l'accepte pas — la priority effective vient de
* {@link StaticAnimation#getPriority()} (déclarée en JSON via
* {@code ClientAnimationProperties.PRIORITY}). Cf. design §12.</p> * {@code ClientAnimationProperties.PRIORITY}). Cf. design §12.</p>
* *
* @param accessor l'animation à jouer (typiquement résolue via * @param accessor l'animation à jouer (typiquement résolue via
@@ -252,34 +284,29 @@ public abstract class LivingEntityPatch<T extends LivingEntity> extends EntityPa
return; return;
} }
// Anti-spam guard : check + record en une étape atomique (cf. helper // 1+2+3. Preconditions : accessor / registryName / animator.
// Validées AVANT checkAndRecordSpam pour éviter de burn le timestamp
// sur un call filtré (cf. BUG-001 / RISK-001).
ResourceLocation animId = resolveBroadcastableAnimId(
accessor, this.animator, this.original.getId()
);
if (animId == null) {
return; // log déjà émis par resolveBroadcastableAnimId
}
// 4. Anti-spam guard : check + record en une étape atomique (cf. helper
// package-private pour tests unitaires sans LivingEntity réel). // package-private pour tests unitaires sans LivingEntity réel).
// Record APRÈS les preconditions pour qu'un call filtré n'empêche pas
// un call légitime suivant dans la fenêtre 400ms.
if (!checkAndRecordSpam(this.original, System.currentTimeMillis())) { if (!checkAndRecordSpam(this.original, System.currentTimeMillis())) {
return; return;
} }
// Server-side : play locally (ServerAnimator track l'état pour // 5. Server-side : play locally (ServerAnimator track l'état pour
// StateSpectrum/EntityState même sans rendu visuel serveur). // StateSpectrum/EntityState même sans rendu visuel serveur).
if (this.animator != null) {
this.animator.playAnimation(accessor, transitionTime); this.animator.playAnimation(accessor, transitionTime);
}
// Broadcast aux clients trackers.
if (accessor == null) {
TiedUpRigConstants.LOGGER.warn(
"[LivingEntityPatch.playAnimationSync] null accessor, cannot broadcast"
);
return;
}
ResourceLocation animId = accessor.registryName();
if (animId == null) {
TiedUpRigConstants.LOGGER.warn(
"[LivingEntityPatch.playAnimationSync] accessor has null registryName, cannot broadcast: {}",
accessor
);
return;
}
// 6. Broadcast aux clients trackers.
PacketPlayRigAnim packet = PacketPlayRigAnim.of( PacketPlayRigAnim packet = PacketPlayRigAnim.of(
this.original.getId(), this.original.getId(),
animId, animId,
@@ -292,4 +319,61 @@ public abstract class LivingEntityPatch<T extends LivingEntity> extends EntityPa
packet packet
); );
} }
/**
* Précondition-check pure pour {@link #playAnimationSync} : retourne la
* {@link ResourceLocation} broadcastable si tout est OK, {@code null} sinon
* (avec un log warn/debug approprié).
*
* <p>Package-private pour tests unitaires sans bootstrap MC (on peut passer
* accessor/animator null ou un stub lambda ; l'entityId est juste un int
* pour le logging).</p>
*
* <p>Side-effect-free pour la spam map : ce helper ne touche pas
* {@link #LAST_SYNC_PLAY_BY_ENTITY}. C'est exactement le point du fix
* BUG-001 / RISK-001 — les preconditions sont validées AVANT d'enregistrer
* le timestamp anti-spam.</p>
*
* @param accessor l'accessor à valider (null → skip)
* @param animator l'animator à valider (null → skip)
* @param entityIdForLog entity id utilisé dans le message de log
* @return la registryName broadcastable, ou null si une precondition fail
*/
@Nullable
static ResourceLocation resolveBroadcastableAnimId(
@Nullable AnimationAccessor<? extends StaticAnimation> accessor,
@Nullable Animator animator,
int entityIdForLog
) {
if (accessor == null) {
TiedUpRigConstants.LOGGER.warn(
"[LivingEntityPatch.playAnimationSync] null accessor for entity={}, skip "
+ "(do NOT burn spam timestamp, do NOT server-play).",
entityIdForLog
);
return null;
}
ResourceLocation animId = accessor.registryName();
if (animId == null) {
TiedUpRigConstants.LOGGER.warn(
"[LivingEntityPatch.playAnimationSync] accessor has null registryName, "
+ "cannot broadcast to trackers (entity={}, class={}). "
+ "Skipping server play too to preserve client-server consistency.",
entityIdForLog, accessor.getClass().getSimpleName()
);
return null;
}
if (animator == null) {
TiedUpRigConstants.LOGGER.debug(
"[LivingEntityPatch.playAnimationSync] animator null (pre-postInit race?), "
+ "skipping for entity={}",
entityIdForLog
);
return null;
}
return animId;
}
} }

View File

@@ -5,11 +5,21 @@
package com.tiedup.remake.rig.patch; package com.tiedup.remake.rig.patch;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
/** /**
* Tests du helper {@link LivingEntityPatch#checkAndRecordSpam} — anti-spam * Tests du helper {@link LivingEntityPatch#checkAndRecordSpam} — anti-spam
* guard de {@code playAnimationSync}. Logique pure, injectable clock (paramètre * guard de {@code playAnimationSync}. Logique pure, injectable clock (paramètre
@@ -138,4 +148,113 @@ class LivingEntityPatchSpamGuardTest {
"SPAM_GUARD_MS doit rester 400ms (tuning master-click-spam). " "SPAM_GUARD_MS doit rester 400ms (tuning master-click-spam). "
+ "Changer cette valeur impacte le feeling game — à reviewer."); + "Changer cette valeur impacte le feeling game — à reviewer.");
} }
// -----------------------------------------------------------------------
// P3-12 review fix : preconditions AVANT spam timestamp (BUG-001 + RISK-001)
// -----------------------------------------------------------------------
//
// Ces tests valident que resolveBroadcastableAnimId() — le helper précondition
// extrait pour testabilité — retourne null SANS burn le spam map quand
// accessor/registryName/animator est invalide.
//
// Rationale (cf. commit P3-12 review fixes) :
// - Si le code burnait le timestamp AVANT les preconditions, un appel
// filtré (null accessor/animator) lockerait l'entity pour 400ms,
// empêchant un call légitime suivant.
// - Side-effect-freeness de resolveBroadcastableAnimId est la garantie
// que l'ordre actuel dans playAnimationSync est correct.
/**
* Accessor null → resolveBroadcastableAnimId retourne null, spam map
* reste vide (pas de timestamp burn).
*/
@Test
void resolveBroadcastableAnimId_nullAccessor_noSpamTimestamp() {
Animator animator = mock(Animator.class);
ResourceLocation result = LivingEntityPatch.resolveBroadcastableAnimId(
null, animator, /* entityIdForLog= */ 42
);
assertNull(result, "Null accessor → null return (no broadcast possible).");
assertTrue(LivingEntityPatch.LAST_SYNC_PLAY_BY_ENTITY.isEmpty(),
"Precondition failure must NOT burn the spam map — sinon un call "
+ "filtré lockerait l'entity pour 400ms (BUG-001 regression).");
}
/**
* Accessor non-null mais {@code registryName()} returns null (cas
* LinkAnimation / LayerOffAnimation) → skip complet. Sans ce guard,
* le server play ferait feu mais les clients ne recevraient rien →
* state desync (RISK-001).
*/
@Test
@SuppressWarnings("unchecked")
void resolveBroadcastableAnimId_nullRegistryName_noSpamTimestamp() {
AnimationAccessor<StaticAnimation> accessor = mock(AnimationAccessor.class);
when(accessor.registryName()).thenReturn(null);
Animator animator = mock(Animator.class);
ResourceLocation result = LivingEntityPatch.resolveBroadcastableAnimId(
accessor, animator, /* entityIdForLog= */ 42
);
assertNull(result,
"Null registryName → null return (preserve client-server consistency).");
assertTrue(LivingEntityPatch.LAST_SYNC_PLAY_BY_ENTITY.isEmpty(),
"Precondition failure (null registryName) must NOT burn spam map.");
}
/**
* Accessor avec registryName valide mais animator null → skip
* (pre-postInit race defensive guard, BUG-001). Sans ce reorder,
* le timestamp serait enregistré alors que rien ne s'est joué.
*/
@Test
@SuppressWarnings("unchecked")
void resolveBroadcastableAnimId_nullAnimator_noSpamTimestamp() {
AnimationAccessor<StaticAnimation> accessor = mock(AnimationAccessor.class);
when(accessor.registryName()).thenReturn(
ResourceLocation.fromNamespaceAndPath("tiedup", "context_stand_idle")
);
ResourceLocation result = LivingEntityPatch.resolveBroadcastableAnimId(
accessor, /* animator= */ null, /* entityIdForLog= */ 42
);
assertNull(result,
"Null animator → null return (skip to avoid 400ms lockout on entity "
+ "that never actually played).");
assertTrue(LivingEntityPatch.LAST_SYNC_PLAY_BY_ENTITY.isEmpty(),
"Precondition failure (null animator) must NOT burn spam map.");
}
/**
* Happy path : accessor valide + animator non-null → retourne la
* registryName broadcastable. Garantit que le helper n'est pas
* trop restrictif (toutes preconditions passent = return OK).
*
* <p>Ce test aussi vérifie que le helper ne touche PAS la spam map
* (c'est {@code checkAndRecordSpam} qui doit s'en charger dans
* {@code playAnimationSync} APRÈS appel de ce helper).</p>
*/
@Test
@SuppressWarnings("unchecked")
void resolveBroadcastableAnimId_happyPath_returnsRegistryName_noSpamTimestamp() {
ResourceLocation expectedId =
ResourceLocation.fromNamespaceAndPath("tiedup", "context_stand_idle");
AnimationAccessor<StaticAnimation> accessor = mock(AnimationAccessor.class);
when(accessor.registryName()).thenReturn(expectedId);
Animator animator = mock(Animator.class);
ResourceLocation result = LivingEntityPatch.resolveBroadcastableAnimId(
accessor, animator, /* entityIdForLog= */ 42
);
assertNotNull(result, "All preconditions pass → return the registry name.");
assertTrue(expectedId.equals(result),
"Returned registryName must be exactly what accessor provided.");
assertTrue(LivingEntityPatch.LAST_SYNC_PLAY_BY_ENTITY.isEmpty(),
"Helper is side-effect-free — spam recording is playAnimationSync's job.");
}
} }