diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index c4ec003..45fa0e5 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -96,7 +96,9 @@ "ses_23413e9bdffemN8WkabmXJVK5t", "ses_233f787b9ffeYWzzTLHpG5VJks", "ses_233f7318effe2R0Vt2ad27KzEZ", - "ses_233cc34d1ffe9ys7V39oRNCcO0" + "ses_233cc34d1ffe9ys7V39oRNCcO0", + "ses_233bcb2b3ffeI06xdt1zAAeqig", + "ses_233bc366effeMrTyc60acFDGv1" ], "plan_name": "thressgame-coverage", "agent": "atlas" diff --git a/packages/chess/e2e/choice-kinds.spec.ts b/packages/chess/e2e/choice-kinds.spec.ts new file mode 100644 index 0000000..ab13c73 --- /dev/null +++ b/packages/chess/e2e/choice-kinds.spec.ts @@ -0,0 +1,456 @@ +/** + * 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(); +}); diff --git a/packages/chess/e2e/move-gen-attrs.spec.ts b/packages/chess/e2e/move-gen-attrs.spec.ts new file mode 100644 index 0000000..ef1c6b7 --- /dev/null +++ b/packages/chess/e2e/move-gen-attrs.spec.ts @@ -0,0 +1,1015 @@ +/** + * Wave 17 — Playwright e2e for the 8 move-gen attrs wired in by Wave 12 + * (T74-T77) inside `packages/chess/src/rules/`. + * + * Two of the 8 attrs already have dedicated e2e coverage in + * `parity-rules.spec.ts`: + * + * - `SlideMustBeMaxDistance` — covered by ice_physics e2e. + * - `BlockAllExceptKing` — covered by all_on_red e2e. + * + * This spec covers the remaining 6: + * + * 1. `MovesAs` — knight moves like a bishop (replacement). + * 2. `MovesAlsoAs` — knight moves like knight + bishop (additive). + * 3. `KingExtraReach` — king can step >1 square per move. + * 4. `BlockedPieceTypes` — game-level paralysis of listed types. + * 5. `MoveClassRestriction` — game-level "must capture / advance / move-to". + * 6. `PawnPushesPiecesEnabled` — pawn shoves the diagonal target instead of capturing. + * + * ───────────────────────────────────────────────────────────────────── + * INTEGRATION GAP — Wave 12 wiring is incomplete at the engine layer + * ───────────────────────────────────────────────────────────────────── + * + * Five of the six attrs in this spec are CURRENTLY UNREACHABLE from the + * authoritative move-gen path the runtime drives. Wave 12 (T74-T77) + * landed the readers inside `packages/chess/src/rules/turn.ts` — the + * dispatcher `getLegalMovesForPiece(session, pieceId)` consults + * `MovesAs`, `MovesAlsoAs`, `BlockedPieceTypes`, `MoveClassRestriction` + * and dispatches per-piece via the in-module `moveGeneratorRegistry`. + * The dispatcher is exhaustively unit-tested: + * + * - `rules/turn.movesas.test.ts` — MovesAs / MovesAlsoAs locked. + * - `rules/turn.moveclass.test.ts` — MoveClassRestriction locked. + * - `rules/turn.blockall.test.ts` — BlockAllExceptKing locked. + * - `rules/king.extrareach.test.ts` — KingExtraReach locked. + * - `rules/sliding.slidemax.test.ts` — SlideMustBeMaxDistance locked. + * - `rules/pawn.push.test.ts` — PawnPushesPiecesEnabled locked. + * + * However: the runtime hot-path in `engine.ts:getAllLegalMoves` (the + * function `findMove` calls, which is what `PredictionManager` and the + * server's authoritative move handler both consume) calls each + * piece-type's generator DIRECTLY via + * `lookupMoveGenerator(piece.type)` against the `PIECE_TYPE_REGISTRY` + * — see `engine.ts` line 1434 + `presets/core-piece-types.ts` for the + * registration. It NEVER calls `rules/turn.ts:getLegalMovesForPiece`, + * and `rules/turn.ts:registerMoveGenerator` is never called outside + * unit tests. The dispatcher is therefore unreachable from real + * gameplay despite passing its own tests. + * + * Two attrs DO surface end-to-end because their readers live INSIDE + * the per-piece generator the engine calls: + * + * - `KingExtraReach` — read inside `rules/king.ts`. + * - `PawnPushesPiecesEnabled` — read inside `rules/pawn.ts` + * (move-gen side only — see below). + * + * The other four (`MovesAs`, `MovesAlsoAs`, `BlockedPieceTypes`, + * `MoveClassRestriction`) are dispatcher-only and don't observe at + * the engine level. The push semantics also break at apply-time: + * `engine.ts:applyMove` doesn't honor the `isPawnPush` / + * `pushedPieceId` / `pushedTo` fields on the LegalMove (see + * `engine.ts` 1644-1697 — the normal-move branch only handles + * `isCapture`). So even though `getLegalPawnMoves` correctly emits + * push moves, applying one drives the white pawn onto the + * already-occupied target square without shoving the defender, + * leaving two pieces on one square in the session. + * + * Each affected test below is marked `test.fixme(...)` with an + * inline diagnosis. Closing these gaps is a follow-up integration + * task: either (a) replace `engine.ts:getAllLegalMoves`'s call to + * `lookupMoveGenerator` with a call to + * `rules/turn.ts:getLegalMovesForPiece` (after registering the + * per-type generators into that dispatcher), and (b) extend + * `engine.ts:applyMove` to handle the `isPawnPush` branch (mirroring + * `rules/turn.ts:applyMove`). + * + * The remaining test (KingExtraReach) is a real, passing e2e and + * locks the king-reach surface. + * + * ───────────────────────────────────────────────────────────────────── + * Methodology (same shape as Wave-15 parity-rules.spec.ts) + * ───────────────────────────────────────────────────────────────────── + * + * Each test: + * + * 1. Opens a host (white) + guest (black) page so we can drive both + * colors through the engine's turn-alternation gate. + * 2. Plays a small number of opening moves to set up the desired + * board topology (e.g. clear a2 to open b1's diagonal, advance + * e2 so the king has space, etc.). + * 3. Drives the `__test__.apply-descriptor` server-debug frame with + * a synthetic descriptor that wraps `set-piece-attr` / + * `block-by-piece-type` / `must-class` / `pawn-pushes-pieces` + * inside `on-rule-activated` (the same shape Wave-15's + * ice_physics e2e uses — `applyCustomDescriptor` runs the + * cascade in-place on first apply). + * 4. Drags the relevant piece via the standard Piece component drag + * surface (`[data-square="X"] [data-piece]` → `[data-square="Y"]`). + * For "should fail" probes we observe the post-drag DOM: if the + * drag was rejected at the move-gen layer, the piece stays at + * its source square (PredictionManager.tryMove returns false and + * no `game.delta` is emitted). For "should succeed" probes we + * assert the destination renders the moved piece. + * + * ───────────────────────────────────────────────────────────────────── + * Why `__test__.apply-descriptor` (not `activate-descriptor`) + * ───────────────────────────────────────────────────────────────────── + * + * `__test__.activate-descriptor` (T79) is for descriptors whose root + * is `on-rule-activated → request-choice` — it lifts the choice and + * re-routes it through the GameClient's pendingChoice slot. Our + * descriptors don't ask for player input; they're one-shot config + * mutations seeded at apply-time. The T83 `apply-descriptor` handler + * is the right frame: it walks `descriptor.primitives` once, fires + * the `on-rule-activated` cascade, and broadcasts a fresh + * `game.state` so the client mirrors the post-apply facts. + * + * ───────────────────────────────────────────────────────────────────── + * Note on game-level vs per-piece descriptor shape + * ───────────────────────────────────────────────────────────────────── + * + * Per-piece attrs (`MovesAs`, `MovesAlsoAs`, `KingExtraReach`) need a + * piece id at write-time. We wrap a `for-each-piece` filter inside + * `on-rule-activated` and bind the iterated piece to a $var so the + * inner `set-piece-attr` (or `set-moves-as` / `set-moves-also-as`) + * writes to the right entity. This mirrors ice_physics's filter on + * `pieceType: "bishop"` etc. + * + * Game-level attrs (`BlockedPieceTypes`, `MoveClassRestriction`, + * `PawnPushesPiecesEnabled`) use the dedicated state primitives + * (`block-by-piece-type`, `must-class`, `pawn-pushes-pieces`) which + * write to `GAME_ENTITY` directly — no piece target needed. They're + * NOT in `IMPERATIVE_KINDS` so they could legally sit at top-level, + * but we still wrap them in `on-rule-activated` for shape consistency + * with the ice_physics fixture. + */ + +import { test, expect, type Page } from '@playwright/test'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Server lifecycle (mirrors `multiplayer.spec.ts` / `parity-rules.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; + } +}); + +// --------------------------------------------------------------------------- +// Shared infra +// --------------------------------------------------------------------------- + +const EVIDENCE_DIR = join( + process.cwd(), + '.sisyphus/evidence/wave17-move-gen-attrs-screenshots', +); +if (!existsSync(EVIDENCE_DIR)) mkdirSync(EVIDENCE_DIR, { recursive: true }); + +async function snapshot(page: Page, label: string): Promise { + await page.screenshot({ + path: join(EVIDENCE_DIR, `${label}.png`), + fullPage: true, + }); +} + +/** + * Minimal CustomModifierDescriptor envelope. Helpers below populate + * `id`, `name`, `description`, and `primitives` per test; everything + * else is fixed. `targetAttrs` is informational — the descriptor + * parser doesn't consult it for behaviour, so we use a permissive + * superset across tests rather than threading the exact set per + * descriptor. + */ +type Descriptor = { + type: 'data'; + id: string; + name: string; + description: string; + version: 1; + uiForm: 'primitive-composer'; + source: 'custom'; + targetAttrs: string[]; + primitives: unknown[]; +}; + +function descriptor(opts: { + id: string; + name: string; + description: string; + targetAttrs: string[]; + primitives: unknown[]; +}): Descriptor { + return { + type: 'data', + id: opts.id, + name: opts.name, + description: opts.description, + version: 1, + uiForm: 'primitive-composer', + source: 'custom', + targetAttrs: opts.targetAttrs, + primitives: opts.primitives, + }; +} + +// --------------------------------------------------------------------------- +// Room helpers (raw WS — no Lobby UI involvement) +// --------------------------------------------------------------------------- + +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(); + await page.waitForFunction( + () => + Boolean( + (globalThis as { __paratypeChessClient?: unknown }).__paratypeChessClient, + ) && + Boolean( + (globalThis as { __paratypeChessPrediction?: unknown }) + .__paratypeChessPrediction, + ), + null, + { timeout: 5000 }, + ); + return room; +} + +async function joinAsGuest(page: Page, code: string): Promise { + await page.goto('http://localhost:5173/'); + await page.evaluate((c) => { + const ws = new WebSocket('ws://localhost:7357/ws'); + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('join timeout')), 5000); + ws.onopen = () => + ws.send( + JSON.stringify({ + v: 1, + seq: 1, + ts: Date.now(), + type: 'room.join', + payload: { code: c }, + }), + ); + ws.onmessage = (e: MessageEvent) => { + const msg = JSON.parse(e.data as string) as { + type: string; + payload: { code: string; token: string; color: string }; + }; + if (msg.type === 'room.joined') { + clearTimeout(t); + sessionStorage.setItem('room-code', msg.payload.code); + sessionStorage.setItem('room-token', msg.payload.token); + sessionStorage.setItem('player-color', msg.payload.color); + ws.close(); + resolve(); + } else if (msg.type === 'error') { + clearTimeout(t); + ws.close(); + reject(new Error('join error')); + } + }; + ws.onerror = () => { + clearTimeout(t); + reject(new Error('ws error')); + }; + }); + }, code); + await page.goto('http://localhost:5173/game'); + await expect(page.locator('[data-testid="my-color"]')).toContainText('black'); +} + +/** + * Drive `__test__.apply-descriptor` through the page's existing + * GameClient socket. The handler runs server-side, mutates the + * engine via `applyCustomDescriptor`, and emits a fresh `game.state` + * snapshot — the page's MultiplayerGame view re-renders the + * post-apply board automatically. + * + * For descriptors with no per-piece root trigger (everything in this + * spec uses `on-rule-activated`), `targetSquare` is omitted and the + * server resolves `applyTarget` to `GAME_ENTITY`. Per-piece writes + * inside the descriptor go through `for-each-piece` $var bindings + * rather than relying on the apply target. + */ +async function applyDescriptor( + page: Page, + args: { code: string; descriptor: Descriptor; targetSquare?: number }, +): Promise { + await page.evaluate((a) => { + const client = ( + globalThis as { + __paratypeChessClient?: { + send: (msg: { type: string; payload: unknown }) => void; + }; + } + ).__paratypeChessClient; + if (!client) + throw new Error('applyDescriptor: __paratypeChessClient not present'); + client.send({ + type: '__test__.apply-descriptor', + payload: { + roomCode: a.code, + descriptor: a.descriptor, + targetSquare: a.targetSquare, + }, + }); + }, args); + // Settle for the game.state round-trip + React render. + await page.waitForTimeout(200); +} + +/** Drag a piece via the same UI path the multiplayer e2e uses. */ +async function drag(page: Page, from: string, to: string): Promise { + await page + .locator(`[data-square="${from}"] [data-piece]`) + .dragTo(page.locator(`[data-square="${to}"]`)); +} + +/** + * Read a session attr via the page's PredictionManager. Returns + * the engine's current attr value at any entity id (default + * GAME_ENTITY = 0). Used to verify trigger-fired writes that + * don't surface in the DOM. + */ +async function readAttr( + page: Page, + attr: string, + entityId: number = 0, +): Promise { + return page.evaluate( + (a) => { + const mgr = ( + globalThis as { + __paratypeChessPrediction?: { + getCurrentEngine: () => { + session: { + get: (id: unknown, attr: string) => unknown; + }; + }; + }; + } + ).__paratypeChessPrediction; + if (!mgr) throw new Error('readAttr: PredictionManager not exposed'); + const engine = mgr.getCurrentEngine(); + return engine.session.get(a.entityId, a.attr); + }, + { attr, entityId }, + ); +} + +/** + * Convenience: wait for a piece to render at a square. Used after + * drags that should succeed. + */ +async function expectPieceAt( + page: Page, + square: string, + pieceCss: string, +): Promise { + await expect( + page.locator(`[data-square="${square}"] [data-piece="${pieceCss}"]`), + ).toBeVisible({ timeout: 5000 }); +} + +/** + * Convenience: wait for a square to be empty (no piece child). + */ +async function expectEmpty(page: Page, square: string): Promise { + await expect(page.locator(`[data-square="${square}"] [data-piece]`)).toHaveCount( + 0, + { timeout: 5000 }, + ); +} + +// --------------------------------------------------------------------------- +// Test 1 — MovesAs (replacement): knight moves like a bishop +// --------------------------------------------------------------------------- +// +// Setup: +// 1.a3 (white pawn a2→a3, opens a2 for the b1 knight to slide on +// the a2-b1 diagonal). +// 1...a6 (quiet black move so it's white's turn again). +// +// Apply descriptor: for-each-piece (pieceType=knight, color=white) → +// set-moves-as bishop. Every white knight now moves as a bishop — +// L-shapes are no longer legal; bishop diagonals are. +// +// Probe 1: drag b1→c3 (the canonical knight L-shape). With MovesAs +// replacing the pattern this is rejected by the move +// generator — knight stays at b1. +// Probe 2: drag b1→a2 (a single bishop-step on the b1 SW diagonal, +// which is empty after 1.a3). Move-gen accepts; knight +// renders on a2. +// +// GAP: `MovesAs` is read in `rules/turn.ts:getLegalMovesForPiece`, +// which `engine.ts:getAllLegalMoves` does not call. The runtime +// hot-path goes through `lookupMoveGenerator(piece.type)` → +// `getLegalKnightMoves`, which has no MovesAs awareness. Until the +// engine is rewired to consult the dispatcher, the descriptor lands +// the `MovesAs='bishop'` fact correctly (the readAttr probe below +// would pass) but the knight's legal-move set is unchanged: the +// L-shape Nb1→c3 is accepted and the diagonal Nb1→a2 is rejected, +// the OPPOSITE of what the wired-up dispatcher delivers. +test.fixme('Wave17/MovesAs: knight under MovesAs=bishop rejects L-shapes, accepts diagonals', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + const room = await joinAsHost(pageA); + expect(room.color).toBe('white'); + + const ctxB = await browser.newContext(); + const pageB = await ctxB.newPage(); + await joinAsGuest(pageB, room.code); + + // 1.a3 — open a2 for the knight's diagonal substitute. + await drag(pageA, 'a2', 'a3'); + await expectPieceAt(pageA, 'a3', 'white-pawn'); + await expectEmpty(pageA, 'a2'); + + // 1...a6 — quiet black move so it's white's turn again. + await drag(pageB, 'a7', 'a6'); + await expectPieceAt(pageB, 'a6', 'black-pawn'); + + // Apply: every white knight gains MovesAs='bishop'. + await applyDescriptor(pageA, { + code: room.code, + descriptor: descriptor({ + id: 'wave17:moves-as-knight-bishop', + name: 'Wave17 MovesAs Knight Bishop', + description: 'Wave17 e2e — every white knight MovesAs bishop.', + targetAttrs: ['MovesAs', 'OnRuleActivatedHooks'], + primitives: [ + { + kind: 'on-rule-activated', + params: { + primitives: [ + { + kind: 'for-each-piece', + params: { + filter: { pieceType: 'knight', color: 'white' }, + bind: 'n', + then: [ + { + kind: 'set-moves-as', + params: { target: { $var: 'n' }, pieceType: 'bishop' }, + }, + ], + }, + }, + ], + }, + }, + ], + }), + }); + + // Verify the attr landed on the b1 knight. + const knightId = await pageA + .locator('[data-square="b1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(knightId).not.toBeNull(); + const movesAs = await readAttr(pageA, 'MovesAs', Number(knightId)); + expect(movesAs).toBe('bishop'); + + await snapshot(pageA, 'moves-as-pre-probe'); + + // Probe 1: L-shape b1 → c3 should be REJECTED. + await drag(pageA, 'b1', 'c3'); + await pageA.waitForTimeout(200); + await expect( + pageA.locator('[data-square="b1"] [data-piece="white-knight"]'), + ).toBeVisible(); + await expectEmpty(pageA, 'c3'); + + // Probe 2: diagonal b1 → a2 should be ACCEPTED. + await drag(pageA, 'b1', 'a2'); + await expectPieceAt(pageA, 'a2', 'white-knight'); + await expectEmpty(pageA, 'b1'); + + await snapshot(pageA, 'moves-as-post-probe'); + + await ctxA.close(); + await ctxB.close(); +}); + +// --------------------------------------------------------------------------- +// Test 2 — MovesAlsoAs (additive): knight moves like knight + bishop +// --------------------------------------------------------------------------- +// +// Same opening setup as Test 1 (1.a3 / 1...a6 — clears a2 for the +// knight's diagonal step). Apply MovesAlsoAs='bishop' on white +// knights. The native L-shapes remain legal AND the bishop diagonals +// become legal additively. +// +// Probe A: drag b1→a2 (diagonal — was illegal pre-descriptor since +// knight L-jumps don't reach a2). Should succeed. +// Probe B: continue the same game — black plays a quiet move; on the +// next white turn drag the now-on-a2 knight via L-shape +// a2→b4 (a real knight L-jump). Should succeed, proving +// the native pattern is still active under MovesAlsoAs. +// +// GAP: same root cause as MovesAs above — `MovesAlsoAs` is read in +// `rules/turn.ts:getLegalMovesForPiece`, which the engine's +// `getAllLegalMoves` doesn't traverse. The fact lands on the +// knight; the move-gen pretends it isn't there. The diagonal probe +// (Probe A) below would fail because b1→a2 is never enumerated by +// `getLegalKnightMoves`. +test.fixme('Wave17/MovesAlsoAs: knight gains diagonal moves while keeping L-shapes', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + const room = await joinAsHost(pageA); + expect(room.color).toBe('white'); + + const ctxB = await browser.newContext(); + const pageB = await ctxB.newPage(); + await joinAsGuest(pageB, room.code); + + await drag(pageA, 'a2', 'a3'); + await expectPieceAt(pageA, 'a3', 'white-pawn'); + await drag(pageB, 'a7', 'a6'); + await expectPieceAt(pageB, 'a6', 'black-pawn'); + + await applyDescriptor(pageA, { + code: room.code, + descriptor: descriptor({ + id: 'wave17:moves-also-as-knight-bishop', + name: 'Wave17 MovesAlsoAs Knight Bishop', + description: 'Wave17 e2e — white knights gain bishop moves additively.', + targetAttrs: ['MovesAlsoAs', 'OnRuleActivatedHooks'], + primitives: [ + { + kind: 'on-rule-activated', + params: { + primitives: [ + { + kind: 'for-each-piece', + params: { + filter: { pieceType: 'knight', color: 'white' }, + bind: 'n', + then: [ + { + kind: 'set-moves-also-as', + params: { target: { $var: 'n' }, pieceType: 'bishop' }, + }, + ], + }, + }, + ], + }, + }, + ], + }), + }); + + // Sanity: attr present on the b1 knight. + const knightId = await pageA + .locator('[data-square="b1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(knightId).not.toBeNull(); + expect(await readAttr(pageA, 'MovesAlsoAs', Number(knightId))).toBe('bishop'); + + // Probe A: diagonal b1 → a2 (was illegal — additive bishop unlocks + // it). + await drag(pageA, 'b1', 'a2'); + await expectPieceAt(pageA, 'a2', 'white-knight'); + await expectEmpty(pageA, 'b1'); + + // Black quiet reply so it's white's turn again. + await drag(pageB, 'b7', 'b6'); + await expectPieceAt(pageB, 'b6', 'black-pawn'); + + // Probe B: native L-shape a2 → b4 (knight L-jump from a2: file +1, + // rank +2 → b4). Empty square. Native pattern preserved under + // MovesAlsoAs. + await drag(pageA, 'a2', 'b4'); + await expectPieceAt(pageA, 'b4', 'white-knight'); + await expectEmpty(pageA, 'a2'); + + await snapshot(pageA, 'moves-also-as-post-probes'); + + await ctxA.close(); + await ctxB.close(); +}); + +// --------------------------------------------------------------------------- +// Test 3 — KingExtraReach=2: king moves up to 3 squares per direction +// --------------------------------------------------------------------------- +// +// `KingExtraReach: N` extends the king's per-move radius to `1 + N`. +// We set N=2 so the king can step up to 3 squares in any direction. +// +// Setup: +// 1.e4 (white pawn e2→e4 — vacates e2 AND e3 for the king). +// 1...e5 (black mirror). +// +// Apply: for-each-piece (pieceType=king, color=white) → +// set-piece-attr KingExtraReach=2. +// +// Probe: drag king e1 → e3 (2 squares forward). Pre-descriptor this +// is illegal (king can only step 1 square in standard chess); +// with reach=2 it lands. +// +test('Wave17/KingExtraReach=2: king steps two squares forward', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + const room = await joinAsHost(pageA); + expect(room.color).toBe('white'); + + const ctxB = await browser.newContext(); + const pageB = await ctxB.newPage(); + await joinAsGuest(pageB, room.code); + + // 1.e4 / 1...e5 — opens e2 and e3 in front of the white king. + await drag(pageA, 'e2', 'e4'); + await expectPieceAt(pageA, 'e4', 'white-pawn'); + await expectEmpty(pageA, 'e2'); + await drag(pageB, 'e7', 'e5'); + await expectPieceAt(pageB, 'e5', 'black-pawn'); + + await applyDescriptor(pageA, { + code: room.code, + descriptor: descriptor({ + id: 'wave17:king-extra-reach-2', + name: 'Wave17 KingExtraReach=2', + description: 'Wave17 e2e — white king gains KingExtraReach=2.', + targetAttrs: ['KingExtraReach', 'OnRuleActivatedHooks'], + primitives: [ + { + kind: 'on-rule-activated', + params: { + primitives: [ + { + kind: 'for-each-piece', + params: { + filter: { pieceType: 'king', color: 'white' }, + bind: 'k', + then: [ + { + kind: 'set-piece-attr', + params: { + target: { $var: 'k' }, + attr: 'KingExtraReach', + value: 2, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }), + }); + + // Sanity: attr landed on the e1 king. + const kingId = await pageA + .locator('[data-square="e1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(kingId).not.toBeNull(); + expect(await readAttr(pageA, 'KingExtraReach', Number(kingId))).toBe(2); + + // Probe: e1 → e3 (2-square forward step — illegal in standard chess, + // legal with reach=2). + await drag(pageA, 'e1', 'e3'); + await expectPieceAt(pageA, 'e3', 'white-king'); + await expectEmpty(pageA, 'e1'); + + await snapshot(pageA, 'king-extra-reach-post-probe'); + + await ctxA.close(); + await ctxB.close(); +}); + +// --------------------------------------------------------------------------- +// Test 4 — BlockedPieceTypes: pawns paralysed, knights still move +// --------------------------------------------------------------------------- +// +// Game-level `BlockedPieceTypes = ['pawn']` — every pawn on either +// side is unable to generate a move. Other piece types are +// unaffected. +// +// Probe 1: drag any white pawn (e.g. e2 → e4). Move-gen filters the +// move out; pawn stays on e2. +// Probe 2: drag a white knight (b1 → c3). Knight type isn't in the +// blocked set; move-gen accepts. +// +// GAP: `BlockedPieceTypes` is read by the dispatcher in +// `rules/turn.ts:getLegalMovesForPiece` but not by the engine's +// runtime path. The fact lands on GAME_ENTITY (readAttr would +// confirm it) but `engine.ts:getAllLegalMoves` happily emits the +// pawn's e2→e4 advance because it never checks the blocked-types +// list. Closing this gap requires rewiring the engine to use the +// dispatcher (the natural T74-T77 follow-up), at which point this +// test should drop the `.fixme`. +test.fixme('Wave17/BlockedPieceTypes: listed types cannot move; others still move', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + const room = await joinAsHost(pageA); + expect(room.color).toBe('white'); + + const ctxB = await browser.newContext(); + const pageB = await ctxB.newPage(); + await joinAsGuest(pageB, room.code); + + await applyDescriptor(pageA, { + code: room.code, + descriptor: descriptor({ + id: 'wave17:blocked-piece-types-pawn', + name: 'Wave17 BlockedPieceTypes pawn', + description: 'Wave17 e2e — pawns are paralysed.', + targetAttrs: ['BlockedPieceTypes', 'OnRuleActivatedHooks'], + primitives: [ + { + kind: 'on-rule-activated', + params: { + primitives: [ + { + kind: 'block-by-piece-type', + params: { pieceTypes: ['pawn'] }, + }, + ], + }, + }, + ], + }), + }); + + // Sanity: BlockedPieceTypes set on GAME_ENTITY. + expect(await readAttr(pageA, 'BlockedPieceTypes')).toEqual(['pawn']); + + // Probe 1: pawn drag is rejected. + await drag(pageA, 'e2', 'e4'); + await pageA.waitForTimeout(200); + await expect( + pageA.locator('[data-square="e2"] [data-piece="white-pawn"]'), + ).toBeVisible(); + await expectEmpty(pageA, 'e4'); + + // Probe 2: knight drag succeeds (b1 → c3, c3 is empty at game start). + await drag(pageA, 'b1', 'c3'); + await expectPieceAt(pageA, 'c3', 'white-knight'); + await expectEmpty(pageA, 'b1'); + + await snapshot(pageA, 'blocked-piece-types-post-probes'); + + await ctxA.close(); + await ctxB.close(); +}); + +// --------------------------------------------------------------------------- +// Test 5 — MoveClassRestriction class:'capture': must capture +// --------------------------------------------------------------------------- +// +// Setup a capturable enemy: +// 1.e4 d5 — black pawn on d5 sits diagonally adjacent to white's +// e4 pawn (capturable via exd5). +// Then activate `must-class capture`. White's next move MUST be a +// capture. +// +// Probe 1: drag a non-capture (e.g. Nb1 → c3). Move-gen filter +// rejects; knight stays at b1. +// Probe 2: drag the capture e4 → d5. Move-gen accepts; white pawn +// ends on d5; black pawn is gone. +// +// GAP: `MoveClassRestriction` is read by the dispatcher's +// post-filter `applyMoveClassRestriction` in +// `rules/turn.ts`. The engine's `getAllLegalMoves` doesn't run +// that filter — it returns the unfiltered move list, so a +// non-capture knight move (Nb1→c3) is still accepted by +// `findMove`. The descriptor seeds the GAME_ENTITY restriction +// correctly (readAttr confirms `class: 'capture'`); the filter +// just isn't observed at the engine layer. +test.fixme("Wave17/MoveClassRestriction: 'must capture' rejects advances, accepts captures", async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + const room = await joinAsHost(pageA); + expect(room.color).toBe('white'); + + const ctxB = await browser.newContext(); + const pageB = await ctxB.newPage(); + await joinAsGuest(pageB, room.code); + + // 1.e4 d5 — black d-pawn lands diagonally adjacent to white e-pawn. + await drag(pageA, 'e2', 'e4'); + await expectPieceAt(pageA, 'e4', 'white-pawn'); + await drag(pageB, 'd7', 'd5'); + await expectPieceAt(pageB, 'd5', 'black-pawn'); + + await applyDescriptor(pageA, { + code: room.code, + descriptor: descriptor({ + id: 'wave17:must-capture', + name: 'Wave17 Must Capture', + description: 'Wave17 e2e — must capture if possible.', + targetAttrs: ['MoveClassRestriction', 'OnRuleActivatedHooks'], + primitives: [ + { + kind: 'on-rule-activated', + params: { + primitives: [{ kind: 'must-class', params: { class: 'capture' } }], + }, + }, + ], + }), + }); + + // Sanity: restriction is on GAME_ENTITY. + const restriction = (await readAttr(pageA, 'MoveClassRestriction')) as + | { class?: string } + | undefined; + expect(restriction?.class).toBe('capture'); + + // Probe 1: non-capture knight move — REJECTED. + await drag(pageA, 'b1', 'c3'); + await pageA.waitForTimeout(200); + await expect( + pageA.locator('[data-square="b1"] [data-piece="white-knight"]'), + ).toBeVisible(); + await expectEmpty(pageA, 'c3'); + + // Probe 2: capture exd5 — ACCEPTED. + await drag(pageA, 'e4', 'd5'); + await expectPieceAt(pageA, 'd5', 'white-pawn'); + await expectEmpty(pageA, 'e4'); + + await snapshot(pageA, 'must-capture-post-probes'); + + await ctxA.close(); + await ctxB.close(); +}); + +// --------------------------------------------------------------------------- +// Test 6 — PawnPushesPiecesEnabled: pawn shoves instead of capturing +// --------------------------------------------------------------------------- +// +// Setup: +// 1.e4 d5 — same as Test 5; white e-pawn at e4, black d-pawn at d5 +// (diagonally adjacent). d6 is empty. +// +// Apply pawn-pushes-pieces (game-level flag). White's "diagonal +// capture" e4×d5 becomes a PUSH: the white pawn lands on d5, the +// black pawn is shoved to d6 (one rank further north — same direction +// the white pawn was moving). +// +// Probe: drag white pawn e4 → d5. Assert post-state: +// - white pawn renders on d5 +// - black pawn renders on d6 +// - e4 is empty +// +// GAP: `PawnPushesPiecesEnabled` is read inside +// `rules/pawn.ts:getLegalPawnMoves` (which the engine DOES call), +// so move-generation correctly emits a push move with +// `isPawnPush: true`, `pushedPieceId`, `pushedTo`. But +// `engine.ts:applyMove` only knows about the FIDE branches +// (en-passant, castling, normal capture, normal advance) — it +// doesn't read the push fields. The result of dragging e4→d5: the +// white pawn advances onto d5, the black pawn at d5 is NOT shoved +// to d6, leaving two pieces on d5 in the session. Closing this gap +// requires extending `engine.ts:applyMove` with the same +// isPawnPush branch that `rules/turn.ts:applyMove` already +// implements (relocate attacker to capSq, relocate target to +// pushTarget, no capture). +test.fixme('Wave17/PawnPushesPiecesEnabled: pawn pushes the diagonal target instead of capturing', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + const room = await joinAsHost(pageA); + expect(room.color).toBe('white'); + + const ctxB = await browser.newContext(); + const pageB = await ctxB.newPage(); + await joinAsGuest(pageB, room.code); + + // 1.e4 d5 — set up the diagonal-adjacent pawn pair with d6 empty. + await drag(pageA, 'e2', 'e4'); + await expectPieceAt(pageA, 'e4', 'white-pawn'); + await drag(pageB, 'd7', 'd5'); + await expectPieceAt(pageB, 'd5', 'black-pawn'); + await expectEmpty(pageA, 'd6'); + + await applyDescriptor(pageA, { + code: room.code, + descriptor: descriptor({ + id: 'wave17:pawn-pushes-pieces', + name: 'Wave17 PawnPushesPiecesEnabled', + description: 'Wave17 e2e — pawns push diagonal targets instead of capturing.', + targetAttrs: ['PawnPushesPiecesEnabled', 'OnRuleActivatedHooks'], + primitives: [ + { + kind: 'on-rule-activated', + params: { + primitives: [ + { kind: 'pawn-pushes-pieces', params: { enabled: true } }, + ], + }, + }, + ], + }), + }); + + // Sanity: flag is on GAME_ENTITY. + expect(await readAttr(pageA, 'PawnPushesPiecesEnabled')).toBe(true); + + // Probe: drag white pawn e4 → d5. Push semantics: white pawn lands + // on d5 (no capture); black d-pawn is shoved to d6. + await drag(pageA, 'e4', 'd5'); + await expectPieceAt(pageA, 'd5', 'white-pawn'); + await expectPieceAt(pageA, 'd6', 'black-pawn'); + await expectEmpty(pageA, 'e4'); + + await snapshot(pageA, 'pawn-push-post-probe'); + + await ctxA.close(); + await ctxB.close(); +}); diff --git a/packages/chess/e2e/orphan-primitives.spec.ts b/packages/chess/e2e/orphan-primitives.spec.ts new file mode 100644 index 0000000..b527e9b --- /dev/null +++ b/packages/chess/e2e/orphan-primitives.spec.ts @@ -0,0 +1,1504 @@ +/** + * Wave 18 — Playwright e2e for orphan primitives. + * + * 12 primitive features that previously had no e2e coverage. Each + * test here activates a synthetic descriptor that exercises ONE + * primitive, triggers it via either descriptor activation or a + * driving move, and asserts a single observable state change. + * + * Coverage list (one test per primitive unless otherwise noted): + * + * 1. place-piece — spawn a queen on e4 via on-rule-activated. + * 2. move-piece — force the white king to e2 via on-rule-activated. + * 3. swap-pieces — atomic position swap of b1 ↔ g1 knights. + * 4. convert-piece-type — convert e2 white pawn to queen on activation. + * 5. for-each-piece — set HpBonus=99 on every white piece; + * probe the king's HpBonus via debug hook. + * 6. for-each-square — drop treasure markers on a1..d1. + * 7. for-each-adjacent — markers next to king on occupied neighbours. + * 8. for-each-marker — destroy every mine after seeding 4. + * 9. block-by-piece-type — ban queen moves; queen drag rejected. + * 10. on-rule-expire — detach descriptor → expire arm spawns marker. + * 11. on-marker-expire — marker with moves=2 lifetime → fires + * replacement marker after 2 white half-moves + * (FullmoveNumber crosses the threshold). + * 12. spawn-marker-pair — portal pair at a1 + h8 with mutual MarkerLinks. + * 13. must-class — `.fixme()` placeholder; the move-gen consumer + * for `MoveClassRestriction` has not landed yet + * (see must-class.ts header — "Move-gen consumer + * — DEFERRED"). Keeping the scenario authored + * so a future wave can flip the gate without + * re-discovering the descriptor shape. + * + * ───────────────────────────────────────────────────────────────────── + * Driving infrastructure + * ───────────────────────────────────────────────────────────────────── + * + * - `__test__.apply-descriptor` (Wave 15 / T83) — runs the full + * `applyCustomDescriptor` walker server-side and broadcasts a + * fresh `game.state` snapshot. For descriptors rooted at + * `on-rule-activated` (most of these tests) the inner cascade + * fires immediately on apply (see custom/apply.ts § + * "fire on-rule-activated hooks EXACTLY ONCE per descriptor + * instance"). + * + * - `custom-modifier.remove` (Wave 11 / T71) — production WS + * handler that calls `engine.detachCustomDescriptor(id)`, which + * fires `on-rule-expire` hooks BEFORE retracting the hook-list + * entries. Used by Test 10. + * + * - `__paratypeChessClient` / `__paratypeChessPrediction` — dev- + * only window hooks (T79). The first sends WS frames through + * the active GameClient socket; the second exposes the + * PredictionManager so e2e probes can read engine attrs that + * don't surface in the DOM (HpBonus, MarkerLinks, BlockedPieceTypes). + * + * - `.sisyphus/scripts/run-pw.sh` — nohup helper. Direct + * `bunx playwright test` hangs in this environment (the + * bundled browsers spawn a watchdog that the parent shell + * can't reap cleanly); run-pw.sh detaches via nohup + a + * sentinel `.done` marker. + * + * ───────────────────────────────────────────────────────────────────── + * Why we re-derive shared helpers (no shared module) + * ───────────────────────────────────────────────────────────────────── + * + * `parity-rules.spec.ts` and `request-choice.spec.ts` each carry + * their own copies of the room / drag / debug-frame helpers. Each + * spec file is loaded by Playwright's worker model in isolation, so + * a shared util module would either need to live under `e2e/` (which + * Playwright treats as test files and would try to execute) or + * outside (which complicates the import graph for an e2e-only + * helper). The historic precedent — verbatim duplication across + * spec files — sidesteps that bikeshed; this file follows suit. + */ + +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 `parity-rules.spec.ts` / `request-choice.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; + } +}); + +// --------------------------------------------------------------------------- +// Room helpers — open a host page connected to the WS server +// --------------------------------------------------------------------------- + +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(); + // The GameClient + PredictionManager are installed on window via + // dev-only hooks (T79); both probes are required for state-attr + // assertions and the WS-frame helpers below. + await page.waitForFunction( + () => + Boolean( + (globalThis as { __paratypeChessClient?: unknown }) + .__paratypeChessClient, + ) && + Boolean( + (globalThis as { __paratypeChessPrediction?: unknown }) + .__paratypeChessPrediction, + ), + null, + { timeout: 5000 }, + ); + return room; +} + +async function joinAsGuest(page: Page, code: string): Promise<{ + code: string; + token: string; + color: string; +}> { + await page.goto('http://localhost:5173/'); + await page.waitForSelector('[data-testid="page-home"]'); + const room = await 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); + 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(); + await page.waitForFunction( + () => + Boolean( + (globalThis as { __paratypeChessClient?: unknown }) + .__paratypeChessClient, + ), + null, + { timeout: 5000 }, + ); + return room; +} + +// --------------------------------------------------------------------------- +// Debug-frame helpers +// --------------------------------------------------------------------------- + +/** + * T83 — `__test__.apply-descriptor`. Runs `applyCustomDescriptor` + * server-side; for `on-rule-activated`-rooted descriptors the inner + * arm fires immediately. Broadcasts a fresh `game.state` snapshot + * post-apply so the prediction manager mirrors the post-apply + * facts. + */ +async function applyDescriptor( + page: Page, + args: { + code: string; + descriptor: unknown; + targetSquare?: number; + rngSeed?: number; + }, +): Promise { + await page.evaluate((a) => { + const client = ( + globalThis as { + __paratypeChessClient?: { + send: (msg: { type: string; payload: unknown }) => void; + }; + } + ).__paratypeChessClient; + if (!client) + throw new Error('applyDescriptor: __paratypeChessClient not present'); + client.send({ + type: '__test__.apply-descriptor', + payload: { + roomCode: a.code, + descriptor: a.descriptor, + targetSquare: a.targetSquare, + rngSeed: a.rngSeed, + }, + }); + }, args); +} + +/** + * Send a production `custom-modifier.remove` frame. Calls + * `engine.detachCustomDescriptor(id)` server-side which fires + * `on-rule-expire` hooks BEFORE retracting hook-list entries. + * + * Sent through the existing GameClient socket — the host token + * is pinned on `ws.data` from the original room.create, so the + * server's host-only gate (broadcast.ts § handleCustomModifierRemove) + * accepts the frame. + */ +async function detachDescriptor( + page: Page, + args: { code: string; descriptorId: string }, +): Promise { + await page.evaluate((a) => { + const client = ( + globalThis as { + __paratypeChessClient?: { + send: (msg: { type: string; payload: unknown }) => void; + }; + } + ).__paratypeChessClient; + if (!client) + throw new Error('detachDescriptor: __paratypeChessClient not present'); + client.send({ + type: 'custom-modifier.remove', + payload: { roomCode: a.code, descriptorId: a.descriptorId }, + }); + }, args); +} + +/** Drag a piece via the same UI path the multiplayer e2e uses. */ +const drag = async (page: Page, from: string, to: string): Promise => { + await page + .locator(`[data-square="${from}"] [data-piece]`) + .dragTo(page.locator(`[data-square="${to}"]`)); +}; + +/** + * Send `game.move` through the page's connected GameClient (mirrors + * parity-rules.spec.ts). More reliable than drag for fast-fire + * sequences where the source-square locator may race UI updates. + */ +async function sendMove(page: Page, from: string, to: string): Promise { + await page.evaluate( + async (a) => { + type Client = { + send: (msg: { type: string; payload: unknown }) => void; + sendMove?: (from: string, to: string) => void; + readonly isConnected?: boolean; + }; + const getClient = (): Client | undefined => + (globalThis as { __paratypeChessClient?: Client }) + .__paratypeChessClient; + const deadline = Date.now() + 3000; + let client = getClient(); + while (Date.now() < deadline) { + client = getClient(); + if (client && client.isConnected === true) break; + await new Promise((r) => setTimeout(r, 50)); + } + if (!client || client.isConnected !== true) { + throw new Error('sendMove: GameClient never became connected'); + } + if (typeof client.sendMove === 'function') { + client.sendMove(a.from, a.to); + return; + } + client.send({ + type: 'game.move', + payload: { from: a.from, to: a.to }, + }); + }, + { from, to }, + ); +} + +/** + * Read an attr from the engine's session via the page's + * PredictionManager (T79 dev-only export). Default `entityId = 0` + * targets `GAME_ENTITY` (per `schema.ts` § "GAME_ENTITY: EntityId + * = 0 as EntityId"); pass a positive id for piece / marker + * facts. Note: parity-rules.spec.ts uses `-1` as a "GAME_ENTITY" + * default in its own helper but never actually probes id=-1 for + * a game-level attr (every probe there reads a piece id off the + * DOM first), so the mistake was never load-bearing there. + * `PRESET_STATE_ENTITY = -1` is a separate scope; if a future + * test needs that, pass `-1` explicitly. + */ +async function readAttr( + page: Page, + attr: string, + entityId: number = 0, +): Promise { + return page.evaluate( + (a) => { + const mgr = ( + globalThis as { + __paratypeChessPrediction?: { + getCurrentEngine: () => { + session: { get: (id: unknown, attr: string) => unknown }; + }; + }; + } + ).__paratypeChessPrediction; + if (!mgr) throw new Error('readAttr: PredictionManager not exposed'); + const engine = mgr.getCurrentEngine(); + return engine.session.get(a.entityId, a.attr); + }, + { attr, entityId }, + ); +} + +/** + * Find every entity id that satisfies a selector predicate (running + * inside the browser context against the engine's session). Useful + * for tests that need to find spawned markers / pieces by attribute. + * + * We can't pass a function across `page.evaluate`, so the caller + * supplies attr+value pairs that the in-browser walker AND-combines. + */ +async function findEntities( + page: Page, + filters: Array<{ attr: string; value: unknown }>, +): Promise { + return page.evaluate( + (f) => { + const mgr = ( + globalThis as { + __paratypeChessPrediction?: { + getCurrentEngine: () => { + session: { + allFacts: () => Iterable<{ + id: unknown; + attr: string; + value: unknown; + }>; + get: (id: unknown, attr: string) => unknown; + }; + }; + }; + } + ).__paratypeChessPrediction; + if (!mgr) throw new Error('findEntities: PredictionManager not exposed'); + const engine = mgr.getCurrentEngine(); + const ids = new Set(); + // Anchor the scan on the first filter, then narrow. + const [head, ...rest] = f; + if (!head) return []; + for (const fact of engine.session.allFacts()) { + if (fact.attr !== head.attr) continue; + if (fact.value !== head.value) continue; + const idNum = fact.id as number; + if (idNum <= 0) continue; + let ok = true; + for (const r of rest) { + if (engine.session.get(fact.id, r.attr) !== r.value) { + ok = false; + break; + } + } + if (ok) ids.add(idNum); + } + return [...ids].sort((a, b) => a - b); + }, + filters, + ); +} + +// --------------------------------------------------------------------------- +// Descriptor builders +// --------------------------------------------------------------------------- + +interface PrimitiveNode { + kind: string; + params: Record; +} + +/** + * Build a descriptor whose top-level primitives execute EXACTLY + * ONCE during `applyCustomDescriptor`'s walker. + * + * ───────────────────────────────────────────────────────────────────── + * Why NOT `on-rule-activated` + * ───────────────────────────────────────────────────────────────────── + * + * `applyCustomDescriptor` (custom/apply.ts) double-executes the + * inner cascade of an `on-rule-activated` block: + * 1. The walker auto-recurses into `on-rule-activated.childPrimitives()` + * and runs each inner primitive (because on-rule-activated does + * NOT have `selfRecurse: true`). + * 2. Then `fireOnRuleActivatedHooks` runs the SAME inner primitives + * a second time via the trigger pipeline. + * + * For idempotent writes (`set-piece-attr` to the same value, deduped + * `block-by-piece-type`) this is a no-op. For NON-IDEMPOTENT + * imperatives (`spawn-marker`, `place-piece`, `swap-pieces`) the + * cascade fires twice — the well-known V1 sharp edge documented in + * `minefield-real.test.ts` § "V1 sharp edge: the dispatcher's + * post-apply child-walk on random-pick may re-enter… can result in + * 10 mines (each random-pick fires its inner twice)". + * + * ───────────────────────────────────────────────────────────────────── + * Why bare top-level primitives DO single-execute + * ───────────────────────────────────────────────────────────────────── + * + * A descriptor whose `primitives` are ALL top-level (no + * `on-rule-activated` wrapper) is walked exactly once by + * `applyCustomDescriptor`'s walker — the post-walker + * `fireOnRuleActivatedHooks` call is a no-op (no hooks were + * seeded). For primitives whose children are walked by their own + * `apply()` (`for-each-square`, `for-each-piece`, etc. — all + * carry `selfRecurse: true`), the walker does NOT auto-recurse + * into their nested arms either, so each iteration's body runs + * exactly once. + * + * The validator's "imperatives must be inside trigger scope" gate + * does NOT apply here — `__test__.apply-descriptor` parses via + * `parseCustomModifierDescriptor` (just the Zod structural schema) + * and skips the deep `validateCustomModifierTree` walk that + * normally enforces the imperative-in-passive constraint at + * register-time. The constraint is a PRE-REGISTER guard for + * AUTHORED descriptors; the test-debug path bypasses it. + * + * ───────────────────────────────────────────────────────────────────── + * + * Use this builder for descriptors whose effect should fire EXACTLY + * ONCE on apply. For descriptors that genuinely need the + * `on-rule-activated` trigger semantics (e.g. carrying an inner arm + * that fires later via `fireOnRuleActivatedHooks` from a separate + * dispatcher path), build the descriptor manually — both shapes are + * accepted by `parseCustomModifierDescriptor`. + */ +function descriptorOneShot( + id: string, + primitives: PrimitiveNode[], + targetAttrs: string[] = [], +): Record { + return { + type: 'data', + id, + name: id, + description: `wave18 e2e — ${id}`, + version: 1, + uiForm: 'primitive-composer', + source: 'custom', + targetAttrs: [...new Set(targetAttrs)], + primitives, + }; +} + +// --------------------------------------------------------------------------- +// Test 1 — place-piece +// --------------------------------------------------------------------------- +// +// Activate a descriptor whose on-rule-activated arm spawns a white +// queen on e4 (square 28). After apply, e4 carries a white queen. +// The starting board has no queen on e4, so observation is +// unambiguous. + +test('W18/place-piece: on-rule-activated → spawns white queen on e4', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot('w18:place-piece', [ + { + kind: 'place-piece', + params: { pieceType: 'queen', color: 'white', square: 28 }, + }, + ]), + }); + + await expect( + page.locator('[data-square="e4"] [data-piece="white-queen"]'), + ).toBeVisible({ timeout: 5000 }); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 2 — move-piece +// --------------------------------------------------------------------------- +// +// Force the white king to e3 (square 20) via `move-piece`. e3 is +// empty on a starting board, so the post-move DOM unambiguously +// shows the king there (no stacking ambiguity). Using e2 as the +// destination would clash with the e2 pawn — both pieces would +// have Position=12 and the Board renderer picks just one to +// display, masking the actual move. + +test('W18/move-piece: forced relocation moves king to empty e3 without player drag', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + const kingIdAttr = await page + .locator('[data-square="e1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(kingIdAttr).not.toBeNull(); + const kingId = Number(kingIdAttr); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot('w18:move-piece', [ + { + kind: 'move-piece', + params: { target: kingId, to: 20 /* e3 */ }, + }, + ]), + }); + + await expect( + page.locator('[data-square="e3"] [data-piece="white-king"]'), + ).toBeVisible({ timeout: 5000 }); + await expect( + page.locator('[data-square="e1"] [data-piece="white-king"]'), + ).toHaveCount(0); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 3 — swap-pieces +// --------------------------------------------------------------------------- +// +// Atomically swap the white knights on b1 and g1. After apply the +// piece on b1 should be the knight that started on g1 (and vice +// versa). Both squares show "white-knight" because they're +// identical types — what we check is the data-piece-id swap. + +test('W18/swap-pieces: atomic position swap b1 ↔ g1', async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + const b1IdAttr = await page + .locator('[data-square="b1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + const g1IdAttr = await page + .locator('[data-square="g1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(b1IdAttr).not.toBeNull(); + expect(g1IdAttr).not.toBeNull(); + const b1Id = Number(b1IdAttr); + const g1Id = Number(g1IdAttr); + expect(b1Id).not.toBe(g1Id); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot('w18:swap-pieces', [ + { + kind: 'swap-pieces', + params: { a: b1Id, b: g1Id }, + }, + ]), + }); + + // Allow the broadcast snapshot to land. The board re-renders + // pieces by Position fact, so post-swap the b1 square should + // expose the knight originally identified by `g1Id`. + await page.waitForTimeout(150); + + const b1IdAfter = await page + .locator('[data-square="b1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + const g1IdAfter = await page + .locator('[data-square="g1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(Number(b1IdAfter)).toBe(g1Id); + expect(Number(g1IdAfter)).toBe(b1Id); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 4 — convert-piece-type +// --------------------------------------------------------------------------- +// +// Convert the e2 pawn to a queen via on-rule-activated. The +// PieceType insert is the load-bearing change; data-piece flips +// from "white-pawn" to "white-queen" once the snapshot lands. + +test('W18/convert-piece-type: e2 pawn → queen on activation', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + const e2IdAttr = await page + .locator('[data-square="e2"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(e2IdAttr).not.toBeNull(); + const e2Id = Number(e2IdAttr); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot('w18:convert-piece-type', [ + { + kind: 'convert-piece-type', + params: { target: e2Id, pieceType: 'queen' }, + }, + ]), + }); + + await expect( + page.locator('[data-square="e2"] [data-piece="white-queen"]'), + ).toBeVisible({ timeout: 5000 }); + await expect( + page.locator('[data-square="e2"] [data-piece="white-pawn"]'), + ).toHaveCount(0); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 5 — for-each-piece +// --------------------------------------------------------------------------- +// +// Set HpBonus=99 on every white piece. We probe the white king's +// HpBonus via the prediction manager — a positive read confirms the +// iteration walked at least one piece (the king is white, and the +// for-each filter narrows to color=white). The unit test +// (for-each-piece.test.ts) pins the full iteration count; the e2e +// only needs the wire-level "did the iteration fire at all" probe. + +test('W18/for-each-piece: filter color=white sets HpBonus=99 on king', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + const kingIdAttr = await page + .locator('[data-square="e1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(kingIdAttr).not.toBeNull(); + const kingId = Number(kingIdAttr); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot( + 'w18:for-each-piece', + [ + { + kind: 'for-each-piece', + params: { + filter: { color: 'white' }, + bind: 'p', + then: [ + { + kind: 'set-piece-attr', + params: { + target: { $var: 'p' }, + attr: 'HpBonus', + value: 99, + }, + }, + ], + }, + }, + ], + ['HpBonus'], + ), + }); + + await page.waitForTimeout(150); + expect(await readAttr(page, 'HpBonus', kingId)).toBe(99); + + // Sanity: a black piece should NOT have HpBonus=99 (filter + // excludes black). e8 is the black king on the starting board. + const blackKingIdAttr = await page + .locator('[data-square="e8"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + const blackKingId = Number(blackKingIdAttr); + expect(await readAttr(page, 'HpBonus', blackKingId)).not.toBe(99); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 6 — for-each-square +// --------------------------------------------------------------------------- +// +// Drop a permanent treasure marker on each of squares 0..3 (the +// a1..d1 file segment). After apply, all four squares carry a +// data-marker-kind="treasure" element. + +test('W18/for-each-square: subset iteration spawns treasure on a1..d1', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot('w18:for-each-square', [ + { + kind: 'for-each-square', + params: { + squares: [0, 1, 2, 3], + bind: 'sq', + then: [ + { + kind: 'spawn-marker', + params: { + markerKind: 'treasure', + square: { $var: 'sq' }, + lifetime: { kind: 'permanent' }, + }, + }, + ], + }, + }, + ]), + }); + + await page.waitForTimeout(200); + for (const square of ['a1', 'b1', 'c1', 'd1']) { + await expect( + page.locator(`[data-square="${square}"] [data-marker-kind="treasure"]`), + ).toHaveCount(1, { timeout: 5000 }); + } + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 7 — for-each-adjacent +// --------------------------------------------------------------------------- +// +// Iterate the neighbours of e2 (square 12) that are OCCUPIED, and +// for each occupied neighbour, set its HpBonus to 77 (the bound +// value is the piece id when filter.occupied===true). e2's +// neighbours on the starting board are: d1, e1, f1 (occupied — +// queen, king, bishop), d2, f2 (occupied — pawns), d3, e3, f3 +// (empty). So the iteration binds 5 piece ids; each gets +// HpBonus=77. We verify on the e1 white king — id is read from +// the DOM up front. + +test('W18/for-each-adjacent: occupied neighbours of e2 receive HpBonus=77', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + const kingIdAttr = await page + .locator('[data-square="e1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + expect(kingIdAttr).not.toBeNull(); + const kingId = Number(kingIdAttr); + + // We use `target: "self"` and pass the e2 pawn as the apply + // target so for-each-adjacent's centre = e2 (square 12). Passing + // a numeric `target: 12` is AMBIGUOUS — for-each-adjacent's dual + // semantics treats a non-negative int as an entity id FIRST, and + // entity 12 happens to BE a real piece on the starting board + // (numeric ids are stably assigned by spawn order). So + // `target: 12` would centre on entity 12's Position, NOT on + // square 12. Using `target: "self"` + an apply target pinned to + // the e2 pawn sidesteps that hazard. + await applyDescriptor(page, { + code: room.code, + targetSquare: 12, // e2 — apply target = the e2 white pawn + descriptor: descriptorOneShot( + 'w18:for-each-adjacent', + [ + { + kind: 'for-each-adjacent', + params: { + target: 'self', + filter: { occupied: true }, + bind: 'adj', + then: [ + { + kind: 'set-piece-attr', + params: { + target: { $var: 'adj' }, + attr: 'HpBonus', + value: 77, + }, + }, + ], + }, + }, + ], + ['HpBonus'], + ), + }); + + await page.waitForTimeout(150); + // e1 (white king) is an occupied neighbour of e2 → HpBonus=77. + expect(await readAttr(page, 'HpBonus', kingId)).toBe(77); + + // Sanity: e3 has no piece on the starting board, so no piece-id + // could have been bound from there. The h1 white rook is NOT + // adjacent to e2 either (rank/file gap > 1) — its HpBonus must + // remain undefined. + const h1IdAttr = await page + .locator('[data-square="h1"] [data-piece-id]') + .first() + .getAttribute('data-piece-id'); + const h1Id = Number(h1IdAttr); + expect(await readAttr(page, 'HpBonus', h1Id)).toBeUndefined(); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 8 — for-each-marker +// --------------------------------------------------------------------------- +// +// 1. First apply: spawn 4 mines on squares 16, 17, 18, 19 (a3..d3) +// via for-each-square + spawn-marker. +// 2. Second apply: for-each-marker(filter: markerKind=mine) → +// destroy-marker each. The fire-once guard on +// PRESET_STATE_ENTITY uses the descriptor id; we use a different +// descriptor id for the sweep so the on-rule-activated arm fires +// a second time. +// Final: zero mine markers on the board. + +test('W18/for-each-marker: destroy every mine after seeding 4', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + // Seed 4 mines on a3..d3. + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot('w18:for-each-marker-seed', [ + { + kind: 'for-each-square', + params: { + squares: [16, 17, 18, 19], + bind: 'sq', + then: [ + { + kind: 'spawn-marker', + params: { + markerKind: 'mine', + square: { $var: 'sq' }, + lifetime: { kind: 'permanent' }, + }, + }, + ], + }, + }, + ]), + }); + await page.waitForTimeout(200); + for (const square of ['a3', 'b3', 'c3', 'd3']) { + await expect( + page.locator(`[data-square="${square}"] [data-marker-kind="mine"]`), + ).toHaveCount(1, { timeout: 5000 }); + } + + // Sweep all mines via for-each-marker. + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot('w18:for-each-marker-sweep', [ + { + kind: 'for-each-marker', + params: { + filter: { markerKind: 'mine' }, + bind: 'm', + then: [ + { + kind: 'destroy-marker', + params: { target: { $var: 'm' } }, + }, + ], + }, + }, + ]), + }); + await page.waitForTimeout(200); + await expect( + page.locator('[data-marker-kind="mine"]'), + ).toHaveCount(0, { timeout: 5000 }); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 9 — block-by-piece-type +// --------------------------------------------------------------------------- +// +// Block queens game-wide. The starting position has no queen with +// legal moves on move 1, so we first push e2-e4 / d7-d5 to +// liberate the d1 white queen's diagonal. Apply the block +// descriptor. White's queen-move attempts (e.g. d1-h5) should +// fail; pawn moves still succeed. We verify by attempting a +// drag and observing the queen STILL on d1 after; then drag a +// pawn to confirm the gate is queen-specific. + +test('W18/block-by-piece-type: primitive seeds BlockedPieceTypes on GAME_ENTITY (rules/turn filter)', async ({ + browser, +}) => { + // V1 wiring note: this primitive WRITES `BlockedPieceTypes` on + // GAME_ENTITY. The downstream consumer that filters generated + // moves against the list lives in `rules/turn.ts § + // getLegalMovesForPiece` and IS unit-tested there + // (`turn.blockall.test.ts` § "BlockedPieceTypes drops every + // move whose mover's PieceType is a member"). However, + // `engine.getAllLegalMoves()` — the path the UI's drag-drop + // legality gate uses — has its OWN move-aggregation pipeline + // that does NOT consult `getLegalMovesForPiece`'s game-level + // filters. This is a known wiring gap (the rules/turn filter + // is the V1 contract; the engine-level wire-in is deferred + // alongside the must-class consumer). + // + // Consequently this e2e cannot verify drag-level rejection + // through the UI. We pin the LOAD-BEARING contract for the + // primitive itself: applying the descriptor surfaces a + // `BlockedPieceTypes` fact on GAME_ENTITY containing the + // requested piece type. Probe via the dev-only PredictionManager + // export (T79); the post-apply game.state broadcast lands the + // fact on every connected client's authoritative-mirror engine. + // + // When the engine-level wire-in lands (a future wave hooking + // `engine.getAllLegalMoves` through `getLegalMovesForPiece` so + // game-level filters apply across the board), this test should + // be extended to drive a queen-drag attempt + assert the queen + // stays on its origin square. + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot( + 'w18:block-queen', + [ + { + kind: 'block-by-piece-type', + params: { pieceTypes: ['queen'] }, + }, + ], + ['BlockedPieceTypes'], + ), + }); + + // Poll for the attr — the broadcast can take longer than 200ms + // in worst-case CI runs. + await page.waitForFunction( + () => { + const mgr = ( + globalThis as { + __paratypeChessPrediction?: { + getCurrentEngine: () => { + session: { get: (id: unknown, attr: string) => unknown }; + }; + }; + } + ).__paratypeChessPrediction; + if (!mgr) return false; + // GAME_ENTITY = 0 per `schema.ts` § "GAME_ENTITY: EntityId = 0". + const v = mgr + .getCurrentEngine() + .session.get(0, 'BlockedPieceTypes'); + return Array.isArray(v) && (v as unknown[]).includes('queen'); + }, + null, + { timeout: 10000 }, + ); + + const blocked = await readAttr(page, 'BlockedPieceTypes'); + expect(Array.isArray(blocked)).toBe(true); + expect(blocked as string[]).toContain('queen'); + + // Repeat-apply with a different piece-type to lock the + // set-union semantics (block-by-piece-type.ts § "Idempotence": + // "Repeated applies with the same pieceTypes are no-ops on the + // stored fact; different pieceTypes UNION"). + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot( + 'w18:block-knight', + [ + { + kind: 'block-by-piece-type', + params: { pieceTypes: ['knight'] }, + }, + ], + ['BlockedPieceTypes'], + ), + }); + await page.waitForFunction( + () => { + const mgr = ( + globalThis as { + __paratypeChessPrediction?: { + getCurrentEngine: () => { + session: { get: (id: unknown, attr: string) => unknown }; + }; + }; + } + ).__paratypeChessPrediction; + if (!mgr) return false; + const v = mgr.getCurrentEngine().session.get(0, 'BlockedPieceTypes'); + return ( + Array.isArray(v) && + (v as unknown[]).includes('queen') && + (v as unknown[]).includes('knight') + ); + }, + null, + { timeout: 5000 }, + ); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 10 — on-rule-expire +// --------------------------------------------------------------------------- +// +// Activate a descriptor whose on-rule-expire arm spawns a treasure +// marker on a8 (square 56). Then send `custom-modifier.remove` +// with the same id. The detach pipeline (engine.ts § +// detachCustomDescriptor) fires `OnRuleExpireHooks` BEFORE +// retracting hook-list entries, so the inner spawn-marker runs +// during detach. Post-detach: a treasure marker is visible on a8. +// +// Pre-test the descriptor's on-rule-activated arm is empty (the +// only inner trigger is on-rule-expire, which seeds OnRuleExpireHooks +// at apply time but does NOT fire the inner block until detach). + +test('W18/on-rule-expire: descriptor detach fires expire-arm spawn-marker', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + // Build the descriptor — on-rule-expire is itself a passive + // trigger primitive (NOT in IMPERATIVE_KINDS), so it can sit at + // the descriptor's top level. The inner `spawn-marker` IS + // imperative but it's nested under the on-rule-expire trigger + // arm, so the validator's imperative-in-passive gate is + // satisfied. + const descriptorId = 'w18:on-rule-expire'; + const descriptor = { + type: 'data', + id: descriptorId, + name: descriptorId, + description: 'wave18 e2e — on-rule-expire', + version: 1, + uiForm: 'primitive-composer', + source: 'custom', + targetAttrs: ['OnRuleExpireHooks'], + primitives: [ + { + kind: 'on-rule-expire', + params: { + primitives: [ + { + kind: 'spawn-marker', + params: { + markerKind: 'treasure', + square: 56, // a8 + lifetime: { kind: 'permanent' }, + }, + }, + ], + }, + }, + ], + }; + + await applyDescriptor(page, { code: room.code, descriptor }); + await page.waitForTimeout(200); + + // Pre-detach: snapshot the treasure-marker count at a8. The + // load-bearing assertion is "DETACH spawned a NEW marker" — we + // capture the pre-detach baseline so the post-detach assertion + // can probe the delta rather than an absolute count. The apply + // walker MAY auto-recurse into on-rule-expire's `childPrimitives()` + // and run the inner spawn-marker once at apply time (V1 sharp + // edge — see custom/apply.ts § "T82 (Wave 14, Gap I) parity" + // for the symmetric fix on iteration primitives; on-rule-expire + // is not in that selfRecurse club, so the apply walker may + // double-execute its inner cascade once at apply + once at + // detach). Both shapes are accepted by the contract — what + // matters is that DETACH causes AT LEAST ONE additional spawn. + const preDetachCount = await page + .locator('[data-square="a8"] [data-marker-kind="treasure"]') + .count(); + + // Detach. + await detachDescriptor(page, { code: room.code, descriptorId }); + await page.waitForTimeout(300); + + // Post-detach: at least ONE more treasure than pre-detach. The + // detach pipeline (engine.detachCustomDescriptor) fires + // OnRuleExpireHooks BEFORE retracting the hook entries — the + // inner spawn-marker runs as part of fireOnRuleExpireHooks's + // runPrimitives invocation. The fire-once guard + // (RuleExpireFiredFor on PRESET_STATE_ENTITY) is engine-state, + // so a re-detach in the same session is a no-op. + await expect(async () => { + const postCount = await page + .locator('[data-square="a8"] [data-marker-kind="treasure"]') + .count(); + expect(postCount).toBeGreaterThan(preDetachCount); + }).toPass({ timeout: 5000 }); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 11 — on-marker-expire +// --------------------------------------------------------------------------- +// +// Strategy: +// 1. Apply a descriptor that seeds an `OnMarkerExpireHooks` entry +// for `markerKind: pit` whose inner arm spawns a treasure on +// h8 (square 63). +// 2. Apply a SECOND descriptor that spawns a pit marker on h1 +// (square 7) with lifetime `{kind:'moves', expiresAtMove: 2}`. +// `FullmoveNumber` starts at 1 on a fresh game; the per-move +// sweep retires the pit when FullmoveNumber >= 2 (after black +// completes move 1). +// 3. Drive 1.a3 a6 (white half-move + black half-move → +// FullmoveNumber advances to 2 after black's reply). +// 4. The lifetime sweep fires fireOnMarkerExpireHooks for the pit +// → inner arm spawns a treasure on h8. Pit is retracted. +// +// Post-conditions: NO pit on h1; treasure on h8. + +test('W18/on-marker-expire: lifetime sweep fires hook → replacement marker', async ({ + browser, +}) => { + const ctxA = await browser.newContext(); + const pageA = await ctxA.newPage(); + const room = await joinAsHost(pageA); + expect(room.color).toBe('white'); + + const ctxB = await browser.newContext(); + const pageB = await ctxB.newPage(); + const roomB = await joinAsGuest(pageB, room.code); + expect(roomB.color).toBe('black'); + + // Seed the on-marker-expire hook. + await applyDescriptor(pageA, { + code: room.code, + descriptor: descriptorOneShot( + 'w18:on-marker-expire-hook', + [ + { + kind: 'on-marker-expire', + params: { + markerKind: 'pit', + primitives: [ + { + kind: 'spawn-marker', + params: { + markerKind: 'treasure', + square: 63, // h8 + lifetime: { kind: 'permanent' }, + }, + }, + ], + }, + }, + ], + ['OnMarkerExpireHooks'], + ), + }); + await pageA.waitForTimeout(150); + + // Spawn the short-lifetime pit on h1. + await applyDescriptor(pageA, { + code: room.code, + descriptor: descriptorOneShot('w18:on-marker-expire-pit', [ + { + kind: 'spawn-marker', + params: { + markerKind: 'pit', + square: 7, // h1 + lifetime: { kind: 'moves', expiresAtMove: 2 }, + }, + }, + ]), + }); + await pageA.waitForTimeout(200); + await expect( + pageA.locator('[data-square="h1"] [data-marker-kind="pit"]'), + ).toHaveCount(1, { timeout: 5000 }); + + // Snapshot the pre-sweep treasure count on h8. The apply walker + // (custom/apply.ts) MAY auto-recurse into on-marker-expire's + // childPrimitives at apply time and run the inner spawn-marker + // — V1 sharp edge mirroring the on-rule-expire case. The + // load-bearing assertion is "the LIFETIME SWEEP added at least + // one more treasure", which is the contract the e2e is locking + // (the sweep DID fire fireOnMarkerExpireHooks for the expired + // pit). An exact-1 count would over-pin the V1-sharp-edge + // double-walk. + const preSweepTreasureCount = await pageA + .locator('[data-square="h8"] [data-marker-kind="treasure"]') + .count(); + + // Drive the move counter forward. FullmoveNumber starts at 1 + // (engine.ts § initial state). It advances AFTER black completes + // its reply. So 1.a3 a6 → FullmoveNumber becomes 2 after black's + // a6, at which point the lifetime sweep retires the pit. + await sendMove(pageA, 'a2', 'a3'); + await pageA.waitForTimeout(150); + await sendMove(pageB, 'a7', 'a6'); + await pageA.waitForTimeout(300); + + // Pit gone (the sweep retracted it). + await expect( + pageA.locator('[data-square="h1"] [data-marker-kind="pit"]'), + ).toHaveCount(0, { timeout: 5000 }); + // At least one MORE treasure on h8 vs pre-sweep — this proves + // the sweep fired fireOnMarkerExpireHooks for the expiring pit. + await expect(async () => { + const postCount = await pageA + .locator('[data-square="h8"] [data-marker-kind="treasure"]') + .count(); + expect(postCount).toBeGreaterThan(preSweepTreasureCount); + }).toPass({ timeout: 5000 }); + + await ctxA.close(); + await ctxB.close(); +}); + +// --------------------------------------------------------------------------- +// Test 12 — spawn-marker-pair +// --------------------------------------------------------------------------- +// +// Spawn a portal-end pair at a1 (square 0) ↔ h8 (square 63). After +// apply: TWO portal-end markers visible (one per square), and each +// marker's MarkerLinks fact references the OTHER marker's id. + +test('W18/spawn-marker-pair: portal-end pair at a1 ↔ h8 with mutual MarkerLinks', async ({ + browser, +}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot('w18:spawn-marker-pair', [ + { + kind: 'spawn-marker-pair', + params: { + markerKind: 'portal-end', + squareA: 0, // a1 + squareB: 63, // h8 + lifetime: { kind: 'permanent' }, + }, + }, + ]), + }); + await page.waitForTimeout(200); + + // Both squares carry a portal-end marker. + await expect( + page.locator('[data-square="a1"] [data-marker-kind="portal-end"]'), + ).toHaveCount(1, { timeout: 5000 }); + await expect( + page.locator('[data-square="h8"] [data-marker-kind="portal-end"]'), + ).toHaveCount(1, { timeout: 5000 }); + + // Resolve the two marker ids by Position fact (markers don't + // surface their entity id in the DOM the same way pieces do — + // we go through the prediction manager). + const a1Markers = await findEntities(page, [ + { attr: 'EntityKind', value: 'marker' }, + { attr: 'Position', value: 0 }, + ]); + const h8Markers = await findEntities(page, [ + { attr: 'EntityKind', value: 'marker' }, + { attr: 'Position', value: 63 }, + ]); + expect(a1Markers.length).toBe(1); + expect(h8Markers.length).toBe(1); + const idA = a1Markers[0]!; + const idH = h8Markers[0]!; + + // MarkerLinks must cross-reference. Each side's link list is a + // single-element array pointing at the other's id (per + // spawn-marker-pair.ts § "Cross-link via session.insert"). + const linksA = (await readAttr(page, 'MarkerLinks', idA)) as + | unknown[] + | undefined; + const linksH = (await readAttr(page, 'MarkerLinks', idH)) as + | unknown[] + | undefined; + expect(Array.isArray(linksA)).toBe(true); + expect(Array.isArray(linksH)).toBe(true); + expect(linksA).toEqual([idH]); + expect(linksH).toEqual([idA]); + + await ctx.close(); +}); + +// --------------------------------------------------------------------------- +// Test 13 — must-class (.fixme) +// --------------------------------------------------------------------------- +// +// `must-class` writes `MoveClassRestriction` on GAME_ENTITY but the +// move-gen consumer that filters generated moves against the +// restriction has NOT landed (must-class.ts § "Move-gen consumer — +// DEFERRED"). Without the consumer, an e2e probe of "the +// restriction prevents non-matching moves" cannot pass — the +// engine accepts any move regardless of the stored restriction. +// +// Authoring the scenario as `.fixme()` keeps it visible in the +// Playwright report so a future wave that lands the consumer can +// flip the gate without re-discovering the descriptor shape. The +// scenario writes a `must-class={class:"capture"}` restriction on +// activation; once the filter lands, white's first move (no +// captures available from the starting position) should be +// REJECTED — the test would then assert an attempted e2-e4 leaves +// the pawn on e2. + +test.fixme( + 'W18/must-class: capture restriction prevents non-capture moves (consumer deferred)', + async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const room = await joinAsHost(page); + expect(room.color).toBe('white'); + + await applyDescriptor(page, { + code: room.code, + descriptor: descriptorOneShot( + 'w18:must-class', + [ + { + kind: 'must-class', + params: { class: 'capture' }, + }, + ], + ['MoveClassRestriction'], + ), + }); + await page.waitForTimeout(150); + + // Pre-consumer: the restriction lands on GAME_ENTITY but the + // move-gen filter doesn't read it yet. Once the filter exists, + // e2-e4 (an advance, not a capture) should be REJECTED. + await drag(page, 'e2', 'e4'); + await expect( + page.locator('[data-square="e2"] [data-piece="white-pawn"]'), + ).toBeVisible({ timeout: 5000 }); + await expect( + page.locator('[data-square="e4"] [data-piece="white-pawn"]'), + ).toHaveCount(0); + + await ctx.close(); + }, +);