From 5d108f51b4848756da3617355ab576c75c270c64 Mon Sep 17 00:00:00 2001 From: notevil Date: Thu, 23 Apr 2026 13:22:25 +0200 Subject: [PATCH] P3-02 : add AnimationBindings record + DataDrivenItemDefinition.animations field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New record AnimationBindings (v2/bondage/datadriven/) carries the JSON item -> rig animation bindings that P3-03 (parser) will populate and P3-05 (ClientRigEquipmentHandler.rebuildBondageAnimations) will consume to rebuild the player's livingAnimations map on equip/unequip. Fields : - livingMotions : Map (immutable, never null, Map.copyOf defensive copy in compact ctor, null input collapsed to emptyMap) - onEquip / onUnequip : optional nullable one-shot triggers - EMPTY constant + isEmpty() for the "no binding" fast path DataDrivenItemDefinition gains a nullable "animations" field (null = vanilla behavior, preserves backward compat for items without rig bindings). DataDrivenItemParser call-site updated to pass null for now — wiring happens in P3-03. Single call-site updated (parser only), no factories or tests constructed the record directly. 7 unit tests cover construction paths, null tolerance, defensive map copy, isEmpty semantics and the EMPTY constant. Full rig suite still 65 GREEN. --- .../bondage/datadriven/AnimationBindings.java | 55 +++++++ .../datadriven/DataDrivenItemDefinition.java | 14 ++ .../datadriven/DataDrivenItemParser.java | 3 + .../datadriven/AnimationBindingsTest.java | 154 ++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java create mode 100644 src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java new file mode 100644 index 0000000..4b06345 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java @@ -0,0 +1,55 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.v2.bondage.datadriven; + +import java.util.Collections; +import java.util.Map; + +import net.minecraft.resources.ResourceLocation; + +import org.jetbrains.annotations.Nullable; + +import com.tiedup.remake.rig.anim.LivingMotion; + +/** + * Bindings animation pour un item bondage data-driven. + * + *

Porte le mapping {@link LivingMotion} → {@link ResourceLocation} animation + * + eventuels one-shots {@code onEquip} / {@code onUnequip}. Consomme par + * {@code ClientRigEquipmentHandler.rebuildBondageAnimations} (P3-05) pour + * builder la map {@code livingAnimations} de l'animator quand un item + * bondage est equipe. + * + *

Parse via {@code DataDrivenItemParser} (P3-03) depuis JSON : + *

{@code
+ * "animations": {
+ *   "living_motions": { "IDLE": "mymod:arms_cuffed_idle", ... },
+ *   "on_equip": "mymod:cuffs_equip_oneshot",
+ *   "on_unequip": "mymod:cuffs_unequip_oneshot"
+ * }
+ * }
+ * + * @param livingMotions map immutable motion → anim ID (jamais null, peut etre vide) + * @param onEquip optionnel one-shot au moment de l'equipement (null = pas de trigger) + * @param onUnequip optionnel one-shot au moment du desequipement (null = pas de trigger) + */ +public record AnimationBindings( + Map livingMotions, + @Nullable ResourceLocation onEquip, + @Nullable ResourceLocation onUnequip +) { + /** Shortcut pour bindings totalement vides (equivalent no-op). */ + public static final AnimationBindings EMPTY = new AnimationBindings(Map.of(), null, null); + + /** Constructor canonique : force livingMotions immutable + non-null. */ + public AnimationBindings { + livingMotions = livingMotions == null ? Collections.emptyMap() : Map.copyOf(livingMotions); + } + + /** {@code true} si aucun binding n'est defini (ni living motions, ni one-shots). */ + public boolean isEmpty() { + return livingMotions.isEmpty() && onEquip == null && onUnequip == null; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java index 0656db4..d44af1b 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java @@ -105,6 +105,20 @@ public record DataDrivenItemDefinition( */ Map> animationBones, + /** + * Optional living-motion animation bindings (P3 rig system). + * + *

When non-null, the client rig equipment handler rebuilds the player's + * living animation map with these bindings whenever this item is equipped. + * When null, no animation bindings are applied (vanilla behavior).

+ * + *

Distinct from {@link #animationBones} : {@code animationBones} is a + * per-clip bone whitelist for the PlayerAnimator pipeline (V1-era), + * whereas {@code animations} drives the Epic Fight-style rig animator + * (P3-05).

+ */ + @Nullable AnimationBindings animations, + /** Raw component configs from JSON, keyed by ComponentType. */ Map componentConfigs ) { diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java index 2084750..426db2f 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java @@ -347,6 +347,9 @@ public final class DataDrivenItemParser { movementModifier, creator, animationBones, + // P3-02 : animations parsing will be wired in P3-03. For now pass + // null so existing items keep their vanilla animator behavior. + null, componentConfigs ); } diff --git a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java new file mode 100644 index 0000000..9ff55c5 --- /dev/null +++ b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java @@ -0,0 +1,154 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.v2.bondage.datadriven; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.Test; + +import com.tiedup.remake.rig.anim.LivingMotion; +import com.tiedup.remake.rig.anim.LivingMotions; + +/** + * Tests unitaires pour {@link AnimationBindings} : + *
    + *
  • construction normale et cas nullables (P3-02),
  • + *
  • comportement du compact ctor (null -> empty map),
  • + *
  • immutabilite defensive de {@code livingMotions},
  • + *
  • contrat de {@link AnimationBindings#isEmpty()},
  • + *
  • constante {@link AnimationBindings#EMPTY}.
  • + *
+ * + * Aucun MC runtime requis — {@link ResourceLocation} est utilisable hors + * MinecraftServer, et {@link LivingMotion} est un interface pur. + */ +class AnimationBindingsTest { + + private static final ResourceLocation ANIM_IDLE = + ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle"); + private static final ResourceLocation ANIM_WALK = + ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_walk"); + private static final ResourceLocation ANIM_EQUIP = + ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_equip"); + private static final ResourceLocation ANIM_UNEQUIP = + ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_unequip"); + + /** Construction nominale avec des donnees valides. */ + @Test + void construction_withValidData_succeeds() { + Map motions = Map.of( + LivingMotions.IDLE, ANIM_IDLE, + LivingMotions.WALK, ANIM_WALK + ); + + AnimationBindings bindings = new AnimationBindings(motions, ANIM_EQUIP, ANIM_UNEQUIP); + + assertEquals(2, bindings.livingMotions().size()); + assertEquals(ANIM_IDLE, bindings.livingMotions().get(LivingMotions.IDLE)); + assertEquals(ANIM_WALK, bindings.livingMotions().get(LivingMotions.WALK)); + assertEquals(ANIM_EQUIP, bindings.onEquip()); + assertEquals(ANIM_UNEQUIP, bindings.onUnequip()); + } + + /** + * Le compact ctor doit remplacer un {@code livingMotions} null par + * {@link java.util.Collections#emptyMap()} — jamais laisser le champ a null. + */ + @Test + void construction_withNullLivingMotions_usesEmptyMap() { + AnimationBindings bindings = new AnimationBindings(null, null, null); + + assertNotNull(bindings.livingMotions(), + "livingMotions ne doit jamais etre null (compact ctor garantit non-null)"); + assertTrue(bindings.livingMotions().isEmpty()); + } + + /** {@code onEquip} / {@code onUnequip} null sont legaux (optionnels). */ + @Test + void construction_withNullEquip_isTolerated() { + Map motions = Map.of(LivingMotions.IDLE, ANIM_IDLE); + + AnimationBindings bindings = new AnimationBindings(motions, null, null); + + assertEquals(1, bindings.livingMotions().size()); + assertNull(bindings.onEquip()); + assertNull(bindings.onUnequip()); + } + + /** + * {@link AnimationBindings#isEmpty()} renvoie {@code true} quand tous les + * champs sont vides/absents. + */ + @Test + void isEmpty_returnsTrueForFullyEmpty() { + AnimationBindings bindings = new AnimationBindings(Map.of(), null, null); + + assertTrue(bindings.isEmpty()); + } + + /** + * {@link AnimationBindings#isEmpty()} doit renvoyer {@code false} des qu'au + * moins un champ est present (motion OU one-shot equip OU one-shot unequip). + */ + @Test + void isEmpty_returnsFalseIfAnyField() { + AnimationBindings onlyMotion = + new AnimationBindings(Map.of(LivingMotions.IDLE, ANIM_IDLE), null, null); + AnimationBindings onlyEquip = + new AnimationBindings(Map.of(), ANIM_EQUIP, null); + AnimationBindings onlyUnequip = + new AnimationBindings(Map.of(), null, ANIM_UNEQUIP); + + assertFalse(onlyMotion.isEmpty(), "isEmpty doit etre false si livingMotions non vide"); + assertFalse(onlyEquip.isEmpty(), "isEmpty doit etre false si onEquip non null"); + assertFalse(onlyUnequip.isEmpty(), "isEmpty doit etre false si onUnequip non null"); + } + + /** + * Le compact ctor doit faire une copie defensive du Map passe — mutation de la + * source apres construction ne doit PAS affecter le record. + */ + @Test + void immutability_livingMotionsCopyIsDefensive() { + Map mutable = new HashMap<>(); + mutable.put(LivingMotions.IDLE, ANIM_IDLE); + + AnimationBindings bindings = new AnimationBindings(mutable, null, null); + + // Mutation de la map source apres construction + mutable.put(LivingMotions.WALK, ANIM_WALK); + mutable.remove(LivingMotions.IDLE); + + // Le record garde bien l'etat initial + assertEquals(1, bindings.livingMotions().size(), + "La map du record ne doit pas refleter les mutations de la source"); + assertEquals(ANIM_IDLE, bindings.livingMotions().get(LivingMotions.IDLE), + "La map du record doit toujours contenir l'entry initiale"); + + // Et la map retournee doit elle-meme etre immutable (Map.copyOf) + assertThrows(UnsupportedOperationException.class, + () -> bindings.livingMotions().put(LivingMotions.RUN, ANIM_WALK), + "livingMotions() doit retourner un Map immutable"); + } + + /** La constante {@link AnimationBindings#EMPTY} est bien vide. */ + @Test + void empty_constant_isEmpty() { + assertTrue(AnimationBindings.EMPTY.isEmpty()); + assertTrue(AnimationBindings.EMPTY.livingMotions().isEmpty()); + assertNull(AnimationBindings.EMPTY.onEquip()); + assertNull(AnimationBindings.EMPTY.onUnequip()); + } +}