diff --git a/src/main/java/com/tiedup/remake/core/TiedUpMod.java b/src/main/java/com/tiedup/remake/core/TiedUpMod.java index eb340eb..5b44c09 100644 --- a/src/main/java/com/tiedup/remake/core/TiedUpMod.java +++ b/src/main/java/com/tiedup/remake/core/TiedUpMod.java @@ -615,6 +615,28 @@ public class TiedUpMod { LOGGER.info( "Registered RoomThemeReloadListener for data-driven room themes" ); + + // Data-driven LivingMotion additions (server-side, from data//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//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" + ); } } } diff --git a/src/main/java/com/tiedup/remake/rig/anim/DataDrivenLivingMotion.java b/src/main/java/com/tiedup/remake/rig/anim/DataDrivenLivingMotion.java new file mode 100644 index 0000000..f8aa17c --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/DataDrivenLivingMotion.java @@ -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. + * + *

Modder path : deposer un fichier JSON dans + * {@code data//tiedup/living_motions/.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}.

+ * + *

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}).

+ * + *

Choix de design : classe, pas record

+ *

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.

+ */ +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(); + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/LivingMotionReloadListener.java b/src/main/java/com/tiedup/remake/rig/anim/LivingMotionReloadListener.java new file mode 100644 index 0000000..14a079a --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/LivingMotionReloadListener.java @@ -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//tiedup/living_motions/*.json} et + * enregistre chacun en tant que {@link DataDrivenLivingMotion} dans le + * registre partage {@link LivingMotion#ENUM_MANAGER}. + * + *

But

+ *

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.

+ * + *

Format JSON attendu

+ *
{@code
+ * {
+ *   "description": "Orgasm shake shiver anim — fired on VX state",
+ *   "category": "vx_reactions"
+ * }
+ * }
+ *

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}...).

+ * + *

Ordinal stability cross-reload

+ *

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".

+ * + *

Limitations connues

+ *
    + *
  • 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.
  • + *
  • Un JSON mal forme (pas de {@code description} ou type invalide) est + * skip avec un WARN ; le reste du batch continue.
  • + *
  • 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.
  • + *
+ * + *

Side & threading

+ *

{@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.

+ */ +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 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 LAST_SEEN_DESCRIPTIONS = + new ConcurrentHashMap<>(); + + /** Dossier scanne : {@code data//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 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. + * + *

En prod, le registre ne se vide jamais — c'est intentionnel + * (preservation des ordinals pendant la session JVM).

+ */ + static void clearForTests() { + PERSISTENT_REGISTRY.clear(); + LAST_SEEN_DESCRIPTIONS.clear(); + } + + @Override + protected void apply( + Map 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 sorted = new TreeMap<>(objectIn); + + int added = 0; + int reloaded = 0; + int skipped = 0; + + for (Map.Entry 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. + * + *

Le {@code ResourceManager} et le {@code ProfilerFiller} ne sont + * pas lus par notre {@code apply}, on peut passer {@code null} en test.

+ */ + public void applyForTests(Map data) { + this.apply(data, null, null); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/DataDrivenPoseType.java b/src/main/java/com/tiedup/remake/v2/bondage/DataDrivenPoseType.java new file mode 100644 index 0000000..8dfa1f6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/DataDrivenPoseType.java @@ -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}. + * + *

Modder path : deposer un JSON dans + * {@code data//tiedup/pose_types/.json}. Le {@link PoseTypeReloadListener} + * le detecte et l'enregistre dans {@link PoseTypeRegistry}.

+ * + *

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

+ * + * @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 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() + ); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeHelper.java b/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeHelper.java index 94c1218..abdc91f 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeHelper.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeHelper.java @@ -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. * *

Reads from the data-driven definition's {@code pose_type} field, * falling back to {@link PoseType#STANDARD} if absent.

+ * + *

Two resolution paths

+ *
    + *
  1. {@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.
  2. + *
  3. {@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}).
  4. + *
+ * + *

Both methods read the same underlying {@code pose_type} field in the item + * definition JSON ; the difference is purely in the return type.

*/ 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) { - try { - return PoseType.valueOf(def.poseType().toUpperCase()); - } catch (IllegalArgumentException e) { - return PoseType.STANDARD; - } + 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 : + *
    + *
  • Enum name (UPPER_SNAKE_CASE) : {@code "DOG"}, {@code "STANDARD"}. + * Legacy format, still supported for V1-authored items.
  • + *
  • 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).
  • + *
+ */ + 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 + ); + } } diff --git a/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRef.java b/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRef.java new file mode 100644 index 0000000..9b2c978 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRef.java @@ -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. + * + *

Type sealed : seules deux implementations ({@link Builtin} et + * {@link DataDriven}) sont reconnues. Permet au switch-statement de consumer + * exhaustif sur les deux variantes.

+ * + *

Pattern d'usage

+ *
{@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
+ * }
+ * }
+ * + *

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

+ */ +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 {} +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRegistry.java b/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRegistry.java new file mode 100644 index 0000000..a4ad8e4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeRegistry.java @@ -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}. + * + *

Approche additive

+ *

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.

+ * + *

IDs canoniques

+ *

Chaque valeur enum builtin est mappee a un ID canonique + * {@code tiedup:} : + *

    + *
  • {@link PoseType#STANDARD} -> {@code tiedup:standard}
  • + *
  • {@link PoseType#STRAITJACKET} -> {@code tiedup:straitjacket}
  • + *
  • {@link PoseType#WRAP} -> {@code tiedup:wrap}
  • + *
  • {@link PoseType#LATEX_SACK} -> {@code tiedup:latex_sack}
  • + *
  • {@link PoseType#DOG} -> {@code tiedup:dog}
  • + *
  • {@link PoseType#HUMAN_CHAIR} -> {@code tiedup:human_chair}
  • + *
+ * 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.

+ * + *

Thread safety

+ *

{@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.

+ */ +public final class PoseTypeRegistry { + + private PoseTypeRegistry() {} + + /** Builtin pose types derived from {@link PoseType} enum. */ + private static final Map BUILTIN_BY_ID; + + /** Inverse : {@link PoseType} -> canonical {@link ResourceLocation}. */ + private static final Map BUILTIN_BY_ENUM; + + static { + Map byId = new HashMap<>(); + Map 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 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. + * + *

Check order : + *

    + *
  1. Builtin enum mapping — the 6 reserved IDs.
  2. + *
  3. Datapack map — any {@link DataDrivenPoseType} registered by + * {@link PoseTypeReloadListener}.
  4. + *
+ * + * @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 allIds() { + java.util.Set 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 dataDrivenView() { + return Collections.unmodifiableMap(DATAPACK_TYPES); + } + + // ========== Mutators (reload path) ========== + + /** + * Registers (or replaces) a data-driven pose type. + * + *

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.

+ * + *

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.

+ * + * @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(); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeReloadListener.java b/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeReloadListener.java new file mode 100644 index 0000000..fe91150 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/PoseTypeReloadListener.java @@ -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//tiedup/pose_types/*.json} et alimente + * {@link PoseTypeRegistry#registerDataDriven}. + * + *

Format JSON

+ *
{@code
+ * {
+ *   "description": "Quadrupede hog-tie pose",
+ *   "default_animation": "mymod:quadruped_hogtie_idle",
+ *   "suggested_priority": 25
+ * }
+ * }
+ * + *

Champs : + *

    + *
  • {@code description} (string, obligatoire) — libelle humain pour debug/logs.
  • + *
  • {@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.
  • + *
  • {@code suggested_priority} (int, optionnel, defaut 0) — priorite par + * defaut si un item reference cette pose sans preciser sa propre + * priorite.
  • + *
  • Tous les autres champs sont collectes dans {@code metadata} et + * conserves telles quelles pour usages futurs (pas de schema strict).
  • + *
+ * + *

Isolation vs enum builtin

+ *

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.

+ */ +public class PoseTypeReloadListener extends SimpleJsonResourceReloadListener { + + /** Dossier scanne : {@code data//tiedup/pose_types/*.json}. */ + public static final String DIRECTORY = "tiedup/pose_types"; + + public PoseTypeReloadListener() { + super(new GsonBuilder().create(), DIRECTORY); + } + + @Override + protected void apply( + Map 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 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 metadata = new LinkedHashMap<>(); + for (Map.Entry 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 data) { + this.apply(data, null, null); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java index 2448ca8..6852994 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java @@ -4,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. * - *

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). + *

Resolution order (first match wins) : + *

    + *
  1. Vanilla EF {@link LivingMotions} — enum name lookup (UPPER_SNAKE). + * Covers common motions (IDLE, WALK, RUN, JUMP...).
  2. + *
  3. TiedUp! custom {@link TiedUpLivingMotions} — enum name lookup. + * Covers bondage-specific motions (STRUGGLE_BOUND, WALK_BOUND...).
  4. + *
  5. 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.
  6. + *
* - * @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 + *

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).

+ * + * @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; } diff --git a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java index 07a0f02..919a1a3 100644 --- a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java +++ b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java @@ -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" ); } diff --git a/src/test/java/com/tiedup/remake/rig/anim/LivingMotionReloadListenerTest.java b/src/test/java/com/tiedup/remake/rig/anim/LivingMotionReloadListenerTest.java new file mode 100644 index 0000000..f7fddaa --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/anim/LivingMotionReloadListenerTest.java @@ -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. + * + *

Scope

+ *
    + *
  • Happy-path : un JSON valide est parse + enregistre + resolvable.
  • + *
  • Ordinal stability : re-apply le meme JSON => meme instance, meme ordinal.
  • + *
  • Error tolerance : JSON non-objet / sans description => skip.
  • + *
  • Collision check : ordinal unique vs {@link LivingMotions} et + * {@link TiedUpLivingMotions} (pas de recouvrement dans le pool + * partage {@link LivingMotion#ENUM_MANAGER}).
  • + *
  • 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).
  • + *
+ * + *

Isolation entre tests

+ *

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_}) 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).

+ */ +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 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 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 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 firstData = new HashMap<>(); + firstData.put(id, jsonElem(""" + {"description": "Original"} + """)); + listener.applyForTests(firstData); + + Map 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 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 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 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 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 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 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 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)"); + } +} diff --git a/src/test/java/com/tiedup/remake/v2/bondage/PoseTypeRegistryTest.java b/src/test/java/com/tiedup/remake/v2/bondage/PoseTypeRegistryTest.java new file mode 100644 index 0000000..75a8f70 --- /dev/null +++ b/src/test/java/com/tiedup/remake/v2/bondage/PoseTypeRegistryTest.java @@ -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}. + * + *

Scope

+ *
    + *
  • Les 6 IDs canoniques builtin resolvent vers les {@link PoseType} enum.
  • + *
  • Un ID inconnu resout vers {@code null}.
  • + *
  • Un datapack JSON ajoute un {@link DataDrivenPoseType} resolvable via + * {@link PoseTypeRegistry#get}.
  • + *
  • Une tentative de redefinition d'un builtin est skip avec WARN ; + * {@link PoseType#DOG} reste accessible via son ID canonique.
  • + *
  • {@link PoseTypeRegistry#clearDataDriven} preserve les 6 builtins.
  • + *
+ * + *

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()}.

+ */ +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 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 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 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 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 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 + } + } +} diff --git a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java index 8861468..dabb4ea 100644 --- a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java +++ b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParserAnimationsTest.java @@ -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//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 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