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:
Joey Yakimowich-Payne 2026-04-20 16:58:48 -06:00
commit 4819676d84
No known key found for this signature in database
5 changed files with 195 additions and 0 deletions

View file

@ -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: "",
}),
);

View file

@ -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>;