/** * W5.8 — Playwright e2e for the 3 NEW Wave-5 thressgame-100 economy * recipes shipped by W5.7 (`recipes.ts` lines 2904-3032). * * Coverage shape (mirrors `wave4-topology-pairing.spec.ts`, scaled to * the Wave-5 batch): * * 1. Three LOAD-AND-VALIDATE tests — one per recipe id. Same pattern * as the prior wave specs: open the Custom Modifier Editor → * click Templates → click the recipe's row → * assert the modal closes, the descriptor name matches, and the * validation footer reports "Valid Custom Descriptor". * * 2. Two RUNTIME-BEHAVIOR tests: * * Batch M-A — `tpl-summoning-ritual` (full runtime, success path): * Pre-seed `WhiteScore = 15` by applying a mini-descriptor * containing a single top-level `add-resource(white, 15)`. * `__test__.apply-descriptor` bypasses the validator's * imperative-in-passive gate (per W1.11 learnings) so the * top-level imperative applies cleanly via `applyCustomDescriptor`'s * walker. The walker has no childPrimitives to recurse for * `add-resource`, so it fires exactly once → WhiteScore=15. * * Then apply the real `tpl-summoning-ritual` recipe. Its * activation arm contains `on-rule-activated → spend-resource(...)`. * The walker recurses into `on-rule-activated.childPrimitives()` * (selfRecurse !== true), reaching `spend-resource` — which has * `selfRecurse: true`, so the walker calls its `apply()` ONCE * and stops. Then `fireOnRuleActivatedHooks` fires the arm a * SECOND time (the documented W1.11 walker-double-fire * artifact). Net effect: spend-resource runs twice, each spending * 5 → WhiteScore = 15 - 5 - 5 = 5. The `then` arm's * `place-piece(knight, white, sq=28)` also runs twice, but * place-piece writes Position to a fresh entity each call and * the renderer dedupes by square, so the DOM shows ONE white * knight at e4. * * Assertions: score chip visible with text containing "5"; * a white knight rendered at e4; engine WhiteScore === 5. * * Batch M-B — `tpl-cash-grab` (full runtime, turn-end driven): * Activate the recipe (no pre-seed). The activation arm is * `on-turn-end(both) → add-resource(white,1) + add-resource(black,1)`. * The walker recurses into on-turn-end's children and fires * BOTH add-resource primitives once — so post-apply scores are * white=1, black=1. * * Drive 2 plies (1 white move + 1 black move). Each ply ends * a turn → fires `OnTurnEndHooks` → +1 to each player. Final * scores: white=3, black=3. * * Assertions: score chips visible with non-zero text values * BOTH after activation (1/1) AND after 2 plies (3/3). * * ───────────────────────────────────────────────────────────────────── * Driving infrastructure * ───────────────────────────────────────────────────────────────────── * * Per the precedent set by all prior wave specs, e2e helpers are * duplicated rather than extracted into a shared module — Playwright's * worker model loads each spec in isolation and `e2e/` is in `testMatch` * so a shared module under `e2e/` would itself be treated as a test file. * * Helper script: * `.sisyphus/scripts/run-pw.sh /tmp/ ` (NEVER * set CI=true — it flips reuseExistingServer:false and collides * with the docker compose dev stack on :5173 / :7357). */ import { test, expect, type Browser, type Page } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; // --------------------------------------------------------------------------- // LocalStorage / SessionStorage hygiene keys (mirror prior wave specs) // --------------------------------------------------------------------------- const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1'; const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1'; // --------------------------------------------------------------------------- // The 3 new W5 recipe ids + canonical descriptor.name strings (sourced // from recipes.ts lines 2921-3032). Pinned constants — must stay in sync // with `descriptorForRecipe(id, name, ...)` in recipes.ts. // --------------------------------------------------------------------------- const NEW_RECIPE_IDS = [ 'tpl-treasure-chest', 'tpl-cash-grab', 'tpl-summoning-ritual', ] as const; type NewRecipeId = (typeof NEW_RECIPE_IDS)[number]; const RECIPE_NAMES: Record = { 'tpl-treasure-chest': 'Treasure Chest', 'tpl-cash-grab': 'Cash Grab', 'tpl-summoning-ritual': 'Summoning Ritual', }; // --------------------------------------------------------------------------- // Inline copies of the 2 runtime-tested descriptors. Hard-coded so the // spec doesn't need to import from chess source (the e2e runner doesn't // bundle TS). Drift surfaces as a name-mismatch in load-and-validate. // // MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`: // - tpl-cash-grab (lines 2966-2994) // - tpl-summoning-ritual (lines 2995-3031) // --------------------------------------------------------------------------- const CASH_GRAB_DESCRIPTOR = { type: 'data', id: 'tpl-cash-grab', name: 'Cash Grab', description: 'At every turn end, both players gain +1 score. Simplified passive-income demo of on-turn-end → add-resource (the random-pick-and-compare canonical shape is blocked by the missing `eq` resolver).', version: 1, primitives: [ { kind: 'on-turn-end', params: { color: 'both', primitives: [ { kind: 'add-resource', params: { player: 'white', amount: 1 }, }, { kind: 'add-resource', params: { player: 'black', amount: 1 }, }, ], }, }, ], targetAttrs: [], uiForm: 'primitive-composer', source: 'custom', } as const; const SUMMONING_RITUAL_DESCRIPTOR = { type: 'data', id: 'tpl-summoning-ritual', name: 'Summoning Ritual', description: 'Pay 5 score (from WhiteScore) to summon a white knight at e4. Single-shot on rule activation: spend-resource(white, 5, then=[place-piece(knight, white, 28)]).', version: 1, primitives: [ { kind: 'on-rule-activated', params: { primitives: [ { kind: 'spend-resource', params: { player: 'white', amount: 5, then: [ { kind: 'place-piece', params: { pieceType: 'knight', color: 'white', square: 28, }, }, ], }, }, ], }, }, ], targetAttrs: [], uiForm: 'primitive-composer', source: 'custom', } as const; /** * Mini-descriptor used to pre-seed `WhiteScore = 15` before applying * `tpl-summoning-ritual`. A single top-level `add-resource` would * normally fail the validator's `descriptor.primitives.imperative-in-passive` * gate, but `__test__.apply-descriptor` bypasses validation (W1.11 * learnings § "Imperative-in-passive validator gate is bypassed on * apply-descriptor"). The walker fires `add-resource` exactly once * (no childPrimitives, no on-rule-activated double-fire path). * * 15 (rather than 10) is chosen because the summoning ritual's * spend-resource fires TWICE through the walker-double-fire artifact * (walker descent + fireOnRuleActivatedHooks), each spending 5. So * 15 - 5 - 5 = 5 — the locked target value asserted in the test. */ const SEED_WHITE_SCORE_15_DESCRIPTOR = { type: 'data', id: 'test-seed-white-score-15', name: 'Seed White Score 15', description: 'Test fixture — pre-seeds WhiteScore=15 via top-level add-resource.', version: 1, primitives: [ { kind: 'add-resource', params: { player: 'white', amount: 15 }, }, ], targetAttrs: [], uiForm: 'primitive-composer', source: 'custom', } as const; // --------------------------------------------------------------------------- // Server lifecycle (mirrors wave4-topology-pairing.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; } }); // --------------------------------------------------------------------------- // LOAD-PATH helpers (lobby → profile editor → custom-modifier editor). // Mirrors wave4-topology-pairing.spec.ts. // --------------------------------------------------------------------------- async function freshLobby(page: Page): Promise { await page.goto('/'); await page.evaluate( ({ profileKey, customKey }) => { localStorage.removeItem(profileKey); localStorage.removeItem(customKey); sessionStorage.removeItem('room-code'); sessionStorage.removeItem('room-token'); sessionStorage.removeItem('player-color'); sessionStorage.removeItem('layout-name'); sessionStorage.removeItem('modifier-profile-name'); }, { profileKey: PROFILE_LIBRARY_KEY, customKey: CUSTOM_LIBRARY_KEY }, ); await page.reload(); } async function openProfileEditor(page: Page): Promise { const picker = page.getByTestId('profile-picker'); await expect(picker).toBeVisible(); await picker.selectOption('custom'); await expect( page .getByTestId('per-type-panel-paste') .or(page.locator('[role="dialog"], .fixed.inset-0').first()), ).toBeVisible({ timeout: 3000 }); } async function openCustomModifierEditor(page: Page): Promise { await page.getByTestId('open-custom-modifier-editor').click(); await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({ timeout: 3000, }); } // --------------------------------------------------------------------------- // RUNTIME-PATH helpers (raw WS room creation + apply-descriptor + sendMove). // Mirrors wave4-topology-pairing.spec.ts + parity-rules.spec.ts. // --------------------------------------------------------------------------- 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; } /** * Drive `__test__.apply-descriptor` through the page's existing GameClient * socket. Mirror of parity-rules.spec.ts § applyDescriptor. */ async function applyDescriptor( page: Page, args: { code: string; descriptor: unknown; 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); } /** * Read a session attr via the page's PredictionManager. * GAME_ENTITY = 0 (per `schema.ts` § "GAME_ENTITY: EntityId = 0"). */ async function readGameAttr( 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('readGameAttr: PredictionManager not exposed'); const engine = mgr.getCurrentEngine(); return engine.session.get(a.entityId, a.attr); }, { attr, entityId }, ); } /** * Send `game.move` through the page's connected GameClient. * Cribbed from parity-rules.spec.ts § sendMove. */ 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 }, ); } /** * Open a second context as the black guest in the host's room. Returns * the guest's page handle. Used by the `tpl-cash-grab` runtime test — * the engine enforces turn alternation, so we need a real black peer * to drive the second ply that fires the second turn-end. */ async function joinAsGuest( browser: Browser, roomCode: string, ): Promise<{ page: Page; close: () => Promise }> { const ctxB = await browser.newContext(); const pageB = await ctxB.newPage(); await pageB.goto('http://localhost:5173/'); await pageB.evaluate((c: string) => { 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')); }; }); }, roomCode); await pageB.goto('http://localhost:5173/game'); await expect(pageB.locator('[data-testid="my-color"]')).toContainText( 'black', ); await pageB.waitForFunction( () => Boolean( (globalThis as { __paratypeChessClient?: unknown }) .__paratypeChessClient, ), null, { timeout: 5000 }, ); return { page: pageB, close: async () => { await ctxB.close(); }, }; } // --------------------------------------------------------------------------- // Test suite — 3 load-and-validate + 2 runtime // --------------------------------------------------------------------------- test.describe('W5.8 — Wave-5 thressgame-100 economy recipes (3 load + 2 runtime)', () => { // ── 3 LOAD-AND-VALIDATE tests ──────────────────────────────────────── for (const id of NEW_RECIPE_IDS) { test(`loads ${id} into the editor without error`, async ({ page }) => { const pageErrors: Error[] = []; page.on('pageerror', (err) => pageErrors.push(err)); await freshLobby(page); await openProfileEditor(page); await openCustomModifierEditor(page); // Open the templates modal. await page.getByTestId('custom-templates').click(); await expect(page.getByTestId('custom-templates-modal')).toBeVisible(); // Click the recipe's row. await page.getByTestId(`custom-template-${id}`).click(); // Modal closes after pick. await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0); // The descriptor name field reflects the recipe's `descriptor.name`. await expect( page.locator('input[placeholder="Modifier Name"]'), ).toHaveValue(RECIPE_NAMES[id]); // Footer reports the descriptor as VALID. await expect(page.getByText('Valid Custom Descriptor')).toBeVisible(); // Page-side runtime check. expect(pageErrors).toEqual([]); }); } // ── RUNTIME 1 (Batch M-A) — tpl-summoning-ritual (success-path full runtime) ── // // Pre-seed WhiteScore=15 via mini-descriptor (top-level add-resource; // apply-descriptor bypasses validator). The walker fires it once → // WhiteScore=15. // // Then apply tpl-summoning-ritual. The walker recurses into // on-rule-activated.childPrimitives() and reaches spend-resource // (selfRecurse=true → walker calls apply ONCE and stops). // fireOnRuleActivatedHooks then fires the arm AGAIN (the documented // W1.11 walker-double-fire artifact for trigger primitives). // // Net: spend-resource fires twice, each spending 5 → WhiteScore = // 15 - 5 - 5 = 5. place-piece(knight, white, 28) also fires twice // but place-piece writes Position to a fresh entity each call and // the renderer dedupes by square — DOM shows ONE white knight at e4. // // Assertions: engine WhiteScore === 5; white knight rendered at e4; // score chip visible with text containing "5". test('tpl-summoning-ritual: pre-seed 15 → spend twice → WhiteScore=5, knight at e4, chip shows 5', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const pageErrors: Error[] = []; page.on('pageerror', (err) => pageErrors.push(err)); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Pre-state: e4 empty, scores zero, no economy chip rendered. await expect(page.locator('[data-square="e4"] [data-piece]')).toHaveCount( 0, ); expect(await readGameAttr(page, 'WhiteScore', 0)).toBe(0); await expect(page.getByTestId('score-white')).toHaveCount(0); // Pre-seed WhiteScore=15. await applyDescriptor(page, { code: room.code, descriptor: SEED_WHITE_SCORE_15_DESCRIPTOR, }); await expect .poll(async () => readGameAttr(page, 'WhiteScore', 0), { timeout: 3000, }) .toBe(15); // Now apply the real recipe. await applyDescriptor(page, { code: room.code, descriptor: SUMMONING_RITUAL_DESCRIPTOR, }); // Post-apply: spend-resource ran twice (walker double-fire), // each spending 5 → WhiteScore = 15 - 5 - 5 = 5. await expect .poll(async () => readGameAttr(page, 'WhiteScore', 0), { timeout: 5000, }) .toBe(5); // White knight rendered at e4 (DOM dedupes the double-spawn). await expect( page.locator('[data-square="e4"] [data-piece="white-knight"]'), ).toHaveCount(1, { timeout: 5000 }); // Score chip visible with text containing "5". The chip renders // as `⚪ 5` — toContainText absorbs the emoji + whitespace. const chip = page.getByTestId('score-white'); await expect(chip).toBeVisible(); await expect(chip).toContainText('5'); expect(pageErrors).toEqual([]); await ctx.close(); }); // ── RUNTIME 2 (Batch M-B) — tpl-cash-grab (turn-end driven full runtime) ── // // Activate the recipe. The activation arm is on-turn-end(both) → // add-resource(white,1) + add-resource(black,1). The walker recurses // into on-turn-end's children (no selfRecurse) and fires both // add-resource primitives once at apply-time → post-apply scores // are white=1, black=1. // // Drive 2 plies (white plays, then black plays). Each ply ends a // turn → fires OnTurnEndHooks → +1 to each player. Final scores: // white=3, black=3. // // Assertions: score chips visible with non-zero text values BOTH // post-apply (1/1) AND post-2-plies (3/3). test('tpl-cash-grab: activation seeds OnTurnEndHooks; 2 plies bump scores via on-turn-end', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const pageErrors: Error[] = []; page.on('pageerror', (err) => pageErrors.push(err)); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Open black guest so the engine accepts a 2nd ply. const guest = await joinAsGuest(browser, room.code); // Pre-state: scores zero, chips hidden. expect(await readGameAttr(page, 'WhiteScore', 0)).toBe(0); expect(await readGameAttr(page, 'BlackScore', 0)).toBe(0); await expect(page.getByTestId('score-white')).toHaveCount(0); await expect(page.getByTestId('score-black')).toHaveCount(0); // Activate against the white king (e1, sq=4). `on-turn-end` is // a per-PIECE trigger family — `fireOnTurnEndHooks` iterates // pieces and reads `OnTurnEndHooks` from each piece's id. // Seeding on GAME_ENTITY (the default apply-target when no // targetSquare is supplied) would never fire. Same precedent // as parity-rules.spec.ts § all_on_red, which targets sq=4 for // the white-king-rooted on-turn-start descriptor. await applyDescriptor(page, { code: room.code, descriptor: CASH_GRAB_DESCRIPTOR, targetSquare: 4, }); // Find the white king's piece-id so we can verify the hook // landed on the right entity. const kingIdAttr = await page .locator('[data-square="e1"] [data-piece-id]') .first() .getAttribute('data-piece-id'); expect(kingIdAttr).not.toBeNull(); const kingId = Number(kingIdAttr); // Post-apply: walker has fired the 2 add-resource leaves once // each as it recursed into on-turn-end's children → 1/1. await expect .poll(async () => readGameAttr(page, 'WhiteScore', 0), { timeout: 3000, }) .toBe(1); await expect .poll(async () => readGameAttr(page, 'BlackScore', 0), { timeout: 3000, }) .toBe(1); // OnTurnEndHooks must be seeded on the white king for the // dispatcher to fire on subsequent turn-ends. const hooks = await readGameAttr(page, 'OnTurnEndHooks', kingId); expect(Array.isArray(hooks)).toBe(true); expect((hooks as unknown[]).length).toBeGreaterThan(0); // Score chips visible (both non-zero now). const whiteChip = page.getByTestId('score-white'); const blackChip = page.getByTestId('score-black'); await expect(whiteChip).toBeVisible(); await expect(blackChip).toBeVisible(); await expect(whiteChip).toContainText('1'); await expect(blackChip).toContainText('1'); // Drive 2 plies. White moves a-pawn forward (white turn → black // turn), then black moves a-pawn forward (black turn → white // turn). Each move triggers a turn-end fire of the cash-grab arm. await sendMove(page, 'a2', 'a3'); await page.waitForTimeout(150); // settle game.delta + dispatcher fire await sendMove(guest.page, 'a7', 'a6'); await page.waitForTimeout(150); // After 2 turn-ends, scores must have advanced from 1 to 3 each. await expect .poll(async () => readGameAttr(page, 'WhiteScore', 0), { timeout: 5000, }) .toBe(3); await expect .poll(async () => readGameAttr(page, 'BlackScore', 0), { timeout: 5000, }) .toBe(3); // Chips reflect the new values. await expect(whiteChip).toContainText('3'); await expect(blackChip).toContainText('3'); expect(pageErrors).toEqual([]); await guest.close(); await ctx.close(); }); });