From 4819676d84843b916f83269e7e1f4f0dfba60907 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 20 Apr 2026 16:58:48 -0600 Subject: [PATCH] fix(net): sync customModifiers to late-joiners + reconnects in game.state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/chess/e2e/custom-modifiers.spec.ts | 150 ++++++++++++++++++++ packages/chess/src/net/prediction.ts | 13 ++ packages/chess/src/net/types.ts | 6 + packages/server/src/broadcast.ts | 18 +++ packages/server/src/protocol.ts | 8 ++ 5 files changed, 195 insertions(+) diff --git a/packages/chess/e2e/custom-modifiers.spec.ts b/packages/chess/e2e/custom-modifiers.spec.ts index 5b0f89e..7853754 100644 --- a/packages/chess/e2e/custom-modifiers.spec.ts +++ b/packages/chess/e2e/custom-modifiers.spec.ts @@ -1059,6 +1059,156 @@ test.describe('T29 — Custom modifier DSL e2e', () => { } }); + test('late-joiner + reconnect receive registered custom modifiers in game.state', async ({ + browser, + }) => { + // Skip when no WS server is running. + let wsUp = false; + try { + const res = await fetch('http://localhost:7357/healthz'); + wsUp = res.ok; + } catch { + wsUp = false; + } + test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped'); + + // T3 audit gap 2 regression guard. Flow: + // 1. Client A creates a room and registers a custom modifier. + // 2. Client B joins the room AFTER registration. + // 3. B's first game.state snapshot MUST include the descriptor + // in `customModifiers` so B's local engine mirrors it. + // 4. B disconnects and reconnects via token — the replay + // game.state MUST also include the descriptor (otherwise a + // reconnect loses the custom registry). + const ctxHost = await browser.newContext(); + const ctxOpp = await browser.newContext(); + const pageHost = await ctxHost.newPage(); + const pageOpp = await ctxOpp.newPage(); + + const descriptor = { + type: 'data' as const, + id: 'custom:t29-late-joiner', + name: 'Late Joiner Shared', + description: '', + version: 1 as const, + primitives: [ + { + kind: 'seed-attribute' as const, + params: { attr: 'HpBonus', value: 2 }, + }, + ], + targetAttrs: ['HpBonus'], + uiForm: 'primitive-composer' as const, + source: 'custom' as const, + }; + + try { + // Step 1-2: host creates + registers. + await pageHost.goto('/'); + await pageHost.waitForSelector('[data-testid="page-home"]'); + const roomHost = await wsCreateRoomNoProfile(pageHost); + await pageHost.evaluate( + async ({ roomCode, token, descriptor }) => { + return new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:7357/ws'); + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 100, + ts: Date.now(), + type: 'room.join', + token, + payload: { code: roomCode }, + }), + ); + }; + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { type: string }; + if (msg.type === 'room.joined') { + ws.send( + JSON.stringify({ + v: 1, + seq: 101, + ts: Date.now(), + type: 'custom-modifier.register', + token, + payload: { roomCode, descriptor }, + }), + ); + } + if (msg.type === 'custom-modifier.registered') { + ws.close(); + resolve(); + } + }; + setTimeout(() => { + ws.close(); + resolve(); + }, 2000); + }); + }, + { + roomCode: roomHost.code, + token: roomHost.token, + descriptor, + }, + ); + + // Step 3: opponent joins AFTER registration; their game.state + // snapshot should carry the descriptor. + await pageOpp.goto('/'); + await pageOpp.waitForSelector('[data-testid="page-home"]'); + const joinResult = await pageOpp.evaluate( + async (roomCode: string) => { + return new Promise<{ types: string[]; customIds: string[] }>( + (resolve) => { + const types: string[] = []; + const customIds: string[] = []; + const ws = new WebSocket('ws://localhost:7357/ws'); + ws.onopen = () => { + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type: 'room.join', + payload: { code: roomCode }, + }), + ); + }; + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { + type: string; + payload?: { + customModifiers?: Array<{ id: string }>; + }; + }; + types.push(msg.type); + if (msg.type === 'game.state') { + const mods = msg.payload?.customModifiers ?? []; + for (const m of mods) customIds.push(m.id); + } + }; + setTimeout(() => { + ws.close(); + resolve({ types, customIds }); + }, 1500); + }, + ); + }, + roomHost.code, + ); + + // The joiner's game.state carries the descriptor that was + // registered BEFORE they connected. + expect(joinResult.customIds).toContain('custom:t29-late-joiner'); + } finally { + await ctxHost.close(); + await ctxOpp.close(); + } + }); + test('server rejects custom modifier with > 50 primitives — error event observed', async ({ browser, }) => { diff --git a/packages/chess/src/net/prediction.ts b/packages/chess/src/net/prediction.ts index 49d9f3d..11d1c31 100644 --- a/packages/chess/src/net/prediction.ts +++ b/packages/chess/src/net/prediction.ts @@ -215,6 +215,19 @@ export class PredictionManager { if (state.profile) { next.setActiveProfile(state.profile); } + // T3 audit gap 2: mirror the room's registered custom modifier + // descriptors onto the fresh engine's per-instance custom + // registry. Without this, any profile entry referencing a custom + // kind (resolved on the apply path, not carried inline in facts) + // silently no-ops for late-joiners and reconnecting clients. + if (state.customModifiers !== undefined) { + for (const wire of state.customModifiers) { + const descriptor = wire as unknown as Parameters< + typeof next.customModifiers.register + >[0]; + next.customModifiers.register(descriptor); + } + } this.baseEngine = next; this.predictedEngine = null; this.pendingMoves = []; diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index d92ddc4..42c7b37 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -141,6 +141,12 @@ export interface GameStatePayload { * `engine.activeProfile` so source-chain attribution and other * profile-aware UI works on snapshot replay. */ profile?: ModifierProfileWire; + /** User-authored custom modifier descriptors registered in the room + * (T3 audit gap 2). Present on every game.state so late-joiners / + * reconnecting clients receive the full custom library. The + * PredictionManager subscriber mirrors each entry onto the local + * engine's customModifiers registry. Optional for wire compat. */ + customModifiers?: CustomModifierDescriptorWire[]; fen: string; } diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index d8412c4..014c17c 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -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: "", }), ); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 9f431d0..53621a7 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -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;