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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (< 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 >= 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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user