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
|
|
@ -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 ({
|
test('server rejects custom modifier with > 50 primitives — error event observed', async ({
|
||||||
browser,
|
browser,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,19 @@ export class PredictionManager {
|
||||||
if (state.profile) {
|
if (state.profile) {
|
||||||
next.setActiveProfile(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.baseEngine = next;
|
||||||
this.predictedEngine = null;
|
this.predictedEngine = null;
|
||||||
this.pendingMoves = [];
|
this.pendingMoves = [];
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,12 @@ export interface GameStatePayload {
|
||||||
* `engine.activeProfile` so source-chain attribution and other
|
* `engine.activeProfile` so source-chain attribution and other
|
||||||
* profile-aware UI works on snapshot replay. */
|
* profile-aware UI works on snapshot replay. */
|
||||||
profile?: ModifierProfileWire;
|
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;
|
fen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -493,6 +493,14 @@ function handleRoomJoin(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const activeProfile = session.getProfile();
|
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(
|
broadcastToRoom(
|
||||||
payload.code,
|
payload.code,
|
||||||
envelope("game.state", {
|
envelope("game.state", {
|
||||||
|
|
@ -503,6 +511,7 @@ function handleRoomJoin(
|
||||||
activeRules: [...result.activeRules],
|
activeRules: [...result.activeRules],
|
||||||
activations: session.getPresetActivations(),
|
activations: session.getPresetActivations(),
|
||||||
...(activeProfile !== undefined ? { profile: activeProfile } : {}),
|
...(activeProfile !== undefined ? { profile: activeProfile } : {}),
|
||||||
|
...(customModifiers.length > 0 ? { customModifiers } : {}),
|
||||||
// fen is a UI convenience for v1; we haven't wired FEN generation
|
// 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
|
// on the server yet, so we send an empty string. Clients that need
|
||||||
// FEN can derive it from `facts`.
|
// FEN can derive it from `facts`.
|
||||||
|
|
@ -581,6 +590,12 @@ function handleReconnect(
|
||||||
// Snapshot: authoritative state for the returning client. The client
|
// Snapshot: authoritative state for the returning client. The client
|
||||||
// discards its local fact store and rebuilds from this frame.
|
// discards its local fact store and rebuilds from this frame.
|
||||||
const reconnectActiveProfile = session.getProfile();
|
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(
|
sendTo(
|
||||||
ws,
|
ws,
|
||||||
envelope("game.state", {
|
envelope("game.state", {
|
||||||
|
|
@ -595,6 +610,9 @@ function handleReconnect(
|
||||||
activeRules: [...room.rulesetIds],
|
activeRules: [...room.rulesetIds],
|
||||||
activations: session.getPresetActivations(),
|
activations: session.getPresetActivations(),
|
||||||
...(reconnectActiveProfile !== undefined ? { profile: reconnectActiveProfile } : {}),
|
...(reconnectActiveProfile !== undefined ? { profile: reconnectActiveProfile } : {}),
|
||||||
|
...(reconnectCustomModifiers.length > 0
|
||||||
|
? { customModifiers: reconnectCustomModifiers }
|
||||||
|
: {}),
|
||||||
fen: "",
|
fen: "",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -654,6 +654,14 @@ export const GameStatePayloadSchema = z.object({
|
||||||
* Clients use this to populate `engine.activeProfile` so source-chain
|
* Clients use this to populate `engine.activeProfile` so source-chain
|
||||||
* attribution and other profile-aware UI works on snapshot replay. */
|
* attribution and other profile-aware UI works on snapshot replay. */
|
||||||
profile: ModifierProfileSchema.optional(),
|
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(),
|
fen: z.string(),
|
||||||
});
|
});
|
||||||
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
|
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue