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