Wave B data-driven : LivingMotion + PoseType datapack extensions
D4 — LivingMotion custom via datapack : - DataDrivenLivingMotion implements LivingMotion interface - LivingMotionReloadListener scans data/<ns>/tiedup/living_motions/*.json - Stable ordinals cross-reload via PERSISTENT_REGISTRY map - Parser resolveMotionByName now falls back to registry lookup - Modders can add custom LivingMotions purely via JSON D11 — PoseType registry additive : - PoseTypeRegistry maps canonical IDs to builtin enum values - DataDrivenPoseType allows datapack extensions without touching the 17 V1 legacy call-sites (MixinCamera, DogPoseRenderHandler, etc.) - PoseTypeReloadListener scans data/<ns>/tiedup/pose_types/*.json - Builtin enum semantics preserved — modder custom types coexist Artist impact : new LivingMotions + pose types addable without any Java code. Phase 3 pipeline fully consumes both paths.
This commit is contained in:
@@ -615,6 +615,28 @@ public class TiedUpMod {
|
|||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"Registered RoomThemeReloadListener for data-driven room themes"
|
"Registered RoomThemeReloadListener for data-driven room themes"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Data-driven LivingMotion additions (server-side, from data/<namespace>/tiedup/living_motions/)
|
||||||
|
// Enables modders to add new LivingMotion values via datapack JSON,
|
||||||
|
// without writing Java enum extensions. Ordinals remain stable for
|
||||||
|
// the lifetime of the JVM (see LivingMotionReloadListener javadoc).
|
||||||
|
event.addListener(
|
||||||
|
new com.tiedup.remake.rig.anim.LivingMotionReloadListener()
|
||||||
|
);
|
||||||
|
LOGGER.info(
|
||||||
|
"Registered LivingMotionReloadListener for data-driven motion additions"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Data-driven PoseType additions (server-side, from data/<namespace>/tiedup/pose_types/)
|
||||||
|
// Additive registry — the 6 builtin PoseType enum values remain the
|
||||||
|
// only poses consumable by legacy V1 call-sites. Datapack types are
|
||||||
|
// visible only to Phase 3 consumers (DataDrivenItemParser, etc.).
|
||||||
|
event.addListener(
|
||||||
|
new com.tiedup.remake.v2.bondage.PoseTypeReloadListener()
|
||||||
|
);
|
||||||
|
LOGGER.info(
|
||||||
|
"Registered PoseTypeReloadListener for data-driven pose_type additions"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.rig.anim;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Motion {@link LivingMotion} ajoute via datapack, sans code Java.
|
||||||
|
*
|
||||||
|
* <p>Modder path : deposer un fichier JSON dans
|
||||||
|
* {@code data/<ns>/tiedup/living_motions/<name>.json}. Le {@link
|
||||||
|
* LivingMotionReloadListener} le detecte au chargement des datapacks, l'enregistre
|
||||||
|
* dans {@link LivingMotion#ENUM_MANAGER} (meme ordinal pool que les enums Java
|
||||||
|
* builtin {@link LivingMotions} et {@link TiedUpLivingMotions}), et le rend
|
||||||
|
* resolvable via {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemParser}.</p>
|
||||||
|
*
|
||||||
|
* <p>L'id de la motion est derive du path du fichier :
|
||||||
|
* {@code data/mymod/tiedup/living_motions/orgasm_shake.json} devient
|
||||||
|
* {@code mymod:orgasm_shake}. Le {@code toString()} renvoie la ResourceLocation
|
||||||
|
* complete — {@link com.tiedup.remake.rig.util.ExtendableEnumManager#assign}
|
||||||
|
* utilise {@code toString()} lowercased comme cle unique, donc deux motions
|
||||||
|
* de namespace differents coexistent sans collision (p.ex.
|
||||||
|
* {@code mymod:orgasm_shake} vs {@code tiedup:orgasm_shake}).</p>
|
||||||
|
*
|
||||||
|
* <h2>Choix de design : classe, pas record</h2>
|
||||||
|
* <p>Le {@code universalOrdinal()} doit etre assigne par
|
||||||
|
* {@link com.tiedup.remake.rig.util.ExtendableEnumManager#assign} APRES
|
||||||
|
* construction de l'instance (l'{@code ExtendableEnumManager} prend le
|
||||||
|
* {@link LivingMotion} en parametre, lit sa cle via {@code toString()}, et
|
||||||
|
* retourne l'ordinal a posteriori). Un {@code record} Java a tous ses champs
|
||||||
|
* immuables — il faudrait donc construire deux instances (placeholder +
|
||||||
|
* final), ce qui laisse un {@code placeholder.universalOrdinal() == -1} stocke
|
||||||
|
* dans les maps internes du {@code ExtendableEnumManager}. Une classe
|
||||||
|
* mutable-at-first-call (pattern identique a {@link LivingMotions#id}) evite
|
||||||
|
* ce double-hop et garantit que l'instance stockee dans
|
||||||
|
* {@code ENUM_MANAGER.enumMapByName} porte le bon ordinal.</p>
|
||||||
|
*/
|
||||||
|
public final class DataDrivenLivingMotion implements LivingMotion {
|
||||||
|
|
||||||
|
private final ResourceLocation id;
|
||||||
|
private final String description;
|
||||||
|
@Nullable
|
||||||
|
private final String category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordinal attribue par {@link LivingMotion#ENUM_MANAGER}. Non-final : set
|
||||||
|
* une seule fois par {@link LivingMotionReloadListener} juste apres
|
||||||
|
* {@code assign()}. {@code volatile} garantit visibilite cross-thread si
|
||||||
|
* un client lit {@code universalOrdinal()} pendant que le server thread
|
||||||
|
* est en train de finir l'assignation (peu probable en pratique — assign
|
||||||
|
* est sous {@code synchronized}).
|
||||||
|
*/
|
||||||
|
private volatile int ordinal = -1;
|
||||||
|
|
||||||
|
public DataDrivenLivingMotion(
|
||||||
|
ResourceLocation id,
|
||||||
|
String description,
|
||||||
|
@Nullable String category
|
||||||
|
) {
|
||||||
|
this.id = Objects.requireNonNull(id, "id");
|
||||||
|
this.description = Objects.requireNonNull(description, "description");
|
||||||
|
this.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appele exactement une fois par {@link LivingMotionReloadListener} pour
|
||||||
|
* poser l'ordinal retourne par {@code ExtendableEnumManager.assign}.
|
||||||
|
*
|
||||||
|
* @param ordinal ordinal >= 0
|
||||||
|
* @throws IllegalStateException si deja assigne
|
||||||
|
*/
|
||||||
|
void setOrdinal(int ordinal) {
|
||||||
|
if (this.ordinal != -1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"DataDrivenLivingMotion " + this.id + " ordinal already set to "
|
||||||
|
+ this.ordinal + " (tried to set " + ordinal + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ordinal < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Negative ordinal " + ordinal + " for " + this.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.ordinal = ordinal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceLocation id() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String description() {
|
||||||
|
return this.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String category() {
|
||||||
|
return this.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int universalOrdinal() {
|
||||||
|
return this.ordinal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La cle de dedup dans {@link com.tiedup.remake.rig.util.ExtendableEnumManager}
|
||||||
|
* est {@code toString().toLowerCase()}. On expose la RL complete pour garantir
|
||||||
|
* l'unicite cross-namespace ({@code mymod:foo} != {@code tiedup:foo}).
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return this.id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof DataDrivenLivingMotion other)) return false;
|
||||||
|
return this.id.equals(other.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return this.id.hashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.rig.anim;
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.packs.resources.ResourceManager;
|
||||||
|
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener;
|
||||||
|
import net.minecraft.util.profiling.ProfilerFiller;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import com.tiedup.remake.rig.TiedUpRigConstants;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanne les fichiers JSON {@code data/<ns>/tiedup/living_motions/*.json} et
|
||||||
|
* enregistre chacun en tant que {@link DataDrivenLivingMotion} dans le
|
||||||
|
* registre partage {@link LivingMotion#ENUM_MANAGER}.
|
||||||
|
*
|
||||||
|
* <h2>But</h2>
|
||||||
|
* <p>Permettre a un modder/resourcepack-maker d'ajouter de nouvelles
|
||||||
|
* {@link LivingMotion} (ex. {@code mymod:orgasm_shake}) sans coder un enum
|
||||||
|
* Java + sans appel explicite a {@code LivingMotion.ENUM_MANAGER.registerEnumCls}.
|
||||||
|
* Workflow 100% data-driven.</p>
|
||||||
|
*
|
||||||
|
* <h2>Format JSON attendu</h2>
|
||||||
|
* <pre>{@code
|
||||||
|
* {
|
||||||
|
* "description": "Orgasm shake shiver anim — fired on VX state",
|
||||||
|
* "category": "vx_reactions"
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
* <p>Le champ {@code description} est obligatoire (humain + logs). Le champ
|
||||||
|
* {@code category} est optionnel et sert au regroupement editorial uniquement
|
||||||
|
* (ex. {@code locomotion}, {@code vx_reactions}, {@code restraint}...).</p>
|
||||||
|
*
|
||||||
|
* <h2>Ordinal stability cross-reload</h2>
|
||||||
|
* <p>Le {@link com.tiedup.remake.rig.util.ExtendableEnumManager#assign} refuse
|
||||||
|
* d'enregistrer deux fois la meme cle (throw {@link IllegalArgumentException}).
|
||||||
|
* On garde donc une vue persistante {@link #PERSISTENT_REGISTRY} : au premier
|
||||||
|
* load d'un id, l'ordinal est attribue et le {@link DataDrivenLivingMotion}
|
||||||
|
* est cache ; les reloads ulterieurs re-utilisent la meme instance (et donc
|
||||||
|
* le meme ordinal). Le cache survit aux {@code /reload} (static + JVM lifetime),
|
||||||
|
* mais PAS aux restart de serveur — voir section "Limitations".</p>
|
||||||
|
*
|
||||||
|
* <h2>Limitations connues</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Les ordinals sont stables pendant une session JVM mais re-attribues
|
||||||
|
* apres restart — l'ordre de decouverte des fichiers JSON (alpha-sorted
|
||||||
|
* par ResourceLocation) determine l'ordinal initial. Si un modder
|
||||||
|
* serialise l'ordinal (ex. network packet ou NBT), la reference cassera
|
||||||
|
* apres restart si un nouveau motion est ajoute avant dans l'ordre de
|
||||||
|
* scan. En pratique, tous les consumers internes TiedUp! referencent
|
||||||
|
* les motions par {@link ResourceLocation}, pas par ordinal — le
|
||||||
|
* probleme ne se manifeste que si un mod tiers persiste l'ordinal.</li>
|
||||||
|
* <li>Un JSON mal forme (pas de {@code description} ou type invalide) est
|
||||||
|
* skip avec un WARN ; le reste du batch continue.</li>
|
||||||
|
* <li>Un meme id re-charge avec une description differente emet un WARN
|
||||||
|
* mais garde la PREMIERE description en memoire (immuable). La nouvelle
|
||||||
|
* description apparait au prochain restart JVM.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Side & threading</h2>
|
||||||
|
* <p>{@link SimpleJsonResourceReloadListener#apply} s'execute cote serveur a
|
||||||
|
* chaque {@code /reload} (+ worldload), et cote client au resource reload
|
||||||
|
* (F3+T). Les deux sides partagent le meme {@link LivingMotion#ENUM_MANAGER}
|
||||||
|
* (static JVM-wide) — sur serveur integre, les sides pointent vers le meme
|
||||||
|
* registre ; pas de double comptabilite. {@code synchronized} sur
|
||||||
|
* {@code ExtendableEnumManager.assign} absorbe le risque theorique de race
|
||||||
|
* entre le thread de reload serveur et le thread client.</p>
|
||||||
|
*/
|
||||||
|
public class LivingMotionReloadListener extends SimpleJsonResourceReloadListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache JVM-wide des motions data-driven : une entree = un ordinal
|
||||||
|
* definitivement reserve. Le mutex {@link LivingMotion#ENUM_MANAGER} (via
|
||||||
|
* {@code synchronized} sur {@code assign}) protege les inserts ; cette
|
||||||
|
* {@link ConcurrentHashMap} supporte les {@code get} concurrents sans
|
||||||
|
* bloquer.
|
||||||
|
*/
|
||||||
|
private static final Map<ResourceLocation, DataDrivenLivingMotion> PERSISTENT_REGISTRY =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache des descriptions pour permettre un WARN quand un reload change la
|
||||||
|
* description d'un motion existant (l'instance en memoire ne peut pas etre
|
||||||
|
* mise a jour — ordinal deja consomme et enregistre dans le ENUM_MANAGER).
|
||||||
|
*/
|
||||||
|
private static final Map<ResourceLocation, String> LAST_SEEN_DESCRIPTIONS =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Dossier scanne : {@code data/<ns>/tiedup/living_motions/*.json}. */
|
||||||
|
public static final String DIRECTORY = "tiedup/living_motions";
|
||||||
|
|
||||||
|
public LivingMotionReloadListener() {
|
||||||
|
super(new GsonBuilder().create(), DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout une motion data-driven par sa ResourceLocation.
|
||||||
|
*
|
||||||
|
* @param id identifiant namespace:path (ex. {@code mymod:orgasm_shake})
|
||||||
|
* @return la motion enregistree, ou {@code null} si jamais vue
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static DataDrivenLivingMotion get(ResourceLocation id) {
|
||||||
|
return PERSISTENT_REGISTRY.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre de motions data-driven actuellement connues. */
|
||||||
|
public static int size() {
|
||||||
|
return PERSISTENT_REGISTRY.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue immutable du registre, exposee pour debug / tests.
|
||||||
|
*/
|
||||||
|
public static Map<ResourceLocation, DataDrivenLivingMotion> view() {
|
||||||
|
return Collections.unmodifiableMap(PERSISTENT_REGISTRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test hook — vide le registre data-driven. NE vide PAS le
|
||||||
|
* {@link LivingMotion#ENUM_MANAGER} sous-jacent (qui ne le supporte pas
|
||||||
|
* nativement), donc a n'utiliser que dans des tests isoles ou le reset
|
||||||
|
* d'ordinal ne cause pas de collision avec les enum builtin.
|
||||||
|
*
|
||||||
|
* <p>En prod, le registre ne se vide jamais — c'est intentionnel
|
||||||
|
* (preservation des ordinals pendant la session JVM).</p>
|
||||||
|
*/
|
||||||
|
static void clearForTests() {
|
||||||
|
PERSISTENT_REGISTRY.clear();
|
||||||
|
LAST_SEEN_DESCRIPTIONS.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void apply(
|
||||||
|
Map<ResourceLocation, JsonElement> objectIn,
|
||||||
|
ResourceManager resourceManager,
|
||||||
|
ProfilerFiller profileFiller
|
||||||
|
) {
|
||||||
|
// Ordre alphabetique stable : si deux JSON sont vus pour la premiere
|
||||||
|
// fois au meme reload, l'ordre de ResourceLocation.compareTo
|
||||||
|
// determine l'ordre d'assignation. Reproductible entre deux boot JVM
|
||||||
|
// avec le meme ensemble de fichiers (evite les ordinals qui dansent).
|
||||||
|
Map<ResourceLocation, JsonElement> sorted = new TreeMap<>(objectIn);
|
||||||
|
|
||||||
|
int added = 0;
|
||||||
|
int reloaded = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
for (Map.Entry<ResourceLocation, JsonElement> entry : sorted.entrySet()) {
|
||||||
|
ResourceLocation id = entry.getKey();
|
||||||
|
JsonElement element = entry.getValue();
|
||||||
|
|
||||||
|
if (!element.isJsonObject()) {
|
||||||
|
TiedUpRigConstants.LOGGER.warn(
|
||||||
|
"[LivingMotionReloadListener] Skipping {} : top-level JSON is not an object",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject obj = element.getAsJsonObject();
|
||||||
|
String description = readStringOrNull(obj, "description");
|
||||||
|
if (description == null) {
|
||||||
|
TiedUpRigConstants.LOGGER.warn(
|
||||||
|
"[LivingMotionReloadListener] Skipping {} : missing or invalid 'description'",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String category = readStringOrNull(obj, "category");
|
||||||
|
|
||||||
|
// Deja connu ? Reutilise l'instance — ordinal stable, pas de
|
||||||
|
// double-assign (qui throw IAE dans ExtendableEnumManager).
|
||||||
|
DataDrivenLivingMotion existing = PERSISTENT_REGISTRY.get(id);
|
||||||
|
if (existing != null) {
|
||||||
|
String lastDesc = LAST_SEEN_DESCRIPTIONS.get(id);
|
||||||
|
if (!Objects.equals(lastDesc, description)) {
|
||||||
|
TiedUpRigConstants.LOGGER.warn(
|
||||||
|
"[LivingMotionReloadListener] Motion {} reloaded with a different description "
|
||||||
|
+ "(was '{}', now '{}') — ordinal remains {}, new description takes effect "
|
||||||
|
+ "at next JVM restart",
|
||||||
|
id, lastDesc, description, existing.universalOrdinal()
|
||||||
|
);
|
||||||
|
LAST_SEEN_DESCRIPTIONS.put(id, description);
|
||||||
|
}
|
||||||
|
reloaded++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nouveau motion : construction -> assign() -> pose ordinal.
|
||||||
|
// Le {@code DataDrivenLivingMotion} est construit avec ordinal=-1,
|
||||||
|
// puis {@code ExtendableEnumManager.assign} l'indexe par son
|
||||||
|
// {@code toString()} (la RL full) et retourne l'ordinal concret.
|
||||||
|
// On pose ensuite l'ordinal sur l'instance via {@code setOrdinal}.
|
||||||
|
// L'instance stockee dans les maps internes du ENUM_MANAGER EST
|
||||||
|
// la meme reference que celle dans PERSISTENT_REGISTRY — le set
|
||||||
|
// est donc visible a travers tous les lookups.
|
||||||
|
DataDrivenLivingMotion motion =
|
||||||
|
new DataDrivenLivingMotion(id, description, category);
|
||||||
|
int assignedOrdinal;
|
||||||
|
try {
|
||||||
|
assignedOrdinal = LivingMotion.ENUM_MANAGER.assign(motion);
|
||||||
|
} catch (IllegalArgumentException dup) {
|
||||||
|
// Le ENUM_MANAGER contient deja un motion avec cette cle
|
||||||
|
// (cas limite : conflit avec un enum builtin qui reserverait
|
||||||
|
// deliberement le meme toString(), p.ex. un modder qui
|
||||||
|
// appelle {@code mymod:orgasm_shake} un enum Java ET un JSON).
|
||||||
|
TiedUpRigConstants.LOGGER.warn(
|
||||||
|
"[LivingMotionReloadListener] Skipping {} : name collision in ENUM_MANAGER ({})",
|
||||||
|
id, dup.getMessage()
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
motion.setOrdinal(assignedOrdinal);
|
||||||
|
PERSISTENT_REGISTRY.put(id, motion);
|
||||||
|
LAST_SEEN_DESCRIPTIONS.put(id, description);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpRigConstants.LOGGER.info(
|
||||||
|
"[LivingMotionReloadListener] Reload done : {} new motion(s) registered, "
|
||||||
|
+ "{} motion(s) reloaded (ordinal preserved), {} skipped",
|
||||||
|
added, reloaded, skipped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static String readStringOrNull(JsonObject obj, String key) {
|
||||||
|
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
||||||
|
JsonElement elem = obj.get(key);
|
||||||
|
if (!elem.isJsonPrimitive() || !elem.getAsJsonPrimitive().isString()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return elem.getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook d'apply direct pour tests — {@link SimpleJsonResourceReloadListener#apply}
|
||||||
|
* est {@code protected}, ce helper l'expose en public pour que les tests
|
||||||
|
* cross-package ({@code DataDrivenItemParserAnimationsTest}) puissent
|
||||||
|
* alimenter le registry sans bootstrap MC.
|
||||||
|
*
|
||||||
|
* <p>Le {@code ResourceManager} et le {@code ProfilerFiller} ne sont
|
||||||
|
* pas lus par notre {@code apply}, on peut passer {@code null} en test.</p>
|
||||||
|
*/
|
||||||
|
public void applyForTests(Map<ResourceLocation, JsonElement> data) {
|
||||||
|
this.apply(data, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.v2.bondage;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose type ajoute via datapack, coexistant avec les 6 valeurs builtin de
|
||||||
|
* {@link com.tiedup.remake.items.base.PoseType}.
|
||||||
|
*
|
||||||
|
* <p>Modder path : deposer un JSON dans
|
||||||
|
* {@code data/<ns>/tiedup/pose_types/<name>.json}. Le {@link PoseTypeReloadListener}
|
||||||
|
* le detecte et l'enregistre dans {@link PoseTypeRegistry}.</p>
|
||||||
|
*
|
||||||
|
* <p>Ces poses sont INVISIBLES aux 17 call-sites V1 de {@code PoseType} (qui
|
||||||
|
* ne connaissent que les 6 enum values). Seuls les consumers Phase 3
|
||||||
|
* ({@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemParser},
|
||||||
|
* {@link com.tiedup.remake.v2.bondage.PoseTypeHelper#getPoseTypeRef}, etc.)
|
||||||
|
* peuvent resoudre ces poses via {@link PoseTypeRef}.</p>
|
||||||
|
*
|
||||||
|
* @param id identifiant canonique (namespace:path)
|
||||||
|
* @param description texte humain pour debug / logs
|
||||||
|
* @param defaultAnimation animation de reference suggérée pour cette pose,
|
||||||
|
* ou {@code null}. Ne pas confondre avec le binding
|
||||||
|
* effectif dans un item — les items choisissent eux-memes
|
||||||
|
* leurs animations via le champ {@code animations}.
|
||||||
|
* @param suggestedPriority priorite par defaut suggeree si un item n'en
|
||||||
|
* declare pas (peut etre override par item).
|
||||||
|
* @param metadata map additionnelle modder-free-form, conservee telle quelle
|
||||||
|
* pour usages futurs (intensity tier, tag set, etc.).
|
||||||
|
* Toujours immuable apres construction.
|
||||||
|
*/
|
||||||
|
public record DataDrivenPoseType(
|
||||||
|
ResourceLocation id,
|
||||||
|
String description,
|
||||||
|
@Nullable ResourceLocation defaultAnimation,
|
||||||
|
int suggestedPriority,
|
||||||
|
Map<String, Object> metadata
|
||||||
|
) {
|
||||||
|
|
||||||
|
public DataDrivenPoseType {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
Objects.requireNonNull(description, "description");
|
||||||
|
// Defensive copy + unmodifiable view : mutations to the caller's map
|
||||||
|
// after construction must NOT leak into the record's view. A plain
|
||||||
|
// {@code Collections.unmodifiableMap(caller)} would forward the
|
||||||
|
// caller's mutations — we need a snapshot.
|
||||||
|
metadata = (metadata == null || metadata.isEmpty())
|
||||||
|
? Collections.emptyMap()
|
||||||
|
: Collections.unmodifiableMap(new java.util.LinkedHashMap<>(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience factory without metadata. */
|
||||||
|
public static DataDrivenPoseType of(
|
||||||
|
ResourceLocation id,
|
||||||
|
String description,
|
||||||
|
@Nullable ResourceLocation defaultAnimation,
|
||||||
|
int suggestedPriority
|
||||||
|
) {
|
||||||
|
return new DataDrivenPoseType(
|
||||||
|
id, description, defaultAnimation, suggestedPriority,
|
||||||
|
Collections.emptyMap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,28 +3,129 @@ package com.tiedup.remake.v2.bondage;
|
|||||||
import com.tiedup.remake.items.base.PoseType;
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
||||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the {@link PoseType} for any bondage item stack.
|
* Resolves the {@link PoseType} for any bondage item stack.
|
||||||
*
|
*
|
||||||
* <p>Reads from the data-driven definition's {@code pose_type} field,
|
* <p>Reads from the data-driven definition's {@code pose_type} field,
|
||||||
* falling back to {@link PoseType#STANDARD} if absent.</p>
|
* falling back to {@link PoseType#STANDARD} if absent.</p>
|
||||||
|
*
|
||||||
|
* <h2>Two resolution paths</h2>
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link #getPoseType(ItemStack)} — LEGACY path, returns a {@link PoseType}
|
||||||
|
* enum value. Used by the 17 V1 call-sites that consume the enum directly
|
||||||
|
* (MixinCamera, DogPoseRenderHandler, etc.). Data-driven pose types are
|
||||||
|
* invisible here — the method falls back to {@link PoseType#STANDARD}
|
||||||
|
* for any custom ID it does not recognize.</li>
|
||||||
|
* <li>{@link #getPoseTypeRef(ItemStack)} — NEW path (Phase 3 / Wave B), returns
|
||||||
|
* a {@link PoseTypeRef} that may be either {@link PoseTypeRef.Builtin} or
|
||||||
|
* {@link PoseTypeRef.DataDriven}. Used by Phase 3 consumers that WANT to
|
||||||
|
* see modder custom poses (e.g. {@code mymod:quadruped_hogtie}).</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Both methods read the same underlying {@code pose_type} field in the item
|
||||||
|
* definition JSON ; the difference is purely in the return type.</p>
|
||||||
*/
|
*/
|
||||||
public final class PoseTypeHelper {
|
public final class PoseTypeHelper {
|
||||||
|
|
||||||
private PoseTypeHelper() {}
|
private PoseTypeHelper() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy path — returns a {@link PoseType} enum value suitable for V1
|
||||||
|
* consumers. Any data-driven (modder) pose ID falls back to
|
||||||
|
* {@link PoseType#STANDARD}.
|
||||||
|
*/
|
||||||
public static PoseType getPoseType(ItemStack stack) {
|
public static PoseType getPoseType(ItemStack stack) {
|
||||||
// V2: read from data-driven definition
|
// V2: read from data-driven definition
|
||||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||||
if (def != null && def.poseType() != null) {
|
if (def != null && def.poseType() != null) {
|
||||||
try {
|
return resolveEnumFromString(def.poseType());
|
||||||
return PoseType.valueOf(def.poseType().toUpperCase());
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return PoseType.STANDARD;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return PoseType.STANDARD;
|
return PoseType.STANDARD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New path — returns a {@link PoseTypeRef} that preserves the distinction
|
||||||
|
* between builtin enum values and data-driven types. Phase 3 consumers
|
||||||
|
* should prefer this over {@link #getPoseType(ItemStack)} to observe
|
||||||
|
* modder-added poses.
|
||||||
|
*
|
||||||
|
* @param stack the item stack to inspect
|
||||||
|
* @return a {@link PoseTypeRef.Builtin} wrapping {@link PoseType#STANDARD}
|
||||||
|
* if no definition or unresolvable, a {@link PoseTypeRef.Builtin}
|
||||||
|
* for builtin enum IDs, or a {@link PoseTypeRef.DataDriven} for
|
||||||
|
* datapack-defined types
|
||||||
|
*/
|
||||||
|
public static PoseTypeRef getPoseTypeRef(ItemStack stack) {
|
||||||
|
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||||
|
String raw = (def == null) ? null : def.poseType();
|
||||||
|
if (raw == null || raw.isEmpty()) {
|
||||||
|
return new PoseTypeRef.Builtin(
|
||||||
|
PoseTypeRegistry.idOf(PoseType.STANDARD),
|
||||||
|
PoseType.STANDARD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolveRefFromString(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a raw string from the {@code pose_type} JSON field to a
|
||||||
|
* {@link PoseType} enum value. Two input formats supported :
|
||||||
|
* <ul>
|
||||||
|
* <li>Enum name (UPPER_SNAKE_CASE) : {@code "DOG"}, {@code "STANDARD"}.
|
||||||
|
* Legacy format, still supported for V1-authored items.</li>
|
||||||
|
* <li>ResourceLocation string : {@code "tiedup:dog"}. New format,
|
||||||
|
* matched against {@link PoseTypeRegistry#get}. If the ID
|
||||||
|
* resolves to a data-driven type, this method returns
|
||||||
|
* {@link PoseType#STANDARD} (data-driven invisible to the enum path).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private static PoseType resolveEnumFromString(String raw) {
|
||||||
|
// Try RL path first (new format) — ID matching takes priority so
|
||||||
|
// {@code "DOG"} could also be interpreted as {@code "minecraft:DOG"}
|
||||||
|
// by ResourceLocation.tryParse, but that's clearly not a valid RL
|
||||||
|
// (uppercase chars rejected by path regex). Try enum first, fall
|
||||||
|
// back to RL.
|
||||||
|
try {
|
||||||
|
return PoseType.valueOf(raw.toUpperCase(java.util.Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// not an enum name — try RL
|
||||||
|
}
|
||||||
|
ResourceLocation rl = ResourceLocation.tryParse(raw);
|
||||||
|
if (rl != null) {
|
||||||
|
PoseTypeRef ref = PoseTypeRegistry.get(rl);
|
||||||
|
if (ref instanceof PoseTypeRef.Builtin b) return b.poseType();
|
||||||
|
// Data-driven / unknown : fallback STANDARD for legacy path
|
||||||
|
}
|
||||||
|
return PoseType.STANDARD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a raw string to a {@link PoseTypeRef}. Same dual format as
|
||||||
|
* {@link #resolveEnumFromString}, but data-driven IDs return a
|
||||||
|
* {@link PoseTypeRef.DataDriven}.
|
||||||
|
*/
|
||||||
|
private static PoseTypeRef resolveRefFromString(String raw) {
|
||||||
|
// Enum-name path first
|
||||||
|
try {
|
||||||
|
PoseType pt = PoseType.valueOf(raw.toUpperCase(java.util.Locale.ROOT));
|
||||||
|
return new PoseTypeRef.Builtin(PoseTypeRegistry.idOf(pt), pt);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// not an enum name
|
||||||
|
}
|
||||||
|
// RL path — could resolve to builtin or data-driven
|
||||||
|
ResourceLocation rl = ResourceLocation.tryParse(raw);
|
||||||
|
if (rl != null) {
|
||||||
|
@Nullable PoseTypeRef ref = PoseTypeRegistry.get(rl);
|
||||||
|
if (ref != null) return ref;
|
||||||
|
}
|
||||||
|
// Unknown — fallback to STANDARD, mirroring the legacy behavior.
|
||||||
|
return new PoseTypeRef.Builtin(
|
||||||
|
PoseTypeRegistry.idOf(PoseType.STANDARD),
|
||||||
|
PoseType.STANDARD
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRef.java
Normal file
59
src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRef.java
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.v2.bondage;
|
||||||
|
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference abstraite a un pose type — soit une valeur de l'enum builtin
|
||||||
|
* {@link PoseType}, soit un {@link DataDrivenPoseType} defini via datapack.
|
||||||
|
*
|
||||||
|
* <p>Type sealed : seules deux implementations ({@link Builtin} et
|
||||||
|
* {@link DataDriven}) sont reconnues. Permet au switch-statement de consumer
|
||||||
|
* exhaustif sur les deux variantes.</p>
|
||||||
|
*
|
||||||
|
* <h2>Pattern d'usage</h2>
|
||||||
|
* <pre>{@code
|
||||||
|
* PoseTypeRef ref = PoseTypeRegistry.get(id);
|
||||||
|
* if (ref instanceof PoseTypeRef.Builtin b) {
|
||||||
|
* PoseType vanilla = b.poseType(); // rendering legacy
|
||||||
|
* } else if (ref instanceof PoseTypeRef.DataDriven dd) {
|
||||||
|
* DataDrivenPoseType ext = dd.poseType(); // rendering custom
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Les V1 call-sites de {@link PoseType} ne devraient pas voir les
|
||||||
|
* {@link DataDriven} refs — ils ne savent pas quoi en faire (pas de
|
||||||
|
* {@link PoseType#getAnimationId} sur un pose custom). Les Phase 3 consumers
|
||||||
|
* (V2 data-driven pipeline) sont les seuls a beneficier du chemin
|
||||||
|
* {@link DataDriven}.</p>
|
||||||
|
*/
|
||||||
|
public sealed interface PoseTypeRef permits PoseTypeRef.Builtin, PoseTypeRef.DataDriven {
|
||||||
|
|
||||||
|
/** ResourceLocation canonique (namespace:path). */
|
||||||
|
ResourceLocation id();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience : retourne la {@link PoseType} enum si builtin, {@code null}
|
||||||
|
* si data-driven. Permet aux consumers legacy de continuer a lire la
|
||||||
|
* valeur enum sans switch explicite.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
default PoseType asBuiltinOrNull() {
|
||||||
|
return (this instanceof Builtin b) ? b.poseType() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference vers une valeur enum {@link PoseType} builtin.
|
||||||
|
*/
|
||||||
|
record Builtin(ResourceLocation id, PoseType poseType) implements PoseTypeRef {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference vers un {@link DataDrivenPoseType} defini par datapack.
|
||||||
|
*/
|
||||||
|
record DataDriven(ResourceLocation id, DataDrivenPoseType poseType) implements PoseTypeRef {}
|
||||||
|
}
|
||||||
197
src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRegistry.java
Normal file
197
src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRegistry.java
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.v2.bondage;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registre unifie des poses — fusionne les 6 valeurs enum builtin de
|
||||||
|
* {@link PoseType} avec les poses additionnelles definies par datapack via
|
||||||
|
* {@link PoseTypeReloadListener}.
|
||||||
|
*
|
||||||
|
* <h2>Approche additive</h2>
|
||||||
|
* <p>L'enum {@link PoseType} reste la source de verite pour les 17 V1
|
||||||
|
* call-sites ({@code MixinCamera}, {@code DogPoseRenderHandler}, etc.). On
|
||||||
|
* ne le modifie PAS. Ce registre expose un chemin parallele via
|
||||||
|
* {@link PoseTypeRef} que les consumers Phase 3 peuvent emprunter quand ils
|
||||||
|
* veulent consommer aussi les poses data-driven.</p>
|
||||||
|
*
|
||||||
|
* <h2>IDs canoniques</h2>
|
||||||
|
* <p>Chaque valeur enum builtin est mappee a un ID canonique
|
||||||
|
* {@code tiedup:<lowercase_enum_name>} :
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link PoseType#STANDARD} -> {@code tiedup:standard}</li>
|
||||||
|
* <li>{@link PoseType#STRAITJACKET} -> {@code tiedup:straitjacket}</li>
|
||||||
|
* <li>{@link PoseType#WRAP} -> {@code tiedup:wrap}</li>
|
||||||
|
* <li>{@link PoseType#LATEX_SACK} -> {@code tiedup:latex_sack}</li>
|
||||||
|
* <li>{@link PoseType#DOG} -> {@code tiedup:dog}</li>
|
||||||
|
* <li>{@link PoseType#HUMAN_CHAIR} -> {@code tiedup:human_chair}</li>
|
||||||
|
* </ul>
|
||||||
|
* Ces IDs sont reserves pour toujours — meme si un datapack tiers tente de
|
||||||
|
* re-enregistrer l'un d'eux, le {@link #registerDataDriven} leve un WARN et
|
||||||
|
* garde la valeur builtin intacte.</p>
|
||||||
|
*
|
||||||
|
* <h2>Thread safety</h2>
|
||||||
|
* <p>{@link #BUILTIN_BY_ID} est initialise en classload et n'est jamais mute
|
||||||
|
* (final fields + {@link Map#copyOf}). {@link #DATAPACK_TYPES} est une
|
||||||
|
* {@link ConcurrentHashMap} pour absorber les {@code get} concurrents pendant
|
||||||
|
* qu'un reload re-ecrit la map cote server thread.</p>
|
||||||
|
*/
|
||||||
|
public final class PoseTypeRegistry {
|
||||||
|
|
||||||
|
private PoseTypeRegistry() {}
|
||||||
|
|
||||||
|
/** Builtin pose types derived from {@link PoseType} enum. */
|
||||||
|
private static final Map<ResourceLocation, PoseType> BUILTIN_BY_ID;
|
||||||
|
|
||||||
|
/** Inverse : {@link PoseType} -> canonical {@link ResourceLocation}. */
|
||||||
|
private static final Map<PoseType, ResourceLocation> BUILTIN_BY_ENUM;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<ResourceLocation, PoseType> byId = new HashMap<>();
|
||||||
|
Map<PoseType, ResourceLocation> byEnum = new EnumMap<>(PoseType.class);
|
||||||
|
for (PoseType pt : PoseType.values()) {
|
||||||
|
ResourceLocation rl = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
TiedUpMod.MOD_ID,
|
||||||
|
pt.name().toLowerCase(java.util.Locale.ROOT)
|
||||||
|
);
|
||||||
|
byId.put(rl, pt);
|
||||||
|
byEnum.put(pt, rl);
|
||||||
|
}
|
||||||
|
BUILTIN_BY_ID = Collections.unmodifiableMap(byId);
|
||||||
|
BUILTIN_BY_ENUM = Collections.unmodifiableMap(byEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data-driven pose types registered at datapack reload. */
|
||||||
|
private static final Map<ResourceLocation, DataDrivenPoseType> DATAPACK_TYPES =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// ========== Lookups ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the canonical ID for a builtin enum value.
|
||||||
|
*
|
||||||
|
* @param pose enum value, must not be null
|
||||||
|
* @return its canonical ResourceLocation (e.g. {@code tiedup:dog})
|
||||||
|
* @throws IllegalStateException if the enum value is not in the builtin
|
||||||
|
* map (should be impossible at runtime — defensive)
|
||||||
|
*/
|
||||||
|
public static ResourceLocation idOf(PoseType pose) {
|
||||||
|
ResourceLocation rl = BUILTIN_BY_ENUM.get(pose);
|
||||||
|
if (rl == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"PoseType " + pose + " has no canonical ID — registry bug"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a pose type by ID.
|
||||||
|
*
|
||||||
|
* <p>Check order :
|
||||||
|
* <ol>
|
||||||
|
* <li>Builtin enum mapping — the 6 reserved IDs.</li>
|
||||||
|
* <li>Datapack map — any {@link DataDrivenPoseType} registered by
|
||||||
|
* {@link PoseTypeReloadListener}.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param id the canonical or custom ID
|
||||||
|
* @return a {@link PoseTypeRef} wrapping the enum or data-driven type,
|
||||||
|
* or {@code null} if no pose is registered under that ID
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static PoseTypeRef get(ResourceLocation id) {
|
||||||
|
if (id == null) return null;
|
||||||
|
PoseType builtin = BUILTIN_BY_ID.get(id);
|
||||||
|
if (builtin != null) {
|
||||||
|
return new PoseTypeRef.Builtin(id, builtin);
|
||||||
|
}
|
||||||
|
DataDrivenPoseType dp = DATAPACK_TYPES.get(id);
|
||||||
|
if (dp != null) {
|
||||||
|
return new PoseTypeRef.DataDriven(id, dp);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return {@code true} if this ID maps to a builtin enum value. */
|
||||||
|
public static boolean isBuiltin(ResourceLocation id) {
|
||||||
|
return id != null && BUILTIN_BY_ID.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return {@code true} if this ID maps to a data-driven type. */
|
||||||
|
public static boolean isDataDriven(ResourceLocation id) {
|
||||||
|
return id != null && DATAPACK_TYPES.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View of all known pose type IDs (builtin + data-driven).
|
||||||
|
* Useful for debug commands / suggestion providers.
|
||||||
|
*/
|
||||||
|
public static java.util.Set<ResourceLocation> allIds() {
|
||||||
|
java.util.Set<ResourceLocation> out = new java.util.HashSet<>(BUILTIN_BY_ID.keySet());
|
||||||
|
out.addAll(DATAPACK_TYPES.keySet());
|
||||||
|
return Collections.unmodifiableSet(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only view of data-driven pose types only. */
|
||||||
|
public static Map<ResourceLocation, DataDrivenPoseType> dataDrivenView() {
|
||||||
|
return Collections.unmodifiableMap(DATAPACK_TYPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mutators (reload path) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers (or replaces) a data-driven pose type.
|
||||||
|
*
|
||||||
|
* <p>If the ID is already reserved by a builtin enum value, the call is
|
||||||
|
* rejected with a WARN — builtin IDs ({@code tiedup:dog}, etc.) are
|
||||||
|
* immutable.</p>
|
||||||
|
*
|
||||||
|
* <p>If the ID is already in the datapack map, the previous entry is
|
||||||
|
* replaced silently (reload semantics). The caller may see the old
|
||||||
|
* metadata via {@link #dataDrivenView} BEFORE calling this method.</p>
|
||||||
|
*
|
||||||
|
* @param type the data-driven type to register, must not be null
|
||||||
|
*/
|
||||||
|
public static void registerDataDriven(DataDrivenPoseType type) {
|
||||||
|
if (type == null) return;
|
||||||
|
ResourceLocation id = type.id();
|
||||||
|
if (BUILTIN_BY_ID.containsKey(id)) {
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[PoseTypeRegistry] Refusing to register data-driven pose {} : "
|
||||||
|
+ "ID is reserved by a builtin PoseType enum value ({})",
|
||||||
|
id, BUILTIN_BY_ID.get(id).name()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DataDrivenPoseType prev = DATAPACK_TYPES.put(id, type);
|
||||||
|
if (prev != null) {
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[PoseTypeRegistry] Replaced data-driven pose {} (reload)",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears ALL data-driven pose types. Called at the start of every
|
||||||
|
* {@link PoseTypeReloadListener#apply(Map, net.minecraft.server.packs.resources.ResourceManager, net.minecraft.util.profiling.ProfilerFiller)}.
|
||||||
|
* Builtins are never affected.
|
||||||
|
*/
|
||||||
|
public static void clearDataDriven() {
|
||||||
|
DATAPACK_TYPES.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.v2.bondage;
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.packs.resources.ResourceManager;
|
||||||
|
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener;
|
||||||
|
import net.minecraft.util.profiling.ProfilerFiller;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanne {@code data/<ns>/tiedup/pose_types/*.json} et alimente
|
||||||
|
* {@link PoseTypeRegistry#registerDataDriven}.
|
||||||
|
*
|
||||||
|
* <h2>Format JSON</h2>
|
||||||
|
* <pre>{@code
|
||||||
|
* {
|
||||||
|
* "description": "Quadrupede hog-tie pose",
|
||||||
|
* "default_animation": "mymod:quadruped_hogtie_idle",
|
||||||
|
* "suggested_priority": 25
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Champs :
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code description} (string, obligatoire) — libelle humain pour debug/logs.</li>
|
||||||
|
* <li>{@code default_animation} (ResourceLocation string, optionnel) —
|
||||||
|
* suggestion d'animation par defaut. Les items choisissent leurs
|
||||||
|
* bindings via leur propre champ {@code animations} — cette valeur
|
||||||
|
* n'est PAS auto-appliquee.</li>
|
||||||
|
* <li>{@code suggested_priority} (int, optionnel, defaut 0) — priorite par
|
||||||
|
* defaut si un item reference cette pose sans preciser sa propre
|
||||||
|
* priorite.</li>
|
||||||
|
* <li>Tous les autres champs sont collectes dans {@code metadata} et
|
||||||
|
* conserves telles quelles pour usages futurs (pas de schema strict).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Isolation vs enum builtin</h2>
|
||||||
|
* <p>Les 6 IDs canoniques {@code tiedup:{standard,straitjacket,wrap,latex_sack,dog,human_chair}}
|
||||||
|
* sont reserves par {@link PoseTypeRegistry}. Un JSON qui tente de les
|
||||||
|
* redefinir est skip avec WARN. Les V1 call-sites de {@link com.tiedup.remake.items.base.PoseType}
|
||||||
|
* ne voient JAMAIS les poses data-driven — ils consomment l'enum directement
|
||||||
|
* et cette classe n'y touche pas.</p>
|
||||||
|
*/
|
||||||
|
public class PoseTypeReloadListener extends SimpleJsonResourceReloadListener {
|
||||||
|
|
||||||
|
/** Dossier scanne : {@code data/<ns>/tiedup/pose_types/*.json}. */
|
||||||
|
public static final String DIRECTORY = "tiedup/pose_types";
|
||||||
|
|
||||||
|
public PoseTypeReloadListener() {
|
||||||
|
super(new GsonBuilder().create(), DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void apply(
|
||||||
|
Map<ResourceLocation, JsonElement> objectIn,
|
||||||
|
ResourceManager resourceManager,
|
||||||
|
ProfilerFiller profileFiller
|
||||||
|
) {
|
||||||
|
// Full replace — datapack reload is authoritative. Builtins are
|
||||||
|
// unaffected (clearDataDriven only touches DATAPACK_TYPES).
|
||||||
|
PoseTypeRegistry.clearDataDriven();
|
||||||
|
|
||||||
|
int loaded = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
for (Map.Entry<ResourceLocation, JsonElement> entry : objectIn.entrySet()) {
|
||||||
|
ResourceLocation id = entry.getKey();
|
||||||
|
JsonElement element = entry.getValue();
|
||||||
|
|
||||||
|
if (!element.isJsonObject()) {
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[PoseTypeReloadListener] Skipping {} : top-level JSON is not an object",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataDrivenPoseType type = parse(id, element.getAsJsonObject());
|
||||||
|
if (type == null) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerDataDriven auto-rejette les IDs reserves par l'enum
|
||||||
|
// builtin (tiedup:dog etc.). Pas besoin de check ici.
|
||||||
|
PoseTypeRegistry.registerDataDriven(type);
|
||||||
|
loaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[PoseTypeReloadListener] Reload done : {} data-driven pose type(s) loaded, {} skipped",
|
||||||
|
loaded, skipped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse un JSON object en {@link DataDrivenPoseType}. Retourne {@code null}
|
||||||
|
* si des champs obligatoires manquent.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private DataDrivenPoseType parse(ResourceLocation id, JsonObject obj) {
|
||||||
|
String description = readStringOrNull(obj, "description");
|
||||||
|
if (description == null) {
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[PoseTypeReloadListener] Skipping {} : missing or invalid 'description'",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// default_animation : optional RL string
|
||||||
|
ResourceLocation defaultAnimation = null;
|
||||||
|
String animStr = readStringOrNull(obj, "default_animation");
|
||||||
|
if (animStr != null) {
|
||||||
|
defaultAnimation = ResourceLocation.tryParse(animStr);
|
||||||
|
if (defaultAnimation == null) {
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[PoseTypeReloadListener] In {} : invalid default_animation '{}', setting to null",
|
||||||
|
id, animStr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggested_priority : optional int, default 0
|
||||||
|
int suggestedPriority = 0;
|
||||||
|
if (obj.has("suggested_priority")
|
||||||
|
&& obj.get("suggested_priority").isJsonPrimitive()
|
||||||
|
&& obj.get("suggested_priority").getAsJsonPrimitive().isNumber()) {
|
||||||
|
try {
|
||||||
|
suggestedPriority = obj.get("suggested_priority").getAsInt();
|
||||||
|
} catch (Exception e) {
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[PoseTypeReloadListener] In {} : suggested_priority not an int, defaulting to 0",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadata : tout le reste dans la map (hors reserved keys).
|
||||||
|
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
|
||||||
|
String key = e.getKey();
|
||||||
|
if (isReservedKey(key)) continue;
|
||||||
|
// Stocke la string brute — le consumer typer lui-meme si besoin.
|
||||||
|
JsonElement v = e.getValue();
|
||||||
|
if (v.isJsonPrimitive()) {
|
||||||
|
var prim = v.getAsJsonPrimitive();
|
||||||
|
if (prim.isString()) metadata.put(key, prim.getAsString());
|
||||||
|
else if (prim.isNumber()) metadata.put(key, prim.getAsNumber());
|
||||||
|
else if (prim.isBoolean()) metadata.put(key, prim.getAsBoolean());
|
||||||
|
} else {
|
||||||
|
// JsonObject / JsonArray preserved as-is
|
||||||
|
metadata.put(key, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DataDrivenPoseType(
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
defaultAnimation,
|
||||||
|
suggestedPriority,
|
||||||
|
metadata.isEmpty() ? null : new HashMap<>(metadata)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isReservedKey(String key) {
|
||||||
|
return "description".equals(key)
|
||||||
|
|| "default_animation".equals(key)
|
||||||
|
|| "suggested_priority".equals(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static String readStringOrNull(JsonObject obj, String key) {
|
||||||
|
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
||||||
|
JsonElement elem = obj.get(key);
|
||||||
|
if (!elem.isJsonPrimitive() || !elem.getAsJsonPrimitive().isString()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return elem.getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook d'apply direct pour tests — {@link SimpleJsonResourceReloadListener#apply}
|
||||||
|
* est {@code protected}, ce helper l'expose via package-private.
|
||||||
|
*/
|
||||||
|
void applyForTests(Map<ResourceLocation, JsonElement> data) {
|
||||||
|
this.apply(data, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ 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.DataDrivenLivingMotion;
|
||||||
import com.tiedup.remake.rig.anim.LivingMotion;
|
import com.tiedup.remake.rig.anim.LivingMotion;
|
||||||
import com.tiedup.remake.rig.anim.LivingMotions;
|
import com.tiedup.remake.rig.anim.LivingMotions;
|
||||||
|
import com.tiedup.remake.rig.anim.LivingMotionReloadListener;
|
||||||
import com.tiedup.remake.rig.anim.TiedUpLivingMotions;
|
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;
|
||||||
@@ -812,12 +814,29 @@ public final class DataDrivenItemParser {
|
|||||||
/**
|
/**
|
||||||
* Resolve a motion name string to its {@link LivingMotion} instance.
|
* Resolve a motion name string to its {@link LivingMotion} instance.
|
||||||
*
|
*
|
||||||
* <p>Both vanilla-EF {@link LivingMotions} and custom {@link TiedUpLivingMotions}
|
* <p>Resolution order (first match wins) :
|
||||||
* are standard Java enums, so {@link Enum#valueOf(Class, String)} is used. The
|
* <ol>
|
||||||
* lookup is case-sensitive (must match the enum constant name exactly).
|
* <li>Vanilla EF {@link LivingMotions} — enum name lookup (UPPER_SNAKE).
|
||||||
|
* Covers common motions (IDLE, WALK, RUN, JUMP...).</li>
|
||||||
|
* <li>TiedUp! custom {@link TiedUpLivingMotions} — enum name lookup.
|
||||||
|
* Covers bondage-specific motions (STRUGGLE_BOUND, WALK_BOUND...).</li>
|
||||||
|
* <li>Data-driven {@link DataDrivenLivingMotion} via
|
||||||
|
* {@link LivingMotionReloadListener#get(ResourceLocation)} — resolved
|
||||||
|
* from datapack JSON files. The input is parsed as a
|
||||||
|
* {@link ResourceLocation} (must contain a {@code :} separator,
|
||||||
|
* e.g. {@code mymod:orgasm_shake}). Names without a namespace
|
||||||
|
* fail this stage silently — they never look like a RL.</li>
|
||||||
|
* </ol>
|
||||||
*
|
*
|
||||||
* @param name the motion name from JSON (e.g. "WALK", "STRUGGLE_BOUND")
|
* <p>Stages 1 and 2 are case-sensitive (must match enum constant name).
|
||||||
* @return the resolved {@link LivingMotion}, or {@code null} if unknown in both enums
|
* Stage 3 is case-sensitive per {@link ResourceLocation} rules (path is
|
||||||
|
* lowercased by the parser, so authors should write their IDs in
|
||||||
|
* lowercase per Minecraft convention).</p>
|
||||||
|
*
|
||||||
|
* @param name the motion name from JSON (e.g. "WALK", "STRUGGLE_BOUND",
|
||||||
|
* "mymod:orgasm_shake")
|
||||||
|
* @return the resolved {@link LivingMotion}, or {@code null} if unknown
|
||||||
|
* in all three sources
|
||||||
*/
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
static LivingMotion resolveMotionByName(String name) {
|
static LivingMotion resolveMotionByName(String name) {
|
||||||
@@ -834,6 +853,16 @@ public final class DataDrivenItemParser {
|
|||||||
} catch (IllegalArgumentException ignored) {
|
} catch (IllegalArgumentException ignored) {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
|
// Try data-driven motions (datapack JSON).
|
||||||
|
// Only names that parse as a ResourceLocation (i.e. contain ':' with
|
||||||
|
// both namespace and path non-empty) are considered here — bare enum
|
||||||
|
// names like "IDEL" never look like a RL, so they skip this stage
|
||||||
|
// cleanly and fall through to the {@code return null} below.
|
||||||
|
ResourceLocation rl = ResourceLocation.tryParse(name);
|
||||||
|
if (rl != null && name.indexOf(':') > 0) {
|
||||||
|
DataDrivenLivingMotion dd = LivingMotionReloadListener.get(rl);
|
||||||
|
if (dd != null) return dd;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,8 +119,18 @@ public class V2ClientSetup {
|
|||||||
event.registerReloadListener(
|
event.registerReloadListener(
|
||||||
new com.tiedup.remake.rig.anim.client.property.JointMaskReloadListener()
|
new com.tiedup.remake.rig.anim.client.property.JointMaskReloadListener()
|
||||||
);
|
);
|
||||||
|
// Wave B : data-driven LivingMotion + PoseType additions also fire
|
||||||
|
// client-side (F3+T / resource pack swap) so GUI / tooltip code
|
||||||
|
// observes the same registry as the server.
|
||||||
|
event.registerReloadListener(
|
||||||
|
new com.tiedup.remake.rig.anim.LivingMotionReloadListener()
|
||||||
|
);
|
||||||
|
event.registerReloadListener(
|
||||||
|
new com.tiedup.remake.v2.bondage.PoseTypeReloadListener()
|
||||||
|
);
|
||||||
TiedUpMod.LOGGER.info(
|
TiedUpMod.LOGGER.info(
|
||||||
"[V2ClientSetup] Data-driven item + GLB validation + joint mask reload listeners registered"
|
"[V2ClientSetup] Data-driven item + GLB validation + joint mask + living motion "
|
||||||
|
+ "+ pose type reload listeners registered"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.rig.anim;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||||
|
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.JsonElement;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de {@link LivingMotionReloadListener} — validation du chemin
|
||||||
|
* data-driven pour ajouter des {@link LivingMotion} depuis un datapack
|
||||||
|
* JSON sans code Java.
|
||||||
|
*
|
||||||
|
* <h2>Scope</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Happy-path : un JSON valide est parse + enregistre + resolvable.</li>
|
||||||
|
* <li>Ordinal stability : re-apply le meme JSON => meme instance, meme ordinal.</li>
|
||||||
|
* <li>Error tolerance : JSON non-objet / sans description => skip.</li>
|
||||||
|
* <li>Collision check : ordinal unique vs {@link LivingMotions} et
|
||||||
|
* {@link TiedUpLivingMotions} (pas de recouvrement dans le pool
|
||||||
|
* partage {@link LivingMotion#ENUM_MANAGER}).</li>
|
||||||
|
* <li>Integration parser : {@code mymod:orgasm_shake} est resolu par
|
||||||
|
* {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemParser#resolveMotionByName}
|
||||||
|
* via le fallback data-driven (test indirect au niveau du registry).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Isolation entre tests</h2>
|
||||||
|
* <p>Le {@link LivingMotion#ENUM_MANAGER} est global JVM-wide — il ne supporte
|
||||||
|
* pas le reset. Chaque test utilise donc des IDs uniques (namespace =
|
||||||
|
* {@code tiedup_test_llml_<suffix>}) pour eviter les collisions cross-test
|
||||||
|
* si le meme JVM lance plusieurs tests. Le {@link #clearRegistry()} helper
|
||||||
|
* ne touche que le cache PERSISTENT_REGISTRY data-driven, pas le
|
||||||
|
* ENUM_MANAGER — donc un meme {@code toString()} ne peut pas etre
|
||||||
|
* re-utilise meme apres clear (ce serait une collision silencieuse).</p>
|
||||||
|
*/
|
||||||
|
class LivingMotionReloadListenerTest {
|
||||||
|
|
||||||
|
/** Helper : parse un JSON string en JsonElement (pour feed apply()). */
|
||||||
|
private static JsonElement jsonElem(String s) {
|
||||||
|
return JsonParser.parseString(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper : cree une ResourceLocation unique par test pour eviter les
|
||||||
|
* collisions avec ENUM_MANAGER qui garde un registre JVM-wide entre les
|
||||||
|
* tests. On utilise le hash du test name + nanoTime pour etre sur de
|
||||||
|
* l'unicite intra-session.
|
||||||
|
*/
|
||||||
|
private static ResourceLocation uniqueId(String testSuffix) {
|
||||||
|
String ns = "ttest_" + testSuffix + "_" + System.nanoTime();
|
||||||
|
// ResourceLocation namespace regex : [a-z0-9._-]+
|
||||||
|
ns = ns.toLowerCase().replaceAll("[^a-z0-9._-]", "_");
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(ns, "motion_a");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset le cache data-driven uniquement (ENUM_MANAGER reste pollue mais
|
||||||
|
* on utilise des IDs uniques — pas de collision observable). */
|
||||||
|
private static void clearRegistry() {
|
||||||
|
LivingMotionReloadListener.clearForTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Happy path ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_withValidJson_registersMotion() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation id = uniqueId("valid");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("""
|
||||||
|
{
|
||||||
|
"description": "Orgasm shake anim",
|
||||||
|
"category": "vx_reactions"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
LivingMotionReloadListener listener = new LivingMotionReloadListener();
|
||||||
|
listener.applyForTests(data);
|
||||||
|
|
||||||
|
DataDrivenLivingMotion motion = LivingMotionReloadListener.get(id);
|
||||||
|
assertNotNull(motion, "Motion doit etre enregistree apres apply()");
|
||||||
|
assertEquals(id, motion.id());
|
||||||
|
assertEquals("Orgasm shake anim", motion.description());
|
||||||
|
assertEquals("vx_reactions", motion.category());
|
||||||
|
assertTrue(motion.universalOrdinal() >= 0,
|
||||||
|
"Ordinal doit etre pose (>= 0) apres apply");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_withoutCategory_succeedsWithNullCategory() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation id = uniqueId("nocategory");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("""
|
||||||
|
{
|
||||||
|
"description": "Minimal motion"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new LivingMotionReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
DataDrivenLivingMotion motion = LivingMotionReloadListener.get(id);
|
||||||
|
assertNotNull(motion);
|
||||||
|
assertNull(motion.category(), "Category absente => null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Stable ordinal cross-reload ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_twice_stableOrdinal() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation id = uniqueId("stable");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("""
|
||||||
|
{"description": "First description"}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
LivingMotionReloadListener listener = new LivingMotionReloadListener();
|
||||||
|
listener.applyForTests(data);
|
||||||
|
DataDrivenLivingMotion first = LivingMotionReloadListener.get(id);
|
||||||
|
int firstOrdinal = first.universalOrdinal();
|
||||||
|
|
||||||
|
// Simule un /reload — on re-apply le meme dataset.
|
||||||
|
listener.applyForTests(data);
|
||||||
|
DataDrivenLivingMotion second = LivingMotionReloadListener.get(id);
|
||||||
|
assertSame(first, second,
|
||||||
|
"Reload du meme id => meme instance (pas de nouveau DataDrivenLivingMotion)");
|
||||||
|
assertEquals(firstOrdinal, second.universalOrdinal(),
|
||||||
|
"Ordinal identique apres reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_twice_differentDescription_warnsButKeepsOriginal() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation id = uniqueId("descchange");
|
||||||
|
|
||||||
|
LivingMotionReloadListener listener = new LivingMotionReloadListener();
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> firstData = new HashMap<>();
|
||||||
|
firstData.put(id, jsonElem("""
|
||||||
|
{"description": "Original"}
|
||||||
|
"""));
|
||||||
|
listener.applyForTests(firstData);
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> secondData = new HashMap<>();
|
||||||
|
secondData.put(id, jsonElem("""
|
||||||
|
{"description": "Changed"}
|
||||||
|
"""));
|
||||||
|
listener.applyForTests(secondData);
|
||||||
|
|
||||||
|
DataDrivenLivingMotion motion = LivingMotionReloadListener.get(id);
|
||||||
|
assertNotNull(motion);
|
||||||
|
assertEquals("Original", motion.description(),
|
||||||
|
"La description initiale doit etre conservee (record immuable, "
|
||||||
|
+ "l'ordinal etant deja consomme dans ENUM_MANAGER)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Invalid JSON tolerance ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_withInvalidJson_skipsAndContinues() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation badId = uniqueId("bad");
|
||||||
|
ResourceLocation goodId = uniqueId("good");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(badId, jsonElem("42")); // JSON array/number, pas un object
|
||||||
|
data.put(goodId, jsonElem("""
|
||||||
|
{"description": "Good motion"}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new LivingMotionReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
assertNull(LivingMotionReloadListener.get(badId),
|
||||||
|
"JSON non-object => skip, jamais enregistre");
|
||||||
|
assertNotNull(LivingMotionReloadListener.get(goodId),
|
||||||
|
"Le bon JSON du meme batch est toujours enregistre");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_missingDescription_skipsWithWarn() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation id = uniqueId("nodesc");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("""
|
||||||
|
{"category": "lonely_category"}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new LivingMotionReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
assertNull(LivingMotionReloadListener.get(id),
|
||||||
|
"Pas de description => skip");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_nonStringDescription_skipsWithWarn() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation id = uniqueId("numdesc");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("""
|
||||||
|
{"description": 123}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new LivingMotionReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
assertNull(LivingMotionReloadListener.get(id),
|
||||||
|
"Description non-string => skip");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Ordinal uniqueness vs builtins ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dataDrivenMotion_hasUniqueOrdinal() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation id = uniqueId("uniqord");
|
||||||
|
|
||||||
|
// Class-load les deux enums builtin (idempotent — values() trigger init).
|
||||||
|
LivingMotions[] vanilla = LivingMotions.values();
|
||||||
|
TiedUpLivingMotions[] custom = TiedUpLivingMotions.values();
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("""
|
||||||
|
{"description": "Unique ordinal test"}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new LivingMotionReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
DataDrivenLivingMotion dd = LivingMotionReloadListener.get(id);
|
||||||
|
assertNotNull(dd);
|
||||||
|
|
||||||
|
// L'ordinal data-driven doit etre strictement > tout ordinal builtin
|
||||||
|
// (ExtendableEnumManager attribue sequentiellement, pas de decrochage).
|
||||||
|
Set<Integer> ordinals = new HashSet<>();
|
||||||
|
for (LivingMotions m : vanilla) ordinals.add(m.universalOrdinal());
|
||||||
|
for (TiedUpLivingMotions m : custom) ordinals.add(m.universalOrdinal());
|
||||||
|
|
||||||
|
assertTrue(ordinals.add(dd.universalOrdinal()),
|
||||||
|
"L'ordinal de la motion data-driven (" + dd.universalOrdinal()
|
||||||
|
+ ") ne doit PAS collider avec ceux des enums builtin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Parser integration ==========
|
||||||
|
// Note : le fallback data-driven cote parser est teste dans
|
||||||
|
// DataDrivenItemParserAnimationsTest (meme package que
|
||||||
|
// DataDrivenItemParser pour acceder aux helpers package-private).
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registry_size_reflectsRegistered() {
|
||||||
|
clearRegistry();
|
||||||
|
assertEquals(0, LivingMotionReloadListener.size(),
|
||||||
|
"Apres clear, le registre est vide");
|
||||||
|
|
||||||
|
ResourceLocation a = uniqueId("sizea");
|
||||||
|
ResourceLocation b = uniqueId("sizeb");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(a, jsonElem("{\"description\": \"A\"}"));
|
||||||
|
data.put(b, jsonElem("{\"description\": \"B\"}"));
|
||||||
|
|
||||||
|
new LivingMotionReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
assertEquals(2, LivingMotionReloadListener.size(),
|
||||||
|
"2 JSON valides => 2 entries");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoDistinctIds_haveDistinctInstancesAndOrdinals() {
|
||||||
|
clearRegistry();
|
||||||
|
ResourceLocation a = uniqueId("twoa");
|
||||||
|
ResourceLocation b = uniqueId("twob");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(a, jsonElem("{\"description\": \"Motion A\"}"));
|
||||||
|
data.put(b, jsonElem("{\"description\": \"Motion B\"}"));
|
||||||
|
|
||||||
|
new LivingMotionReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
DataDrivenLivingMotion motionA = LivingMotionReloadListener.get(a);
|
||||||
|
DataDrivenLivingMotion motionB = LivingMotionReloadListener.get(b);
|
||||||
|
|
||||||
|
assertNotNull(motionA);
|
||||||
|
assertNotNull(motionB);
|
||||||
|
assertNotSame(motionA, motionB);
|
||||||
|
assertTrue(motionA.universalOrdinal() != motionB.universalOrdinal(),
|
||||||
|
"Deux IDs differents => ordinals differents (pool sequentiel)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.v2.bondage;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||||
|
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.JsonElement;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de {@link PoseTypeRegistry} et {@link PoseTypeReloadListener}.
|
||||||
|
*
|
||||||
|
* <h2>Scope</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Les 6 IDs canoniques builtin resolvent vers les {@link PoseType} enum.</li>
|
||||||
|
* <li>Un ID inconnu resout vers {@code null}.</li>
|
||||||
|
* <li>Un datapack JSON ajoute un {@link DataDrivenPoseType} resolvable via
|
||||||
|
* {@link PoseTypeRegistry#get}.</li>
|
||||||
|
* <li>Une tentative de redefinition d'un builtin est skip avec WARN ;
|
||||||
|
* {@link PoseType#DOG} reste accessible via son ID canonique.</li>
|
||||||
|
* <li>{@link PoseTypeRegistry#clearDataDriven} preserve les 6 builtins.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Pas de bootstrap MC requis — JsonParser et ResourceLocation sont pures
|
||||||
|
* libs, et le registre est statique JVM-wide mais nettoye entre tests via
|
||||||
|
* {@link #cleanup()}.</p>
|
||||||
|
*/
|
||||||
|
class PoseTypeRegistryTest {
|
||||||
|
|
||||||
|
private static JsonElement jsonElem(String s) {
|
||||||
|
return JsonParser.parseString(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() {
|
||||||
|
// Isolation : tout datapack data-driven est vide en entree de test.
|
||||||
|
PoseTypeRegistry.clearDataDriven();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
PoseTypeRegistry.clearDataDriven();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Builtin lookups ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_builtinId_returnsEnumWrapper() {
|
||||||
|
ResourceLocation dogId = PoseTypeRegistry.idOf(PoseType.DOG);
|
||||||
|
PoseTypeRef ref = PoseTypeRegistry.get(dogId);
|
||||||
|
|
||||||
|
assertNotNull(ref);
|
||||||
|
assertInstanceOf(PoseTypeRef.Builtin.class, ref);
|
||||||
|
assertSame(PoseType.DOG, ((PoseTypeRef.Builtin) ref).poseType());
|
||||||
|
assertEquals(dogId, ref.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idOf_eachEnum_returnsCanonicalTiedupId() {
|
||||||
|
for (PoseType pt : PoseType.values()) {
|
||||||
|
ResourceLocation rl = PoseTypeRegistry.idOf(pt);
|
||||||
|
assertNotNull(rl);
|
||||||
|
assertEquals("tiedup", rl.getNamespace(),
|
||||||
|
"Builtin canonical IDs live in the 'tiedup' namespace");
|
||||||
|
assertEquals(pt.name().toLowerCase(java.util.Locale.ROOT),
|
||||||
|
rl.getPath(),
|
||||||
|
"Path = lowercase enum name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_all6BuiltinIds_resolve() {
|
||||||
|
for (PoseType pt : PoseType.values()) {
|
||||||
|
ResourceLocation rl = PoseTypeRegistry.idOf(pt);
|
||||||
|
PoseTypeRef ref = PoseTypeRegistry.get(rl);
|
||||||
|
assertNotNull(ref, "Builtin " + pt + " doit resolve via " + rl);
|
||||||
|
assertSame(pt, ref.asBuiltinOrNull());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_unknownId_returnsNull() {
|
||||||
|
ResourceLocation rl = ResourceLocation.fromNamespaceAndPath("mymod", "quadruped_hogtie");
|
||||||
|
assertNull(PoseTypeRegistry.get(rl),
|
||||||
|
"Unknown ID (jamais enregistree) => null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_nullId_returnsNullNoCrash() {
|
||||||
|
assertNull(PoseTypeRegistry.get(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Data-driven registration ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_datapackId_returnsDataDrivenRef() {
|
||||||
|
ResourceLocation id = ResourceLocation.fromNamespaceAndPath("mymod", "quadruped_hogtie");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("""
|
||||||
|
{
|
||||||
|
"description": "Quadrupede hog-tie pose",
|
||||||
|
"default_animation": "mymod:quadruped_hogtie_idle",
|
||||||
|
"suggested_priority": 25
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new PoseTypeReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
PoseTypeRef ref = PoseTypeRegistry.get(id);
|
||||||
|
assertNotNull(ref);
|
||||||
|
assertInstanceOf(PoseTypeRef.DataDriven.class, ref);
|
||||||
|
|
||||||
|
DataDrivenPoseType dp = ((PoseTypeRef.DataDriven) ref).poseType();
|
||||||
|
assertEquals("Quadrupede hog-tie pose", dp.description());
|
||||||
|
assertEquals(25, dp.suggestedPriority());
|
||||||
|
assertNotNull(dp.defaultAnimation());
|
||||||
|
assertEquals("mymod", dp.defaultAnimation().getNamespace());
|
||||||
|
assertEquals("quadruped_hogtie_idle", dp.defaultAnimation().getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerDataDriven_duplicateId_replacesEntry() {
|
||||||
|
ResourceLocation id = ResourceLocation.fromNamespaceAndPath("mymod", "samepose");
|
||||||
|
|
||||||
|
DataDrivenPoseType first = DataDrivenPoseType.of(
|
||||||
|
id, "First version", null, 10
|
||||||
|
);
|
||||||
|
DataDrivenPoseType second = DataDrivenPoseType.of(
|
||||||
|
id, "Second version", null, 20
|
||||||
|
);
|
||||||
|
|
||||||
|
PoseTypeRegistry.registerDataDriven(first);
|
||||||
|
PoseTypeRegistry.registerDataDriven(second);
|
||||||
|
|
||||||
|
PoseTypeRef ref = PoseTypeRegistry.get(id);
|
||||||
|
assertInstanceOf(PoseTypeRef.DataDriven.class, ref);
|
||||||
|
assertEquals("Second version",
|
||||||
|
((PoseTypeRef.DataDriven) ref).poseType().description(),
|
||||||
|
"Re-registration du meme ID => remplacement (reload semantics)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerDataDriven_builtinId_rejectsWithWarn() {
|
||||||
|
ResourceLocation dogId = PoseTypeRegistry.idOf(PoseType.DOG);
|
||||||
|
|
||||||
|
// Tentative de redefinition d'un ID reserve
|
||||||
|
DataDrivenPoseType hijacker = DataDrivenPoseType.of(
|
||||||
|
dogId, "Hijacker", null, 99
|
||||||
|
);
|
||||||
|
PoseTypeRegistry.registerDataDriven(hijacker);
|
||||||
|
|
||||||
|
// DOG reste un Builtin — le hijacker est ignore.
|
||||||
|
PoseTypeRef ref = PoseTypeRegistry.get(dogId);
|
||||||
|
assertInstanceOf(PoseTypeRef.Builtin.class, ref,
|
||||||
|
"Les IDs builtin sont immuables — un data-driven hijacker est skip");
|
||||||
|
assertSame(PoseType.DOG, ((PoseTypeRef.Builtin) ref).poseType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearDataDriven_preservesBuiltins() {
|
||||||
|
ResourceLocation customId = ResourceLocation.fromNamespaceAndPath("mymod", "custom_a");
|
||||||
|
PoseTypeRegistry.registerDataDriven(
|
||||||
|
DataDrivenPoseType.of(customId, "Custom", null, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(PoseTypeRegistry.isDataDriven(customId));
|
||||||
|
PoseTypeRegistry.clearDataDriven();
|
||||||
|
|
||||||
|
assertFalse(PoseTypeRegistry.isDataDriven(customId));
|
||||||
|
assertNull(PoseTypeRegistry.get(customId),
|
||||||
|
"Apres clear, le custom pose disparait");
|
||||||
|
|
||||||
|
// Mais les builtins restent
|
||||||
|
for (PoseType pt : PoseType.values()) {
|
||||||
|
ResourceLocation rl = PoseTypeRegistry.idOf(pt);
|
||||||
|
assertTrue(PoseTypeRegistry.isBuiltin(rl),
|
||||||
|
"Builtin " + pt + " doit rester accessible apres clearDataDriven");
|
||||||
|
assertNotNull(PoseTypeRegistry.get(rl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Reload listener — error tolerance ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reloadListener_missingDescription_skipsEntry() {
|
||||||
|
ResourceLocation id = ResourceLocation.fromNamespaceAndPath("mymod", "bad_pose");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("""
|
||||||
|
{"default_animation": "mymod:anim"}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new PoseTypeReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
assertNull(PoseTypeRegistry.get(id),
|
||||||
|
"JSON sans 'description' => skip, jamais enregistre");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reloadListener_nonObjectJson_skipsEntry() {
|
||||||
|
ResourceLocation id = ResourceLocation.fromNamespaceAndPath("mymod", "array_pose");
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id, jsonElem("[1, 2, 3]"));
|
||||||
|
|
||||||
|
new PoseTypeReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
assertNull(PoseTypeRegistry.get(id),
|
||||||
|
"JSON root non-object => skip");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reloadListener_clearPurgesPreviousReload() {
|
||||||
|
ResourceLocation id = ResourceLocation.fromNamespaceAndPath("mymod", "a");
|
||||||
|
|
||||||
|
PoseTypeReloadListener listener = new PoseTypeReloadListener();
|
||||||
|
|
||||||
|
Map<ResourceLocation, JsonElement> first = new HashMap<>();
|
||||||
|
first.put(id, jsonElem("{\"description\": \"v1\"}"));
|
||||||
|
listener.applyForTests(first);
|
||||||
|
assertNotNull(PoseTypeRegistry.get(id));
|
||||||
|
|
||||||
|
// Un reload sans l'entry => elle disparait (authoritative replace)
|
||||||
|
listener.applyForTests(new HashMap<>());
|
||||||
|
assertNull(PoseTypeRegistry.get(id),
|
||||||
|
"Reload authoritative : absence du JSON => absence du registre");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allIds_unionsBuiltinsAndDataDriven() {
|
||||||
|
int builtinCount = PoseType.values().length;
|
||||||
|
|
||||||
|
ResourceLocation c1 = ResourceLocation.fromNamespaceAndPath("mymod", "c1");
|
||||||
|
ResourceLocation c2 = ResourceLocation.fromNamespaceAndPath("mymod", "c2");
|
||||||
|
PoseTypeRegistry.registerDataDriven(DataDrivenPoseType.of(c1, "c1", null, 0));
|
||||||
|
PoseTypeRegistry.registerDataDriven(DataDrivenPoseType.of(c2, "c2", null, 0));
|
||||||
|
|
||||||
|
var all = PoseTypeRegistry.allIds();
|
||||||
|
assertEquals(builtinCount + 2, all.size());
|
||||||
|
assertTrue(all.contains(c1));
|
||||||
|
assertTrue(all.contains(c2));
|
||||||
|
assertTrue(all.contains(PoseTypeRegistry.idOf(PoseType.STANDARD)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dataDrivenPoseType_metadataIsImmutable() {
|
||||||
|
Map<String, Object> userMap = new HashMap<>();
|
||||||
|
userMap.put("intensity", 3);
|
||||||
|
DataDrivenPoseType dp = new DataDrivenPoseType(
|
||||||
|
ResourceLocation.fromNamespaceAndPath("mymod", "meta"),
|
||||||
|
"With metadata", null, 0, userMap
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mutating the map passed in must not leak into the record (defensive copy)
|
||||||
|
userMap.put("should_not_appear", "X");
|
||||||
|
assertFalse(dp.metadata().containsKey("should_not_appear"),
|
||||||
|
"Map passed to record ctor must be wrapped unmodifiable (no leak)");
|
||||||
|
|
||||||
|
// And the returned view rejects mutation
|
||||||
|
try {
|
||||||
|
dp.metadata().put("x", "y");
|
||||||
|
throw new AssertionError("Metadata map must be unmodifiable");
|
||||||
|
} catch (UnsupportedOperationException expected) {
|
||||||
|
// ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -521,6 +521,63 @@ class DataDrivenItemParserAnimationsTest {
|
|||||||
"Resolution case-sensitive : 'idle' ne match pas IDLE");
|
"Resolution case-sensitive : 'idle' ne match pas IDLE");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Wave B — Data-driven motion fallback ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un motion declare via JSON dans {@code data/<ns>/tiedup/living_motions/*.json}
|
||||||
|
* doit etre resolvable par le parser quand le JSON d'item reference
|
||||||
|
* {@code "ns:path"} dans son bloc {@code animations.living_motions}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void resolveMotionByName_dataDriven_resolvesViaRegistry() {
|
||||||
|
// Isolation : on utilise un ID unique (nanoTime) pour ne pas polluer
|
||||||
|
// les autres tests. Le registre JVM-wide garde les entries mais les
|
||||||
|
// IDs uniques empechent les collisions.
|
||||||
|
String ns = "ttest_parser_integr_" + System.nanoTime();
|
||||||
|
ns = ns.toLowerCase().replaceAll("[^a-z0-9._-]", "_");
|
||||||
|
net.minecraft.resources.ResourceLocation id =
|
||||||
|
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(ns, "orgasm_shake");
|
||||||
|
|
||||||
|
// Feed the JSON to the listener directly (no MC runtime).
|
||||||
|
java.util.Map<net.minecraft.resources.ResourceLocation, com.google.gson.JsonElement> data =
|
||||||
|
new java.util.HashMap<>();
|
||||||
|
data.put(id, com.google.gson.JsonParser.parseString(
|
||||||
|
"{\"description\": \"Orgasm shake anim\"}"
|
||||||
|
));
|
||||||
|
new com.tiedup.remake.rig.anim.LivingMotionReloadListener()
|
||||||
|
.applyForTests(data);
|
||||||
|
|
||||||
|
// Parser lookup via the full RL string.
|
||||||
|
LivingMotion resolved =
|
||||||
|
DataDrivenItemParser.resolveMotionByName(id.toString());
|
||||||
|
assertNotNull(resolved,
|
||||||
|
"Parser doit fallback sur LivingMotionReloadListener.get() "
|
||||||
|
+ "pour un ID datapack valide");
|
||||||
|
assertSame(
|
||||||
|
com.tiedup.remake.rig.anim.LivingMotionReloadListener.get(id),
|
||||||
|
resolved,
|
||||||
|
"Parser doit retourner la MEME instance que le registry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveMotionByName_unknownDatapackId_returnsNull() {
|
||||||
|
// ResourceLocation valide mais jamais enregistre => null
|
||||||
|
String unknownId = "never_registered_ns_" + System.nanoTime() + ":phantom";
|
||||||
|
assertNull(DataDrivenItemParser.resolveMotionByName(unknownId),
|
||||||
|
"ID datapack jamais enregistree => null (pas de crash)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveMotionByName_bareNameWithoutColon_skipsRLFallback() {
|
||||||
|
// Sanity : un nom non-qualifie (pas de ':') ne declenche PAS le
|
||||||
|
// fallback data-driven. Il tombe direct a null si inconnu des enums.
|
||||||
|
// Cela protege les noms d'enum pur (IDLE, WALK) qui ne doivent pas
|
||||||
|
// etre traites comme des RL.
|
||||||
|
assertNull(DataDrivenItemParser.resolveMotionByName("NONEXISTENT_BARE"),
|
||||||
|
"Nom sans ':' et inconnu des enums => null, pas de tryParse RL");
|
||||||
|
}
|
||||||
|
|
||||||
// ========== suggestClosestMotion ==========
|
// ========== suggestClosestMotion ==========
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user