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)
870 lines
32 KiB
TypeScript
870 lines
32 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|