/** * T84 — Playwright e2e for `religious_conversion` + `kamikaze` parity rules. * * Both descriptors use trigger-rooted cascades that the existing * `__test__.activate-descriptor` lifter (T79) cannot drive — that lifter * requires `on-rule-activated` as the descriptor root so the inner arm * surfaces as a registerable top-level primitive list. religious_conversion * uses `on-move`, kamikaze uses `on-capture` — both seed per-piece hook * facts (OnMoveHooks / OnCaptureHooks) at apply-time and only fire when * the engine's onAfterMove dispatcher reaches `fireOn*Hooks` for the * moving / capturing piece. * * Driving the cascade end-to-end therefore requires a SECOND test-debug * frame that: * 1. Wipes the FIDE starting position. * 2. Places a deterministic minimal set of pieces. * 3. Seeds the descriptor's INNER arm directly onto the relevant * piece's hook fact (mirrors what `applyCustomDescriptor` would * produce on a fixed dispatcher — see religious_conversion-real.test.ts * and kamikaze-real.test.ts § "seeding On{Move,Capture}Hooks"). * 4. Optionally pins `RngSeed` for descriptors with `with-probability` * (kamikaze). * 5. Broadcasts a fresh `game.state` so the client renders the * synthetic board. * * That frame is `__test__.setup-board` (T84, packages/server/src/broadcast.ts). * * ───────────────────────────────────────────────────────────────────── * Test 1 — religious_conversion (on-move + for-each-adjacent + set-piece-attr) * ───────────────────────────────────────────────────────────────────── * * Setup (after clear-board, kings preserved at e1/e8): * - white bishop at d4 (will move to b6, the on-move hook target) * - white pawn at a6 (adjacent to b6 destination — already white, * Color set is a no-op, pin: ally unchanged) * - black pawns at a7, b7, c7 (adjacent to b6 destination — should * flip to white after the move) * * The bishop's path d4→c5→b6 is along an empty diagonal (c5 is the * only square traversed; cleared). After applyMove(d4→b6) fires * fireOnMoveHooks(bishop), the inner arm (for-each-adjacent target=self, * filter={occupied:true, excludeKing:true}, then=set-piece-attr Color) * walks the 8 squares around b6 and flips the Color of every adjacent * non-king piece to the bishop's Color (white). * * Pin: * - a7/b7/c7 render `data-piece="white-pawn"` (was black). * - a6 stays `data-piece="white-pawn"` (ally — Color set was a no-op). * - bishop at b6. * * ───────────────────────────────────────────────────────────────────── * Test 2 — kamikaze (on-capture + with-probability + for-each-adjacent + destroy-piece) * ───────────────────────────────────────────────────────────────────── * * The on-disk fixture has `p: 0.25`. Driving an e2e against that * stochastic branch is brittle — the first applyMove may or may not * consume earlier RNG draws (the integration preset's onAfterMove * runs preset hooks BEFORE fireOnCaptureHooks, and any preset that * calls `engine.rng().next()` shifts the with-probability draw's * stream offset). Instead we use the task-suggested workaround: * a per-test descriptor variant with `p: 1.0` so AOE fires * deterministically on every capture. The kamikaze-real.test.ts * locked-numerics test (seed=42 → 32 hits at exact stream offsets) is * what pins the stochastic contract; this e2e pins the WIRE / UI / * dispatcher path — same cascade, deterministic outcome. * * Setup (after clear-board with kings retracted, then re-placed by us): * - white queen at e2 (will capture the e4 pawn) * - black pawn at e4 (the capture target) * - black pawns at d4, f4 (adjacent to e4 destination — AOE victims) * - black king at e5 (adjacent to e4 destination — IMMUNE) * - white king at e1, black king at e8 (re-placed for legality of the * game state — checking against d4/f4 etc * doesn't matter for hook-firing) * * Queen path e2→e3→e4 (e3 is empty post-clear). The capture fires * fireOnCaptureHooks(queen) — the inner arm: * with-probability(p=1.0) → for-each-adjacent(filter excludeKing) → destroy-piece * always enters the for-each branch, walks {d3,e3,f3,d4,f4,d5,e5,f5}, * narrows by occupied + excludeKing → matches d4 (black pawn), f4 * (black pawn). e5 is a king → IMMUNE. destroy-piece retracts every * piece-identity fact for d4 + f4. * * Pins: * - queen at e4 (capture succeeded; e4 black pawn gone). * - d4 + f4 squares render no piece (AOE destroyed them). * - e5 black king STILL renders `data-piece="black-king"` * (king-immunity invariant from kamikaze.test.ts § "king never * destroyed"). * * ───────────────────────────────────────────────────────────────────── * Why we don't extend `parity-rules.spec.ts` * ───────────────────────────────────────────────────────────────────── * * Per the T84 brief: T83 owns parity-rules (parry, all_on_red, * ice_physics). Keeping religious + kamikaze in a sibling spec file * avoids merge conflicts on the shared describe-block scaffolding and * lets each spec own its own server fixture lifecycle. */ 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, readFileSync } from 'node:fs'; import { join } from 'node:path'; // --------------------------------------------------------------------------- // Server lifecycle (mirrors `multiplayer.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; } }); // --------------------------------------------------------------------------- // Fixtures + screenshot helpers // --------------------------------------------------------------------------- const EVIDENCE_DIR = join(process.cwd(), '.sisyphus/evidence/wave15-t84-screenshots'); if (!existsSync(EVIDENCE_DIR)) mkdirSync(EVIDENCE_DIR, { recursive: true }); const RELIGIOUS_CONVERSION_DESCRIPTOR = JSON.parse( readFileSync( join(process.cwd(), 'packages/chess/src/__fixtures__/parity/religious_conversion.json'), 'utf8', ), ) as Record; const KAMIKAZE_DESCRIPTOR = JSON.parse( readFileSync( join(process.cwd(), 'packages/chess/src/__fixtures__/parity/kamikaze.json'), 'utf8', ), ) as Record; /** * Build a per-test variant of the kamikaze descriptor where * with-probability's `p` is forced to 1.0. This is the * "always-fires" variant the T84 brief flags as the simpler path * compared to brittle seed-fishing for a "first draw < 0.25" RNG * vector. The original 0.25 contract is pinned by * kamikaze.test.ts (locked-stream-offsets) — we don't need to * reproduce its statistical outcome here. */ function kamikazeAlwaysFires(): Record { const cloned = JSON.parse(JSON.stringify(KAMIKAZE_DESCRIPTOR)) as { id: string; primitives: Array<{ kind: string; params: { primitives: Array<{ kind: string; params: { p: number } }> }; }>; }; // Walk on-capture → with-probability and patch p in place. const onCapture = cloned.primitives[0]!; const withProb = onCapture.params.primitives[0]!; if (withProb.kind !== 'with-probability') { throw new Error( `parity fixture drift: expected on-capture > with-probability, got ${withProb.kind}`, ); } withProb.params.p = 1.0; // Use a distinct id so registry entries don't collide with the // canonical id. Brand-coercion is the same `asCustomModifierId` // path the server uses internally. cloned.id = 'parity:kamikaze__test-p1'; return cloned as unknown as Record; } async function snapshot(page: Page, label: string): Promise { await page.screenshot({ path: join(EVIDENCE_DIR, `${label}.png`), fullPage: true, }); } // --------------------------------------------------------------------------- // 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(); // Wait for the GameClient to install on window — the setup-board frame // travels through it. await page.waitForFunction( () => Boolean( (globalThis as { __paratypeChessClient?: unknown }) .__paratypeChessClient, ), null, { timeout: 5000 }, ); return room; } // --------------------------------------------------------------------------- // `__test__.setup-board` driver // --------------------------------------------------------------------------- interface BoardPlacement { square: string | number; type: 'pawn' | 'knight' | 'bishop' | 'rook' | 'queen' | 'king'; color: 'white' | 'black'; hasMoved?: boolean; handle?: string; } interface BoardHookSpec { pieceHandle?: string; pieceSquare?: string | number; hookAttr: | 'OnMoveHooks' | 'OnCaptureHooks' | 'OnCapturedHooks' | 'OnDamagedHooks' | 'OnPromotionHooks' | 'OnTurnStartHooks' | 'OnTurnEndHooks' | 'OnCheckReceivedHooks' | 'OnCheckDeliveredHooks' | 'OnMovedOntoSquareHooks'; descriptor: unknown; descriptorIdOverride?: string; } interface BoardSetupArgs { code: string; clear?: boolean; clearIncludingKings?: boolean; placements?: BoardPlacement[]; rngSeed?: number; hooks?: BoardHookSpec[]; turn?: 'white' | 'black'; } /** * Send `__test__.setup-board` through the page's existing GameClient * socket. The handler runs server-side, mutates the engine, and emits * a fresh `game.state` snapshot — so the page's own MultiplayerGame * view re-renders the synthetic board automatically. The handle→id * echo (`__test__.board-ready`) is intentionally a no-op at the * client layer (the GameClient's `dispatchServerMessage` ignores * unknown types for forward-compat) — DOM-level assertions are what * the e2e relies on for verification. */ async function setupBoard(page: Page, args: BoardSetupArgs): Promise { await page.evaluate(async (a) => { type Client = { send: (msg: { type: string; payload: unknown }) => void; readonly isConnected?: boolean; }; const getClient = (): Client | undefined => (globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient; const deadline = Date.now() + 3000; let client = getClient(); // Wait for an OPEN GameClient socket. `isConnected` is a getter // (NOT a function) — invoke as a property read. The // `__paratypeChessClient` reference itself can ALSO churn during // React StrictMode unmount→remount, so re-fetch every poll. 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('setupBoard: GameClient never became connected'); } client.send({ type: '__test__.setup-board', payload: { roomCode: a.code, clear: a.clear, clearIncludingKings: a.clearIncludingKings, placements: a.placements, rngSeed: a.rngSeed, hooks: a.hooks, turn: a.turn, }, }); }, args); } /** * Send `game.move` straight through the page's existing GameClient * socket. The drag-based UI path used by `multiplayer.spec.ts` works * fine when the engine state is the FIDE starting position (which * is what the prediction layer's BaseEngine matches at mount time), * but on a synthetic board produced via `__test__.setup-board` the * prediction-layer's getAllLegalMoves can disagree with the * server-side engine on edge cases (e.g. a bishop spawned without * the integration preset's spawn hooks may carry default * `OnMoveHooks` that the prediction's WeakMap-keyed onBeforeMove * snapshot doesn't recognise as the same identity). Going around * the prediction layer eliminates that variability — the test * exercises the SERVER's applyMove + fireOnMoveHooks pipeline, * which is the actual contract under test. */ async function sendMove( page: Page, from: string, to: string, ): Promise { // Poll: GameClient.send is a NO-OP when the underlying WebSocket // isn't OPEN ("Messages sent while the socket is not OPEN are // silently dropped" — client.ts § send). React StrictMode + // reconnect on `/game` mount can leave a ~200ms window where // `__paratypeChessClient` is fresh but the socket is still // connecting. Poll `isConnected` (a GETTER, not a function) until // OPEN, then send. 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 }, ); } // --------------------------------------------------------------------------- // Test 1 — religious_conversion (on-move → for-each-adjacent → set-piece-attr) // --------------------------------------------------------------------------- test('T84/religious_conversion: bishop move converts adjacent enemy non-king pieces to its color', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Configure the synthetic board. Kings preserve their FIDE // starting positions (e1 / e8); we add our own minimal piece set // around the bishop's destination square b6. Server's setup-board // handler emits a fresh `game.state` after applying the placement // set; the client's existing snapshot subscription re-renders the // board, so we don't need to wait for an explicit ack. await setupBoard(page, { code: room.code, clear: true, clearIncludingKings: false, turn: 'white', placements: [ { square: 'd4', type: 'bishop', color: 'white', handle: 'bishop' }, { square: 'a6', type: 'pawn', color: 'white', handle: 'allyA6' }, { square: 'a7', type: 'pawn', color: 'black', handle: 'enemyA7' }, { square: 'b7', type: 'pawn', color: 'black', handle: 'enemyB7' }, { square: 'c7', type: 'pawn', color: 'black', handle: 'enemyC7' }, ], hooks: [ { pieceHandle: 'bishop', hookAttr: 'OnMoveHooks', descriptor: RELIGIOUS_CONVERSION_DESCRIPTOR, }, ], }); // Wait for the snapshot broadcast to land — the bishop should // appear at d4 (its starting FIDE square is c1/f1, so a fresh // d4 placement confirms the broadcast hit). await expect( page.locator('[data-square="d4"] [data-piece="white-bishop"]'), ).toBeVisible({ timeout: 5000 }); await expect( page.locator('[data-square="a7"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="b7"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="c7"] [data-piece="black-pawn"]'), ).toBeVisible(); await snapshot(page, 'religious-pre-move'); // Drive the move via the GameClient's `game.move` send (skipping // the prediction layer — see `sendMove` rationale). The server's // applyMove fires fireOnMoveHooks, which walks the seeded arm, // and broadcasts the resulting game.delta / game.state. await sendMove(page, 'd4', 'b6'); // Bishop landed. await expect( page.locator('[data-square="b6"] [data-piece="white-bishop"]'), ).toBeVisible({ timeout: 5000 }); // Black pawns adjacent to b6 (a7, b7, c7) are NOW white. await expect( page.locator('[data-square="a7"] [data-piece="white-pawn"]'), ).toBeVisible({ timeout: 5000 }); await expect( page.locator('[data-square="b7"] [data-piece="white-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="c7"] [data-piece="white-pawn"]'), ).toBeVisible(); // The white ally at a6 (also adjacent) is unchanged — still a // white pawn. The set-piece-attr Color was a no-op for ally // squares because the descriptor's value resolver pulls // self.Color (white) and the ally was already white. await expect( page.locator('[data-square="a6"] [data-piece="white-pawn"]'), ).toBeVisible(); // Negative pin: NO black pawns remain on a7/b7/c7. await expect( page.locator('[data-square="a7"] [data-piece="black-pawn"]'), ).toHaveCount(0); await expect( page.locator('[data-square="b7"] [data-piece="black-pawn"]'), ).toHaveCount(0); await expect( page.locator('[data-square="c7"] [data-piece="black-pawn"]'), ).toHaveCount(0); await snapshot(page, 'religious-post-conversion'); await ctx.close(); }); // --------------------------------------------------------------------------- // Test 2 — kamikaze (on-capture → with-probability → for-each-adjacent → destroy-piece) // --------------------------------------------------------------------------- test('T84/kamikaze: capture triggers AOE destroying adjacent non-king pieces; king immune', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Setup: full clear (kings retracted too) so we can re-place // both kings explicitly — the test's "AOE-victim" set sits next // to a black king on e5, and we need a clean board to assert // king-immunity without ambiguity from the FIDE starting kings. const descriptor = kamikazeAlwaysFires(); await setupBoard(page, { code: room.code, clear: true, clearIncludingKings: true, turn: 'white', rngSeed: 42, // fixed; with p=1.0 the draw is moot but pinning the // seed makes the test order-independent w.r.t. unrelated // preset RNG draws on the integration onAfterMove path. placements: [ // Re-place both kings — required for engine legality (in-check // detection walks both kings; absent kings throw). { square: 'e1', type: 'king', color: 'white', handle: 'wKing' }, { square: 'h8', type: 'king', color: 'black', handle: 'bKing' }, // The capturing piece + its target. { square: 'e2', type: 'queen', color: 'white', handle: 'queen' }, { square: 'e4', type: 'pawn', color: 'black', handle: 'target' }, // AOE victims — adjacent to e4 (the queen's destination). { square: 'd4', type: 'pawn', color: 'black', handle: 'aoeD4' }, { square: 'f4', type: 'pawn', color: 'black', handle: 'aoeF4' }, // King-immunity test: a SECOND black king adjacent to e4. The // descriptor's filter excludes kings unconditionally, so this // king must survive the AOE. { square: 'e5', type: 'king', color: 'black', handle: 'aoeKing' }, ], hooks: [ { pieceHandle: 'queen', hookAttr: 'OnCaptureHooks', descriptor, descriptorIdOverride: 'parity:kamikaze__test-p1', }, ], }); // Pre-capture board pin. await expect( page.locator('[data-square="e2"] [data-piece="white-queen"]'), ).toBeVisible({ timeout: 5000 }); await expect( page.locator('[data-square="e4"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="d4"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="f4"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="e5"] [data-piece="black-king"]'), ).toBeVisible(); await snapshot(page, 'kamikaze-pre-capture'); // Drive the capture: queen e2 → e4 (e3 is empty). The server's // applyMove fires fireOnCaptureHooks(queen) on the post-move // pass; the inner arm enters with-probability(p=1.0) — always // fires — walks adjacent squares of the queen's NEW position // (e4) with excludeKing+occupied filters, and destroy-piece // retracts every matched id. await sendMove(page, 'e2', 'e4'); // Capture succeeded: queen on e4, the target pawn is gone (its // Position fact retracted by the standard capture pipeline before // the AOE arm runs). await expect( page.locator('[data-square="e4"] [data-piece="white-queen"]'), ).toBeVisible({ timeout: 5000 }); // AOE victims destroyed — d4 and f4 squares no longer hold a piece // (destroy-piece retracts the full piece-identity attribute set). await expect(page.locator('[data-square="d4"] [data-piece]')).toHaveCount( 0, { timeout: 5000 }, ); await expect(page.locator('[data-square="f4"] [data-piece]')).toHaveCount(0); // King immunity — the black king on e5 is STILL there. This is // the load-bearing pin from kamikaze.test.ts § "king never // destroyed". await expect( page.locator('[data-square="e5"] [data-piece="black-king"]'), ).toBeVisible(); await snapshot(page, 'kamikaze-post-aoe'); await ctx.close(); });