diff --git a/src/main/java/com/tiedup/remake/rig/network/PacketPlayRigAnim.java b/src/main/java/com/tiedup/remake/rig/network/PacketPlayRigAnim.java index 5ce1570..61b7932 100644 --- a/src/main/java/com/tiedup/remake/rig/network/PacketPlayRigAnim.java +++ b/src/main/java/com/tiedup/remake/rig/network/PacketPlayRigAnim.java @@ -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. touche net.minecraftforge.common.capabilities +// qui cascade sur Registries. → requiert MC bootstrap. Les tests unitaires +// injectent un patch-lookup fake via l'overload resolveAndPlay(..., Function>) 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}). + * + *

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).

+ */ + @OnlyIn(Dist.CLIENT) + private static final Function> 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). + * + *

Null-cascade guards — tous non-fatals, log debug + return :

+ *
    + *
  1. {@code level} null → client pas encore en partie (loading screen, + * packet arrivé pendant transition dimension)
  2. + *
  3. {@code level.getEntity(id)} null ou non-LivingEntity → entité hors + * range du client (out-of-chunk-tracking) ou malformed packet
  4. + *
  5. patch null → capability pas attachée (NPC sans rig, init race)
  6. + *
  7. animator null → postInit pas encore exécuté (ne devrait pas arriver + * car onConstructed initialise eager, mais paranoia)
  8. + *
+ * + *

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.

+ * + *

Package-private pour tests unitaires.

+ * + * @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> 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 anim = + TiedUpAnimationRegistry.resolveWithFallback(animId); + animator.playAnimation(anim, transitionTime); + + TiedUpRigConstants.LOGGER.debug( + "[PacketPlayRigAnim] played anim={} on entity={} (transition={}s)", + animId, entityId, transitionTime + ); + } } diff --git a/src/main/java/com/tiedup/remake/rig/patch/LivingEntityPatch.java b/src/main/java/com/tiedup/remake/rig/patch/LivingEntityPatch.java index 6f7a8f3..4e1c9d4 100644 --- a/src/main/java/com/tiedup/remake/rig/patch/LivingEntityPatch.java +++ b/src/main/java/com/tiedup/remake/rig/patch/LivingEntityPatch.java @@ -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 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). + * + *

Cleanup : {@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.

+ * + *

{@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.

+ */ + /** + * 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 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. + * + *

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}). + * Side-effect : enregistre {@code nowMillis} dans la map si le call passe.

+ * + *

Why not atomic compute ? {@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é.

+ * + * @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}. + * + *

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.

+ * + *

Anti-spam guard : 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.

+ * + *

Server-side play : 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.

+ * + *

Priority non-forwardée : 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.

+ * + * @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 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 + ); + } } diff --git a/src/test/java/com/tiedup/remake/rig/network/PacketPlayRigAnimHandlerTest.java b/src/test/java/com/tiedup/remake/rig/network/PacketPlayRigAnimHandlerTest.java new file mode 100644 index 0000000..ccdffbe --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/network/PacketPlayRigAnimHandlerTest.java @@ -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}. + * + *

L'overload {@code resolveAndPlay(level, id, animId, transition, patchLookup)} + * accepte un {@code Function>} 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 } de {@code TiedUpCapabilities} (qui + * cascade sur {@code Registries.} → requiert MC bootstrap).

+ * + *

Scope unit test : 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.

+ * + *

Gameday QA couvre les branches restantes :

+ *
    + *
  • entity absent → spam packets avec entityId inexistant (dev command), + * vérifier absence de crash + DEBUG log "not a LivingEntity"
  • + *
  • patch null → tester sur vanilla mob (zombie) — vérifier absence de + * crash + DEBUG log "no LivingEntityPatch"
  • + *
  • happy path → {@code PacketPlayRigAnim.of(entityId, anim, 0.15F, MIDDLE)} + * via dev command, vérifier que l'anim joue sur le NPC ciblé
  • + *
+ */ +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> 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é. + * + *

Cas fréquent en prod :

+ *
    + *
  • Packet reçu pendant un {@code /execute in} dim change
  • + *
  • Respawn : level refqueued pendant quelques ticks
  • + *
  • Client dé-co pendant qu'un packet cinematic est en transit
  • + *
+ */ + @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) + ); + } +} diff --git a/src/test/java/com/tiedup/remake/rig/patch/LivingEntityPatchSpamGuardTest.java b/src/test/java/com/tiedup/remake/rig/patch/LivingEntityPatchSpamGuardTest.java new file mode 100644 index 0000000..24813bb --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/patch/LivingEntityPatchSpamGuardTest.java @@ -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. + * + *

Ce que ces tests verrouillent :

+ *
    + *
  • Premier call pour une entity → pass (pas d'entry dans la map).
  • + *
  • Second call immédiat (< 400ms) → drop.
  • + *
  • Second call après 400ms+ → pass (window expirée).
  • + *
  • Deux entities différentes → pas de cross-contamination (un call sur + * entity A n'impacte pas l'entity B).
  • + *
+ * + *

Pas de bootstrap MC : la map est typée {@code Map} + * (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.

+ * + *

Package-private access : 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).

+ */ +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 >= 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."); + } +}