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:
notevil
2026-04-24 14:42:58 +02:00
parent 76587c0393
commit c9d5271102
13 changed files with 1755 additions and 11 deletions

View File

@@ -615,6 +615,28 @@ public class TiedUpMod {
LOGGER.info(
"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"
);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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()
);
}
}

View File

@@ -3,28 +3,129 @@ package com.tiedup.remake.v2.bondage;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Resolves the {@link PoseType} for any bondage item stack.
*
* <p>Reads from the data-driven definition's {@code pose_type} field,
* 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 {
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) {
// V2: read from data-driven definition
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null && def.poseType() != null) {
return resolveEnumFromString(def.poseType());
}
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(def.poseType().toUpperCase());
} catch (IllegalArgumentException e) {
return PoseType.STANDARD;
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
);
}
}

View 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 {}
}

View 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} -&gt; {@code tiedup:standard}</li>
* <li>{@link PoseType#STRAITJACKET} -&gt; {@code tiedup:straitjacket}</li>
* <li>{@link PoseType#WRAP} -&gt; {@code tiedup:wrap}</li>
* <li>{@link PoseType#LATEX_SACK} -&gt; {@code tiedup:latex_sack}</li>
* <li>{@link PoseType#DOG} -&gt; {@code tiedup:dog}</li>
* <li>{@link PoseType#HUMAN_CHAIR} -&gt; {@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();
}
}

View File

@@ -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);
}
}

View File

@@ -4,8 +4,10 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.rig.anim.DataDrivenLivingMotion;
import com.tiedup.remake.rig.anim.LivingMotion;
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.v2.BodyRegionV2;
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.
*
* <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).
* <p>Resolution order (first match wins) :
* <ol>
* <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")
* @return the resolved {@link LivingMotion}, or {@code null} if unknown in both enums
* <p>Stages 1 and 2 are case-sensitive (must match enum constant name).
* 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
static LivingMotion resolveMotionByName(String name) {
@@ -834,6 +853,16 @@ public final class DataDrivenItemParser {
} catch (IllegalArgumentException ignored) {
// 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;
}

View File

@@ -119,8 +119,18 @@ public class V2ClientSetup {
event.registerReloadListener(
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(
"[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"
);
}

View File

@@ -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)");
}
}

View File

@@ -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
}
}
}

View File

@@ -521,6 +521,63 @@ class DataDrivenItemParserAnimationsTest {
"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 ==========
@Test