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:
@@ -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 → returns {@code null} (no binding, vanilla behavior).</li>
|
||||||
|
* <li>Key present but {@code {}} → 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 → {@code null},</li>
|
||||||
|
* <li>bloc vide → {@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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user