fix(net): sync customModifiers to late-joiners + reconnects in game.state
T3 audit gap 2 (CRITICAL). The server's Room kept registered custom
modifier descriptors in a per-room Map but the game.state snapshot
carried no field for them. Impact:
- Client A registers 'custom:shield' → server broadcasts
custom-modifier.registered → A + any currently-connected B see it.
- Client C joins AFTER the registration → receives game.state →
has no knowledge of 'custom:shield'.
- Client C's engine applies a profile with kind='custom:shield' →
registry-dispatch fallback silently no-ops → apparent cosmetic
modifier mismatch between A/B and C.
Symmetric fix across the wire:
- GameStatePayloadSchema (server + client types) gains an optional
customModifiers: CustomModifierDescriptorWire[] field.
- Both emit sites in broadcast.ts (late-joiner path +
reconnect-with-buffered-deltas path) include the room's registered
descriptors.
- PredictionManager.applyFullState mirrors received descriptors
onto the fresh engine's customModifiers registry before handing
control to the UI. Unknown descriptor shapes are accepted as-is
(the wire-shape cast at the single boundary bridges the Zod v3/v4
type split same as the custom-modifier.registered subscriber).
E2E regression guard (Oracle Q4.1 recommendation): new scenario
'late-joiner + reconnect receive registered custom modifiers in
game.state'. Host creates + registers, opponent joins AFTER
registration, asserts opponent's game.state carries the descriptor.
Would have caught the pre-fix behaviour as a test failure instead of
a manual audit find.
1393 unit + 19/19 custom-modifiers e2e green.
This commit is contained in:
parent
33b5910839
commit
4819676d84
5 changed files with 195 additions and 0 deletions
|
|
@ -493,6 +493,14 @@ function handleRoomJoin(
|
|||
return;
|
||||
}
|
||||
const activeProfile = session.getProfile();
|
||||
// T3 audit gap 2: include the room's registered custom modifier
|
||||
// descriptors so the joiner can mirror them onto their local
|
||||
// engine's customModifiers registry. Without this, any profile
|
||||
// entry referencing a custom kind silently no-ops on the joiner.
|
||||
const joinedRoom = roomRegistry.getRoom(payload.code);
|
||||
const customModifiers = [
|
||||
...(joinedRoom?.customModifiers?.values() ?? []),
|
||||
];
|
||||
broadcastToRoom(
|
||||
payload.code,
|
||||
envelope("game.state", {
|
||||
|
|
@ -503,6 +511,7 @@ function handleRoomJoin(
|
|||
activeRules: [...result.activeRules],
|
||||
activations: session.getPresetActivations(),
|
||||
...(activeProfile !== undefined ? { profile: activeProfile } : {}),
|
||||
...(customModifiers.length > 0 ? { customModifiers } : {}),
|
||||
// fen is a UI convenience for v1; we haven't wired FEN generation
|
||||
// on the server yet, so we send an empty string. Clients that need
|
||||
// FEN can derive it from `facts`.
|
||||
|
|
@ -581,6 +590,12 @@ function handleReconnect(
|
|||
// Snapshot: authoritative state for the returning client. The client
|
||||
// discards its local fact store and rebuilds from this frame.
|
||||
const reconnectActiveProfile = session.getProfile();
|
||||
// T3 audit gap 2: same custom-modifier inclusion as the late-joiner
|
||||
// path — reconnecting clients need to re-hydrate their local custom
|
||||
// registry after the old socket's state was discarded.
|
||||
const reconnectCustomModifiers = [
|
||||
...(room.customModifiers?.values() ?? []),
|
||||
];
|
||||
sendTo(
|
||||
ws,
|
||||
envelope("game.state", {
|
||||
|
|
@ -595,6 +610,9 @@ function handleReconnect(
|
|||
activeRules: [...room.rulesetIds],
|
||||
activations: session.getPresetActivations(),
|
||||
...(reconnectActiveProfile !== undefined ? { profile: reconnectActiveProfile } : {}),
|
||||
...(reconnectCustomModifiers.length > 0
|
||||
? { customModifiers: reconnectCustomModifiers }
|
||||
: {}),
|
||||
fen: "",
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -654,6 +654,14 @@ export const GameStatePayloadSchema = z.object({
|
|||
* Clients use this to populate `engine.activeProfile` so source-chain
|
||||
* attribution and other profile-aware UI works on snapshot replay. */
|
||||
profile: ModifierProfileSchema.optional(),
|
||||
/** User-authored custom modifier descriptors registered in the room
|
||||
* (T3 audit gap 2). Optional — absent for rooms with no custom
|
||||
* registrations. Sent on every game.state so late-joiners AND
|
||||
* reconnecting clients receive the full custom library and can
|
||||
* resolve any profile entry that references one by id. Clients
|
||||
* mirror each entry onto their local engine's customModifiers
|
||||
* registry. */
|
||||
customModifiers: z.array(CustomModifierDescriptorSchema).optional(),
|
||||
fen: z.string(),
|
||||
});
|
||||
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue