diff --git a/src/main/java/com/tiedup/remake/network/ModNetwork.java b/src/main/java/com/tiedup/remake/network/ModNetwork.java
index 49db8ae..dc13654 100644
--- a/src/main/java/com/tiedup/remake/network/ModNetwork.java
+++ b/src/main/java/com/tiedup/remake/network/ModNetwork.java
@@ -64,6 +64,7 @@ import com.tiedup.remake.network.sync.PacketSyncPetBedState;
import com.tiedup.remake.network.sync.PacketSyncStruggleState;
import com.tiedup.remake.network.trader.PacketBuyCaptive;
import com.tiedup.remake.network.trader.PacketOpenTraderScreen;
+import com.tiedup.remake.rig.network.PacketPlayRigAnim;
import com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment;
import com.tiedup.remake.v2.bondage.network.PacketV2LockToggle;
import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip;
@@ -592,6 +593,14 @@ public class ModNetwork {
PacketSyncMovementStyle::handle
);
+ // RIG animation system (S2C cinematic one-shot)
+ reg(
+ PacketPlayRigAnim.class,
+ PacketPlayRigAnim::encode,
+ PacketPlayRigAnim::decode,
+ PacketPlayRigAnim::handleOnClient
+ );
+
TiedUpMod.LOGGER.info("Registered {} network packets", packetId);
}
diff --git a/src/main/java/com/tiedup/remake/rig/network/PacketPlayRigAnim.java b/src/main/java/com/tiedup/remake/rig/network/PacketPlayRigAnim.java
new file mode 100644
index 0000000..ae7f65c
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/network/PacketPlayRigAnim.java
@@ -0,0 +1,85 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.network;
+
+import java.util.function.Supplier;
+
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraftforge.network.NetworkEvent;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.client.Layer;
+
+/**
+ * Packet S→C : déclenche une animation one-shot sur une entité distante.
+ *
+ *
Utilisé pour les animations cinematic serveur-authoritatives (NPC capture
+ * grab, hit stun, death). Le client résout l'entité par ID, récupère son
+ * {@code LivingEntityPatch} via {@code TiedUpCapabilities}, et appelle
+ * {@code animator.playAnimation(anim, transitionTime)} avec la priorité donnée.
+ *
+ *
Le server-side dispatch (méthode {@code LivingEntityPatch.playAnimationSync})
+ * sera implémenté en P3-12. Ici on pose juste l'infra packet + handler stub
+ * qui log et termine.
+ *
+ * @param entityId ID runtime de l'entité ciblée (Entity.getId())
+ * @param animId ResourceLocation de l'animation à jouer
+ * @param transitionTime durée de blend en secondes (typiquement 0.15F = 3 ticks)
+ * @param priorityOrdinal ordinal de Layer.Priority (LOWEST=0...HIGHEST=4),
+ * transmis sous forme byte pour compacité
+ */
+public record PacketPlayRigAnim(
+ int entityId,
+ ResourceLocation animId,
+ float transitionTime,
+ byte priorityOrdinal
+) {
+ public static final int PRIORITY_COUNT = Layer.Priority.values().length;
+
+ /** Factory avec Layer.Priority (plus expressif qu'un byte direct). */
+ public static PacketPlayRigAnim of(int entityId, ResourceLocation animId, float transitionTime, Layer.Priority priority) {
+ return new PacketPlayRigAnim(entityId, animId, transitionTime, (byte) priority.ordinal());
+ }
+
+ public Layer.Priority priority() {
+ // Guard out-of-range si un peer malveillant envoie un byte invalide
+ int idx = priorityOrdinal & 0xFF;
+ if (idx < 0 || idx >= PRIORITY_COUNT) {
+ return Layer.Priority.LOWEST; // fallback safe
+ }
+ return Layer.Priority.values()[idx];
+ }
+
+ public void encode(FriendlyByteBuf buf) {
+ buf.writeVarInt(this.entityId);
+ buf.writeResourceLocation(this.animId);
+ buf.writeFloat(this.transitionTime);
+ buf.writeByte(this.priorityOrdinal);
+ }
+
+ public static PacketPlayRigAnim decode(FriendlyByteBuf buf) {
+ int entityId = buf.readVarInt();
+ ResourceLocation animId = buf.readResourceLocation();
+ float transitionTime = buf.readFloat();
+ byte priorityOrdinal = buf.readByte();
+ return new PacketPlayRigAnim(entityId, animId, transitionTime, priorityOrdinal);
+ }
+
+ /**
+ * Handler client — stub P3-11. Le corps complet (resolve entity, lookup
+ * patch, playAnimation) viendra en P3-12.
+ */
+ public static void handleOnClient(PacketPlayRigAnim pkt, Supplier ctx) {
+ ctx.get().enqueueWork(() -> {
+ TiedUpRigConstants.LOGGER.debug(
+ "[PacketPlayRigAnim] received (stub P3-11): entityId={}, animId={}, transition={}s, priority={}",
+ pkt.entityId, pkt.animId, pkt.transitionTime, pkt.priority()
+ );
+ // TODO P3-12 : resolve entity + patch + animator.playAnimation
+ });
+ ctx.get().setPacketHandled(true);
+ }
+}
diff --git a/src/test/java/com/tiedup/remake/rig/network/PacketPlayRigAnimTest.java b/src/test/java/com/tiedup/remake/rig/network/PacketPlayRigAnimTest.java
new file mode 100644
index 0000000..dbd6a03
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/rig/network/PacketPlayRigAnimTest.java
@@ -0,0 +1,148 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.network;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import io.netty.buffer.Unpooled;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+
+import org.junit.jupiter.api.Test;
+
+import com.tiedup.remake.rig.anim.client.Layer;
+
+/**
+ * Tests de {@link PacketPlayRigAnim} : encode/decode roundtrip + priority
+ * fallback. Aucune dépendance MC bootstrap — on instancie juste
+ * {@code FriendlyByteBuf(Unpooled.buffer())} et on vérifie que les bytes
+ * écrits roundtrip correctement.
+ */
+class PacketPlayRigAnimTest {
+
+ private static FriendlyByteBuf newBuf() {
+ return new FriendlyByteBuf(Unpooled.buffer());
+ }
+
+ /** Roundtrip nominal : packet construit avec valeurs usuelles. */
+ @Test
+ void encode_decode_roundtrip_nominal() {
+ ResourceLocation anim = ResourceLocation.fromNamespaceAndPath("tiedup", "context_stand_idle");
+ PacketPlayRigAnim original = PacketPlayRigAnim.of(42, anim, 0.15F, Layer.Priority.MIDDLE);
+
+ FriendlyByteBuf buf = newBuf();
+ original.encode(buf);
+ PacketPlayRigAnim decoded = PacketPlayRigAnim.decode(buf);
+
+ assertEquals(original.entityId(), decoded.entityId(), "entityId roundtrip");
+ assertEquals(original.animId(), decoded.animId(), "animId roundtrip");
+ assertEquals(original.transitionTime(), decoded.transitionTime(), 0.0F, "transitionTime roundtrip");
+ assertEquals(original.priorityOrdinal(), decoded.priorityOrdinal(), "priorityOrdinal roundtrip");
+ assertEquals(original, decoded, "record equals()");
+ assertEquals(0, buf.readableBytes(), "buffer must be fully consumed");
+ }
+
+ /** Boundary entity IDs : 0, Integer.MAX_VALUE, negatif. */
+ @Test
+ void encode_decode_entityId_boundary() {
+ ResourceLocation anim = ResourceLocation.fromNamespaceAndPath("tiedup", "test");
+
+ for (int entityId : new int[]{0, 1, Integer.MAX_VALUE, -1, Integer.MIN_VALUE}) {
+ PacketPlayRigAnim original = PacketPlayRigAnim.of(entityId, anim, 0.1F, Layer.Priority.LOW);
+ FriendlyByteBuf buf = newBuf();
+ original.encode(buf);
+ PacketPlayRigAnim decoded = PacketPlayRigAnim.decode(buf);
+
+ assertEquals(entityId, decoded.entityId(),
+ "entityId " + entityId + " must roundtrip via writeVarInt/readVarInt");
+ }
+ }
+
+ /**
+ * transitionTime=0.0F roundtrip (no blend = cut) + negative (-1.0F) roundtrip.
+ * Le packet ne doit PAS valider silencieusement — roundtrip brut.
+ */
+ @Test
+ void encode_decode_transitionTime_zero_and_negative() {
+ ResourceLocation anim = ResourceLocation.fromNamespaceAndPath("tiedup", "test");
+
+ for (float t : new float[]{0.0F, -1.0F, 99.9F, Float.MIN_VALUE, Float.MAX_VALUE}) {
+ PacketPlayRigAnim original = PacketPlayRigAnim.of(7, anim, t, Layer.Priority.LOWEST);
+ FriendlyByteBuf buf = newBuf();
+ original.encode(buf);
+ PacketPlayRigAnim decoded = PacketPlayRigAnim.decode(buf);
+
+ assertEquals(t, decoded.transitionTime(), 0.0F,
+ "transitionTime " + t + " must roundtrip verbatim (no silent validation)");
+ }
+ }
+
+ /** Loop sur les 5 Layer.Priority : of() + roundtrip, priority() retourne pareil. */
+ @Test
+ void encode_decode_allPriorities() {
+ ResourceLocation anim = ResourceLocation.fromNamespaceAndPath("tiedup", "test");
+
+ for (Layer.Priority p : Layer.Priority.values()) {
+ PacketPlayRigAnim original = PacketPlayRigAnim.of(1, anim, 0.15F, p);
+ FriendlyByteBuf buf = newBuf();
+ original.encode(buf);
+ PacketPlayRigAnim decoded = PacketPlayRigAnim.decode(buf);
+
+ assertEquals(p, decoded.priority(),
+ "priority " + p + " must roundtrip and priority() must return same enum");
+ assertEquals((byte) p.ordinal(), decoded.priorityOrdinal(),
+ "priorityOrdinal must match enum.ordinal()");
+ }
+ }
+
+ /**
+ * priorityOrdinal hors-range (99) : priority() doit retourner LOWEST (fallback safe),
+ * PAS throw. Simule un peer malveillant ou un mismatch de protocole.
+ */
+ @Test
+ void priority_outOfRange_returnsLowestFallback() {
+ ResourceLocation anim = ResourceLocation.fromNamespaceAndPath("tiedup", "test");
+
+ // Construit directement avec ordinal invalide (bypass of())
+ PacketPlayRigAnim malformed = new PacketPlayRigAnim(1, anim, 0.1F, (byte) 99);
+ assertEquals(Layer.Priority.LOWEST, malformed.priority(),
+ "priorityOrdinal=99 doit fallback sur LOWEST (pas de throw)");
+
+ // Idem pour ordinal négatif signé (qui devient 128+ via & 0xFF)
+ PacketPlayRigAnim negative = new PacketPlayRigAnim(1, anim, 0.1F, (byte) -1);
+ assertEquals(Layer.Priority.LOWEST, negative.priority(),
+ "priorityOrdinal=-1 (byte) doit fallback sur LOWEST (& 0xFF → 255, hors range)");
+
+ // Sanity : ordinal exactement PRIORITY_COUNT (5) est hors-range
+ PacketPlayRigAnim boundary = new PacketPlayRigAnim(1, anim, 0.1F, (byte) PacketPlayRigAnim.PRIORITY_COUNT);
+ assertEquals(Layer.Priority.LOWEST, boundary.priority(),
+ "priorityOrdinal=PRIORITY_COUNT doit fallback sur LOWEST");
+ }
+
+ /** animId avec namespace + path contenant underscores et slashes. */
+ @Test
+ void encode_decode_animId_namespaceSpecialChars() {
+ ResourceLocation anim = ResourceLocation.fromNamespaceAndPath("my_mod", "deep/path/with_underscores");
+ PacketPlayRigAnim original = PacketPlayRigAnim.of(123, anim, 0.2F, Layer.Priority.HIGH);
+
+ FriendlyByteBuf buf = newBuf();
+ original.encode(buf);
+ PacketPlayRigAnim decoded = PacketPlayRigAnim.decode(buf);
+
+ assertEquals(anim, decoded.animId(), "ResourceLocation complexe doit roundtrip verbatim");
+ assertEquals("my_mod", decoded.animId().getNamespace());
+ assertEquals("deep/path/with_underscores", decoded.animId().getPath());
+ }
+
+ /** Sanity check : deux packets distincts ne sont pas equal. */
+ @Test
+ void records_notEqual_whenDifferent() {
+ ResourceLocation anim = ResourceLocation.fromNamespaceAndPath("tiedup", "a");
+ PacketPlayRigAnim a = PacketPlayRigAnim.of(1, anim, 0.15F, Layer.Priority.LOWEST);
+ PacketPlayRigAnim b = PacketPlayRigAnim.of(2, anim, 0.15F, Layer.Priority.LOWEST);
+ assertNotEquals(a, b, "packets avec entityId différents ne doivent pas être equal");
+ }
+}