/** * 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( '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(); }, );