From 76587c0393943aea8e33d2b7e1a1db733861a1a0 Mon Sep 17 00:00:00 2001 From: notevil Date: Fri, 24 Apr 2026 14:02:02 +0200 Subject: [PATCH] Wave A data-driven : JSON-serializable properties + equip_sound + metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlocks 3 artist-freedom quick wins : 1. ClientAnimationProperties.LAYER_TYPE / PRIORITY / JOINT_MASK now serializable via name + Codec. Modders can override these in their anim JSON 'properties' block (previously threw IllegalStateException). 2. AnimationBindings.equipSound / unequipSound — per-item JSON override for on_equip/on_unequip audio feedback. ClientRigEquipmentHandler resolves via ForgeRegistries.SOUND_EVENTS, falls back to vanilla ARMOR_EQUIP_LEATHER if unknown. 3. AnimationBindings.restraintLevel / tooltipOverride — metadata fields for UX consumers (HUD severity indicator, tooltip customization). Parsed but not yet rendered — available for Phase 4 features without schema change. All additive. Existing items without these fields work unchanged. --- .../property/ClientAnimationProperties.java | 125 +++++++++- .../bondage/datadriven/AnimationBindings.java | 68 +++++- .../datadriven/DataDrivenItemParser.java | 62 ++++- .../v2/client/ClientRigEquipmentHandler.java | 107 ++++++-- .../ClientAnimationPropertiesCodecTest.java | 229 ++++++++++++++++++ .../datadriven/AnimationBindingsTest.java | 85 +++++++ .../DataDrivenItemParserAnimationsTest.java | 204 ++++++++++++++++ .../client/ClientRigEquipmentHandlerTest.java | 78 ++++++ 8 files changed, 921 insertions(+), 37 deletions(-) create mode 100644 src/test/java/com/tiedup/remake/rig/anim/client/property/ClientAnimationPropertiesCodecTest.java 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 LAYER_TYPE = new StaticAnimationProperty (); - + public static final StaticAnimationProperty LAYER_TYPE = new StaticAnimationProperty( + "layer_type", + Codec.STRING.xmap( + s -> Layer.LayerType.valueOf(s.toUpperCase()), + Enum::name + ) + ); + /** * Priority of composite layer. + * + *

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 PRIORITY = new StaticAnimationProperty (); - + public static final StaticAnimationProperty PRIORITY = new StaticAnimationProperty( + "priority", + Codec.STRING.xmap( + s -> Layer.Priority.valueOf(s.toUpperCase()), + Enum::name + ) + ); + /** * Joint mask for composite layer. + * + *

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 JOINT_MASK = new StaticAnimationProperty (); - + public static final StaticAnimationProperty JOINT_MASK = new StaticAnimationProperty( + "joint_mask", + Codec.STRING.xmap( + ClientAnimationProperties::decodeJointMaskEntry, + ClientAnimationProperties::encodeJointMaskEntry + ) + ); + /** * Trail particle information */ public static final StaticAnimationProperty> TRAIL_EFFECT = new StaticAnimationProperty> (); - + /** * An animation clip being played in first person. */ public static final StaticAnimationProperty POV_ANIMATION = new StaticAnimationProperty (); - + /** * An animation clip being played in first person. */ public static final StaticAnimationProperty POV_SETTINGS = new StaticAnimationProperty (); - + /** - * Multilayer for living animations (e.g. Greatsword holding animation should be played simultaneously with jumping animation) + * Multilayer for living animations (e.g. Greatsword holding animation should be played simultaneously with jumping animation) */ public static final StaticAnimationProperty MULTILAYER_ANIMATION = new StaticAnimationProperty (); + + // === Wave A helpers : JOINT_MASK codec (package-private for unit testing) === + + /** + * Decode a joint-mask ID string into a {@link JointMaskEntry} whose default + * mask is the set registered under that ID. Unknown IDs fall back to the + * {@code tiedup:none} empty mask (already handled by + * {@link JointMaskReloadListener#getJointMaskEntry(String)}). + * + *

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 : *

{@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 livingMotions, @Nullable ResourceLocation onEquip, - @Nullable ResourceLocation onUnequip + @Nullable ResourceLocation onUnequip, + @Nullable ResourceLocation equipSound, + @Nullable ResourceLocation unequipSound, + @Nullable Integer restraintLevel, + @Nullable String tooltipOverride ) { /** Shortcut pour bindings totalement vides (equivalent no-op). */ - public static final AnimationBindings EMPTY = new AnimationBindings(Map.of(), null, null); + public static final AnimationBindings EMPTY = new AnimationBindings( + Map.of(), null, null, null, null, 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). */ + /** + * Legacy 3-arg constructor preserved for existing call-sites (tests, + * parser pre-Wave A, internal construction). Delegates to the canonical + * ctor with all Wave-A extension fields {@code null}. + * + *

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.

+ */ + public AnimationBindings( + Map livingMotions, + @Nullable ResourceLocation onEquip, + @Nullable ResourceLocation onUnequip + ) { + this(livingMotions, onEquip, onUnequip, null, null, null, null); + } + + /** {@code true} si aucun binding n'est defini (ni living motions, ni one-shots, ni metadata). */ public boolean isEmpty() { - return livingMotions.isEmpty() && onEquip == null && onUnequip == null; + return livingMotions.isEmpty() + && onEquip == null + && onUnequip == null + && equipSound == null + && unequipSound == null + && restraintLevel == null + && tooltipOverride == null; } } 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 bdf4595..2448ca8 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 @@ -746,7 +746,67 @@ public final class DataDrivenItemParser { ? tryParseAnimationRL(animBlock.get("on_unequip"), "on_unequip", itemId) : null; - return new AnimationBindings(motions, onEquip, onUnequip); + // Wave A (2026-04-23) : additive metadata fields, all optional. + // Missing / malformed values fall back to null without crashing. + ResourceLocation equipSound = animBlock.has("equip_sound") + ? tryParseAnimationRL(animBlock.get("equip_sound"), "equip_sound", itemId) + : null; + ResourceLocation unequipSound = animBlock.has("unequip_sound") + ? tryParseAnimationRL(animBlock.get("unequip_sound"), "unequip_sound", itemId) + : null; + + // restraint_level : optional integer severity indicator in [1, 5]. + // We tolerate out-of-range values (passed through as-is) but emit a + // WARN so the author notices the typo. Non-integer JSON types resolve + // to null (no throw). + Integer restraintLevel = null; + if (animBlock.has("restraint_level")) { + JsonElement lvlElem = animBlock.get("restraint_level"); + try { + if (lvlElem.isJsonPrimitive() && lvlElem.getAsJsonPrimitive().isNumber()) { + int lvl = lvlElem.getAsInt(); + if (lvl < 1 || lvl > 5) { + LOGGER.warn( + "[DataDrivenItemParser] restraint_level {} in item {} " + + "is outside the expected range [1, 5] — value kept as-is.", + lvl, itemId + ); + } + restraintLevel = lvl; + } else { + LOGGER.warn( + "[DataDrivenItemParser] restraint_level in item {} is not a " + + "number, skipping.", itemId + ); + } + } catch (Exception e) { + LOGGER.warn( + "[DataDrivenItemParser] restraint_level in item {} failed to parse " + + "as int ({}), skipping.", itemId, e.getMessage() + ); + } + } + + // tooltip_override : optional free-form string. Empty strings are + // accepted (author's choice to blank the tooltip). + String tooltipOverride = null; + if (animBlock.has("tooltip_override")) { + JsonElement tipElem = animBlock.get("tooltip_override"); + if (tipElem.isJsonPrimitive() && tipElem.getAsJsonPrimitive().isString()) { + tooltipOverride = tipElem.getAsString(); + } else { + LOGGER.warn( + "[DataDrivenItemParser] tooltip_override in item {} is not a " + + "string, skipping.", itemId + ); + } + } + + return new AnimationBindings( + motions, onEquip, onUnequip, + equipSound, unequipSound, + restraintLevel, tooltipOverride + ); } /** diff --git a/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java b/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java index 50741b1..cc34f7b 100644 --- a/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java +++ b/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java @@ -151,9 +151,9 @@ public final class ClientRigEquipmentHandler { private static final float ONESHOT_TRANSITION_TIME = 0.15F; /** - * Son vanilla par défaut pour on_equip / on_unequip. Placeholder MVP : - * future evolution = field {@code equip_sound} dans - * {@link DataDrivenItemDefinition} pour custom per-item sound event. + * Son vanilla par défaut pour on_equip / on_unequip. Fallback quand + * {@link AnimationBindings#equipSound()} / {@link AnimationBindings#unequipSound()} + * ne sont pas déclarés dans le JSON item. * *

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

+ *
    + *
  1. {@code bindings == null} → {@code null} (caller falls back to + * vanilla leather).
  2. + *
  3. For equip : return {@code bindings.equipSound()} as-is (may be + * {@code null}).
  4. + *
  5. For unequip : return {@code bindings.unequipSound()} if non-null, + * otherwise fall back to {@code bindings.equipSound()} — the + * rationale is symmetry: an artist who only declared + * {@code equip_sound} probably wants the same SFX both ways.
  6. + *
+ * + *

Package-private, static, pure — testable.

+ * + * @param bindings the item bindings, may be null + * @param isEquip true for equip direction, false for unequip + * @return the resolved sound ID, or null if the caller should use the + * vanilla default + */ + static ResourceLocation pickSoundId(@org.jetbrains.annotations.Nullable AnimationBindings bindings, boolean isEquip) { + if (bindings == null) return null; + if (isEquip) return bindings.equipSound(); + ResourceLocation unequip = bindings.unequipSound(); + return unequip != null ? unequip : bindings.equipSound(); + } + /** * Snapshot stable {@code (region → itemId)} — utilisé pour calculer les * diffs {@code newlyEquipped} / {@code newlyUnequipped} tick-to-tick afin @@ -350,7 +382,7 @@ public final class ClientRigEquipmentHandler { DataDrivenItemRegistry::get, TiedUpAnimationRegistry::resolveWithFallback ); - playEquipSound(player); + playEquipSound(player, itemId, /* isEquip */ false); } } @@ -406,7 +438,7 @@ public final class ClientRigEquipmentHandler { DataDrivenItemRegistry::get, TiedUpAnimationRegistry::resolveWithFallback ); - playEquipSound(player); + playEquipSound(player, itemId, /* isEquip */ true); } } @@ -571,30 +603,39 @@ public final class ClientRigEquipmentHandler { } /** - * Joue le son equip/unequip vanilla au voisinage du player. Client-only + * Joue le son equip/unequip au voisinage du player. Client-only * ({@link net.minecraft.world.level.Level#playLocalSound} n'émet aucun * paquet réseau). * - *

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

* - * @param player le player source du son (position utilisée pour la - * spatialisation 3D) + * @param player le player source du son (position utilisée pour la + * spatialisation 3D) + * @param itemId l'id de l'item source (via {@link DataDrivenItemRegistry}) + * ; null → fallback vanilla + * @param isEquip true si equip, false si unequip (impacte le fallback + * unequip→equip dans {@link #pickSoundId}) */ - private static void playEquipSound(Player player) { + private static void playEquipSound(Player player, ResourceLocation itemId, boolean isEquip) { if (player == null) return; + + SoundEvent sound = resolveSoundEvent(itemId, isEquip); + player.level().playLocalSound( player.getX(), player.getY(), player.getZ(), - defaultEquipSound(), + sound, SoundSource.PLAYERS, /* volume */ 1.0F, /* pitch */ 1.0F, @@ -602,6 +643,36 @@ public final class ClientRigEquipmentHandler { ); } + /** + * Resolve the effective {@link SoundEvent} for an item's equip/unequip + * action. Does the registry lookup + null-checks so that + * {@link #playEquipSound} stays straightforward. + * + *

Internal — requires {@code SoundEvents.} to have run (MC + * bootstrap). Pure selection logic is factored into + * {@link #pickSoundId} for test purposes.

+ */ + private static SoundEvent resolveSoundEvent(ResourceLocation itemId, boolean isEquip) { + if (itemId == null) return defaultEquipSound(); + + DataDrivenItemDefinition def = DataDrivenItemRegistry.get(itemId); + if (def == null) return defaultEquipSound(); + + ResourceLocation soundId = pickSoundId(def.animations(), isEquip); + if (soundId == null) return defaultEquipSound(); + + SoundEvent sound = net.minecraftforge.registries.ForgeRegistries.SOUND_EVENTS.getValue(soundId); + if (sound == null) { + TiedUpRigConstants.LOGGER.warn( + "[ClientRigEquipmentHandler] Unknown sound event '{}' declared by item {}, " + + "falling back to vanilla ARMOR_EQUIP_LEATHER.", + soundId, itemId + ); + return defaultEquipSound(); + } + return sound; + } + /** * Vide les caches de snapshots {@link #LAST_EQUIPPED_KEYS} et * {@link #FIRST_REBUILD_DONE}. Utilisé dans : diff --git a/src/test/java/com/tiedup/remake/rig/anim/client/property/ClientAnimationPropertiesCodecTest.java b/src/test/java/com/tiedup/remake/rig/anim/client/property/ClientAnimationPropertiesCodecTest.java new file mode 100644 index 0000000..46fee1f --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/anim/client/property/ClientAnimationPropertiesCodecTest.java @@ -0,0 +1,229 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.client.property; + +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.assertTrue; + +import com.google.gson.JsonPrimitive; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.tiedup.remake.rig.anim.client.Layer; +import com.tiedup.remake.rig.anim.property.AnimationProperty; + +/** + * Wave A — tests unitaires des 3 properties qu'on rend serialisables + * ({@link ClientAnimationProperties#LAYER_TYPE}, + * {@link ClientAnimationProperties#PRIORITY}, + * {@link ClientAnimationProperties#JOINT_MASK}). + * + *

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 codec = ClientAnimationProperties.LAYER_TYPE.getCodecs(); + assertNotNull(codec, "LAYER_TYPE doit avoir un Codec non-null"); + + // Encode + DataResult enc = + codec.encodeStart(JsonOps.INSTANCE, Layer.LayerType.COMPOSITE_LAYER); + assertTrue(enc.result().isPresent(), "encode doit reussir"); + assertEquals("COMPOSITE_LAYER", enc.result().get().getAsString()); + + // Decode + Layer.LayerType parsed = ClientAnimationProperties.LAYER_TYPE.parseFrom( + new JsonPrimitive("COMPOSITE_LAYER") + ); + assertEquals(Layer.LayerType.COMPOSITE_LAYER, parsed); + } + + /** + * Case-insensitive parse — l'artist peut ecrire {@code "composite_layer"} + * ou {@code "Composite_Layer"}, on accepte tant que la forme UPPERCASE + * matche. + */ + @Test + void layerType_caseInsensitive() { + Layer.LayerType parsed = ClientAnimationProperties.LAYER_TYPE.parseFrom( + new JsonPrimitive("base_layer") + ); + assertEquals(Layer.LayerType.BASE_LAYER, parsed); + } + + // ===== PRIORITY ===== + + /** Accesseur serializable pour PRIORITY. */ + @Test + void priority_registeredUnderName() { + AnimationProperty prop = + AnimationProperty.getSerializableProperty("priority"); + assertNotNull(prop); + assertEquals(ClientAnimationProperties.PRIORITY, prop); + } + + /** Round-trip PRIORITY=HIGH. */ + @Test + void priority_serializable_roundtrip() { + Codec codec = ClientAnimationProperties.PRIORITY.getCodecs(); + assertNotNull(codec); + + DataResult enc = + codec.encodeStart(JsonOps.INSTANCE, Layer.Priority.HIGH); + assertTrue(enc.result().isPresent()); + assertEquals("HIGH", enc.result().get().getAsString()); + + Layer.Priority parsed = ClientAnimationProperties.PRIORITY.parseFrom( + new JsonPrimitive("HIGH") + ); + assertEquals(Layer.Priority.HIGH, parsed); + } + + /** + * Valeur inconnue : le Codec throw au parse (Enum.valueOf). On attrape via + * {@code parseFrom.orElseThrow} → NoSuchElementException (unwrap du + * DataResult vide). Le contrat actuel de + * {@link AnimationProperty#parseFrom} loggue un WARN via + * {@code resultOrPartial} puis throw — on accepte n'importe quelle + * RuntimeException tant qu'on ne crash pas silencieusement. + */ + @Test + void priority_unknownValue_throws() { + try { + ClientAnimationProperties.PRIORITY.parseFrom(new JsonPrimitive("INVALID_ENUM")); + org.junit.jupiter.api.Assertions.fail("parseFrom devrait throw pour une valeur inconnue"); + } catch (RuntimeException expected) { + // OK — le contrat parseFrom fait orElseThrow sur un DataResult.error + // La cause sous-jacente est IllegalArgumentException("No enum constant ...") + // ou une NoSuchElementException selon la pile resultOrPartial -> orElseThrow. + } + } + + // ===== JOINT_MASK ===== + + /** Accesseur serializable pour JOINT_MASK. */ + @Test + void jointMask_registeredUnderName() { + AnimationProperty prop = + AnimationProperty.getSerializableProperty("joint_mask"); + assertNotNull(prop); + assertEquals(ClientAnimationProperties.JOINT_MASK, prop); + } + + /** + * Décode un joint-mask name string en {@link JointMaskEntry} via les + * helpers Codec. Le {@link JointMaskReloadListener} n'a jamais été + * reloaded en test unitaire → le lookup renvoie le fallback + * {@code tiedup:none} (une entry null-default). Le contrat garanti ici : + *

    + *
  • pas d'exception,
  • + *
  • entry non-null,
  • + *
  • {@code isValid()} peut être false (defaultMask est le none-mask + * qui n'est lui-même pas en registry → null). On accepte both.
  • + *
+ */ + @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