P3-03 : parse DataDrivenItem JSON animations block

Wire le parser JSON pour le bloc "animations" (living_motions + on_equip
+ on_unequip). Résolution motion cross-enum (LivingMotions vanilla EF +
TiedUpLivingMotions custom). Fuzzy-match Levenshtein pour suggestion
en cas de typo modder (Did you mean 'IDLE'?).

Remplace le null hardcodé à DataDrivenItemParser:352 (P3-02 TODO).

body : tolerance malformed RL, non-string values, unknown motions.
This commit is contained in:
notevil
2026-04-23 16:01:30 +02:00
parent c1ecfd75c7
commit 744aef63b5
2 changed files with 676 additions and 3 deletions

View File

@@ -4,6 +4,9 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.TiedUpLivingMotions;
import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.component.ComponentType; import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.movement.MovementModifier; import com.tiedup.remake.v2.bondage.movement.MovementModifier;
@@ -11,11 +14,14 @@ import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
@@ -326,6 +332,12 @@ public final class DataDrivenItemParser {
idPath idPath
); );
// Optional: animations (living_motions + on_equip + on_unequip)
// P3-03 : replaces the hardcoded null from P3-02.
// When the "animations" key is absent in JSON, we keep null so that
// items authored before P3-03 keep their vanilla animator behavior.
AnimationBindings animations = parseAnimationBindings(root, id);
return new DataDrivenItemDefinition( return new DataDrivenItemDefinition(
id, id,
displayName, displayName,
@@ -347,9 +359,7 @@ public final class DataDrivenItemParser {
movementModifier, movementModifier,
creator, creator,
animationBones, animationBones,
// P3-02 : animations parsing will be wired in P3-03. For now pass animations,
// null so existing items keep their vanilla animator behavior.
null,
componentConfigs componentConfigs
); );
} }
@@ -629,4 +639,238 @@ public final class DataDrivenItemParser {
return Collections.unmodifiableMap(result); return Collections.unmodifiableMap(result);
} }
// ===== Animations (P3-03) =====
/**
* Parse the optional {@code "animations"} JSON block into an
* {@link AnimationBindings}.
*
* <p>Format :
* <pre>{@code
* "animations": {
* "living_motions": {
* "WALK": "mymod:arms_cuffed_walk",
* "SNEAK": "mymod:arms_cuffed_sneak"
* },
* "on_equip": "mymod:cuffs_equip_oneshot",
* "on_unequip": "mymod:cuffs_unequip_oneshot"
* }
* }</pre>
*
* <p>Tolerance rules :
* <ul>
* <li>Key absent &rarr; returns {@code null} (no binding, vanilla behavior).</li>
* <li>Key present but {@code {}} &rarr; returns {@link AnimationBindings#EMPTY}.</li>
* <li>Unknown motion names are logged with a Levenshtein fuzzy-match suggestion
* and skipped (no crash).</li>
* <li>Malformed ResourceLocation strings are logged and skipped.</li>
* <li>Non-string values in {@code living_motions} are logged and skipped.</li>
* </ul>
*
* @param rootJson the parsed root JSON object of the item definition
* @param itemId the resolved item ID (used in log messages)
* @return the parsed bindings, {@link AnimationBindings#EMPTY} for an empty block,
* or {@code null} if the {@code "animations"} key is absent
*/
@Nullable
static AnimationBindings parseAnimationBindings(
JsonObject rootJson,
ResourceLocation itemId
) {
if (!rootJson.has("animations")) return null;
JsonElement animElem = rootJson.get("animations");
if (!animElem.isJsonObject()) {
LOGGER.warn(
"[DataDrivenItems] In {}: 'animations' is not an object, ignoring",
itemId
);
return null;
}
JsonObject animBlock = animElem.getAsJsonObject();
// Fast-path : present but empty -> EMPTY sentinel (distinguishable from "absent = null").
if (animBlock.size() == 0) return AnimationBindings.EMPTY;
// living_motions : optional map of motionName -> animationResourceLocation
Map<LivingMotion, ResourceLocation> motions = new HashMap<>();
if (animBlock.has("living_motions")) {
JsonElement motionsElem = animBlock.get("living_motions");
if (!motionsElem.isJsonObject()) {
LOGGER.warn(
"[DataDrivenItems] In {}: 'animations.living_motions' is not an object, skipping",
itemId
);
} else {
JsonObject motionsJson = motionsElem.getAsJsonObject();
for (Map.Entry<String, JsonElement> entry : motionsJson.entrySet()) {
String motionName = entry.getKey();
LivingMotion motion = resolveMotionByName(motionName);
if (motion == null) {
String suggest = suggestClosestMotion(motionName);
LOGGER.warn(
"[DataDrivenItemParser] Unknown motion '{}' in item {}, skipping.{}",
motionName,
itemId,
suggest != null ? " Did you mean '" + suggest + "'?" : ""
);
continue;
}
ResourceLocation animId = tryParseAnimationRL(
entry.getValue(),
"living_motions['" + motionName + "']",
itemId
);
if (animId == null) continue;
motions.put(motion, animId);
}
}
}
// on_equip / on_unequip : optional single RL strings
ResourceLocation onEquip = animBlock.has("on_equip")
? tryParseAnimationRL(animBlock.get("on_equip"), "on_equip", itemId)
: null;
ResourceLocation onUnequip = animBlock.has("on_unequip")
? tryParseAnimationRL(animBlock.get("on_unequip"), "on_unequip", itemId)
: null;
return new AnimationBindings(motions, onEquip, onUnequip);
}
/**
* Resolve a motion name string to its {@link LivingMotion} instance.
*
* <p>Both vanilla-EF {@link LivingMotions} and custom {@link TiedUpLivingMotions}
* are standard Java enums, so {@link Enum#valueOf(Class, String)} is used. The
* lookup is case-sensitive (must match the enum constant name exactly).
*
* @param name the motion name from JSON (e.g. "WALK", "STRUGGLE_BOUND")
* @return the resolved {@link LivingMotion}, or {@code null} if unknown in both enums
*/
@Nullable
static LivingMotion resolveMotionByName(String name) {
if (name == null || name.isEmpty()) return null;
// Try vanilla EF motions first (most common case)
try {
return LivingMotions.valueOf(name);
} catch (IllegalArgumentException ignored) {
// fall through
}
// Try TiedUp! custom motions
try {
return TiedUpLivingMotions.valueOf(name);
} catch (IllegalArgumentException ignored) {
// fall through
}
return null;
}
/**
* Suggest the closest known motion name for an unknown input, using
* Levenshtein edit distance with a tolerance of 3 edits.
*
* <p>Cross-enum search (vanilla {@link LivingMotions} + {@link TiedUpLivingMotions}).
*
* @param unknown the unknown name from JSON (case-insensitive comparison)
* @return the closest candidate (enum name), or {@code null} if no candidate
* is within 3 edits
*/
@Nullable
static String suggestClosestMotion(String unknown) {
if (unknown == null || unknown.isEmpty()) return null;
String needle = unknown.toUpperCase();
List<String> all = new ArrayList<>();
for (LivingMotions m : LivingMotions.values()) all.add(m.name());
for (TiedUpLivingMotions m : TiedUpLivingMotions.values()) all.add(m.name());
String best = null;
int bestDist = Integer.MAX_VALUE;
for (String candidate : all) {
int dist = levenshtein(needle, candidate);
if (dist < bestDist && dist <= 3) {
bestDist = dist;
best = candidate;
}
}
return best;
}
/**
* Classic Levenshtein edit distance (2D dynamic programming).
*
* <p>Pure computation, no allocations beyond the DP table. Used for typo
* suggestions on unknown motion names.
*/
static int levenshtein(String a, String b) {
if (a == null) a = "";
if (b == null) b = "";
int la = a.length();
int lb = b.length();
if (la == 0) return lb;
if (lb == 0) return la;
int[][] dp = new int[la + 1][lb + 1];
for (int i = 0; i <= la; i++) dp[i][0] = i;
for (int j = 0; j <= lb; j++) dp[0][j] = j;
for (int i = 1; i <= la; i++) {
for (int j = 1; j <= lb; j++) {
int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1;
dp[i][j] = Math.min(
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
dp[i - 1][j - 1] + cost
);
}
}
return dp[la][lb];
}
/**
* Parse a JSON element as a ResourceLocation for an animation binding.
* Logs warn + returns null if the element is not a string or the string
* is not a valid RL.
*/
@Nullable
private static ResourceLocation tryParseAnimationRL(
JsonElement elem,
String context,
ResourceLocation itemId
) {
if (elem == null || elem.isJsonNull() || !elem.isJsonPrimitive()
|| !elem.getAsJsonPrimitive().isString()) {
LOGGER.warn(
"[DataDrivenItemParser] {} in item {} is not a string, skipping.",
context,
itemId
);
return null;
}
String s = elem.getAsString();
// Strict RL parsing : animations MUST carry an explicit namespace
// (e.g. "mymod:foo"). ResourceLocation.tryParse defaults bare paths
// to the "minecraft" namespace, which silently masks modder typos —
// we reject strings without ':' here.
if (s.isEmpty() || s.indexOf(':') < 0) {
LOGGER.warn(
"[DataDrivenItemParser] Malformed ResourceLocation '{}' for {} in item {}, skipping.",
s,
context,
itemId
);
return null;
}
ResourceLocation rl = ResourceLocation.tryParse(s);
if (rl == null) {
LOGGER.warn(
"[DataDrivenItemParser] Malformed ResourceLocation '{}' for {} in item {}, skipping.",
s,
context,
itemId
);
}
return rl;
}
} }

View File

@@ -0,0 +1,429 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.bondage.datadriven;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.minecraft.resources.ResourceLocation;
import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.TiedUpLivingMotions;
/**
* Tests unitaires pour le parsing du bloc {@code "animations"} par
* {@link DataDrivenItemParser#parseAnimationBindings} (P3-03).
*
* <p>Couvre :
* <ul>
* <li>absence &rarr; {@code null},</li>
* <li>bloc vide &rarr; {@link AnimationBindings#EMPTY},</li>
* <li>motions vanilla EF et custom TiedUp!,</li>
* <li>tolerance typos (unknown motion) avec fuzzy-match suggestion,</li>
* <li>tolerance ResourceLocation malformee,</li>
* <li>tolerance values non-string,</li>
* <li>one-shots on_equip / on_unequip,</li>
* <li>Levenshtein distance (sanity check).</li>
* </ul>
*
* Pur Java (JsonParser + ResourceLocation hors runtime MC).
*/
class DataDrivenItemParserAnimationsTest {
private static final ResourceLocation ITEM_ID =
ResourceLocation.fromNamespaceAndPath("tiedup", "test_item");
/** Helper : parse un JSON string en JsonObject. */
private static JsonObject json(String s) {
return JsonParser.parseString(s).getAsJsonObject();
}
// ========== absence / presence de la cle ==========
@Test
void parseAnimations_absent_returnsNull() {
JsonObject root = json("{\"display_name\": \"test\"}");
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(root, ITEM_ID);
assertNull(result,
"cle 'animations' absente => null (pas de binding, vanilla behavior)");
}
@Test
void parseAnimations_empty_returnsEmpty() {
JsonObject root = json("{\"animations\": {}}");
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(root, ITEM_ID);
assertNotNull(result);
assertSame(AnimationBindings.EMPTY, result,
"bloc 'animations' vide => sentinel EMPTY");
assertTrue(result.isEmpty());
}
@Test
void parseAnimations_notObject_returnsNull() {
JsonObject root = json("{\"animations\": 42}");
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(root, ITEM_ID);
assertNull(result, "animations non-objet => null (tolere, logue warn)");
}
// ========== living_motions : cas nominaux ==========
@Test
void parseAnimations_validLivingMotions_parses() {
String jsonStr = """
{
"animations": {
"living_motions": {
"IDLE": "tiedup:arms_cuffed_idle",
"WALK": "tiedup:arms_cuffed_walk",
"SNEAK": "tiedup:arms_cuffed_sneak"
}
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertEquals(3, result.livingMotions().size());
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle"),
result.livingMotions().get(LivingMotions.IDLE)
);
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_walk"),
result.livingMotions().get(LivingMotions.WALK)
);
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_sneak"),
result.livingMotions().get(LivingMotions.SNEAK)
);
assertNull(result.onEquip());
assertNull(result.onUnequip());
}
@Test
void parseAnimations_tiedupCustomMotions_parses() {
String jsonStr = """
{
"animations": {
"living_motions": {
"STRUGGLE_BOUND": "tiedup:bondage_struggle",
"POSE_DOG": "tiedup:pose_dog_idle",
"WALK_BOUND": "tiedup:cuffed_walk"
}
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertEquals(3, result.livingMotions().size());
assertNotNull(result.livingMotions().get(TiedUpLivingMotions.STRUGGLE_BOUND));
assertNotNull(result.livingMotions().get(TiedUpLivingMotions.POSE_DOG));
assertNotNull(result.livingMotions().get(TiedUpLivingMotions.WALK_BOUND));
}
@Test
void parseAnimations_mixedVanillaAndCustomMotions_parses() {
String jsonStr = """
{
"animations": {
"living_motions": {
"WALK": "tiedup:arms_cuffed_walk",
"STRUGGLE_BOUND": "tiedup:bondage_struggle"
}
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertEquals(2, result.livingMotions().size());
assertNotNull(result.livingMotions().get(LivingMotions.WALK));
assertNotNull(result.livingMotions().get(TiedUpLivingMotions.STRUGGLE_BOUND));
}
// ========== living_motions : tolerance erreurs ==========
@Test
void parseAnimations_unknownMotion_logsWarnAndSkips() {
// "IDEL" (typo) doit etre skip, "WALK" doit passer
String jsonStr = """
{
"animations": {
"living_motions": {
"IDEL": "tiedup:foo",
"WALK": "tiedup:arms_cuffed_walk"
}
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertEquals(1, result.livingMotions().size(),
"La motion inconnue est skip, l'autre passe");
assertNotNull(result.livingMotions().get(LivingMotions.WALK));
}
@Test
void parseAnimations_malformedResourceLocation_skipsEntry() {
// "noNamespace" n'a pas de ':' => RL invalide (strict parser)
String jsonStr = """
{
"animations": {
"living_motions": {
"WALK": "noNamespace",
"IDLE": "tiedup:arms_cuffed_idle"
}
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertEquals(1, result.livingMotions().size(),
"RL malformee => skip, l'autre passe");
assertNotNull(result.livingMotions().get(LivingMotions.IDLE));
assertNull(result.livingMotions().get(LivingMotions.WALK));
}
@Test
void parseAnimations_nonStringValue_skipsEntry() {
String jsonStr = """
{
"animations": {
"living_motions": {
"WALK": 42,
"IDLE": "tiedup:arms_cuffed_idle"
}
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertEquals(1, result.livingMotions().size(),
"Value non-string => skip");
assertNotNull(result.livingMotions().get(LivingMotions.IDLE));
}
@Test
void parseAnimations_livingMotionsNotObject_tolerates() {
// living_motions est un array au lieu d'un objet => tolere (warn + skip bloc)
String jsonStr = """
{
"animations": {
"living_motions": [1, 2, 3],
"on_equip": "tiedup:equip_oneshot"
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertTrue(result.livingMotions().isEmpty(),
"living_motions non-objet => map vide, pas crash");
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "equip_oneshot"),
result.onEquip(),
"on_equip reste traite normalement"
);
}
// ========== on_equip / on_unequip ==========
@Test
void parseAnimations_onEquipOnUnequip_parses() {
String jsonStr = """
{
"animations": {
"on_equip": "tiedup:cuffs_equip_oneshot",
"on_unequip": "tiedup:cuffs_unequip_oneshot"
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertTrue(result.livingMotions().isEmpty());
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_equip_oneshot"),
result.onEquip()
);
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_unequip_oneshot"),
result.onUnequip()
);
}
@Test
void parseAnimations_onEquipMissing_isNull() {
String jsonStr = """
{
"animations": {
"living_motions": {
"IDLE": "tiedup:arms_cuffed_idle"
}
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertNull(result.onEquip(), "on_equip absent => null");
assertNull(result.onUnequip(), "on_unequip absent => null");
assertFalse(result.livingMotions().isEmpty());
}
@Test
void parseAnimations_onEquipMalformed_isNull() {
String jsonStr = """
{
"animations": {
"on_equip": "noNamespace",
"on_unequip": "tiedup:valid_rl"
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertNull(result.onEquip(),
"on_equip RL malformee => null (skip)");
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "valid_rl"),
result.onUnequip(),
"on_unequip valide => parse OK"
);
}
// ========== resolveMotionByName ==========
@Test
void resolveMotionByName_vanilla_resolves() {
LivingMotion m = DataDrivenItemParser.resolveMotionByName("IDLE");
assertSame(LivingMotions.IDLE, m);
}
@Test
void resolveMotionByName_custom_resolves() {
LivingMotion m =
DataDrivenItemParser.resolveMotionByName("STRUGGLE_BOUND");
assertSame(TiedUpLivingMotions.STRUGGLE_BOUND, m);
}
@Test
void resolveMotionByName_unknown_returnsNull() {
assertNull(DataDrivenItemParser.resolveMotionByName("IDEL"));
assertNull(DataDrivenItemParser.resolveMotionByName(""));
assertNull(DataDrivenItemParser.resolveMotionByName(null));
}
@Test
void resolveMotionByName_caseSensitive() {
// JSON convention = UPPER_SNAKE_CASE (matches enum constants)
assertNull(DataDrivenItemParser.resolveMotionByName("idle"),
"Resolution case-sensitive : 'idle' ne match pas IDLE");
}
// ========== suggestClosestMotion ==========
@Test
void suggestClosestMotion_oneEdit_suggestsMatch() {
// "IDEL" est a 1 edit de "IDLE" (swap E<->L)
String s = DataDrivenItemParser.suggestClosestMotion("IDEL");
assertEquals("IDLE", s);
}
@Test
void suggestClosestMotion_completelyUnrelated_returnsNull() {
// "XYZQWERTY" n'est a moins de 3 edits d'aucune motion
String s = DataDrivenItemParser.suggestClosestMotion("XYZQWERTY");
assertNull(s);
}
@Test
void suggestClosestMotion_caseInsensitive() {
// "idel" en minuscule doit quand meme suggerer "IDLE"
String s = DataDrivenItemParser.suggestClosestMotion("idel");
assertEquals("IDLE", s);
}
@Test
void suggestClosestMotion_customMotion_suggestsCustom() {
// "STRUGLE_BOUND" (typo) devrait suggerer "STRUGGLE_BOUND"
String s = DataDrivenItemParser.suggestClosestMotion("STRUGLE_BOUND");
assertEquals("STRUGGLE_BOUND", s);
}
// ========== levenshtein (sanity) ==========
@Test
void levenshtein_sameString_returnsZero() {
assertEquals(0, DataDrivenItemParser.levenshtein("IDLE", "IDLE"));
}
@Test
void levenshtein_oneEdit_returnsOne() {
// substitution
assertEquals(1, DataDrivenItemParser.levenshtein("IDLE", "IDLA"));
// insertion
assertEquals(1, DataDrivenItemParser.levenshtein("IDLE", "IDLEE"));
// deletion
assertEquals(1, DataDrivenItemParser.levenshtein("IDLE", "IDL"));
}
@Test
void levenshtein_emptyStrings_handled() {
assertEquals(0, DataDrivenItemParser.levenshtein("", ""));
assertEquals(4, DataDrivenItemParser.levenshtein("IDLE", ""));
assertEquals(4, DataDrivenItemParser.levenshtein("", "IDLE"));
}
@Test
void levenshtein_swap_returnsTwo() {
// "IDEL" -> "IDLE" : 2 edits (swap E/L = substitution + substitution)
assertEquals(2, DataDrivenItemParser.levenshtein("IDEL", "IDLE"));
}
}