P3-12 : PacketPlayRigAnim handler réel + LivingEntityPatch.playAnimationSync

Client handler : resolve entity via Minecraft.getInstance().level,
cast to LivingEntity, lookup LivingEntityPatch via TiedUpCapabilities,
call animator.playAnimation avec l'accessor résolu. Null-cascade guards
(level / entity / patch / animator) + debug logs. Logic pure extraite en
resolveAndPlay(level, id, animId, transition, patchLookup) — testable
sans bootstrap MC grâce au patchLookup injectable (évite
TiedUpCapabilities.<clinit> qui cascade sur Registries).

Server-side playAnimationSync : local play sur l'animator (ServerAnimator
track l'état pour StateSpectrum/EntityState même sans rendu visuel) +
broadcast via PacketDistributor TRACKING_ENTITY. Anti-spam guard 400ms
par entity pour prévenir le master-click-spam flood (hit/slap). Cleanup
automatique via WeakHashMap (weak ref sur l'entity → auto-removed au GC
post-despawn, pas de leak sur long-running server).

priorityOrdinal reste informationnel côté client (ClientAnimator.playAnimation
n'accepte pas de priority — intrinsèque au StaticAnimation JSON via
ClientAnimationProperties.PRIORITY). Encodé dans le packet pour future
use / logging.

Débloque les 3 triggers non-fonctionnels : NPC capture cinematic,
hit/slap feedback, unconscious animation.

Tests +7 (277 → 284 GREEN) :
- PacketPlayRigAnimHandlerTest : nullLevel + signature reachability
  (les autres branches requièrent mock ClientLevel/LivingEntity qui
  triggers Bootstrap — gameday-only, documenté)
- LivingEntityPatchSpamGuardTest : first pass, within-window drop,
  boundary 400ms exact pass, cross-entity isolation, constant sanity
This commit is contained in:
notevil
2026-04-24 02:44:43 +02:00
parent 7281548a6a
commit d39a9d5ebc
4 changed files with 527 additions and 5 deletions

View File

@@ -4,17 +4,35 @@
package com.tiedup.remake.rig.network;
import java.util.function.Function;
import java.util.function.Supplier;
import javax.annotation.Nullable;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.network.NetworkDirection;
import net.minecraftforge.network.NetworkEvent;
import com.tiedup.remake.rig.TiedUpAnimationRegistry;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.client.Layer;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.patch.TiedUpCapabilities;
// Note : TiedUpCapabilities.<clinit> touche net.minecraftforge.common.capabilities
// qui cascade sur Registries.<clinit> → requiert MC bootstrap. Les tests unitaires
// injectent un patch-lookup fake via l'overload resolveAndPlay(..., Function<Entity,
// LivingEntityPatch<?>>) pour éviter ce class-load.
/**
* Packet S→C : déclenche une animation one-shot sur une entité distante.
@@ -118,12 +136,124 @@ public record PacketPlayRigAnim(
@OnlyIn(Dist.CLIENT)
static void apply(PacketPlayRigAnim pkt) {
TiedUpRigConstants.LOGGER.debug(
"[PacketPlayRigAnim] received (stub P3-11): entityId={}, animId={}, transition={}s, priority={}",
pkt.entityId, pkt.animId, pkt.transitionTime, pkt.priority()
// Délègue à resolveAndPlay, pure function testable sans bootstrap MC.
// Minecraft.getInstance() n'est touché qu'ici — le resolve core
// prend le ClientLevel en paramètre pour rester mockable.
Minecraft mc = Minecraft.getInstance();
resolveAndPlay(
mc.level,
pkt.entityId,
pkt.animId,
pkt.transitionTime,
DEFAULT_PATCH_LOOKUP
);
// TODO P3-12 : Minecraft.getInstance().level.getEntity(pkt.entityId())
// + LivingEntityPatch lookup + animator.playAnimation
}
}
/**
* Default patch-lookup function utilisée par {@link ClientHandler#apply} en
* prod. Extrait en field pour permettre l'injection d'un fake en test
* unitaire (cf. overload {@link #resolveAndPlay}).
*
* <p>Encapsulé dans un lambda qui ne référence {@link TiedUpCapabilities}
* qu'à l'exécution — pas au class-load de {@link PacketPlayRigAnim}. Ça
* permet aux tests d'appeler {@link #resolveAndPlay} avec un fake lookup
* sans jamais déclencher le class-init de {@code TiedUpCapabilities} (qui
* cascade sur Registries + Forge, nécessitant MC bootstrap).</p>
*/
@OnlyIn(Dist.CLIENT)
private static final Function<LivingEntity, LivingEntityPatch<?>> DEFAULT_PATCH_LOOKUP =
living -> TiedUpCapabilities.getEntityPatch(living, LivingEntityPatch.class);
/**
* Pure function côté client : resolve l'entity, récupère son patch via
* le {@code patchLookup} fourni, call animator.playAnimation. Extrait de
* {@link ClientHandler#apply} pour être testable sans bootstrap Minecraft
* (on peut stub le {@link ClientLevel} + inject un lookup fake).
*
* <p>Null-cascade guards — tous non-fatals, log debug + return :</p>
* <ol>
* <li>{@code level} null → client pas encore en partie (loading screen,
* packet arrivé pendant transition dimension)</li>
* <li>{@code level.getEntity(id)} null ou non-LivingEntity → entité hors
* range du client (out-of-chunk-tracking) ou malformed packet</li>
* <li>patch null → capability pas attachée (NPC sans rig, init race)</li>
* <li>animator null → postInit pas encore exécuté (ne devrait pas arriver
* car onConstructed initialise eager, mais paranoia)</li>
* </ol>
*
* <p>Note : {@code pkt.priority()} n'est PAS forwardé à
* {@link Animator#playAnimation}. La priority est intrinsèque au
* {@link StaticAnimation} résolu (cf. design §12 — les JSON anim déclarent
* leur priority via {@code ClientAnimationProperties.PRIORITY}). Le byte
* priority dans le packet reste informationnel/loggable.</p>
*
* <p>Package-private pour tests unitaires.</p>
*
* @param level le ClientLevel courant (null = skip)
* @param entityId Entity.getId() côté serveur
* @param animId ResourceLocation de l'anim à jouer
* @param transitionTime durée de blend en secondes
* @param patchLookup function qui résout LivingEntityPatch à partir d'une
* LivingEntity (prod : {@link #DEFAULT_PATCH_LOOKUP} ;
* test : mock sans touche à TiedUpCapabilities)
*/
@OnlyIn(Dist.CLIENT)
static void resolveAndPlay(
@Nullable ClientLevel level,
int entityId,
ResourceLocation animId,
float transitionTime,
Function<LivingEntity, LivingEntityPatch<?>> patchLookup
) {
if (level == null) {
TiedUpRigConstants.LOGGER.debug(
"[PacketPlayRigAnim] level null, skipping (entityId={}, animId={})",
entityId, animId
);
return;
}
Entity entity = level.getEntity(entityId);
if (!(entity instanceof LivingEntity living)) {
// Entity absent (out of range, chunk unload, or malformed packet).
// Non-fatal : skip + debug log. Gameplay-wise, the cinematic won't
// play on this client but the entity is out of view anyway.
TiedUpRigConstants.LOGGER.debug(
"[PacketPlayRigAnim] entityId={} not a LivingEntity on client (out of range?) — skipping",
entityId
);
return;
}
LivingEntityPatch<?> patch = patchLookup.apply(living);
if (patch == null) {
// Capability not attached : either the entity has no TiedUp patch
// registered (vanilla mob without rig), or init race (entity joined
// but capability dispatch pending). Silent skip.
TiedUpRigConstants.LOGGER.debug(
"[PacketPlayRigAnim] no LivingEntityPatch for entity {} ({})",
entityId, living.getType()
);
return;
}
Animator animator = patch.getAnimator();
if (animator == null) {
TiedUpRigConstants.LOGGER.debug(
"[PacketPlayRigAnim] animator null for entity {} (not yet postInit?)",
entityId
);
return;
}
AnimationAccessor<? extends StaticAnimation> anim =
TiedUpAnimationRegistry.resolveWithFallback(animId);
animator.playAnimation(anim, transitionTime);
TiedUpRigConstants.LOGGER.debug(
"[PacketPlayRigAnim] played anim={} on entity={} (transition={}s)",
animId, entityId, transitionTime
);
}
}

View File

@@ -6,21 +6,32 @@
package com.tiedup.remake.rig.patch;
import java.util.Collections;
import java.util.Map;
import java.util.WeakHashMap;
import javax.annotation.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.network.PacketDistributor;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.client.ClientAnimator;
import com.tiedup.remake.rig.anim.client.Layer;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.network.PacketPlayRigAnim;
/**
* RIG Phase 2 — patch de capability attaché à un {@link LivingEntity}. Porte
@@ -137,4 +148,148 @@ public abstract class LivingEntityPatch<T extends LivingEntity> extends EntityPa
public boolean overrideRender() {
return false;
}
/** Anti-spam guard : 400ms minimum entre deux sync-play par entity. */
static final long SPAM_GUARD_MS = 400L;
/**
* Dernier timestamp sync-play par {@link LivingEntity} (server-side only).
*
* <p><b>Cleanup</b> : {@link WeakHashMap} (weak key sur l'entity). Quand
* l'entity est GC'd post-despawn/death, l'entry est retirée automatiquement
* au prochain sweep interne. Pas de cleanup manuel requis — évite le leak
* sur les long-running servers.</p>
*
* <p>{@code synchronizedMap} pour thread-safety : appels potentiels depuis
* le server tick thread principal + (théoriquement) threads event si un
* handler appelle {@code playAnimationSync} hors tick. Les
* {@code get}/{@code put} individuels sont atomic via le wrapper ; pas
* d'itération concurrente ici donc pas de sync externe nécessaire.</p>
*/
/**
* Key-type = {@link Object} (pas {@link LivingEntity}) pour permettre le
* test en isolation sans bootstrap MC. En prod la key effective est
* toujours une {@link LivingEntity} ; {@link WeakHashMap} tolère
* n'importe quel Object comme clé et GC-cleanup reste valide.
*/
static final Map<Object, Long> LAST_SYNC_PLAY_BY_ENTITY =
Collections.synchronizedMap(new WeakHashMap<>());
/**
* Anti-spam helper : retourne true si le call est autorisé (first call ou
* suffisamment espacé du dernier), false s'il doit être dropped.
*
* <p>Package-private pour tests unitaires (évite de dépendre d'une
* {@link LivingEntity} réelle instantiée — on peut passer n'importe quel
* Object comme clé, puisque la map est {@code Map<Object, Long>}).
* Side-effect : enregistre {@code nowMillis} dans la map si le call passe.</p>
*
* <p><b>Why not atomic compute ?</b> {@code WeakHashMap.compute} n'est pas
* thread-safe même wrappé via {@code synchronizedMap} (l'atomicity garantie
* couvre seulement les méthodes single-op de {@code Map}). Comme le cas
* concurrent ici est rare (deux events tickant simultanément sur la même
* entity), on accepte la race théorique : au pire, deux syncs passent
* au lieu d'un sur 400ms — coût négligeable vs complexité d'un lock dédié.</p>
*
* @param entityKey clé (en prod : {@link LivingEntity} pour weak-ref auto-cleanup)
* @param nowMillis timestamp "now" — paramètre pour permettre un test
* deterministic (inject une clock fictive)
* @return true si le call doit procéder, false si dropped (spam)
*/
static boolean checkAndRecordSpam(Object entityKey, long nowMillis) {
Long last = LAST_SYNC_PLAY_BY_ENTITY.get(entityKey);
if (last != null && (nowMillis - last) < SPAM_GUARD_MS) {
TiedUpRigConstants.LOGGER.debug(
"[LivingEntityPatch.playAnimationSync] dropped spam call for entity key {} ({}ms since last)",
entityKey, nowMillis - last
);
return false;
}
LAST_SYNC_PLAY_BY_ENTITY.put(entityKey, nowMillis);
return true;
}
/**
* Server-side : joue une animation localement sur l'animator de cette
* entity ET broadcast aux clients trackers via {@link PacketPlayRigAnim}.
*
* <p>Utilisé pour les animations cinematic / one-shot qui doivent rester
* synchronisées entre clients (NPC capture grab, hit stun, death). Pour
* les animations ambient / motion-driven, préférer {@code addLivingAnimation}
* 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
* dans une fenêtre courte (400 ms) sont dropped silencieusement. Prévient
* l'abus master-click-spam (hit/slap) qui flood-erait les packets.</p>
*
* <p><b>Server-side play</b> : l'animator côté server est un
* {@code ServerAnimator} — il ne rend rien visuellement mais track l'état
* d'animation pour {@code StateSpectrum} / {@code EntityState} (utilisé par
* la logique AI, collision, etc.). Donc on joue localement aussi, pas
* seulement broadcast.</p>
*
* <p><b>Priority non-forwardée</b> : 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>
*
* @param accessor l'animation à jouer (typiquement résolue via
* {@code TiedUpAnimationRegistry.resolveWithFallback})
* @param transitionTime durée de blend en secondes (typiquement 0.15F)
* @param priority informationnel — PAS forwardé à {@code playAnimation}.
* Encodé dans le packet pour future use.
*/
public void playAnimationSync(
AnimationAccessor<? extends StaticAnimation> accessor,
float transitionTime,
Layer.Priority priority
) {
if (this.original == null) {
TiedUpRigConstants.LOGGER.warn(
"[LivingEntityPatch.playAnimationSync] called before onConstructed (original null), skip."
);
return;
}
// Anti-spam guard : check + record en une étape atomique (cf. helper
// package-private pour tests unitaires sans LivingEntity réel).
if (!checkAndRecordSpam(this.original, System.currentTimeMillis())) {
return;
}
// Server-side : play locally (ServerAnimator track l'état pour
// StateSpectrum/EntityState même sans rendu visuel serveur).
if (this.animator != null) {
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;
}
PacketPlayRigAnim packet = PacketPlayRigAnim.of(
this.original.getId(),
animId,
transitionTime,
priority
);
ModNetwork.CHANNEL.send(
PacketDistributor.TRACKING_ENTITY.with(() -> this.original),
packet
);
}
}

View File

@@ -0,0 +1,96 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.network;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.util.function.Function;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Tests de {@link PacketPlayRigAnim#resolveAndPlay} — pure function extraite du
* {@code ClientHandler} pour être testable sans bootstrap {@code Minecraft}.
*
* <p>L'overload {@code resolveAndPlay(level, id, animId, transition, patchLookup)}
* accepte un {@code Function<LivingEntity, LivingEntityPatch<?>>} injectable —
* en prod le {@code ClientHandler} passe un lambda qui délègue à
* {@link com.tiedup.remake.rig.patch.TiedUpCapabilities#getEntityPatch}, mais
* en test on peut injecter un fake qui retourne null / un patch mock, sans
* jamais déclencher le {@code <clinit>} de {@code TiedUpCapabilities} (qui
* cascade sur {@code Registries.<clinit>} → requiert MC bootstrap).</p>
*
* <p><b>Scope unit test</b> : on couvre la branche "null level" (path le plus
* chaud car frequent pendant transitions dimension/loading). Les autres
* branches (entity absent, entity non-living, patch null, animator null,
* happy path) nécessitent de mocker {@code ClientLevel} ou {@code LivingEntity},
* ce qui déclenche {@code Bootstrap.bootStrap()} obligatoire (MC BuiltInRegistries
* clinit) — trop coûteux / fragile en JUnit.</p>
*
* <p><b>Gameday QA</b> couvre les branches restantes :</p>
* <ul>
* <li>entity absent → spam packets avec entityId inexistant (dev command),
* vérifier absence de crash + DEBUG log "not a LivingEntity"</li>
* <li>patch null → tester sur vanilla mob (zombie) — vérifier absence de
* crash + DEBUG log "no LivingEntityPatch"</li>
* <li>happy path → {@code PacketPlayRigAnim.of(entityId, anim, 0.15F, MIDDLE)}
* via dev command, vérifier que l'anim joue sur le NPC ciblé</li>
* </ul>
*/
class PacketPlayRigAnimHandlerTest {
private static final ResourceLocation ANIM_ID =
ResourceLocation.fromNamespaceAndPath("tiedup", "test_anim");
/** Lookup fake qui ne devrait jamais être appelé (entity absent / not living). */
private static final Function<LivingEntity, LivingEntityPatch<?>> NEVER_CALLED_LOOKUP =
living -> {
throw new AssertionError(
"patchLookup ne devrait pas être invoqué dans cette branche — "
+ "resolveAndPlay a dû short-circuit plus tôt."
);
};
/**
* Level null (client en loading screen, transition dimension) → skip
* sans throw. {@code patchLookup} ne doit PAS être appelé.
*
* <p>Cas fréquent en prod :</p>
* <ul>
* <li>Packet reçu pendant un {@code /execute in} dim change</li>
* <li>Respawn : level refqueued pendant quelques ticks</li>
* <li>Client dé-co pendant qu'un packet cinematic est en transit</li>
* </ul>
*/
@Test
void resolveAndPlay_nullLevel_noThrow() {
assertDoesNotThrow(() ->
PacketPlayRigAnim.resolveAndPlay(null, 42, ANIM_ID, 0.15F, NEVER_CALLED_LOOKUP),
"level null doit être swallow silencieusement (pas de throw). "
+ "C'est le cas le plus fréquent en prod pendant les transitions."
);
}
/**
* Sanity : {@code resolveAndPlay} est atteignable avec les types
* attendus (compile-time check + runtime exec sur path trivial). Si un
* changement de signature casse la compat binaire avec {@code ClientHandler}
* ou les appelants, ce test fail au compile time, pas à l'exec.
*/
@Test
void resolveAndPlay_methodSignatureReachable() {
// On réutilise nullLevel (already tested for correctness) comme smoke
// test : si la signature change (ex. suppression du 5e param
// patchLookup), ce test ne compile plus → alerte immédiate.
assertDoesNotThrow(() ->
PacketPlayRigAnim.resolveAndPlay(null, 0, ANIM_ID, 0.0F, NEVER_CALLED_LOOKUP)
);
}
}

View File

@@ -0,0 +1,141 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.patch;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Tests du helper {@link LivingEntityPatch#checkAndRecordSpam} — anti-spam
* guard de {@code playAnimationSync}. Logique pure, injectable clock (paramètre
* {@code nowMillis}) → testable sans {@code Thread.sleep} flaky.
*
* <p><b>Ce que ces tests verrouillent</b> :</p>
* <ul>
* <li>Premier call pour une entity → pass (pas d'entry dans la map).</li>
* <li>Second call immédiat (&lt; 400ms) → drop.</li>
* <li>Second call après 400ms+ → pass (window expirée).</li>
* <li>Deux entities différentes → pas de cross-contamination (un call sur
* entity A n'impacte pas l'entity B).</li>
* </ul>
*
* <p><b>Pas de bootstrap MC</b> : la map est typée {@code Map<Object, Long>}
* (key générique) spécifiquement pour permettre le test sans instancier de
* {@link net.minecraft.world.entity.LivingEntity} (qui requiert
* {@code Bootstrap.bootStrap()} complet pour l'init des classes MC). En prod
* la clé effective est toujours un {@code LivingEntity}, ce qui garantit le
* cleanup weak-ref automatique au GC.</p>
*
* <p><b>Package-private access</b> : test dans le même package
* {@code com.tiedup.remake.rig.patch} pour accéder à
* {@code checkAndRecordSpam}, {@code LAST_SYNC_PLAY_BY_ENTITY} et
* {@code SPAM_GUARD_MS} (tous package-private — intentionnel, pas API publique).</p>
*/
class LivingEntityPatchSpamGuardTest {
/** Reset la map statique entre tests pour éviter la pollution inter-tests. */
@BeforeEach
void clearSpamMap() {
LivingEntityPatch.LAST_SYNC_PLAY_BY_ENTITY.clear();
}
/**
* Premier appel pour une entity inconnue → pass. La map doit ensuite
* contenir l'entry.
*/
@Test
void firstCall_passes() {
Object entity = new Object();
boolean allowed = LivingEntityPatch.checkAndRecordSpam(entity, 1000L);
assertTrue(allowed, "Premier call doit passer (entry absente de la map).");
assertTrue(LivingEntityPatch.LAST_SYNC_PLAY_BY_ENTITY.containsKey(entity),
"Après un call passant, la map doit contenir l'entry.");
}
/**
* Deux calls consécutifs dans la fenêtre 400ms → 2e call droppé.
* Le timestamp recordé reste celui du 1er call (pas overwrite par le 2e).
*/
@Test
void secondCall_withinWindow_dropped() {
Object entity = new Object();
// t=1000 : pass
boolean first = LivingEntityPatch.checkAndRecordSpam(entity, 1000L);
// t=1100 (100ms plus tard, < 400ms) : drop
boolean second = LivingEntityPatch.checkAndRecordSpam(entity, 1100L);
assertTrue(first, "1er call pass.");
assertFalse(second, "2e call dans fenêtre 400ms doit être droppé.");
// Sanity : le timestamp n'a PAS été overwrite par le call droppé.
// (si le spam-dropped call overwriteait, on pourrait spam indéfiniment
// sans jamais sortir de la fenêtre → bug).
Long recorded = LivingEntityPatch.LAST_SYNC_PLAY_BY_ENTITY.get(entity);
assertTrue(recorded != null && recorded == 1000L,
"Timestamp dans la map doit rester 1000 (celui du 1er call passant).");
}
/**
* Deux calls séparés par &gt;= 400ms → 2e call pass (fenêtre expirée).
* Borne exacte : SPAM_GUARD_MS = 400ms, donc 400ms exacts doit passer.
*/
@Test
void secondCall_outsideWindow_passes() {
Object entity = new Object();
boolean first = LivingEntityPatch.checkAndRecordSpam(entity, 1000L);
// t=1400 (exactement 400ms plus tard → !(diff < 400) donc pass).
boolean second = LivingEntityPatch.checkAndRecordSpam(entity, 1400L);
assertTrue(first, "1er call pass.");
assertTrue(second, "2e call à 400ms exacts doit passer (borne inclusive).");
// Le timestamp est update au dernier pass.
Long recorded = LivingEntityPatch.LAST_SYNC_PLAY_BY_ENTITY.get(entity);
assertTrue(recorded != null && recorded == 1400L,
"Timestamp update au dernier call passant (1400ms).");
}
/**
* Deux entities distinctes : pas de cross-contamination. Un call sur A
* ne doit pas bloquer un call sur B même au même timestamp.
*/
@Test
void differentEntities_independentGuards() {
Object a = new Object();
Object b = new Object();
boolean aFirst = LivingEntityPatch.checkAndRecordSpam(a, 1000L);
boolean bFirst = LivingEntityPatch.checkAndRecordSpam(b, 1000L);
assertTrue(aFirst, "A premier call pass.");
assertTrue(bFirst, "B premier call pass (indépendant de A).");
// Même window, entity B doit être droppé sur son propre 2e call mais
// pas sur A.
boolean bSecond = LivingEntityPatch.checkAndRecordSpam(b, 1100L);
boolean aAgain = LivingEntityPatch.checkAndRecordSpam(a, 2000L); // bien après window
assertFalse(bSecond, "B 2e call dans window doit drop (propre entry).");
assertTrue(aAgain, "A call après window pass (propre entry indépendante).");
}
/**
* Sanity constante : {@code SPAM_GUARD_MS} = 400ms. Si quelqu'un modifie
* cette valeur dans le code prod, ce test fail explicitement — force
* à relire les call sites (hit/slap master-click-spam tuning).
*/
@Test
void spamGuardMs_isFourHundred() {
assertTrue(LivingEntityPatch.SPAM_GUARD_MS == 400L,
"SPAM_GUARD_MS doit rester 400ms (tuning master-click-spam). "
+ "Changer cette valeur impacte le feeling game — à reviewer.");
}
}