Caveat important — Forge sémantique "server-side only"
+ *
La javadoc Forge de {@link LivingEquipmentChangeEvent} indique explicitement
+ * "This event is fired on server-side only". L'appel
+ * {@code ForgeEventFactory.onLivingEquipmentChange} dans {@code LivingEntity#tick()}
+ * n'a pas de garde {@code level.isClientSide} explicite, mais en pratique
+ * Forge le documente comme authoritative server. On enregistre néanmoins ce
+ * handler client-only comme filet de sécurité :
+ *
+ *
Dans un environnement client intégré (solo / LAN host), le
+ * tick {@code LivingEntity} s'exécute côté client aussi pour les
+ * entités chargées — l'event peut donc, dans certains paths, fire
+ * côté client.
+ *
Dans un serveur dédié, le handler ne sera jamais chargé
+ * (registre gated par {@code value = Dist.CLIENT}).
+ *
Si l'event ne fire jamais côté client, ce handler est dead
+ * code bénin — pas de régression. Le path canonique P3-06
+ * {@link com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment}
+ * couvre 100% des changements V2 puisque V2 utilise la capability
+ * dédiée et non les slots armor.
+ *
+ *
+ *
Décision design : V2 bondage n'utilise PAS les slots armor vanilla
+ * — toute l'equipment passe par la capability {@code V2_BONDAGE_EQUIPMENT} et
+ * son packet {@code PacketSyncV2Equipment}. Ce listener est donc défensif :
+ * il attrape les cas edge où un item bondage atterrirait dans un slot armor
+ * (ex : creative mode {@code /item replace}, mods tiers qui manipulent
+ * directement {@code setItemSlot}, future migration partielle).
+ *
+ *
Workflow
+ *
+ *
Filtre par {@link #isBondageRelevant} : au moins un des deux stacks
+ * (from/to) doit être un item bondage data-driven, sinon ignore.
+ *
Filtre par {@code instanceof Player} — le handler ne gère que les
+ * {@link Player} pour le moment. NPCs bondage (Damsels, etc.) seront
+ * câblés dans P3-07/P3-08 séparément via leur propre pipeline.
+ *
Guard {@code level().isClientSide()} pour éviter le rebuild
+ * côté serveur au cas où le tick fire sur les deux côtés.
Si {@code PacketSyncV2Equipment} ET ce listener fire pour le même
+ * changement (cas hypothétique d'un item bondage V2 déplacé vers un slot
+ * armor et syncé via la capability en parallèle), on rebuild 2×. Le rebuild
+ * est idempotent côté {@link ClientRigEquipmentHandler} — aucune corruption
+ * d'état, juste du gaspillage CPU. Pas de dedup tick-level pour l'instant
+ * (MVP), à mesurer si l'overhead devient visible.
+ *
+ * @see ClientRigEquipmentHandler
+ * @see com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment
+ */
+@OnlyIn(Dist.CLIENT)
+@Mod.EventBusSubscriber(
+ modid = TiedUpMod.MOD_ID,
+ bus = Mod.EventBusSubscriber.Bus.FORGE,
+ value = Dist.CLIENT
+)
+public final class BondageEquipmentChangeListener {
+
+ private BondageEquipmentChangeListener() {
+ // utility subscriber class
+ }
+
+ /**
+ * Subscriber principal — déclenche un rebuild si au moins un des items
+ * (from/to) est un bondage item data-driven.
+ *
+ *
Ce handler est un best-effort pour les changements d'armor slot ;
+ * le path canonique reste {@code PacketSyncV2Equipment.handleOnClient}
+ * qui cible la capability V2 dédiée. Cf. class javadoc pour la
+ * sémantique "server-side only" de l'event.
+ *
+ * @param event l'event Forge (from/to ItemStack non-null par contrat)
+ */
+ @SubscribeEvent
+ public static void onEquipmentChange(LivingEquipmentChangeEvent event) {
+ if (!(event.getEntity() instanceof Player player)) return;
+
+ // Double-guard : en cas de fire côté serveur (comportement Forge
+ // documenté), on skip. L'event peut fire côté client dans certains
+ // paths malgré la doc — on gate explicitement.
+ if (!player.level().isClientSide()) return;
+
+ // En prod : emptiness check = ItemStack::isEmpty ; bondage check =
+ // DataDrivenItemRegistry::get non-null. Factorisés en deux method-refs
+ // pour permettre leur substitution en test par des lambdas génériques
+ // (les overloads acceptent n'importe quel type opaque — l'Object
+ // test-friendly évite le MC bootstrap qu'impose Mockito sur ItemStack).
+ if (!isBondageRelevant(
+ event.getFrom(),
+ event.getTo(),
+ ItemStack::isEmpty,
+ DEFAULT_BONDAGE_PREDICATE
+ )) {
+ return;
+ }
+
+ ClientRigEquipmentHandler.rebuildBondageAnimations(player);
+ }
+
+ /**
+ * Predicate production qui utilise {@link DataDrivenItemRegistry#get(ItemStack)}.
+ * Factoré en constante pour permettre l'injection en test (les overloads
+ * package-private {@code isBondageRelevant}/{@code isBondageItem} acceptent
+ * un predicate alternatif, évitant le besoin de MC bootstrap pour les
+ * ItemStack + registry init).
+ */
+ static final Predicate DEFAULT_BONDAGE_PREDICATE =
+ stack -> DataDrivenItemRegistry.get(stack) != null;
+
+ /**
+ * Détermine si un changement d'équipement (from → to) implique au moins
+ * un item bondage data-driven. Overload générique {@code } pour
+ * permettre les unit tests sans {@link ItemStack} réel (les tests passent
+ * des {@code Object} dummy + predicates contrôlés, évitant le crash
+ * {@code Mockito.mock(ItemStack.class)} qui déclenche la static init MC).
+ *
+ *
Sémantique : vrai ssi au moins un des deux stacks n'est ni
+ * null ni vide ET matche le {@code bondagePredicate}. On utilise le
+ * pattern registry (plutôt que {@code instanceof AbstractV2BondageItem})
+ * car l'animation rebuild n'a d'effet que pour les items qui portent une
+ * {@code DataDrivenItemDefinition} — un item V2 legacy sans JSON
+ * definition ne contribuerait pas au rebuild et gaspillerait un appel.
+ * Même pattern que {@link ClientRigEquipmentHandler#extractSortedDefinitions}
+ * pour la cohérence.
+ *
+ *
Null-safety : les deux stacks sont contractuellement non-null
+ * selon {@link LivingEquipmentChangeEvent#getFrom()} et
+ * {@link LivingEquipmentChangeEvent#getTo()} ({@code @NotNull}), mais on
+ * double-check null + empty dans {@link #isBondageItem}.
+ *
+ * @param type du stack ({@link ItemStack} en prod,
+ * {@link Object} en test)
+ * @param from item précédemment équipé (tolère null)
+ * @param to item nouvellement équipé (tolère null)
+ * @param emptyPredicate predicate qui retourne {@code true} si le stack
+ * est "empty" (en prod {@code ItemStack::isEmpty})
+ * @param bondagePredicate predicate qui retourne {@code true} si un stack
+ * non-null/non-empty est data-driven (en prod
+ * {@link #DEFAULT_BONDAGE_PREDICATE})
+ * @return {@code true} si au moins un item est un bondage data-driven
+ */
+ static boolean isBondageRelevant(
+ T from,
+ T to,
+ Predicate emptyPredicate,
+ Predicate bondagePredicate
+ ) {
+ return isBondageItem(from, emptyPredicate, bondagePredicate)
+ || isBondageItem(to, emptyPredicate, bondagePredicate);
+ }
+
+ /**
+ * Vérifie si un stack est un item bondage data-driven (overload générique
+ * pour testabilité).
+ *
+ *
Gère null/empty : retourne {@code false} sans appeler
+ * {@code bondagePredicate}. Ne throw jamais. Le {@code bondagePredicate}
+ * n'est appelé que pour des stacks non-null et non-empty
+ * (short-circuit).
L'entrée publique
+ * {@link BondageEquipmentChangeListener#onEquipmentChange} nécessite un
+ * {@code LivingEquipmentChangeEvent} + {@code Player} + MC runtime — non
+ * testable sans bootstrap. On se limite ici à la logique pure
+ * {@link BondageEquipmentChangeListener#isBondageItem} et
+ * {@link BondageEquipmentChangeListener#isBondageRelevant} via leurs overloads
+ * génériques {@code }.
+ *
+ *
Les {@link net.minecraft.world.item.ItemStack ItemStack} sont remplacés
+ * par des {@code Object} dummy (même pattern que
+ * {@link ClientRigEquipmentHandlerTest#extractSortedDefinitions_nullResolver_skipsItem}).
+ * Les fonctions {@code isEmpty} et {@code isBondage} sont des lambdas
+ * contrôlées qui inspectent via {@link IdentityHashMap} les objets dummy —
+ * évite le crash {@code Mockito.mock(ItemStack.class)} qui déclenche la
+ * static init MC.
+ */
+class BondageEquipmentChangeListenerTest {
+
+ /** Predicate qui considère chaque stack distinct comme "non-empty". */
+ private static final Predicate