houserules/packages/chess/e2e/multiplayer.spec.ts
Joey Yakimowich-Payne 2951a2d547
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)
2026-04-21 11:56:35 -06:00

870 lines
32 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* P4.12 — E2E multiplayer scenario (Phase 4 gate)
*
* Two browser contexts create/join a room and play Scholar's Mate with
* LIVE sync: every drag on one page appears on the other via server
* `game.delta` broadcast. Ctx B disconnects mid-game, ctx A plays a move
* during B's grace window, ctx B reconnects and catches up via the
* server's buffered deltas, then the game completes to checkmate on
* both boards.
*
* Architecture notes
* ──────────────────
* 1. Room create/join is driven directly via raw WebSocket inside
* page.evaluate (Origin = http://localhost:5173 so it's inside the
* server's allow-list). This matches what the Lobby UI does.
*
* 2. Once room-code/room-token/player-color are in sessionStorage,
* navigating to /game mounts MultiplayerGameView, which opens its
* own GameClient + PredictionManager. Moves are sent as `game.move`
* and the server echoes `game.delta` to BOTH sockets, so each player
* sees the opponent's moves live.
*
* 3. Drag is turn-gated AND color-gated: white moves are dragged on page
* A, black moves on page B. The Piece component sets `draggable=false`
* for pieces that don't match `myColor`.
*
* Server-level reconnect (seq tracking, game.state replay, game.delta
* buffering) is unit-tested in packages/server/src/broadcast.test.ts.
*/
import { test, expect, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
// ---------------------------------------------------------------------------
// Server lifecycle
// ---------------------------------------------------------------------------
let wsServerProcess: ChildProcess | null = null;
async function isWsServerRunning(): Promise<boolean> {
try {
const res = await fetch('http://localhost:7357/healthz');
return res.ok;
} catch {
return false;
}
}
test.beforeAll(async () => {
if (await isWsServerRunning()) return; // reuse existing (local dev)
wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], {
stdio: 'pipe',
env: { ...process.env, PORT: '7357' },
});
// Poll until ready (up to 5 s)
for (let i = 0; i < 20; i++) {
await sleep(250);
if (await isWsServerRunning()) break;
}
});
test.afterAll(async () => {
if (wsServerProcess) {
wsServerProcess.kill('SIGINT');
await sleep(200);
wsServerProcess = null;
}
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Drag a piece from `from` square to `to` square (algebraic notation). */
const drag = async (page: Page, from: string, to: string): Promise<void> => {
await page
.locator(`[data-square="${from}"] [data-piece]`)
.dragTo(page.locator(`[data-square="${to}"]`));
};
/**
* Create a room via raw WebSocket from within the browser context.
* Returns the room payload: { code, token, color }.
*/
async function wsCreateRoom(
page: Page,
options?: { preferredColor?: 'white' | 'black' | 'random' },
): Promise<{ code: string; token: string; color: string }> {
return page.evaluate(async (opts: { preferredColor?: 'white' | 'black' | 'random' } | undefined) => {
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('wsCreateRoom: timeout')),
5000,
);
ws.onopen = () => {
const payload: Record<string, unknown> = {};
if (opts?.preferredColor !== undefined) {
payload.preferredColor = opts.preferredColor;
}
ws.send(
JSON.stringify({
v: 1,
seq: 1,
ts: Date.now(),
type: 'room.create',
payload,
}),
);
};
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('wsCreateRoom: WebSocket error'));
};
},
);
}, options);
}
/**
* Join an existing room via raw WebSocket from within the browser context.
* Returns the join payload: { code, token, color }.
*/
async function wsJoinRoom(
page: Page,
code: string,
): Promise<{ code: string; token: string; color: string }> {
return page.evaluate(async (roomCode: 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('wsJoinRoom: timeout')),
5000,
);
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: { code: string; token: string; color: string; message?: string };
};
if (msg.type === 'room.joined') {
clearTimeout(timer);
ws.close();
resolve(msg.payload);
} else if (msg.type === 'error') {
clearTimeout(timer);
ws.close();
reject(new Error(msg.payload.message ?? 'room.join error'));
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('wsJoinRoom: WebSocket error'));
};
},
);
}, code);
}
// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate", async ({
browser,
}) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
// ── Step 1: ctx A navigates to home, creates a room via WebSocket ─────────
await pageA.goto('http://localhost:5173/');
await pageA.waitForSelector('[data-testid="page-home"]');
const roomA = await wsCreateRoom(pageA);
expect(roomA.code).toHaveLength(6);
// Store session data (mirrors what Lobby.tsx does on room.created)
await pageA.evaluate((r) => {
sessionStorage.setItem('room-code', r.code);
sessionStorage.setItem('room-token', r.token);
sessionStorage.setItem('player-color', r.color);
}, roomA);
await pageA.goto('http://localhost:5173/game');
await expect(pageA.locator('[data-testid="turn-indicator"]')).toBeVisible();
// ── Step 2: ctx B navigates to home, joins the room via WebSocket ─────────
await pageB.goto('http://localhost:5173/');
await pageB.waitForSelector('[data-testid="page-home"]');
const roomB = await wsJoinRoom(pageB, roomA.code);
expect(roomB.code).toBe(roomA.code);
await pageB.evaluate((r) => {
sessionStorage.setItem('room-code', r.code);
sessionStorage.setItem('room-token', r.token);
sessionStorage.setItem('player-color', r.color);
}, roomB);
await pageB.goto('http://localhost:5173/game');
await expect(pageB.locator('[data-testid="turn-indicator"]')).toBeVisible();
// Both pages should now be connected to the server via their own
// MultiplayerGameViews. Wait for each to show its color badge so we
// know the initial game.state snapshot has arrived and the board is
// interactive.
await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white');
await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black');
// ── Step 3: Play moves 1–6 with live sync ─────────────────────────────────
//
// 9-move Scholar's Mate sequence, alternating sides:
// 1. a2-a3 (white on A)
// 2. h7-h6 (black on B)
// 3. e2-e4 (white on A)
// 4. e7-e5 (black on B)
// 5. f1-c4 (white on A) Bc4
// 6. b8-c6 (black on B) Nc6 ← disconnect B after this move
// 7. d1-h5 (white on A) Qh5 played while B is disconnected
// ← reconnect B
// 8. g8-f6 (black on B) Nf6?? — fatal Scholar's Mate blunder
// 9. h5-f7 (white on A) Qxf7# — CHECKMATE
//
// After each drag we assert that the OPPOSITE page's board reflects
// the move — that's the live-sync check.
await drag(pageA, 'a2', 'a3'); // 1. white
await expect(pageB.locator('[data-square="a3"] [data-piece="white-pawn"]')).toBeVisible();
await drag(pageB, 'h7', 'h6'); // 2. black
await expect(pageA.locator('[data-square="h6"] [data-piece="black-pawn"]')).toBeVisible();
await drag(pageA, 'e2', 'e4'); // 3. white
await expect(pageB.locator('[data-square="e4"] [data-piece="white-pawn"]')).toBeVisible();
await drag(pageB, 'e7', 'e5'); // 4. black
await expect(pageA.locator('[data-square="e5"] [data-piece="black-pawn"]')).toBeVisible();
await drag(pageA, 'f1', 'c4'); // 5. white Bc4
await expect(pageB.locator('[data-square="c4"] [data-piece="white-bishop"]')).toBeVisible();
await drag(pageB, 'b8', 'c6'); // 6. black Nc6
await expect(pageA.locator('[data-square="c6"] [data-piece="black-knight"]')).toBeVisible();
// After 6 half-moves the turn returns to white. In multiplayer the
// indicator is personalized: the white player (pageA) sees "Your
// turn"; the black player (pageB) sees "Opponent's turn".
await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Your turn");
await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn");
// ── Step 4: ctx B disconnects (simulates network drop) ───────────────────
await pageB.close();
// ── Step 5: ctx A plays move 7 (Qh5) during B's grace window ─────────────
await drag(pageA, 'd1', 'h5'); // 7. white Qh5
await expect(pageA.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible();
// After Qh5 it's black to move. PageA is white → sees "Opponent's turn".
await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn");
// ── Step 6: ctx B reconnects ─────────────────────────────────────────────
// A new page in the SAME browser context inherits sessionStorage
// (room-code, room-token, player-color). MultiplayerGameView opens a
// new GameClient, sends `room.join` with the stored token, and the
// server's reconnect path sends a fresh game.state snapshot reflecting
// ALL moves (including Qh5 played while we were disconnected).
const pageB2 = await ctxB.newPage();
// Re-plant sessionStorage on the new tab. sessionStorage is per-tab
// per the browser spec, so ctxB.newPage() does NOT inherit pageB's
// session storage — only localStorage. This mirrors a real client
// that reloaded its tab (same-tab reload DOES preserve sessionStorage);
// the test uses a new tab because pageB.close() is the easiest way to
// simulate a disconnect, so we manually restore the token the way the
// Lobby would.
await pageB2.goto('http://localhost:5173/');
await pageB2.evaluate((r) => {
sessionStorage.setItem('room-code', r.code);
sessionStorage.setItem('room-token', r.token);
sessionStorage.setItem('player-color', r.color);
}, roomB);
await pageB2.goto('http://localhost:5173/game');
await expect(pageB2.locator('[data-testid="turn-indicator"]')).toBeVisible();
// The snapshot should include Qh5 on h5.
await expect(pageB2.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible();
// ── Step 7: Complete Scholar's Mate with live sync ───────────────────────
await drag(pageB2, 'g8', 'f6'); // 8. black Nf6??
await expect(pageA.locator('[data-square="f6"] [data-piece="black-knight"]')).toBeVisible();
await drag(pageA, 'h5', 'f7'); // 9. white Qxf7# — CHECKMATE
await expect(pageA.locator('[data-testid="game-over"]')).toBeVisible();
await expect(pageB2.locator('[data-testid="game-over"]')).toBeVisible();
await ctxA.close();
await ctxB.close();
});
/**
* Scope bug regression test.
*
* Before per-color preset scoping shipped, a client that enabled
* `knights-leap-twice` locally would predict a double-leap move
* (legal under the client's rule set), send it to the server, and
* receive ILLEGAL_MOVE because the server's ChessEngine didn't know
* the preset was active. The snap-back rolled the board back and the
* user saw an error toast.
*
* With per-room authoritative rule state + `room.setPresets`, the
* client dispatches the activation to the server, which broadcasts
* `game.presets` to both players. The next move calc on both sides
* uses the same rule set, so predictions and validations agree.
*
* The test specifically exercises `scope=white`: only white's knights
* can double-leap, proving both that the server respects the preset
* AND that the scope narrowing reaches the server.
*/
test('multiplayer presets: white-only knights-leap-twice applies to white, not black', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
// 1. Room create/join.
await pageA.goto('http://localhost:5173/');
const roomA = await wsCreateRoom(pageA);
await pageA.evaluate((r) => {
sessionStorage.setItem('room-code', r.code);
sessionStorage.setItem('room-token', r.token);
sessionStorage.setItem('player-color', r.color);
}, roomA);
await pageA.goto('http://localhost:5173/game');
await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white');
await pageB.goto('http://localhost:5173/');
const roomB = await wsJoinRoom(pageB, roomA.code);
await pageB.evaluate((r) => {
sessionStorage.setItem('room-code', r.code);
sessionStorage.setItem('room-token', r.token);
sessionStorage.setItem('player-color', r.color);
}, roomB);
await pageB.goto('http://localhost:5173/game');
await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black');
// 2. White enables knights-leap-twice with scope=white via the drawer.
await pageA.locator('[data-action="open-rules-drawer"]').click();
const knightRow = pageA.locator('[data-preset="knights-leap-twice"]');
await knightRow.locator('[data-role="toggle"]').click();
// Default scope is 'both' — change to 'white'.
await knightRow.locator('[data-role="scope-white"]').click();
await pageA.locator('[data-action="close-rules-drawer"]').click();
// 3. Wait for pageB to receive the `game.presets` broadcast and render
// the active-preset count in its drawer pill.
await expect(pageB.locator('[data-action="open-rules-drawer"]')).toContainText('1');
// 4. White drags g1 knight to d4 — a double-leap that's ONLY legal
// with knights-leap-twice active. Before the fix this produced an
// ILLEGAL_MOVE; after the fix the move is accepted.
await drag(pageA, 'g1', 'd4');
await expect(pageA.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible();
await expect(pageB.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible();
// 5. Black attempts the same double-leap with its g8 knight. Preset
// scope is white-only so this must NOT be legal for black.
// `dragTo` still performs the mouse movement but the client's
// legal-move set won't include it, so the board stays unchanged.
await drag(pageB, 'g8', 'd5');
await expect(pageB.locator('[data-square="d5"]')).not.toContainText('black-knight');
// The knight should still be on g8.
await expect(pageB.locator('[data-square="g8"] [data-piece="black-knight"]')).toBeVisible();
await ctxA.close();
await ctxB.close();
});
// ---------------------------------------------------------------------------
// F1 (post-epic-deferrals): host color preference
// ---------------------------------------------------------------------------
test('F1 color preference: host picks black → host=black, joiner=white', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
await pageA.goto('/');
await pageB.goto('/');
const createdA = await wsCreateRoom(pageA, { preferredColor: 'black' });
expect(createdA.color).toBe('black');
const joinedB = await wsJoinRoom(pageB, createdA.code);
expect(joinedB.color).toBe('white');
expect(joinedB.code).toBe(createdA.code);
await ctxA.close();
await ctxB.close();
});
test('F1 color preference: host picks random → both colors assigned disjointly', async ({
browser,
}) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
await pageA.goto('/');
await pageB.goto('/');
const createdA = await wsCreateRoom(pageA, { preferredColor: 'random' });
expect(['white', 'black']).toContain(createdA.color);
const joinedB = await wsJoinRoom(pageB, createdA.code);
expect(['white', 'black']).toContain(joinedB.color);
// Disjoint: exactly one is white, the other black — never the same.
expect(joinedB.color).not.toBe(createdA.color);
await ctxA.close();
await ctxB.close();
});
test('F1 color preference: default (no field) keeps legacy creator=white', async ({
browser,
}) => {
// Back-compat sanity: omitting `preferredColor` from the payload
// must produce the pre-F1 hardcoded assignment.
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
await pageA.goto('/');
const createdA = await wsCreateRoom(pageA); // no options → no field on wire
expect(createdA.color).toBe('white');
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,
}) => {
await page.goto('/');
// All three buttons exist.
await expect(
page.locator('[data-testid="color-preference-white"]'),
).toBeVisible();
await expect(
page.locator('[data-testid="color-preference-black"]'),
).toBeVisible();
await expect(
page.locator('[data-testid="color-preference-random"]'),
).toBeVisible();
// Default is white — aria-checked reflects radiogroup semantics.
await expect(
page.locator('[data-testid="color-preference-white"]'),
).toHaveAttribute('aria-checked', 'true');
await expect(
page.locator('[data-testid="color-preference-black"]'),
).toHaveAttribute('aria-checked', 'false');
// Click black → now checked.
await page.locator('[data-testid="color-preference-black"]').click();
await expect(
page.locator('[data-testid="color-preference-black"]'),
).toHaveAttribute('aria-checked', 'true');
await expect(
page.locator('[data-testid="color-preference-white"]'),
).toHaveAttribute('aria-checked', 'false');
// Click random → cycles again.
await page.locator('[data-testid="color-preference-random"]').click();
await expect(
page.locator('[data-testid="color-preference-random"]'),
).toHaveAttribute('aria-checked', 'true');
});