/** * Wave 16 — Playwright E2E: request-choice round-trips for the * three remaining choice kinds (square, row, coin-flip). * * Coverage matrix (T58 supports 6 kinds; rps/piece/column already * covered by `request-choice.spec.ts`): * * 1. square — modal renders the 8x8 ParamSquarePicker grid; * click e4 (LERF index 28) → continuation spawns * a permanent treasure marker on e4. * * 2. row — modal renders 8 row buttons; click row 3 → * continuation iterates `for-column` over all 8 * columns and spawns a treasure marker at every * square in row 3 (LERF squares 24..31). * * 3. coin-flip — modal renders heads/tails buttons; click heads → * continuation writes the bound value onto * GAME_ENTITY (id 0) under the `CoinFlipResult` * attribute. Verified via the dev-only * `__paratypeChessPrediction` global (mirroring * the assertion strategy of T68/3). * * Activation path * ---------------- * Same pattern as `request-choice.spec.ts`: * * - `__test__.activate-descriptor` debug WS frame routed through * the existing `__paratypeChessClient` socket. The handler * lifts the inner `on-rule-activated` arm so primitives[0] * becomes the request-choice (T79). * * - The PendingChoice frame is pushed onto the LIFO stack with * the lifted descriptor's id; submitChoiceAndResume resolves * it on the engine's customModifiers registry. * * - `forPlayer: "white"` keeps the broadcast on the host's socket * only — single-context tests below. * * Continuation gaps documented in `request-choice.spec.ts` (gap I — * `for-column` double-recurse) apply to scenario 2 below: the inner * arm spawns all 8 markers BEFORE the post-iteration child-walk * trips a BindingError on `$row`, which the server's submit-choice * handler swallows with a logger.warn. The post-resolve board state * is correct (8 treasure markers), so the test passes. */ 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 (mirrors `request-choice.spec.ts` / `multiplayer.spec.ts`) // --------------------------------------------------------------------------- let wsServerProcess: ChildProcess | null = null; async function isWsServerRunning(): Promise { try { const res = await fetch('http://localhost:7357/healthz'); return res.ok; } catch { return false; } } test.beforeAll(async () => { if (await isWsServerRunning()) return; wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], { stdio: 'pipe', env: { ...process.env, PORT: '7357' }, }); for (let i = 0; i < 40; i++) { await sleep(250); if (await isWsServerRunning()) break; } }); test.afterAll(async () => { if (wsServerProcess) { wsServerProcess.kill('SIGINT'); await sleep(200); wsServerProcess = null; } }); // --------------------------------------------------------------------------- // Helpers (copied from request-choice.spec.ts — kept inline to keep this // spec independently runnable in CI sharding). // --------------------------------------------------------------------------- async function wsCreateRoom( page: Page, ): Promise<{ code: string; token: string; color: string }> { return page.evaluate(async () => { 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 = () => { 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')); }; }, ); }); } async function joinAsHost( page: Page, ): Promise<{ code: string; token: string; color: string }> { await page.goto('http://localhost:5173/'); await page.waitForSelector('[data-testid="page-home"]'); const room = await wsCreateRoom(page); await page.evaluate((r) => { sessionStorage.setItem('room-code', r.code); sessionStorage.setItem('room-token', r.token); sessionStorage.setItem('player-color', r.color); }, room); await page.goto('http://localhost:5173/game'); await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible(); return room; } /** * T79 — drive the test-only `__test__.activate-descriptor` frame * over the existing __paratypeChessClient socket. The lifted * descriptor's primitives[0] must be `request-choice` (the lift * helper unwraps the on-rule-activated arm before registering). */ async function activateDescriptor( page: Page, args: { code: string; descriptor: unknown; chooserColor: 'white' | 'black'; liftedId: string; }, ): Promise { await page.waitForFunction( () => Boolean( (globalThis as { __paratypeChessClient?: unknown }) .__paratypeChessClient, ), null, { timeout: 5000 }, ); await page.evaluate((a) => { const client = ( globalThis as { __paratypeChessClient?: { send: (msg: { type: string; payload: unknown }) => void; }; } ).__paratypeChessClient; if (!client) throw new Error('activateDescriptor: __paratypeChessClient not present'); client.send({ type: '__test__.activate-descriptor', payload: { roomCode: a.code, descriptor: a.descriptor, chooserColor: a.chooserColor, liftedId: a.liftedId, }, }); }, args); } /** * Build a minimal CustomModifierDescriptor envelope around an * `on-rule-activated` arm. Saves the per-test boilerplate of * declaring `type/version/source/uiForm/targetAttrs`. */ function makeDescriptor( id: string, arm: ReadonlyArray<{ kind: string; params: Record }>, ): Record { return { type: 'data', id, name: id, description: `Wave 16 e2e descriptor (${id}).`, version: 1, uiForm: 'primitive-composer', source: 'custom', targetAttrs: [], primitives: [ { kind: 'on-rule-activated', params: { primitives: arm }, }, ], }; } // --------------------------------------------------------------------------- // Test A — square choice-kind // --------------------------------------------------------------------------- test('Wave16/A square choice-kind: modal grid → click e4 → treasure marker spawns', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); const descriptor = makeDescriptor('wave16:square-choice', [ { kind: 'request-choice', params: { kind: 'square', prompt: 'Pick a square', forPlayer: 'white', bind: 'sq', then: [ { kind: 'spawn-marker', params: { markerKind: 'treasure', square: { $var: 'sq' }, lifetime: { kind: 'permanent' }, }, }, ], }, }, ]); await activateDescriptor(page, { code: room.code, descriptor, chooserColor: 'white', liftedId: 'wave16:square-choice__lifted', }); // Modal opens with kind=square. ParamSquarePicker renders an // 8x8 grid of buttons (64 total). const modal = page.locator('[data-testid="request-choice-modal"]'); await expect(modal).toBeVisible({ timeout: 5000 }); await expect(modal).toHaveAttribute('data-choice-kind', 'square'); await expect(modal.locator('button[aria-label^="Square "]')).toHaveCount(64); // Click e4 (LERF index 28). ParamSquarePicker tags each button // with `aria-label="Square "`; we click via that label. await modal.locator('button[aria-label="Square 28"]').click(); await expect(modal).not.toBeVisible({ timeout: 5000 }); // Treasure marker visible at e4. Default marker rendering in // Board.tsx puts data-marker-kind={kind} on the absolutely- // positioned label div inside the [data-square=…] cell. await expect( page.locator('[data-square="e4"] [data-marker-kind="treasure"]'), ).toBeVisible({ timeout: 5000 }); await ctx.close(); }); // --------------------------------------------------------------------------- // Test B — row choice-kind // --------------------------------------------------------------------------- test('Wave16/B row choice-kind: modal row buttons → click row 3 → 8 treasure markers along row 3', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Continuation: for-column iterates 0..7, binding `col`; inner // spawn-marker resolves square via {ctx-build:{col,row}} → drops // a treasure marker on every square in the chosen row. const descriptor = makeDescriptor('wave16:row-choice', [ { kind: 'request-choice', params: { kind: 'row', prompt: 'Pick a row', forPlayer: 'white', bind: 'row', then: [ { kind: 'for-column', params: { columns: [0, 1, 2, 3, 4, 5, 6, 7], bind: 'col', then: [ { kind: 'spawn-marker', params: { markerKind: 'treasure', square: { 'ctx-build': { col: { $var: 'col' }, row: { $var: 'row' }, }, }, lifetime: { kind: 'permanent' }, }, }, ], }, }, ], }, }, ]); await activateDescriptor(page, { code: room.code, descriptor, chooserColor: 'white', liftedId: 'wave16:row-choice__lifted', }); const modal = page.locator('[data-testid="request-choice-modal"]'); await expect(modal).toBeVisible({ timeout: 5000 }); await expect(modal).toHaveAttribute('data-choice-kind', 'row'); // Modal shows 8 row buttons (data-row="0".."7"); same picker // layout as the column kind. await expect(modal.locator('button[data-row]')).toHaveCount(8); // Click row 3 (LERF rank 3 → squares 24..31, files a4..h4). await modal.locator('button[data-row="3"]').click(); await expect(modal).not.toBeVisible({ timeout: 5000 }); // 8 treasure markers along row 3. for (const square of ['a4', 'b4', 'c4', 'd4', 'e4', 'f4', 'g4', 'h4']) { await expect( page.locator(`[data-square="${square}"] [data-marker-kind="treasure"]`), ).toBeVisible({ timeout: 5000 }); } await ctx.close(); }); // --------------------------------------------------------------------------- // Test C — coin-flip choice-kind // --------------------------------------------------------------------------- test('Wave16/C coin-flip choice-kind: modal heads/tails → click heads → CoinFlipResult set on GAME_ENTITY', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Continuation writes the bound choice value onto GAME_ENTITY // (id 0) under a debug attr (CoinFlipResult). set-piece-attr's // `attr` field is free-form (string-min-1), and `target=0` // targets GAME_ENTITY per its longDescription. const descriptor = makeDescriptor('wave16:coin-flip-choice', [ { kind: 'request-choice', params: { kind: 'coin-flip', prompt: 'Heads or tails?', forPlayer: 'white', bind: 'flip', then: [ { kind: 'set-piece-attr', params: { target: 0, attr: 'CoinFlipResult', value: { $var: 'flip' }, }, }, ], }, }, ]); await activateDescriptor(page, { code: room.code, descriptor, chooserColor: 'white', liftedId: 'wave16:coin-flip-choice__lifted', }); const modal = page.locator('[data-testid="request-choice-modal"]'); await expect(modal).toBeVisible({ timeout: 5000 }); await expect(modal).toHaveAttribute('data-choice-kind', 'coin-flip'); // Modal renders exactly 2 buttons — heads + tails. await expect(modal.locator('button[data-coin]')).toHaveCount(2); await expect(modal.locator('button[data-coin="heads"]')).toBeVisible(); await expect(modal.locator('button[data-coin="tails"]')).toBeVisible(); // Click heads → submitChoiceAndResume runs the continuation // → set-piece-attr writes CoinFlipResult="heads" on GAME_ENTITY. await modal.locator('button[data-coin="heads"]').click(); await expect(modal).not.toBeVisible({ timeout: 5000 }); // Verify the continuation ran by reading the engine session via // the dev-only PredictionManager hook (same pattern as T68/3's // OnCapturedHooks assertion). await expect .poll( async () => page.evaluate(() => { const mgr = ( globalThis as { __paratypeChessPrediction?: { getCurrentEngine: () => { session: { get: (id: number, attr: string) => unknown }; }; }; } ).__paratypeChessPrediction; if (!mgr) return null; return mgr.getCurrentEngine().session.get(0, 'CoinFlipResult') ?? null; }), { timeout: 5000 }, ) .toBe('heads'); await ctx.close(); });