feat(multiplayer): game.action WS message for PlayerActions

Wire up the game.action WebSocket message so multiplayer games can
dispatch PlayerActions (F4b of post-epic-deferrals).

- Export PlayerAction/ActionResult from @paratype/chess barrel
- Add performAction wrapper to GameSession
- Protocol: GameActionMessageSchema + ClientMessage union update
- Server: handleGameAction handler with turn gate + error mapping
- Client: sendAction helper in useMultiplayerGame + net/types update
- Reuse game.state broadcast (no new server→client message type)

Unit tests: 1709 (baseline 1699 + 10 new: 5 protocol, 3 game-session, 2 net)
Playwright: 91 (baseline 89 + 2 new F4b multiplayer scenarios)
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 11:56:35 -06:00
commit 2951a2d547
No known key found for this signature in database
10 changed files with 939 additions and 1 deletions

View file

@ -469,6 +469,367 @@ test('F1 color preference: default (no field) keeps legacy creator=white', async
await ctxA.close();
});
// ---------------------------------------------------------------------------
// F4b (post-epic-deferrals): game.action WS message
// ---------------------------------------------------------------------------
/**
* Create a room with a preset list preloaded via raw `room.create`.
* Mirrors `wsCreateRoom` but threads the ruleset ids so the
* transferable-royalty action has an owning preset on both sides
* from move 1 — no drawer interaction required.
*/
async function wsCreateRoomWithPresets(
page: Page,
rulesetIds: string[],
): Promise<{ code: string; token: string; color: string }> {
return page.evaluate(async (ids: string[]) => {
return new Promise<{ code: string; token: string; color: string }>(
(resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const timer = setTimeout(
() => reject(new Error('wsCreateRoomWithPresets: timeout')),
5000,
);
ws.onopen = () => {
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.create',
payload: { rulesetIds: ids },
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: {
code: string;
token: string;
color: string;
message?: string;
};
};
if (msg.type === 'room.created') {
clearTimeout(timer);
ws.close();
resolve(msg.payload);
} else if (msg.type === 'error') {
clearTimeout(timer);
ws.close();
reject(new Error(msg.payload.message ?? 'room.create error'));
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('wsCreateRoomWithPresets: WebSocket error'));
};
},
);
}, rulesetIds);
}
/**
* Send a `game.action` envelope over a fresh authenticated WebSocket
* and return the first server message that either acks (via a
* `game.state` broadcast) or rejects (via `error`).
*
* Opens its own socket per call because the e2e tests interact with
* the multiplayer UI pages' sockets too — sharing one socket across
* the test scope would race with the UI's PredictionManager.
*/
async function wsSendAction(
page: Page,
code: string,
token: string,
action: { kind: string; fromPieceId: number; toPieceId: number },
): Promise<{ kind: 'state'; turn: string } | { kind: 'error'; code: string; message: string }> {
return page.evaluate(
async (args: {
code: string;
token: string;
action: { kind: string; fromPieceId: number; toPieceId: number };
}) => {
return new Promise<
| { kind: 'state'; turn: string }
| { kind: 'error'; code: string; message: string }
>((resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const timer = setTimeout(
() => reject(new Error('wsSendAction: timeout')),
5000,
);
let joined = false;
let actionSent = false;
ws.onopen = () => {
// Re-join with the stored token so the server recognises us.
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
token: args.token,
type: 'room.join',
payload: { code: args.code },
}),
);
};
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: Record<string, unknown>;
};
if (msg.type === 'room.joined' && !joined) {
joined = true;
// Don't fire action yet — wait for the initial game.state
// snapshot that the server sends on join.
return;
}
if (msg.type === 'game.state' && !actionSent) {
// This is the initial state snapshot from join. Now fire
// the action so the next game.state is the post-action one.
actionSent = true;
ws.send(
JSON.stringify({
v: 1,
seq: 2,
ts: Date.now(),
token: args.token,
type: 'game.action',
payload: { action: args.action },
}),
);
return;
}
if (msg.type === 'game.state' && actionSent) {
clearTimeout(timer);
ws.close();
resolve({ kind: 'state', turn: msg.payload.turn as string });
return;
}
if (msg.type === 'error') {
clearTimeout(timer);
ws.close();
resolve({
kind: 'error',
code: msg.payload.code as string,
message: msg.payload.message as string,
});
return;
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('wsSendAction: WebSocket error'));
};
});
},
{ code, token, action },
);
}
test('F4b game.action: transfer-royalty via wire succeeds and broadcasts to both clients', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
// 1. Room with transferable-royalty preset active from the start.
await pageA.goto('http://localhost:5173/');
const roomA = await wsCreateRoomWithPresets(pageA, ['transferable-royalty']);
expect(roomA.color).toBe('white');
await pageB.goto('http://localhost:5173/');
const roomB = await wsJoinRoom(pageB, roomA.code);
expect(roomB.color).toBe('black');
// 2. Probe the starting layout so we know which entity ids the engine
// assigned to white's king (e1, square 4) and queen (d1, square 3).
// A one-shot socket queries `game.state` via room.join.
const startingFacts = await pageA.evaluate(
async (args: { code: string; token: string }) => {
return new Promise<{ id: number; attr: string; value: unknown }[]>(
(resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const t = setTimeout(() => reject(new Error('timeout')), 5000);
ws.onopen = () =>
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
token: args.token,
type: 'room.join',
payload: { code: args.code },
}),
);
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: {
facts?: { id: number; attr: string; value: unknown }[];
};
};
if (msg.type === 'game.state') {
clearTimeout(t);
ws.close();
resolve(msg.payload.facts ?? []);
}
};
ws.onerror = () => {
clearTimeout(t);
reject(new Error('ws error'));
};
},
);
},
{ code: roomA.code, token: roomA.token },
);
// Locate white king (e1=4) and queen (d1=3) ids via fact scan.
const findPiece = (
facts: { id: number; attr: string; value: unknown }[],
pieceType: string,
square: number,
): number | null => {
const typeIds = new Set(
facts
.filter((f) => f.attr === 'PieceType' && f.value === pieceType)
.map((f) => f.id),
);
const hit = facts.find(
(f) => f.attr === 'Position' && f.value === square && typeIds.has(f.id),
);
return hit ? hit.id : null;
};
const whiteKing = findPiece(startingFacts, 'king', 4);
const whiteQueen = findPiece(startingFacts, 'queen', 3);
expect(whiteKing).not.toBeNull();
expect(whiteQueen).not.toBeNull();
// 3. White sends game.action: transfer royalty king→queen. Server
// accepts and broadcasts a fresh game.state to both sockets.
const whiteResult = await wsSendAction(pageA, roomA.code, roomA.token, {
kind: 'transfer-royalty',
fromPieceId: whiteKing!,
toPieceId: whiteQueen!,
});
expect(whiteResult.kind).toBe('state');
if (whiteResult.kind === 'state') {
// Turn has flipped to black post-action (performAction consumes
// the turn just like applyMove).
expect(whiteResult.turn).toBe('black');
}
// 4. Black tries the SAME action. The server rejects with
// ILLEGAL_ACTION because white's transfer already consumed the
// per-color slot in the preset's state (second transfer by same
// color is REJECTED; using white's ids from black's socket also
// can't work because black isn't that color). Either way the
// wire surface must be ILLEGAL_ACTION — never a crash or silent
// accept.
const blackResult = await wsSendAction(pageB, roomA.code, roomB.token, {
kind: 'transfer-royalty',
fromPieceId: whiteKing!,
toPieceId: whiteQueen!,
});
expect(blackResult.kind).toBe('error');
if (blackResult.kind === 'error') {
// Could be ILLEGAL_ACTION (INVALID_TARGET — can't transfer from
// opponent's king) OR NOT_YOUR_TURN if ordering of the two socket
// opens lands with the turn check first. Either is acceptable
// F4b behaviour — both codes indicate "server refused" rather
// than "silently accepted".
expect(['ILLEGAL_ACTION', 'NOT_YOUR_TURN']).toContain(blackResult.code);
}
await ctxA.close();
await ctxB.close();
});
test('F4b game.action: malformed payload rejected pre-dispatch', async ({
browser,
}) => {
// Schema-level rejection: unknown action kind never reaches the
// engine. Confirms the wire schema fails fast with INVALID_MESSAGE
// (fatal) before the token check runs.
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
await pageA.goto('http://localhost:5173/');
const roomA = await wsCreateRoomWithPresets(pageA, ['transferable-royalty']);
const result = await pageA.evaluate(
async (args: { code: string; token: string }) => {
return new Promise<{ code: string; fatal: boolean }>(
(resolve, reject) => {
const ws = new WebSocket('ws://localhost:7357/ws');
const t = setTimeout(() => reject(new Error('timeout')), 5000);
let joined = false;
ws.onopen = () =>
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
token: args.token,
type: 'room.join',
payload: { code: args.code },
}),
);
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data as string) as {
type: string;
payload: { code?: string; fatal?: boolean };
};
if (msg.type === 'room.joined' && !joined) {
joined = true;
// Malformed action kind — schema union should reject.
ws.send(
JSON.stringify({
v: 1,
seq: 2,
ts: Date.now(),
token: args.token,
type: 'game.action',
payload: {
action: {
kind: 'bogus-kind',
fromPieceId: 1,
toPieceId: 2,
},
},
}),
);
return;
}
if (msg.type === 'error') {
clearTimeout(t);
ws.close();
resolve({
code: msg.payload.code ?? '',
fatal: msg.payload.fatal ?? false,
});
}
};
ws.onerror = () => {
clearTimeout(t);
reject(new Error('ws error'));
};
},
);
},
{ code: roomA.code, token: roomA.token },
);
expect(result.code).toBe('INVALID_MESSAGE');
expect(result.fatal).toBe(true);
await ctxA.close();
});
test('F1 color preference: UI buttons render in Lobby and are toggleable', async ({
page,
}) => {