diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java index 426db2f..721198b 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java @@ -4,6 +4,9 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; 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.bondage.component.ComponentType; 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.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; import java.util.EnumMap; import java.util.EnumSet; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import net.minecraft.resources.ResourceLocation; @@ -326,6 +332,12 @@ public final class DataDrivenItemParser { 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( id, displayName, @@ -347,9 +359,7 @@ public final class DataDrivenItemParser { movementModifier, creator, animationBones, - // P3-02 : animations parsing will be wired in P3-03. For now pass - // null so existing items keep their vanilla animator behavior. - null, + animations, componentConfigs ); } @@ -629,4 +639,238 @@ public final class DataDrivenItemParser { return Collections.unmodifiableMap(result); } + + // ===== Animations (P3-03) ===== + + /** + * Parse the optional {@code "animations"} JSON block into an + * {@link AnimationBindings}. + * + *
Format : + *
{@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"
+ * }
+ * }
+ *
+ * Tolerance rules : + *
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. + * + *
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 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;
+ }
}
diff --git a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java
new file mode 100644
index 0000000..9d7dcdb
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java
@@ -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).
+ *
+ * Couvre :
+ *
+ *
+ *
+ * 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"));
+ }
+}