P3-20 : respawn/dim-change rehydrate (UX P0)

Wire ClientRigEquipmentHandler.rebuildBondageAnimations on :
- ClientPlayerNetworkEvent.LoggingIn (client connects)
- EntityJoinLevelEvent (spawn + dim change, filtered to LocalPlayer)

PlayerRespawnEvent path already covered server-side via
PlayerStateEventHandler.onPlayerRespawn -> V2EquipmentHelper.sync ->
PacketSyncV2Equipment -> rebuild (hook P3-06). This listener fills the
client-only gaps.

Without this, a player who logs in with bondage items equipped (or
teleports cross-dimension) sees their livingAnimations map empty until
the next V2 capability sync or armor slot change arrives. Visible
incoherence : item still equipped, bondage anim dropped, player plays
vanilla WALK. Demo-killing regression.

Handlers are idempotent and fire 2-3x at startup (login + spawn + first
chunk load) — rebuildBondageAnimations is designed for that :
resetLivingAnimations + re-apply current bindings, no state drift.

Pure filter extracted to shouldRehydrate(entity, level, ePred, lPred)
generic overload — mirrors the isBondageItem pattern in the sibling
BondageEquipmentChangeListener. Lets tests bypass the MC bootstrap
crash on Mockito.mock(LocalPlayer.class).

Removed the TODO(P3-20) block in BondageEquipmentChangeListener that
pointed to this task.

5 tests, all paths green (shouldRehydrate short-circuits, production
predicate wiring + null-safety).
This commit is contained in:
notevil
2026-04-24 00:33:35 +02:00
parent de691e24ec
commit cae3572488
3 changed files with 377 additions and 8 deletions

View File

@@ -203,12 +203,8 @@ public final class BondageEquipmentChangeListener {
return bondagePredicate.test(stack); return bondagePredicate.test(stack);
} }
// TODO(P3-20) : Ajouter les sources "rehydrate" pour respawn / dimension // Rehydrate sur login / dimension change / respawn : délégué à
// change / login, qui ne sont pas couvertes par PacketSyncV2Equipment // BondageRehydrateListener (P3-20, task #63). PlayerEvent.PlayerRespawnEvent
// (race possible entre capability sync et client animator bootstrap) : // est couvert côté serveur via PlayerStateEventHandler.onPlayerRespawn →
// - ClientPlayerNetworkEvent.LoggingIn → rebuild sur le LocalPlayer // V2EquipmentHelper.sync → PacketSyncV2Equipment → rebuild (path P3-06).
// - EntityJoinLevelEvent → rebuild si player (remote /
// après dimension change)
// - PlayerEvent.PlayerRespawnEvent → rebuild post-respawn
// Scopé à P3-20 (task #63) pour garder la surface de ce PR minimale.
} }

View File

@@ -0,0 +1,234 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.client;
import java.util.function.Predicate;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.ClientPlayerNetworkEvent;
import net.minecraftforge.event.entity.EntityJoinLevelEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import com.tiedup.remake.rig.TiedUpRigConstants;
/**
* Rehydrate client pour {@link ClientRigEquipmentHandler#rebuildBondageAnimations}
* sur les événements de cycle de vie lourds : login initial et changement de
* dimension / respawn chunk. Sans ça, la map {@code livingAnimations} du
* {@code ClientAnimator} reste vide (ou stale) jusqu'au prochain packet V2 ou
* changement d'équipement — UX pétée dès le 1er respawn/téléport.
*
* <h2>Rationale UX P0</h2>
* <p>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.</p>
*
* <h2>Cas couverts</h2>
* <ul>
* <li><b>{@link ClientPlayerNetworkEvent.LoggingIn}</b> — 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.</li>
* <li><b>{@link EntityJoinLevelEvent}</b> (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.</li>
* </ul>
*
* <h2>Cas hors-scope (déjà couverts)</h2>
* <ul>
* <li><b>{@code PlayerEvent.PlayerRespawnEvent}</b> — couvert côté serveur
* par {@code PlayerStateEventHandler.onPlayerRespawn} qui appelle
* {@code V2EquipmentHelper.sync(player)} → {@code PacketSyncV2Equipment}
* → {@code rebuildBondageAnimations} via le hook P3-06.</li>
* <li><b>Changement d'équipement bondage</b> — {@code PacketSyncV2Equipment}
* (P3-06, capability V2) + {@link BondageEquipmentChangeListener}
* (P3-06 bis, slots armor vanilla).</li>
* </ul>
*
* <h2>Idempotence</h2>
* <p>Les deux handlers peuvent fire 23× 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.</p>
*
* <h2>Threading &amp; side</h2>
* <p>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).</p>
*
* @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}).
*
* <p>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.</p>
*
* <p><b>Pourquoi hook ici quand même ?</b> 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).</p>
*
* @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).
*
* <p><b>Sémantique Forge</b> : l'event fire dans
* {@code Level#addFreshEntity}, ce qui couvre :</p>
* <ul>
* <li><b>Spawn initial</b> après login (le {@code LocalPlayer} est
* ajouté au {@code ClientLevel}),</li>
* <li><b>Dimension change</b> (téléport Overworld ↔ Nether ↔ End : le
* {@code LocalPlayer} est removed de l'ancien level puis ajouté au
* nouveau — fire une fois par transition),</li>
* <li><b>Respawn après mort</b> (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).</li>
* </ul>
*
* <p>Le rebuild peut fire 2× au login (ce handler + {@link #onLoggingIn})
* — comportement attendu, idempotent.</p>
*
* @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<Object> IS_LOCAL_PLAYER = e -> e instanceof LocalPlayer;
/**
* Predicate production : un {@link Level} est-il côté client ? Extrait en
* constante pour la même raison (impossible de mocker {@link Level} sans
* bootstrap MC).
*/
static final Predicate<Object> IS_CLIENT_SIDE =
l -> l instanceof Level level && level.isClientSide();
/**
* Predicate pure testable — détermine si un event {@link EntityJoinLevelEvent}
* doit déclencher un rebuild. Vrai ssi :
* <ol>
* <li>{@code entity} matche {@code entityPredicate}
* (en prod : {@code instanceof LocalPlayer}),</li>
* <li>{@code level} matche {@code levelPredicate}
* (en prod : {@code instanceof Level && isClientSide()}).</li>
* </ol>
*
* <p>Overload générique {@code <E, L>} pour permettre les unit tests
* sans bootstrap MC. Les tests passent des {@code Object} dummy +
* predicates contrôlés ({@code ALWAYS_TRUE} / {@code ALWAYS_FALSE}),
* évitant le crash {@code Mockito.mock(LocalPlayer.class)} qui
* déclenche la static init MC (registries, block states, etc.).</p>
*
* <p><b>Null-safety</b> : les deux predicates sont responsables de leur
* propre null-handling. Les constantes prod {@link #IS_LOCAL_PLAYER} et
* {@link #IS_CLIENT_SIDE} short-circuit sur null via {@code instanceof}
* (qui retourne {@code false} pour null).</p>
*
* @param <E> type de l'entité ({@link Entity} en prod,
* {@link Object} en test)
* @param <L> type du level ({@link Level} en prod,
* {@link Object} en test)
* @param entity entity issue de l'event (tolère null)
* @param level level issu de l'event (tolère null)
* @param entityPredicate predicate "est-ce notre client player ?"
* @param levelPredicate predicate "est-ce un client-side level ?"
* @return {@code true} ssi les deux predicates retournent {@code true}
*/
static <E, L> boolean shouldRehydrate(
E entity,
L level,
Predicate<? super E> entityPredicate,
Predicate<? super L> levelPredicate
) {
if (!entityPredicate.test(entity)) return false;
return levelPredicate.test(level);
}
}

View File

@@ -0,0 +1,139 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.client;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.function.Predicate;
import org.junit.jupiter.api.Test;
/**
* Tests unitaires pour {@link BondageRehydrateListener} (P3-20, task #63).
*
* <h2>Stratégie</h2>
* <p>Les deux handlers publics
* ({@link BondageRehydrateListener#onLoggingIn} et
* {@link BondageRehydrateListener#onEntityJoinLevel}) nécessitent des events
* Forge + capability MC pour fire {@code rebuildBondageAnimations} — non
* testables sans bootstrap. La logique testable est donc isolée dans
* {@link BondageRehydrateListener#shouldRehydrate} (overload générique
* {@code <E, L>} qui permet de bypasser le bootstrap MC).</p>
*
* <p>Même pattern que {@link BondageEquipmentChangeListenerTest} : les
* entités/levels sont remplacés par des {@link Object} dummy et les
* predicates de filtre par des lambdas contrôlées. {@code Mockito.mock}
* sur {@code LocalPlayer}/{@code Level} crashe sur {@code InternalError}
* (MC static init tente de charger les registres hors runtime game).</p>
*/
class BondageRehydrateListenerTest {
/** Predicate qui considère chaque objet comme matchant. */
private static final Predicate<Object> ALWAYS_TRUE = o -> true;
/** Predicate qui considère chaque objet comme non-matchant. */
private static final Predicate<Object> ALWAYS_FALSE = o -> false;
// ========== shouldRehydrate (generic overload) ==========
/**
* Entity ne matche pas (ex : NPC, Zombie, remote player) → false.
* Le {@code levelPredicate} n'est PAS appelé (short-circuit).
*/
@Test
void shouldRehydrate_entityRejected_returnsFalse_levelPredicateNotInvoked() {
Predicate<Object> levelTripwire = l -> {
throw new AssertionError(
"levelPredicate ne doit pas etre appele apres entityPredicate false");
};
assertFalse(
BondageRehydrateListener.shouldRehydrate(
new Object(), new Object(), ALWAYS_FALSE, levelTripwire),
"entityPredicate false doit retourner false sans evaluer levelPredicate"
);
}
/**
* Entity matche (LocalPlayer) mais level server-side → false. Garde
* défensive : en solo intégré les deux sides coexistent dans la même
* JVM, on gate sur {@code isClientSide} pour ne rebuild que dans le
* pipeline client.
*/
@Test
void shouldRehydrate_entityMatchLevelRejected_returnsFalse() {
assertFalse(
BondageRehydrateListener.shouldRehydrate(
new Object(), new Object(), ALWAYS_TRUE, ALWAYS_FALSE),
"LocalPlayer + server-side level doit retourner false"
);
}
/**
* Entity matche (LocalPlayer) ET level matche (client-side) → true.
* Cas nominal : le player local rejoint le {@code ClientLevel} (login,
* dim change, respawn chunk). Déclenche le rebuild.
*/
@Test
void shouldRehydrate_bothMatch_returnsTrue() {
assertTrue(
BondageRehydrateListener.shouldRehydrate(
new Object(), new Object(), ALWAYS_TRUE, ALWAYS_TRUE),
"LocalPlayer + client-side level doit declencher rebuild"
);
}
// ========== Production predicates sanity ==========
/**
* Les predicates production {@link BondageRehydrateListener#IS_LOCAL_PLAYER}
* et {@link BondageRehydrateListener#IS_CLIENT_SIDE} existent et sont
* non-null. Garde contre un refactor qui les nullifierait silencieusement.
*
* <p>On vérifie aussi leur null-safety : {@code instanceof} retourne
* false pour null, donc aucun des deux predicates ne doit throw sur
* un null input.</p>
*/
@Test
void productionPredicates_areWiredAndNullSafe() {
assertNotNull(
BondageRehydrateListener.IS_LOCAL_PLAYER,
"IS_LOCAL_PLAYER doit etre non-null (wiring sanity)"
);
assertNotNull(
BondageRehydrateListener.IS_CLIENT_SIDE,
"IS_CLIENT_SIDE doit etre non-null (wiring sanity)"
);
// Null-safety via instanceof semantics
assertFalse(
BondageRehydrateListener.IS_LOCAL_PLAYER.test(null),
"IS_LOCAL_PLAYER(null) doit retourner false (instanceof null-safe)"
);
assertFalse(
BondageRehydrateListener.IS_CLIENT_SIDE.test(null),
"IS_CLIENT_SIDE(null) doit retourner false (instanceof null-safe)"
);
}
/**
* Les predicates production appliqués à un {@link Object} dummy
* retournent false — ni Object n'est une {@code LocalPlayer}, ni un
* {@code Level}. Valide le {@code instanceof} check.
*/
@Test
void productionPredicates_onDummyObject_returnFalse() {
Object dummy = new Object();
assertFalse(
BondageRehydrateListener.IS_LOCAL_PLAYER.test(dummy),
"Object dummy ne doit pas matcher IS_LOCAL_PLAYER"
);
assertFalse(
BondageRehydrateListener.IS_CLIENT_SIDE.test(dummy),
"Object dummy ne doit pas matcher IS_CLIENT_SIDE"
);
}
}