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 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,18 +18,85 @@ 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
|
||||
* «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
|
||||
@@ -48,4 +117,40 @@ public class ClientAnimationProperties {
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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 → anim ID (jamais null, peut etre vide)
|
||||
* @param onEquip optionnel one-shot au moment de l'equipement (null = pas de trigger)
|
||||
* @param onUnequip optionnel one-shot au moment du desequipement (null = pas de trigger)
|
||||
* @param livingMotions map immutable motion → anim ID (jamais null, peut etre vide)
|
||||
* @param onEquip optionnel one-shot au moment de l'equipement (null = pas de trigger)
|
||||
* @param onUnequip optionnel one-shot au moment du desequipement (null = pas de trigger)
|
||||
* @param equipSound optionnel sound event id joue a l'equipement (null = vanilla leather)
|
||||
* @param unequipSound optionnel sound event id joue au desequipement (null = fallback vers equipSound, puis vanilla)
|
||||
* @param restraintLevel optionnel severity tag {@code [1, 5]} pour consumers UX (HUD overlay). Null = pas declare. Hors plage : WARN au parse, valeur retenue telle quelle.
|
||||
* @param tooltipOverride optionnel ligne de tooltip custom. Null = utiliser le tooltip par defaut.
|
||||
*/
|
||||
public record AnimationBindings(
|
||||
Map<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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} → {@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 → fallback vanilla
|
||||
* @param isEquip true si equip, false si unequip (impacte le fallback
|
||||
* unequip→equip dans {@link #pickSoundId})
|
||||
*/
|
||||
private static void playEquipSound(Player player) {
|
||||
private static void playEquipSound(Player player, ResourceLocation itemId, boolean isEquip) {
|
||||
if (player == null) return;
|
||||
|
||||
SoundEvent sound = resolveSoundEvent(itemId, isEquip);
|
||||
|
||||
player.level().playLocalSound(
|
||||
player.getX(),
|
||||
player.getY(),
|
||||
player.getZ(),
|
||||
defaultEquipSound(),
|
||||
sound,
|
||||
SoundSource.PLAYERS,
|
||||
/* volume */ 1.0F,
|
||||
/* pitch */ 1.0F,
|
||||
@@ -602,6 +643,36 @@ public final class ClientRigEquipmentHandler {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective {@link SoundEvent} for an item's equip/unequip
|
||||
* action. Does the registry lookup + null-checks so that
|
||||
* {@link #playEquipSound} stays straightforward.
|
||||
*
|
||||
* <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 :
|
||||
|
||||
@@ -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());
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user