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:
@@ -8,6 +8,8 @@ package com.tiedup.remake.rig.anim.client.property;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
|
|
||||||
import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty;
|
import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty;
|
||||||
import com.tiedup.remake.rig.anim.types.DirectStaticAnimation;
|
import com.tiedup.remake.rig.anim.types.DirectStaticAnimation;
|
||||||
import com.tiedup.remake.rig.anim.client.AnimationSubFileReader;
|
import com.tiedup.remake.rig.anim.client.AnimationSubFileReader;
|
||||||
@@ -16,18 +18,85 @@ import com.tiedup.remake.rig.anim.client.Layer;
|
|||||||
public class ClientAnimationProperties {
|
public class ClientAnimationProperties {
|
||||||
/**
|
/**
|
||||||
* Layer type. (BASE: Living, attack animations, COMPOSITE: Aiming, weapon holding, digging animation)
|
* 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.
|
* 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.
|
* 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
|
||||||
|
* «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.
|
||||||
|
*
|
||||||
|
* <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
|
* Trail particle information
|
||||||
@@ -48,4 +117,40 @@ public class ClientAnimationProperties {
|
|||||||
* 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> ();
|
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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,34 +22,86 @@ import com.tiedup.remake.rig.anim.LivingMotion;
|
|||||||
* builder la map {@code livingAnimations} de l'animator quand un item
|
* builder la map {@code livingAnimations} de l'animator quand un item
|
||||||
* bondage est equipe.
|
* 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 & exposed but not consumed yet.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
* <p>Parse via {@code DataDrivenItemParser} (P3-03) depuis JSON :
|
* <p>Parse via {@code DataDrivenItemParser} (P3-03) depuis JSON :
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* "animations": {
|
* "animations": {
|
||||||
* "living_motions": { "IDLE": "mymod:arms_cuffed_idle", ... },
|
* "living_motions": { "IDLE": "mymod:arms_cuffed_idle", ... },
|
||||||
* "on_equip": "mymod:cuffs_equip_oneshot",
|
* "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>
|
* }</pre>
|
||||||
*
|
*
|
||||||
* @param livingMotions map immutable motion → anim ID (jamais null, peut etre vide)
|
* @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 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 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(
|
public record AnimationBindings(
|
||||||
Map<LivingMotion, ResourceLocation> livingMotions,
|
Map<LivingMotion, ResourceLocation> livingMotions,
|
||||||
@Nullable ResourceLocation onEquip,
|
@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). */
|
/** 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. */
|
/** Constructor canonique : force livingMotions immutable + non-null. */
|
||||||
public AnimationBindings {
|
public AnimationBindings {
|
||||||
livingMotions = livingMotions == null ? Collections.emptyMap() : Map.copyOf(livingMotions);
|
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() {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -746,7 +746,67 @@ public final class DataDrivenItemParser {
|
|||||||
? tryParseAnimationRL(animBlock.get("on_unequip"), "on_unequip", itemId)
|
? tryParseAnimationRL(animBlock.get("on_unequip"), "on_unequip", itemId)
|
||||||
: null;
|
: 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -151,9 +151,9 @@ public final class ClientRigEquipmentHandler {
|
|||||||
private static final float ONESHOT_TRANSITION_TIME = 0.15F;
|
private static final float ONESHOT_TRANSITION_TIME = 0.15F;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Son vanilla par défaut pour on_equip / on_unequip. Placeholder MVP :
|
* Son vanilla par défaut pour on_equip / on_unequip. Fallback quand
|
||||||
* future evolution = field {@code equip_sound} dans
|
* {@link AnimationBindings#equipSound()} / {@link AnimationBindings#unequipSound()}
|
||||||
* {@link DataDrivenItemDefinition} pour custom per-item sound event.
|
* ne sont pas déclarés dans le JSON item.
|
||||||
*
|
*
|
||||||
* <p>{@link SoundEvents#ARMOR_EQUIP_LEATHER} a été choisi pour sa
|
* <p>{@link SoundEvents#ARMOR_EQUIP_LEATHER} a été choisi pour sa
|
||||||
* sonorité souple (cuir) — adapté aux cuffs / harnesses / gags majoritaires.
|
* sonorité souple (cuir) — adapté aux cuffs / harnesses / gags majoritaires.
|
||||||
@@ -172,6 +172,38 @@ public final class ClientRigEquipmentHandler {
|
|||||||
return SoundEvents.ARMOR_EQUIP_LEATHER;
|
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} → {@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
|
* Snapshot stable {@code (region → itemId)} — utilisé pour calculer les
|
||||||
* diffs {@code newlyEquipped} / {@code newlyUnequipped} tick-to-tick afin
|
* diffs {@code newlyEquipped} / {@code newlyUnequipped} tick-to-tick afin
|
||||||
@@ -350,7 +382,7 @@ public final class ClientRigEquipmentHandler {
|
|||||||
DataDrivenItemRegistry::get,
|
DataDrivenItemRegistry::get,
|
||||||
TiedUpAnimationRegistry::resolveWithFallback
|
TiedUpAnimationRegistry::resolveWithFallback
|
||||||
);
|
);
|
||||||
playEquipSound(player);
|
playEquipSound(player, itemId, /* isEquip */ false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +438,7 @@ public final class ClientRigEquipmentHandler {
|
|||||||
DataDrivenItemRegistry::get,
|
DataDrivenItemRegistry::get,
|
||||||
TiedUpAnimationRegistry::resolveWithFallback
|
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
|
* ({@link net.minecraft.world.level.Level#playLocalSound} n'émet aucun
|
||||||
* paquet réseau).
|
* paquet réseau).
|
||||||
*
|
*
|
||||||
* <p>MVP : son {@link #defaultEquipSound} pour tous les items. Le son
|
* <p>Wave A (2026-04-23) : le son effectif est résolu via
|
||||||
* n'a plus besoin du stack ({@code ItemStack}) en entrée depuis le switch
|
* {@link #pickSoundId} en fonction des {@link AnimationBindings} de l'item
|
||||||
* key-based : le callsite unequip ne possède plus le stack (disparu), et
|
* (id provient du snapshot key-based {@link #LAST_EQUIPPED_KEYS}) — un
|
||||||
* le son vanilla choisi ne varie pas per-item. Future évolution : lire un
|
* JSON peut override per-item via le champ {@code equip_sound} /
|
||||||
* champ {@code equip_sound} dans {@link DataDrivenItemDefinition} via
|
* {@code unequip_sound}. Si l'id est null (pas déclaré) ou si le sound
|
||||||
* l'itemId + registry lookup, sans besoin du stack concret.</p>
|
* 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
|
* <p>Non testable unit (nécessite {@link Player} live + level). La logique
|
||||||
* dans la suite de tests.</p>
|
* 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
|
* @param player le player source du son (position utilisée pour la
|
||||||
* spatialisation 3D)
|
* 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;
|
if (player == null) return;
|
||||||
|
|
||||||
|
SoundEvent sound = resolveSoundEvent(itemId, isEquip);
|
||||||
|
|
||||||
player.level().playLocalSound(
|
player.level().playLocalSound(
|
||||||
player.getX(),
|
player.getX(),
|
||||||
player.getY(),
|
player.getY(),
|
||||||
player.getZ(),
|
player.getZ(),
|
||||||
defaultEquipSound(),
|
sound,
|
||||||
SoundSource.PLAYERS,
|
SoundSource.PLAYERS,
|
||||||
/* volume */ 1.0F,
|
/* volume */ 1.0F,
|
||||||
/* pitch */ 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
|
* Vide les caches de snapshots {@link #LAST_EQUIPPED_KEYS} et
|
||||||
* {@link #FIRST_REBUILD_DONE}. Utilisé dans :
|
* {@link #FIRST_REBUILD_DONE}. Utilisé dans :
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,5 +150,90 @@ class AnimationBindingsTest {
|
|||||||
assertTrue(AnimationBindings.EMPTY.livingMotions().isEmpty());
|
assertTrue(AnimationBindings.EMPTY.livingMotions().isEmpty());
|
||||||
assertNull(AnimationBindings.EMPTY.onEquip());
|
assertNull(AnimationBindings.EMPTY.onEquip());
|
||||||
assertNull(AnimationBindings.EMPTY.onUnequip());
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,4 +580,208 @@ class DataDrivenItemParserAnimationsTest {
|
|||||||
// "IDEL" -> "IDLE" : 2 edits (swap E/L = substitution + substitution)
|
// "IDEL" -> "IDLE" : 2 edits (swap E/L = substitution + substitution)
|
||||||
assertEquals(2, DataDrivenItemParser.levenshtein("IDEL", "IDLE"));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -974,6 +974,84 @@ class ClientRigEquipmentHandlerTest {
|
|||||||
"onEquip==null → pas de trigger");
|
"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
|
* Happy path — bindings avec {@code onEquip} défini → le oneshotPlayer
|
||||||
* est appelé avec l'accessor résolu + la transition time canonique
|
* est appelé avec l'accessor résolu + la transition time canonique
|
||||||
|
|||||||
Reference in New Issue
Block a user