diff --git a/src/main/java/com/tiedup/remake/rig/anim/client/property/ClientAnimationProperties.java b/src/main/java/com/tiedup/remake/rig/anim/client/property/ClientAnimationProperties.java index 373ab00..aedf727 100644 --- a/src/main/java/com/tiedup/remake/rig/anim/client/property/ClientAnimationProperties.java +++ b/src/main/java/com/tiedup/remake/rig/anim/client/property/ClientAnimationProperties.java @@ -8,6 +8,8 @@ package com.tiedup.remake.rig.anim.client.property; import java.util.List; +import com.mojang.serialization.Codec; + import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty; import com.tiedup.remake.rig.anim.types.DirectStaticAnimation; import com.tiedup.remake.rig.anim.client.AnimationSubFileReader; @@ -16,36 +18,139 @@ import com.tiedup.remake.rig.anim.client.Layer; public class ClientAnimationProperties { /** * Layer type. (BASE: Living, attack animations, COMPOSITE: Aiming, weapon holding, digging animation) + * + *
Artist-freedom Wave A : serializable via a {@link Codec} mapping to + * the {@link Layer.LayerType} enum constant name (case-insensitive + * tolerance — accepts {@code "base_layer"} or {@code "BASE_LAYER"}). This + * allows animation JSON authors to override the property through the + * top-level {@code "properties"} block: + *
{@code
+ * "properties": {
+ * "layer_type": "COMPOSITE_LAYER"
+ * }
+ * }
+ *
+ * Upstream Epic Fight deserializes this field exclusively through
+ * {@link AnimationSubFileReader#deserializeLayerInfo} from a {@code
+ * "layer"} key in a sub-file. We keep that legacy path working and add
+ * this {@code name+Codec} registration as an additional access route
+ * (the two surface names — {@code layer_type} here, {@code layer} in the
+ * sub-file — do not conflict because they live in different JSON blocks).
*/
- public static final StaticAnimationProperty Artist-freedom Wave A : serializable via a {@link Codec} mapping to
+ * the {@link Layer.Priority} enum constant name. Accepted values are
+ * {@code "LOWEST"}, {@code "LOW"}, {@code "MIDDLE"}, {@code "HIGH"},
+ * {@code "HIGHEST"} (case-insensitive).
*/
- public static final StaticAnimationProperty Artist-freedom Wave A : serializable via a {@link Codec} that encodes
+ * a {@link JointMaskEntry} as a single {@link net.minecraft.resources.ResourceLocation}
+ * string (namespaced path of a joint-mask JSON file in
+ * {@code animmodels/joint_mask/...}). At parse time the string is resolved
+ * through {@link JointMaskReloadListener#getJointMaskEntry(String)} into a
+ * {@link JointMask.JointMaskSet}, then wrapped into a {@link JointMaskEntry}
+ * with that set as the default mask and no per-motion overrides.
+ *
+ * Round-trip encoding returns {@code "tiedup:none"} when the entry is
+ * unnamed or reference-equal to the vanilla {@code none} fallback. This is
+ * a best-effort lossy encode — a {@link JointMaskEntry} with per-motion
+ * overrides cannot be reduced to a single joint-mask ID without structural
+ * loss, and round-tripping that through the Codec is explicitly NOT
+ * supported (the richer upstream sub-file format in
+ * {@link AnimationSubFileReader} remains the authoring path for those).
+ *
+ * Intent : the {@code properties} JSON block is the
+ * «simple» override path that 99% of artists use — a single
+ * default mask is the only practical shape to serialize there. For
+ * per-motion mask entries the artist still writes a {@code *.data.json}
+ * sub-file alongside the anim.
+ *
+ * Unknown joint-mask IDs resolve to the {@code tiedup:none} fallback
+ * entry (see {@link JointMaskReloadListener#getNoneMask()}) — no crash
+ * but a WARN log is emitted at deserialization.
*/
- public static final StaticAnimationProperty Package-private for direct unit-test coverage — the Codec re-wires
+ * this through {@link Codec#xmap} but the test can exercise the pure
+ * lookup logic without bootstrap.
+ */
+ static JointMaskEntry decodeJointMaskEntry(String id) {
+ JointMask.JointMaskSet set = JointMaskReloadListener.getJointMaskEntry(id);
+ return JointMaskEntry.builder().defaultMask(set).create();
+ }
+
+ /**
+ * Encode a {@link JointMaskEntry} by looking up its default-mask ID in the
+ * {@link JointMaskReloadListener} reverse bimap. Falls back to
+ * {@code "tiedup:none"} when the mask is unregistered (either never
+ * reloaded or built programmatically with a custom set).
+ *
+ * Per-motion override masks inside the entry are intentionally NOT
+ * serialized here — see class-level Javadoc on {@link #JOINT_MASK} for
+ * the rationale.
+ */
+ static String encodeJointMaskEntry(JointMaskEntry entry) {
+ if (entry == null || entry.getDefaultMask() == null) {
+ return "tiedup:none";
+ }
+ net.minecraft.resources.ResourceLocation key =
+ JointMaskReloadListener.getKey(entry.getDefaultMask());
+ return key != null ? key.toString() : "tiedup:none";
+ }
}
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
index 4b06345..362f80f 100644
--- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java
@@ -22,34 +22,86 @@ import com.tiedup.remake.rig.anim.LivingMotion;
* builder la map {@code livingAnimations} de l'animator quand un item
* bondage est equipe.
*
+ * Wave A extensions (2026-04-23) :
+ * Parse via {@code DataDrivenItemParser} (P3-03) depuis JSON :
* Keeping this overload avoids a mechanical cascade of updates to
+ * ~20 tests that build {@code new AnimationBindings(motions, onEquip,
+ * onUnequip)} — the Wave A fields are purely additive. {@link SoundEvents#ARMOR_EQUIP_LEATHER} a été choisi pour sa
* sonorité souple (cuir) — adapté aux cuffs / harnesses / gags majoritaires.
@@ -172,6 +172,38 @@ public final class ClientRigEquipmentHandler {
return SoundEvents.ARMOR_EQUIP_LEATHER;
}
+ /**
+ * Pure helper — pick the effective sound ID to play for an equip / unequip
+ * event, given the item's {@link AnimationBindings}. Extracted from
+ * {@link #playEquipSound} so it can be unit-tested without
+ * {@code SoundEvents} clinit (no MC bootstrap).
+ *
+ * Resolution rules : Package-private, static, pure — testable. MVP : son {@link #defaultEquipSound} pour tous les items. Le son
- * n'a plus besoin du stack ({@code ItemStack}) en entrée depuis le switch
- * key-based : le callsite unequip ne possède plus le stack (disparu), et
- * le son vanilla choisi ne varie pas per-item. Future évolution : lire un
- * champ {@code equip_sound} dans {@link DataDrivenItemDefinition} via
- * l'itemId + registry lookup, sans besoin du stack concret. Wave A (2026-04-23) : le son effectif est résolu via
+ * {@link #pickSoundId} en fonction des {@link AnimationBindings} de l'item
+ * (id provient du snapshot key-based {@link #LAST_EQUIPPED_KEYS}) — un
+ * JSON peut override per-item via le champ {@code equip_sound} /
+ * {@code unequip_sound}. Si l'id est null (pas déclaré) ou si le sound
+ * event n'est pas dans le registry Forge, on fallback sur
+ * {@link #defaultEquipSound} (vanilla {@code ARMOR_EQUIP_LEATHER}). Non testable unit (nécessite {@link Player} live + level) — skip
- * dans la suite de tests. Non testable unit (nécessite {@link Player} live + level). La logique
+ * pure de sélection (inclus fallback unequip→equip) est testée via
+ * {@link #pickSoundId}. Internal — requires {@code SoundEvents. Objectif : garantir que l'artist peut désormais override ces fields
+ * depuis le bloc {@code "properties"} d'un JSON anim sans crash
+ * ({@code IllegalStateException "No property key named ..."} pre-Wave A).
+ *
+ * Aucun bootstrap MC — tous les enums / codecs utilisés ici sont
+ * pur Java (mojang-serialization + Layer inner enums). Le lookup
+ * {@code JointMaskReloadListener} opère sur un BiMap static qui est vide
+ * dans un test unitaire (jamais reload), mais les helpers ont des fallbacks
+ * explicites qui évitent tout NPE.
+ */
+class ClientAnimationPropertiesCodecTest {
+
+ /**
+ * Force le class-loading de {@link ClientAnimationProperties} AVANT que
+ * le premier test accede au Map via
+ * {@link AnimationProperty#getSerializableProperty(String)}.
+ *
+ * Sans ce hook, l'ordre alphabetique d'execution des tests JUnit
+ * peut appeler {@code getSerializableProperty("layer_type")} avant
+ * qu'un test ait reference statiquement une property de
+ * {@link ClientAnimationProperties} — et donc avant que le clinit
+ * n'ait peuple le registry. Le fail est :
+ * {@code IllegalStateException "No property key named layer_type"}.
+ *
+ * La reference {@code ClientAnimationProperties.LAYER_TYPE} ici
+ * suffit a trigger le clinit de la classe (JLS 12.4.1).
+ */
+ @BeforeAll
+ static void forceClinit() {
+ // Une simple lecture d'un field static suffit à déclencher le clinit.
+ // On utilise assertNotNull pour éviter une warning "dead code".
+ assertNotNull(ClientAnimationProperties.LAYER_TYPE);
+ assertNotNull(ClientAnimationProperties.PRIORITY);
+ assertNotNull(ClientAnimationProperties.JOINT_MASK);
+ }
+
+ // ===== LAYER_TYPE =====
+
+ /** {@code AnimationProperty.getSerializableProperty("layer_type")} ne doit plus throw. */
+ @Test
+ void layerType_registeredUnderName() {
+ AnimationProperty> prop =
+ AnimationProperty.getSerializableProperty("layer_type");
+ assertNotNull(prop, "LAYER_TYPE doit etre exposee via getSerializableProperty");
+ assertEquals(ClientAnimationProperties.LAYER_TYPE, prop);
+ }
+
+ /** Round-trip : encode LAYER_TYPE.COMPOSITE_LAYER en JSON puis re-decode. */
+ @Test
+ void layerType_serializable_roundtrip() {
+ Codec> TRAIL_EFFECT = new StaticAnimationProperty
> ();
-
+
/**
* An animation clip being played in first person.
*/
public static final StaticAnimationProperty
+ *
+ *
* {@code
* "animations": {
* "living_motions": { "IDLE": "mymod:arms_cuffed_idle", ... },
* "on_equip": "mymod:cuffs_equip_oneshot",
- * "on_unequip": "mymod:cuffs_unequip_oneshot"
+ * "on_unequip": "mymod:cuffs_unequip_oneshot",
+ * "equip_sound": "minecraft:item.armor.equip_iron",
+ * "unequip_sound": "minecraft:item.armor.equip_chain",
+ * "restraint_level": 3,
+ * "tooltip_override": "A heavy iron armbinder."
* }
* }
*
- * @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)
+ * @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)
+ * @param equipSound optionnel sound event id joue a l'equipement (null = vanilla leather)
+ * @param unequipSound optionnel sound event id joue au desequipement (null = fallback vers equipSound, puis vanilla)
+ * @param restraintLevel optionnel severity tag {@code [1, 5]} pour consumers UX (HUD overlay). Null = pas declare. Hors plage : WARN au parse, valeur retenue telle quelle.
+ * @param tooltipOverride optionnel ligne de tooltip custom. Null = utiliser le tooltip par defaut.
*/
public record AnimationBindings(
Map
+ *
+ *
+ *
+ *
+ */
+ @Test
+ void jointMask_serializable_viaName() {
+ JointMaskEntry decoded = ClientAnimationProperties.decodeJointMaskEntry("tiedup:upper_body");
+ assertNotNull(decoded, "decodeJointMaskEntry ne doit jamais etre null");
+ // getDefaultMask() peut être non-null si le reloader a populé, ou
+ // null si on n'a jamais reload (cas unit test). Les deux sont
+ // légaux côté API — ne pas assert isValid() ici.
+ }
+
+ /**
+ * Encode est best-effort — pour un entry sans lookup bimap, on fall back
+ * sur le littéral {@code "tiedup:none"} plutôt que de throw.
+ */
+ @Test
+ void jointMask_encode_fallsBackToNone() {
+ JointMaskEntry unregisteredEntry = JointMaskEntry.builder()
+ .defaultMask(null) // défault mask null → encode fallback
+ .create();
+ String encoded = ClientAnimationProperties.encodeJointMaskEntry(unregisteredEntry);
+ assertEquals("tiedup:none", encoded,
+ "encode avec defaultMask null doit fallback sur 'tiedup:none'");
+ }
+
+ /** Encode avec entry null aussi fallback (defense contre NPE). */
+ @Test
+ void jointMask_encode_nullEntry_fallsBackToNone() {
+ assertEquals("tiedup:none",
+ ClientAnimationProperties.encodeJointMaskEntry(null));
+ }
+
+ // ===== Sanity bulk =====
+
+ /**
+ * Tous les 3 noms doivent être enregistrés distinctement — un typo dans
+ * la déclaration rendrait silencieusement la property non-serializable.
+ */
+ @Test
+ void allThreeWaveANames_registered() {
+ assertNotNull(AnimationProperty.getSerializableProperty("layer_type"));
+ assertNotNull(AnimationProperty.getSerializableProperty("priority"));
+ assertNotNull(AnimationProperty.getSerializableProperty("joint_mask"));
+
+ // Three distinct keys — guard contre un copy-paste accidentel
+ Object layer = AnimationProperty.getSerializableProperty("layer_type");
+ Object priority = AnimationProperty.getSerializableProperty("priority");
+ Object jointMask = AnimationProperty.getSerializableProperty("joint_mask");
+ assertFalse(layer == priority);
+ assertFalse(layer == jointMask);
+ assertFalse(priority == jointMask);
+ }
+}
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
index 9ff55c5..dc9a2a0 100644
--- a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java
+++ b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java
@@ -150,5 +150,90 @@ class AnimationBindingsTest {
assertTrue(AnimationBindings.EMPTY.livingMotions().isEmpty());
assertNull(AnimationBindings.EMPTY.onEquip());
assertNull(AnimationBindings.EMPTY.onUnequip());
+ // Wave A fields (extensions additives)
+ assertNull(AnimationBindings.EMPTY.equipSound());
+ assertNull(AnimationBindings.EMPTY.unequipSound());
+ assertNull(AnimationBindings.EMPTY.restraintLevel());
+ assertNull(AnimationBindings.EMPTY.tooltipOverride());
+ }
+
+ // ========== Wave A extensions (2026-04-23) ==========
+
+ /**
+ * Le constructeur 3-args legacy doit toujours fonctionner — aucun site
+ * d'appel existant ne se casse avec l'ajout de 4 champs.
+ */
+ @Test
+ void legacy3ArgCtor_stillWorks_nullsTheWaveAFields() {
+ AnimationBindings b = new AnimationBindings(Map.of(), ANIM_EQUIP, ANIM_UNEQUIP);
+ assertEquals(ANIM_EQUIP, b.onEquip());
+ assertEquals(ANIM_UNEQUIP, b.onUnequip());
+ assertNull(b.equipSound());
+ assertNull(b.unequipSound());
+ assertNull(b.restraintLevel());
+ assertNull(b.tooltipOverride());
+ }
+
+ /** Le canonical 7-args ctor expose les Wave A fields. */
+ @Test
+ void canonicalCtor_allFields_exposed() {
+ ResourceLocation equipSound =
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_iron");
+ ResourceLocation unequipSound =
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_chain");
+
+ AnimationBindings b = new AnimationBindings(
+ Map.of(),
+ null, null,
+ equipSound, unequipSound,
+ /* restraintLevel */ 3,
+ /* tooltipOverride */ "A heavy iron armbinder"
+ );
+
+ assertEquals(equipSound, b.equipSound());
+ assertEquals(unequipSound, b.unequipSound());
+ assertEquals(3, b.restraintLevel());
+ assertEquals("A heavy iron armbinder", b.tooltipOverride());
+ }
+
+ /**
+ * {@link AnimationBindings#isEmpty()} doit considérer les 4 Wave A fields —
+ * un binding avec juste un {@code restraintLevel} défini n'est plus
+ * "vide", sinon le filter dans le handler skiperait à tort.
+ */
+ @Test
+ void isEmpty_falseIfAnyWaveAField() {
+ ResourceLocation sound =
+ ResourceLocation.fromNamespaceAndPath("minecraft", "entity.item.pickup");
+ AnimationBindings onlyEquipSound =
+ new AnimationBindings(Map.of(), null, null, sound, null, null, null);
+ AnimationBindings onlyUnequipSound =
+ new AnimationBindings(Map.of(), null, null, null, sound, null, null);
+ AnimationBindings onlyRestraintLevel =
+ new AnimationBindings(Map.of(), null, null, null, null, 4, null);
+ AnimationBindings onlyTooltip =
+ new AnimationBindings(Map.of(), null, null, null, null, null, "hi");
+
+ assertFalse(onlyEquipSound.isEmpty(),
+ "isEmpty doit etre false si equipSound defini");
+ assertFalse(onlyUnequipSound.isEmpty(),
+ "isEmpty doit etre false si unequipSound defini");
+ assertFalse(onlyRestraintLevel.isEmpty(),
+ "isEmpty doit etre false si restraintLevel defini");
+ assertFalse(onlyTooltip.isEmpty(),
+ "isEmpty doit etre false si tooltipOverride defini");
+ }
+
+ /**
+ * Le record doit accepter un {@code restraintLevel} hors-range sans
+ * crash (le parser logue un WARN, la classe record n'en sait rien).
+ */
+ @Test
+ void canonicalCtor_outOfRangeRestraintLevel_accepted() {
+ AnimationBindings b = new AnimationBindings(
+ Map.of(), null, null, null, null, /* restraintLevel */ 99, null
+ );
+ assertEquals(99, b.restraintLevel(),
+ "le record ne valide pas — c'est au parser d'emettre le WARN");
}
}
diff --git a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java
index d008eb4..8861468 100644
--- a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java
+++ b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java
@@ -580,4 +580,208 @@ class DataDrivenItemParserAnimationsTest {
// "IDEL" -> "IDLE" : 2 edits (swap E/L = substitution + substitution)
assertEquals(2, DataDrivenItemParser.levenshtein("IDEL", "IDLE"));
}
+
+ // ========== Wave A (2026-04-23) — equip_sound / unequip_sound ==========
+
+ @Test
+ void parseAnimations_withEquipSound_parses() {
+ String jsonStr = """
+ {
+ "animations": {
+ "equip_sound": "minecraft:item.armor.equip_iron"
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertEquals(
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_iron"),
+ result.equipSound()
+ );
+ assertNull(result.unequipSound(), "unequip_sound absent => null");
+ }
+
+ @Test
+ void parseAnimations_withBothSounds_parses() {
+ String jsonStr = """
+ {
+ "animations": {
+ "equip_sound": "minecraft:item.armor.equip_iron",
+ "unequip_sound": "minecraft:item.armor.equip_chain"
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertEquals(
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_iron"),
+ result.equipSound()
+ );
+ assertEquals(
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_chain"),
+ result.unequipSound()
+ );
+ }
+
+ @Test
+ void parseAnimations_equipSoundMalformed_skipsField() {
+ // "notanrl" n'a pas de namespace => rejete par tryParseAnimationRL,
+ // le field reste null, le parser ne crash pas.
+ String jsonStr = """
+ {
+ "animations": {
+ "equip_sound": "notanrl",
+ "unequip_sound": "minecraft:item.armor.equip_chain"
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertNull(result.equipSound(),
+ "equip_sound malforme => skip, pas crash");
+ assertEquals(
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_chain"),
+ result.unequipSound(),
+ "unequip_sound valide reste parse"
+ );
+ }
+
+ // ========== Wave A — restraint_level ==========
+
+ @Test
+ void parseAnimations_withRestraintLevel_parses() {
+ String jsonStr = """
+ {
+ "animations": {
+ "restraint_level": 3
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertEquals(Integer.valueOf(3), result.restraintLevel());
+ }
+
+ @Test
+ void parseAnimations_restraintLevelOutOfRange_keepsValueLogsWarn() {
+ // Level 10 est hors [1..5] : le parser logue un WARN mais garde la
+ // valeur telle quelle (pas de clamping silencieux — l'artist doit voir
+ // le warn et corriger).
+ String jsonStr = """
+ {
+ "animations": {
+ "restraint_level": 10
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertEquals(Integer.valueOf(10), result.restraintLevel(),
+ "Valeur hors-range gardee telle quelle (WARN emis au log, pas verifie ici)");
+ }
+
+ @Test
+ void parseAnimations_restraintLevelNonNumber_skipsField() {
+ String jsonStr = """
+ {
+ "animations": {
+ "restraint_level": "medium"
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertNull(result.restraintLevel(),
+ "Valeur non-number => skip (null), pas crash");
+ }
+
+ // ========== Wave A — tooltip_override ==========
+
+ @Test
+ void parseAnimations_withTooltipOverride_parses() {
+ String jsonStr = """
+ {
+ "animations": {
+ "tooltip_override": "A heavy iron armbinder."
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertEquals("A heavy iron armbinder.", result.tooltipOverride());
+ }
+
+ @Test
+ void parseAnimations_tooltipOverrideNonString_skipsField() {
+ String jsonStr = """
+ {
+ "animations": {
+ "tooltip_override": 42
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertNull(result.tooltipOverride(),
+ "Valeur non-string => skip (null), pas crash");
+ }
+
+ // ========== Wave A — combined fields ==========
+
+ @Test
+ void parseAnimations_allWaveAFields_parses() {
+ // Smoke test : tous les nouveaux champs sont parses simultanement sans
+ // interaction nefaste avec les champs pre-existants.
+ String jsonStr = """
+ {
+ "animations": {
+ "living_motions": {
+ "IDLE": "tiedup:arms_cuffed_idle"
+ },
+ "on_equip": "tiedup:cuffs_equip",
+ "on_unequip": "tiedup:cuffs_unequip",
+ "equip_sound": "minecraft:item.armor.equip_iron",
+ "unequip_sound": "minecraft:item.armor.equip_chain",
+ "restraint_level": 4,
+ "tooltip_override": "Reinforced iron cuffs."
+ }
+ }
+ """;
+
+ AnimationBindings result =
+ DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
+
+ assertNotNull(result);
+ assertFalse(result.livingMotions().isEmpty());
+ assertNotNull(result.onEquip());
+ assertNotNull(result.onUnequip());
+ assertNotNull(result.equipSound());
+ assertNotNull(result.unequipSound());
+ assertEquals(Integer.valueOf(4), result.restraintLevel());
+ assertEquals("Reinforced iron cuffs.", result.tooltipOverride());
+ }
}
diff --git a/src/test/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandlerTest.java b/src/test/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandlerTest.java
index 97503c3..94bae81 100644
--- a/src/test/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandlerTest.java
+++ b/src/test/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandlerTest.java
@@ -974,6 +974,84 @@ class ClientRigEquipmentHandlerTest {
"onEquip==null → pas de trigger");
}
+ // ========== Wave A (2026-04-23) — pickSoundId ==========
+
+ /** Bindings null → null (caller fallback vers vanilla leather). */
+ @Test
+ void pickSoundId_nullBindings_returnsNull() {
+ assertNull(ClientRigEquipmentHandler.pickSoundId(null, /* isEquip */ true));
+ assertNull(ClientRigEquipmentHandler.pickSoundId(null, /* isEquip */ false));
+ }
+
+ /** Equip avec equipSound défini → ce sound id. */
+ @Test
+ void pickSoundId_equipWithEquipSound_returnsEquipSound() {
+ ResourceLocation iron =
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_iron");
+ AnimationBindings bindings = new AnimationBindings(
+ Map.of(), null, null, iron, null, null, null
+ );
+
+ ResourceLocation result =
+ ClientRigEquipmentHandler.pickSoundId(bindings, /* isEquip */ true);
+ assertEquals(iron, result);
+ }
+
+ /** Equip sans equipSound → null (pas de fallback cross-direction pour equip). */
+ @Test
+ void pickSoundId_equipWithoutEquipSound_returnsNull() {
+ AnimationBindings bindings = new AnimationBindings(
+ Map.of(), null, null, null, null, null, null
+ );
+ assertNull(ClientRigEquipmentHandler.pickSoundId(bindings, /* isEquip */ true));
+ }
+
+ /** Unequip avec unequipSound défini → ce sound id. */
+ @Test
+ void pickSoundId_unequipWithUnequipSound_returnsUnequipSound() {
+ ResourceLocation chain =
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_chain");
+ AnimationBindings bindings = new AnimationBindings(
+ Map.of(), null, null, null, chain, null, null
+ );
+ assertEquals(chain,
+ ClientRigEquipmentHandler.pickSoundId(bindings, /* isEquip */ false));
+ }
+
+ /**
+ * Unequip sans unequipSound mais avec equipSound → fallback vers
+ * equipSound (symétrie : l'artist qui déclare uniquement equipSound veut
+ * probablement le même SFX dans les deux sens).
+ */
+ @Test
+ void pickSoundId_unequipFallsBackToEquipSound() {
+ ResourceLocation iron =
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_iron");
+ AnimationBindings bindings = new AnimationBindings(
+ Map.of(), null, null, iron, null, null, null
+ );
+ assertEquals(iron,
+ ClientRigEquipmentHandler.pickSoundId(bindings, /* isEquip */ false),
+ "unequip doit fallback sur equipSound par symetrie");
+ }
+
+ /**
+ * Unequip avec les deux sons définis : unequipSound gagne (prioritaire
+ * sur son propre slot).
+ */
+ @Test
+ void pickSoundId_unequipPrefersUnequipSoundOverEquip() {
+ ResourceLocation iron =
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_iron");
+ ResourceLocation chain =
+ ResourceLocation.fromNamespaceAndPath("minecraft", "item.armor.equip_chain");
+ AnimationBindings bindings = new AnimationBindings(
+ Map.of(), null, null, iron, chain, null, null
+ );
+ assertEquals(chain,
+ ClientRigEquipmentHandler.pickSoundId(bindings, /* isEquip */ false));
+ }
+
/**
* Happy path — bindings avec {@code onEquip} défini → le oneshotPlayer
* est appelé avec l'accessor résolu + la transition time canonique