diff --git a/src/main/java/com/tiedup/remake/rig/TiedUpRigRegistryReloadListener.java b/src/main/java/com/tiedup/remake/rig/TiedUpRigRegistryReloadListener.java new file mode 100644 index 0000000..74fd2b3 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/TiedUpRigRegistryReloadListener.java @@ -0,0 +1,152 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig; + +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; +import net.minecraftforge.event.AddReloadListenerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import com.tiedup.remake.core.TiedUpMod; + +/** + * Hook {@code /reload} (serveur) + {@code F3+T} (client) pour reset le set dedup + * {@link TiedUpAnimationRegistry#resetWarnedMissing()}. + * + *
Le set statique {@code WARNED_MISSING_ANIMS} de + * {@link TiedUpAnimationRegistry} accumule les IDs d'animations introuvables + * pour éviter le spam log (un miss reloggué tick après tick). Sans reset, le + * set grandit sans borne en prod (N items cassés × K sessions × long uptime) + * et empêche de re-signaler un miss qui redevient cassé après un reload.
+ * + *Avec ce listener, chaque reload clear le set — un miss sur une anim + * précédemment warnée redéclenche le WARN (comportement attendu : le modder + * doit voir l'erreur s'il re-casse l'asset).
+ * + *Le set statique dans {@link TiedUpAnimationRegistry} est le MÊME sur les + * deux sides (même JVM pour un serveur intégré / solo) — les deux hooks + * clearent le même set, pas de double comptabilité. Sur serveur dédié + * seul le hook server fire, côté client pur (rare — lobby / fast reconnect) + * seul le hook client.
+ * + *{@link TiedUpAnimationRegistry#resetWarnedMissing()} utilise + * {@link java.util.concurrent.ConcurrentHashMap#newKeySet()} donc safe à + * l'appel concurrent. Les reload events peuvent fire depuis le main thread + * (server tick) ou le render thread (client reload) — aucune contrainte + * supplémentaire.
+ */ +@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID) +public final class TiedUpRigRegistryReloadListener + extends SimplePreparableReloadListenerNote : {@code AddReloadListenerEvent} est sur le FORGE bus (pas MOD bus), + * mais le {@code @Mod.EventBusSubscriber} par défaut cible le MOD bus. On + * spécifie {@link Mod.EventBusSubscriber.Bus#FORGE} sur le subscriber ci-dessus ? + * Non : en 1.20.1, {@code AddReloadListenerEvent extends Event} bus-inferred + * automatiquement — le subscriber static-registered via annotation + * fonctionne sur le FORGE bus par défaut tant qu'on n'indique pas + * {@code Bus.MOD}. Par souci de clarté, on s'aligne sur le pattern du mod + * ({@code TiedUpMod.ForgeEvents} utilise le FORGE bus).
+ */ + @SubscribeEvent + public static void onAddReloadListeners(AddReloadListenerEvent event) { + event.addListener(new TiedUpRigRegistryReloadListener()); + TiedUpRigConstants.LOGGER.debug( + "[TiedUpRigRegistryReloadListener] Registered server-side reload listener for WARN dedup reset" + ); + } + + /** + * Hook {@link RegisterClientReloadListenersEvent} — fire une fois côté client + * pendant le setup pour enregistrer des listeners qui tourneront à chaque + * resource reload client (F3+T, resource pack swap). + * + *Gardé {@link Dist#CLIENT} : sur serveur dédié, l'event n'existe pas + * (event client), le subscriber static est skip sans class-load.
+ * + *Ce subscriber est un inner static class pour isoler la gate de Dist — + * {@code @Mod.EventBusSubscriber(value = Dist.CLIENT)} sur la classe entière + * exclurait aussi le hook {@link #onAddReloadListeners} server-side.
+ */ + @Mod.EventBusSubscriber( + modid = TiedUpMod.MOD_ID, + bus = Mod.EventBusSubscriber.Bus.MOD, + value = Dist.CLIENT + ) + public static final class ClientReloadHook { + + private ClientReloadHook() { + // holder class for @SubscribeEvent static + } + + @SubscribeEvent + public static void onRegisterClientReloadListeners( + RegisterClientReloadListenersEvent event + ) { + event.registerReloadListener(new TiedUpRigRegistryReloadListener()); + TiedUpRigConstants.LOGGER.debug( + "[TiedUpRigRegistryReloadListener] Registered client-side reload listener for WARN dedup reset" + ); + } + } +} 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 df3df40..5a219ff 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 @@ -717,6 +717,24 @@ public final class DataDrivenItemParser { ); continue; } + // P3 Wave α — Option B convention warn : binding IDLE fonctionne + // techniquement mais écrase silencieusement le default EF re-injecté + // par ClientAnimator.resetLivingAnimations() post-rebuild. Design + // BONDAGE_ANIMATION_DESIGN.md §5.1 recommande d'utiliser des motions + // custom (POSE_KNEEL_BOUND / STRUGGLE_BOUND / WALK_BOUND) plutôt que + // d'écraser IDLE. On accepte le binding quand même (rétrocompat + + // cas légitimes rares), juste on pointe le modder vers la convention. + if (motion == LivingMotions.IDLE) { + LOGGER.warn( + "[DataDrivenItemParser] Item {} binds IDLE motion. Convention design Option B " + + "(BONDAGE_ANIMATION_DESIGN.md §5.1) recommends not binding IDLE — " + + "ClientAnimator.resetLivingAnimations() restores default EF IDLE after " + + "rebuild, so binding IDLE overwrites the default silently. Prefer custom " + + "motions like POSE_KNEEL_BOUND / STRUGGLE_BOUND / WALK_BOUND. The binding " + + "will still work but may cause surprises.", + itemId + ); + } ResourceLocation animId = tryParseAnimationRL( entry.getValue(), "living_motions['" + motionName + "']", diff --git a/src/main/java/com/tiedup/remake/v2/client/BondageRehydrateListener.java b/src/main/java/com/tiedup/remake/v2/client/BondageRehydrateListener.java index 0d2349d..ecc3bf9 100644 --- a/src/main/java/com/tiedup/remake/v2/client/BondageRehydrateListener.java +++ b/src/main/java/com/tiedup/remake/v2/client/BondageRehydrateListener.java @@ -16,6 +16,7 @@ import net.minecraftforge.event.entity.EntityJoinLevelEvent; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; +import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.rig.TiedUpRigConstants; /** @@ -82,7 +83,13 @@ import com.tiedup.remake.rig.TiedUpRigConstants; */ @OnlyIn(Dist.CLIENT) @Mod.EventBusSubscriber( - modid = TiedUpRigConstants.MODID, + // P3 Wave α naming : use TiedUpMod.MOD_ID (main mod constant) in v2.client + // files rather than TiedUpRigConstants.MODID (rig sub-module alias). + // Both hold the literal "tiedup" (TiedUpRigConstants.MODID = TiedUpMod.MOD_ID), + // so no behavioral change — just source consistency with sibling + // BondageEquipmentChangeListener. TiedUpRigConstants.LOGGER is kept below + // because the logger identity is rig-scoped for grep-friendly log filtering. + modid = TiedUpMod.MOD_ID, bus = Mod.EventBusSubscriber.Bus.FORGE, value = Dist.CLIENT ) diff --git a/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java b/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java index 16cf624..bbaa14b 100644 --- a/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java +++ b/src/main/java/com/tiedup/remake/v2/client/ClientRigEquipmentHandler.java @@ -335,6 +335,13 @@ public final class ClientRigEquipmentHandler { // rig pendant que la map livingAnimations est encore celle de l'ancien // équipement. Pragmatique : à ce stade le rebuild n'a pas encore tourné, // donc les defaults EF sont toujours en place de la passe précédente. + // + // Snapshot RISK-001 (P3 Wave α review) : si un unequip oneshot throw + // ici, le rebuild (reset + applyDefinitions) n'a pas encore tourné, + // donc l'animator est toujours dans l'état "previousKeys" — on ne + // touche PAS à LAST_EQUIPPED_KEYS. Au prochain tick, le diff + // retentera avec previousKeys inchangé → même unequip redéclenché + // (au pire double son, mais état cohérent garanti). for (Map.EntryLe listener lui-même ne peut être testé bout-en-bout sans runtime Forge + * (registration sur event bus + fire de l'event). On se limite à deux garanties + * observables :
+ *Le fire de l'event réel ({@code AddReloadListenerEvent}, + * {@code RegisterClientReloadListenersEvent}) doit être validé en game-runtime + * (QA manual : run client, /reload, vérifier debug log émis). Impossible à + * mocker sans bootstrap Forge complet.
+ */ +class TiedUpRigRegistryReloadListenerTest { + + /** Simple classLoad-ability sanity — si la classe contient un static init + * qui throw (ex. reference à une API manquante du classpath test), le + * test-runner échoue immédiatement avec ExceptionInInitializerError. */ + @Test + void classLoads_noStaticInitError() { + assertDoesNotThrow(() -> { + Class> cls = Class.forName( + "com.tiedup.remake.rig.TiedUpRigRegistryReloadListener" + ); + assertNotNull(cls); + }, "TiedUpRigRegistryReloadListener doit pouvoir class-loader sans throw " + + "(Forge annotations + event classes resolvables en test classpath)"); + } + + /** L'inner holder class {@code ClientReloadHook} class-load aussi (gated + * {@code Dist.CLIENT} mais la classe Java existe tout le temps — seul le + * subscriber event se no-op côté serveur). */ + @Test + void clientReloadHook_classLoads() { + assertDoesNotThrow(() -> { + Class> cls = Class.forName( + "com.tiedup.remake.rig.TiedUpRigRegistryReloadListener$ClientReloadHook" + ); + assertNotNull(cls); + }, "TiedUpRigRegistryReloadListener.ClientReloadHook doit class-loader " + + "meme sans runtime Forge — juste une classe Java standard"); + } + + /** + * Sanity : le listener est censé appeler + * {@link TiedUpAnimationRegistry#resetWarnedMissing()} dans son apply(). + * On ne peut pas mocker {@code ResourceManager} sans bootstrap MC, donc on + * valide juste le contrat : resetWarnedMissing est callable + repeatable + + * remet effectivement le set en état "fresh". + * + *Pattern : warn sur un ID unknown → reset → warn rewarned (vérification + * indirecte du reset via le comportement observable du {@code Set.add}).
+ */ + @Test + void resetWarnedMissing_idempotentAndReenablesWarn() { + ResourceLocation unknown = ResourceLocation.fromNamespaceAndPath( + "tiedup_test_reload", "never_registered_" + System.nanoTime() + ); + + // Clear initial — pas d'état hérité d'autres tests. + assertDoesNotThrow(TiedUpAnimationRegistry::resetWarnedMissing); + + // Premier call : ID manquant → fallback + (premier) WARN silent-add. + AnimationAccessor extends StaticAnimation> first = + TiedUpAnimationRegistry.resolveWithFallback(unknown); + assertSame(TiedUpRigRegistry.EMPTY_ANIMATION, first, + "Unknown ID → fallback EMPTY_ANIMATION"); + + // Reset simule un /reload — le set interne est cleared. + assertDoesNotThrow(TiedUpAnimationRegistry::resetWarnedMissing, + "resetWarnedMissing doit etre appelable sans throw " + + "(contrat cible du TiedUpRigRegistryReloadListener.apply())"); + + // Deuxième call post-reset : fallback à nouveau, et le set repart de + // zero — le comportement de WARN est re-enabled. On ne peut pas + // observer le log directement mais le contrat set.add(id) == true + // est rétabli (sinon le reset serait une no-op dangereuse). + AnimationAccessor extends StaticAnimation> second = + TiedUpAnimationRegistry.resolveWithFallback(unknown); + assertSame(TiedUpRigRegistry.EMPTY_ANIMATION, second, + "Unknown ID apres reset → toujours fallback EMPTY_ANIMATION"); + + // Cleanup : ne pas polluer les suivants + TiedUpAnimationRegistry.resetWarnedMissing(); + } +} 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 583111b..9eb9f6c 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 @@ -169,6 +169,46 @@ class DataDrivenItemParserAnimationsTest { assertNotNull(result.livingMotions().get(TiedUpLivingMotions.STRUGGLE_BOUND)); } + // ========== P3 Wave α : Option B convention warn on IDLE ========== + + /** + * Option B convention (design BONDAGE_ANIMATION_DESIGN.md §5.1) : les modders + * ne devraient PAS binder {@link LivingMotions#IDLE} car + * {@code ClientAnimator.resetLivingAnimations()} re-injecte le default EF + * IDLE post-rebuild, et le binding custom l'écrase silencieusement — comportement + * fonctionnel mais non-idiomatique. Le parser log un WARN explicite pour pointer + * le modder vers la convention, mais accepte le binding quand même (rétrocompat). + * + *Ce test verrouille le comportement "accepte + ne throw pas" — la présence + * du WARN log n'est pas directement vérifiable sans log capture, mais le + * binding doit être bel et bien présent dans le résultat.
+ */ + @Test + void parseAnimations_IDLEBinding_logsWarnButAccepts() { + String jsonStr = """ + { + "animations": { + "living_motions": { + "IDLE": "tiedup:arms_cuffed_idle" + } + } + } + """; + + AnimationBindings result = + DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); + + assertNotNull(result, + "IDLE binding est tolere (non-idiomatique mais fonctionnel) => result non-null"); + assertEquals(1, result.livingMotions().size(), + "Le binding IDLE est accepte apres le WARN de convention"); + assertEquals( + ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle"), + result.livingMotions().get(LivingMotions.IDLE), + "Le binding IDLE est bien present dans le resultat malgre le WARN" + ); + } + // ========== living_motions : tolerance erreurs ========== @Test