Scénario cassant sans ce listener : le joueur porte un armbinder, meurt,
+ * respawn dans le world spawn. Le {@code PlayerPatch} côté client est
+ * ré-instancié (nouveau LocalPlayer post-respawn), son {@code ClientAnimator}
+ * a les defaults EF mais aucun binding bondage. L'item est toujours équipé
+ * (V2 capability préservée), mais visuellement le player joue WALK vanilla.
+ * Incohérence visuelle → immersion cassée → démo gameday ratée.
+ *
+ *
Cas couverts
+ *
+ *
{@link ClientPlayerNetworkEvent.LoggingIn} — le client vient
+ * de se connecter au serveur (local ou distant). À ce stade, la
+ * capability V2 peut être vide (sync packet pas encore reçu) — le
+ * rebuild est alors no-op bénin. Filet de sécurité pour le cas où le
+ * sync packet arriverait après le bootstrap complet de l'animator.
+ *
{@link EntityJoinLevelEvent} (filtré sur {@link LocalPlayer})
+ * — fire au spawn et au changement de dimension. Note : fire aussi au
+ * premier chunk load (cas "first join" après login). On compte sur
+ * l'idempotence.
+ *
+ *
+ *
Cas hors-scope (déjà couverts)
+ *
+ *
{@code PlayerEvent.PlayerRespawnEvent} — couvert côté serveur
+ * par {@code PlayerStateEventHandler.onPlayerRespawn} qui appelle
+ * {@code V2EquipmentHelper.sync(player)} → {@code PacketSyncV2Equipment}
+ * → {@code rebuildBondageAnimations} via le hook P3-06.
Les deux handlers peuvent fire 2–3× au démarrage (login + spawn + first
+ * chunk load). {@link ClientRigEquipmentHandler#rebuildBondageAnimations} est
+ * idempotent par contrat : chaque appel repart de
+ * {@code resetLivingAnimations()} + re-applique les bindings courants.
+ * Overhead CPU négligeable (une poignée d'items équipés par player, itération
+ * {@code ArrayList} + {@code Map.put}). Pas de dedup tick-level.
+ *
+ *
Threading & side
+ *
Les deux events ne firent que côté client (Forge les émet uniquement
+ * depuis les paths client : {@code ClientPacketListener.handleLogin} pour
+ * {@code LoggingIn}, et {@code Level#addFreshEntity} côté client pour les
+ * entités qui rejoignent un {@code ClientLevel}). Le filtre
+ * {@link Level#isClientSide()} dans {@link #onEntityJoinLevel} est une garde
+ * défensive pour le cas où l'event fire côté serveur (le listener est
+ * {@code value = Dist.CLIENT} donc la classe ne charge pas sur serveur dédié,
+ * mais en solo intégré l'abonnement est actif sur les deux side-worlds d'une
+ * même JVM).
+ *
+ * @see ClientRigEquipmentHandler#rebuildBondageAnimations
+ * @see BondageEquipmentChangeListener
+ * @see com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment
+ */
+@OnlyIn(Dist.CLIENT)
+@Mod.EventBusSubscriber(
+ modid = TiedUpRigConstants.MODID,
+ bus = Mod.EventBusSubscriber.Bus.FORGE,
+ value = Dist.CLIENT
+)
+public final class BondageRehydrateListener {
+
+ private BondageRehydrateListener() {
+ // utility subscriber class
+ }
+
+ /**
+ * Handler {@link ClientPlayerNetworkEvent.LoggingIn} — fire quand le
+ * client a fini de se connecter au serveur (après réception du packet
+ * {@code ClientboundLoginPacket}).
+ *
+ *
Appelle {@link ClientRigEquipmentHandler#rebuildBondageAnimations}
+ * sur le {@link LocalPlayer} fourni par l'event. À ce stade, la capability
+ * V2 est probablement vide — {@code getAllEquipped} retourne un map vide
+ * et le rebuild est un no-op bénin (animator.resetLivingAnimations + 0
+ * bindings). Le vrai rebuild arrive via
+ * {@code PacketSyncV2Equipment.handleOnClient} quand le serveur push la
+ * sync peu après.
+ *
+ *
Pourquoi hook ici quand même ? Filet de sécurité : si le
+ * sync packet est perdu ou retardé, on a au moins tenté un rebuild avec
+ * l'état courant. Et si l'animator n'est pas encore initialisé
+ * (race bootstrap), {@code rebuildBondageAnimations} retourne
+ * silencieusement (null-check cascade).
+ *
+ * @param event l'event Forge ; {@link ClientPlayerNetworkEvent#getPlayer()}
+ * peut théoriquement être null (ex : event fire pendant
+ * {@code onDisconnect} mid-login) — on guard.
+ */
+ @SubscribeEvent
+ public static void onLoggingIn(ClientPlayerNetworkEvent.LoggingIn event) {
+ LocalPlayer player = event.getPlayer();
+ if (player == null) return;
+
+ ClientRigEquipmentHandler.rebuildBondageAnimations(player);
+
+ TiedUpRigConstants.LOGGER.debug(
+ "[BondageRehydrateListener] Login rehydrate fired for {}",
+ player.getName().getString()
+ );
+ }
+
+ /**
+ * Handler {@link EntityJoinLevelEvent} — fire au spawn d'une entité dans
+ * un {@code Level}. Filtré sur {@link LocalPlayer} uniquement : on ignore
+ * les NPCs, remote players et toute autre entité (leur rebuild passe par
+ * d'autres sources).
+ *
+ *
Sémantique Forge : l'event fire dans
+ * {@code Level#addFreshEntity}, ce qui couvre :
+ *
+ *
Spawn initial après login (le {@code LocalPlayer} est
+ * ajouté au {@code ClientLevel}),
+ *
Dimension change (téléport Overworld ↔ Nether ↔ End : le
+ * {@code LocalPlayer} est removed de l'ancien level puis ajouté au
+ * nouveau — fire une fois par transition),
+ *
Respawn après mort (le client reçoit un nouveau
+ * {@code LocalPlayer} post-respawn ; même s'il est déjà couvert
+ * côté serveur via {@code PacketSyncV2Equipment}, un double-fire
+ * est idempotent).
+ *
+ *
+ *
Le rebuild peut fire 2× au login (ce handler + {@link #onLoggingIn})
+ * — comportement attendu, idempotent.
+ *
+ * @param event l'event Forge ; {@code getEntity()} et {@code getLevel()}
+ * sont contractuellement non-null.
+ */
+ @SubscribeEvent
+ public static void onEntityJoinLevel(EntityJoinLevelEvent event) {
+ // Filter : uniquement le LocalPlayer courant. On ignore tous les NPCs
+ // et entities non-player ; les remote players (autres clients sur le
+ // serveur) voient leur rebuild via PacketSyncV2Equipment.handleOnClient
+ // quand leur capability sync arrive.
+ Entity entity = event.getEntity();
+ Level level = event.getLevel();
+ if (!shouldRehydrate(entity, level, IS_LOCAL_PLAYER, IS_CLIENT_SIDE)) return;
+
+ LocalPlayer player = (LocalPlayer) entity;
+ ClientRigEquipmentHandler.rebuildBondageAnimations(player);
+
+ TiedUpRigConstants.LOGGER.debug(
+ "[BondageRehydrateListener] EntityJoinLevel rehydrate fired for {} in level {}",
+ player.getName().getString(),
+ level.dimension().location()
+ );
+ }
+
+ /**
+ * Predicate production : une {@link Entity} est-elle un {@link LocalPlayer} ?
+ * Factorisé en constante pour permettre l'injection d'une variante test
+ * basée sur {@link Object} dummy — le pattern {@code Mockito.mock(LocalPlayer.class)}
+ * crashe sur {@code InternalError} car MC static init tente de charger le
+ * registre officiel hors runtime game.
+ */
+ static final Predicate