Wave A data-driven : JSON-serializable properties + equip_sound + metadata

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.
This commit is contained in:
notevil
2026-04-24 14:02:02 +02:00
parent ed0fb49792
commit 76587c0393
8 changed files with 921 additions and 37 deletions

View File

@@ -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)
*
* <p>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:
* <pre>{@code
* "properties": {
* "layer_type": "COMPOSITE_LAYER"
* }
* }</pre>
*
* <p>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.LayerType> LAYER_TYPE = new StaticAnimationProperty<Layer.LayerType> ();
public static final StaticAnimationProperty<Layer.LayerType> LAYER_TYPE = new StaticAnimationProperty<Layer.LayerType>(
"layer_type",
Codec.STRING.xmap(
s -> Layer.LayerType.valueOf(s.toUpperCase()),
Enum::name
)
);
/**
* Priority of composite layer.
*
* <p>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<Layer.Priority> PRIORITY = new StaticAnimationProperty<Layer.Priority> ();
public static final StaticAnimationProperty<Layer.Priority> PRIORITY = new StaticAnimationProperty<Layer.Priority>(
"priority",
Codec.STRING.xmap(
s -> Layer.Priority.valueOf(s.toUpperCase()),
Enum::name
)
);
/**
* Joint mask for composite layer.
*
* <p>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.
*
* <p>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).
*
* <p><b>Intent</b> : the {@code properties} JSON block is the
* &laquo;simple&raquo; 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.
*
* <p>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<JointMaskEntry> JOINT_MASK = new StaticAnimationProperty<JointMaskEntry> ();
public static final StaticAnimationProperty<JointMaskEntry> JOINT_MASK = new StaticAnimationProperty<JointMaskEntry>(
"joint_mask",
Codec.STRING.xmap(
ClientAnimationProperties::decodeJointMaskEntry,
ClientAnimationProperties::encodeJointMaskEntry
)
);
/**
* Trail particle information
*/
public static final StaticAnimationProperty<List<TrailInfo>> TRAIL_EFFECT = new StaticAnimationProperty<List<TrailInfo>> ();
/**
* An animation clip being played in first person.
*/
public static final StaticAnimationProperty<DirectStaticAnimation> POV_ANIMATION = new StaticAnimationProperty<DirectStaticAnimation> ();
/**
* An animation clip being played in first person.
*/
public static final StaticAnimationProperty<AnimationSubFileReader.PovSettings> POV_SETTINGS = new StaticAnimationProperty<AnimationSubFileReader.PovSettings> ();
/**
* 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<DirectStaticAnimation> MULTILAYER_ANIMATION = new StaticAnimationProperty<DirectStaticAnimation> ();
// === 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)}).
*
* <p>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).
*
* <p>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";
}
}

View File

@@ -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.
*
* <p>Wave A extensions (2026-04-23) :
* <ul>
* <li>{@link #equipSound} / {@link #unequipSound} — per-item override of the
* SFX played at equip/unequip, read by
* {@code ClientRigEquipmentHandler.playEquipSound}. When null, the
* handler falls back to the vanilla {@code ARMOR_EQUIP_LEATHER}.</li>
* <li>{@link #restraintLevel} — optional integer severity marker in
* {@code [1, 5]}. Exposed in the API for future UX consumers (HUD
* overlay, tooltip renderer) ; no gameplay effect at this stage. Values
* outside the range are accepted but emit a WARN at parse time.</li>
* <li>{@link #tooltipOverride} — optional free-form string the tooltip
* renderer can display in place of (or in addition to) the default
* description. Again parsed &amp; exposed but not consumed yet.</li>
* </ul>
*
* <p>Parse via {@code DataDrivenItemParser} (P3-03) depuis JSON :
* <pre>{@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."
* }
* }</pre>
*
* @param livingMotions map immutable motion &rarr; 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 &rarr; 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<LivingMotion, ResourceLocation> 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}.
*
* <p>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.</p>
*/
public AnimationBindings(
Map<LivingMotion, ResourceLocation> 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;
}
}

View File

@@ -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
);
}
/**

View File

@@ -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.
*
* <p>{@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).
*
* <p>Resolution rules :</p>
* <ol>
* <li>{@code bindings == null} &rarr; {@code null} (caller falls back to
* vanilla leather).</li>
* <li>For equip : return {@code bindings.equipSound()} as-is (may be
* {@code null}).</li>
* <li>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.</li>
* </ol>
*
* <p>Package-private, static, pure — testable.</p>
*
* @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).
*
* <p>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.</p>
* <p>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}).</p>
*
* <p>Non testable unit (nécessite {@link Player} live + level) — skip
* dans la suite de tests.</p>
* <p>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}.</p>
*
* @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 &rarr; fallback vanilla
* @param isEquip true si equip, false si unequip (impacte le fallback
* unequip&rarr;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.
*
* <p>Internal — requires {@code SoundEvents.<clinit>} to have run (MC
* bootstrap). Pure selection logic is factored into
* {@link #pickSoundId} for test purposes.</p>
*/
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 :

View File

@@ -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}).
*
* <p>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).
*
* <p><b>Aucun bootstrap MC</b> — 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)}.
*
* <p>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"}.
*
* <p>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<Layer.LayerType> codec = ClientAnimationProperties.LAYER_TYPE.getCodecs();
assertNotNull(codec, "LAYER_TYPE doit avoir un Codec non-null");
// Encode
DataResult<com.google.gson.JsonElement> 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<Layer.Priority> codec = ClientAnimationProperties.PRIORITY.getCodecs();
assertNotNull(codec);
DataResult<com.google.gson.JsonElement> 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 :
* <ul>
* <li>pas d'exception,</li>
* <li>entry non-null,</li>
* <li>{@code isValid()} peut être false (defaultMask est le none-mask
* qui n'est lui-même pas en registry → null). On accepte both.</li>
* </ul>
*/
@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);
}
}

View File

@@ -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");
}
}

View File

@@ -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());
}
}

View File

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