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

@ -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<void>((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,
}) => {

View file

@ -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 = [];

View file

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