/** * Rule-variants epic — vertical slice e2e. * * Three representative scenarios covering the full lobby → game flow * for the 2026 rule-variants epic. These are INTEGRATION smoke tests: * they verify the UI wiring (layout picker, suggested-rules chips, * rules drawer category grouping, per-preset toggle) correctly * plumbs preset activations into the solo engine. They are NOT * chess-logic tests — the unit suites in * packages/chess/src/presets/*.test.ts already exhaustively cover * preset behaviour. * * All three scenarios are solo-play; no WS server required. * * 1. Knightmate: layout-picker chip toggles `knightmate-rules` * which loads on Play Solo without errors. * 2. Double-move: drawer toggle of `double-move`; white plays two * consecutive moves and the turn indicator lands on black only * after the second. * 3. Suicide-chess: drawer toggle of `suicide-chess`; a non-capture * move is NOT offered when a capture is available (compulsory * capture via `filterLegalMoves`). * * Patterns borrowed from: * - solo-smoke.spec.ts (localStorage reset, drag helper, console * error collection). * - modifier-profiles.spec.ts (rules drawer open + preset toggle). */ import { test, expect } from '@playwright/test'; import type { Page } from '@playwright/test'; /** * Reset localStorage so a previous test's preset activations or * profile selections don't leak into this one. Mirrors the * solo-smoke pattern. */ async function resetStorage(page: Page): Promise { await page.goto('/'); await page.evaluate(() => { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k); } }); } /** * Drag a piece from `from` to `to` (algebraic squares). */ async function drag(page: Page, from: string, to: string): Promise { await page .locator(`[data-square="${from}"] [data-piece]`) .dragTo(page.locator(`[data-square="${to}"]`)); // small settle for animation + state commit await page.waitForTimeout(120); } /** * Toggle a preset via the rules drawer inside a GAME VIEW. * * The rules drawer is part of `GameView`, not the lobby — opening * it requires an active game. These scenarios use the pattern: * * 1. Play Solo (get into the game with no presets). * 2. Open the drawer. * 3. Toggle the preset ON (applies immediately to the running * engine). * 4. Close the drawer. * 5. Drive moves with the preset active. */ async function togglePresetInGameDrawer( page: Page, presetId: string, ): Promise { const drawer = page.locator('[data-testid="rules-drawer"]'); const isOpen = await drawer.isVisible().catch(() => false); if (!isOpen) { await page.locator('[data-action="open-rules-drawer"]').click(); await expect(drawer).toBeVisible(); } const toggle = page.locator( `[data-preset="${presetId}"] [data-role="toggle"]`, ); await expect(toggle).toBeVisible(); await toggle.click(); // Give the engine a frame to re-activate with the new preset set. await page.waitForTimeout(120); // Close the drawer so the board is interactable. await page.locator('[data-action="close-rules-drawer"]').click(); await expect(drawer).not.toBeVisible(); } test.describe('F2 post-epic: extinction-chess target cycler', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.evaluate(() => { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k); } }); }); test('cycler renders when extinction-chess is active; cycles through target types', async ({ page, }) => { // Solo game (cycler is solo-only in v1 per plan decision 2a). await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); // Open rules drawer and enable extinction-chess. await page.locator('[data-action="open-rules-drawer"]').click(); const toggle = page.locator( '[data-preset="extinction-chess"] [data-role="toggle"]', ); await expect(toggle).toBeVisible(); await toggle.click(); // Default target is "Pawns" (seeded on activation). const cycler = page.locator('[data-testid="extinction-target-cycler"]'); await expect(cycler).toBeVisible(); await expect(cycler).toHaveText('Pawns'); // Cycle: pawn → knight → bishop → rook → queen → king → pawn. const expected = [ 'Knights', 'Bishops', 'Rooks', 'Queens', 'Kings', 'Pawns', ]; for (const label of expected) { await cycler.click(); await expect(cycler).toHaveText(label); } }); test('cycler is absent when extinction-chess is not active', async ({ page, }) => { await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); await page.locator('[data-action="open-rules-drawer"]').click(); // Drawer open, no preset toggled → no cycler. await expect( page.locator('[data-testid="extinction-target-cycler"]'), ).toHaveCount(0); }); }); test.describe('Rule variants — lobby vertical slice', () => { test.beforeEach(async ({ page }) => { await resetStorage(page); }); // ──────────────────────────────────────────────────────────────── // Scenario 1: Knightmate // ──────────────────────────────────────────────────────────────── test('knightmate: layout chip toggles rules, solo game loads', async ({ page, }) => { const errors: string[] = []; page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`)); page.on('console', (msg) => { if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`); }); // Pick the knightmate layout. await page .locator('[data-testid="layout-picker"]') .selectOption('knightmate'); // The layout's suggestedPresets declares "knightmate-rules". // LayoutPicker renders a chip for it. const chip = page.locator( '[data-testid="layout-suggested-preset-knightmate-rules"]', ); await expect(chip).toBeVisible(); // Activate the preset via the chip. await chip.click(); // Start the solo game. await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game', { timeout: 5000 }); // Board renders; back rank is the knightmate shape (3 knights // per side, no queens). We don't assert piece identity across // every square — just sanity that the board loaded and no // console/page errors fired through the activation path. await expect(page.locator('[data-square="e1"]')).toBeVisible({ timeout: 3000, }); await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible(); // Make a simple move — pawn e2→e4. await drag(page, 'e2', 'e4'); await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000, }); if (errors.length > 0) throw new Error(errors.join('; ')); }); // ──────────────────────────────────────────────────────────────── // Scenario 2: Double-move // ──────────────────────────────────────────────────────────────── test('double-move: white plays two half-moves before the flip', async ({ page, }) => { const errors: string[] = []; page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`)); // Start a solo game first (the drawer lives in GameView). await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); // Open the drawer and verify the multi-move category header // rendered (Phase F.3 grouping). await page.locator('[data-action="open-rules-drawer"]').click(); await expect( page.locator('[data-testid="rules-category-multi-move"]'), ).toBeVisible(); // Toggle double-move ON. Engine re-activates the preset set // immediately — applies to the NEXT move. const toggle = page.locator('[data-preset="double-move"] [data-role="toggle"]'); await expect(toggle).toBeVisible(); await toggle.click(); await page.waitForTimeout(120); await page.locator('[data-action="close-rules-drawer"]').click(); await expect( page.locator('[data-testid="rules-drawer"]'), ).not.toBeVisible(); // White's first half-move: e2 → e4. await drag(page, 'e2', 'e4'); await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000, }); // At this point HalfMovesThisTurn=1 and the engine's // shouldAdvanceTurn hook vetoed the flip → still white's turn. // White plays a second half-move. If it's still white's turn, // a white-owned piece like b1 knight should be movable. await drag(page, 'g1', 'f3'); await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible({ timeout: 3000, }); // Now it should be black's turn. Black plays first half-move: // e7 → e5. If the flip didn't happen, this move would fail // because e7 belongs to black and the engine would reject a // white-mover attempting it (or the drag would be ignored by // the UI gating). await drag(page, 'e7', 'e5'); await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible({ timeout: 3000, }); if (errors.length > 0) throw new Error(errors.join('; ')); }); // ──────────────────────────────────────────────────────────────── // Scenario 3: Suicide-chess (compulsory capture) // ──────────────────────────────────────────────────────────────── test('suicide-chess: compulsory capture hides non-capture moves', async ({ page, }) => { const errors: string[] = []; page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`)); // Play Solo FIRST (drawer lives in GameView). We'll play // without the preset briefly to establish a capture position, // THEN toggle suicide-chess on, THEN verify the compulsion. // // Approach rationale: suicide-chess' `filterLegalMoves` // applies to BOTH sides. Driving a fresh-FIDE game forward // with the preset active-from-start requires both sides to // resolve captures as they arise, which is brittle to script // through the UI. Instead: set up a known capture opportunity // OFF-preset, then flip the preset ON and verify white's // next move is constrained. await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); // Build a position with one clear capture: 1. e4 d5. // Now white's e4 attacks d5 → e4xd5 is the only capture // available to white on ply 3. Other white moves (pawn // pushes, knight developments) are non-captures. await drag(page, 'e2', 'e4'); await drag(page, 'd7', 'd5'); // Now flip suicide-chess ON. With the preset active, // `filterLegalMoves` will restrict white's next move to // captures only — just e4xd5. await togglePresetInGameDrawer(page, 'suicide-chess'); // Try a non-capture: a2 → a3. Should be rejected (board // unchanged). await drag(page, 'a2', 'a3'); await expect(page.locator('[data-square="a2"] [data-piece]')).toBeVisible({ timeout: 2000, }); await expect( page.locator('[data-square="a3"] [data-piece]'), ).toHaveCount(0); // Play the compulsory capture. e4xd5 should succeed — the // pawn moves to d5, captures the black pawn. await drag(page, 'e4', 'd5'); await expect(page.locator('[data-square="d5"] [data-piece]')).toBeVisible({ timeout: 3000, }); // e4 should now be empty. await expect( page.locator('[data-square="e4"] [data-piece]'), ).toHaveCount(0); if (errors.length > 0) throw new Error(errors.join('; ')); }); }); test.describe('F4c post-epic: transferable-royalty UI', () => { test('transfer royalty action popover and selection flow', async ({ page }) => { await page.goto('/'); // Open rules drawer await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); // Open the drawer await page.locator('[data-action="open-rules-drawer"]').click(); await expect(page.locator('[data-testid="rules-drawer"]')).toBeVisible(); // Toggle Transferable Royalty ON const trToggle = page.locator('[data-preset="transferable-royalty"] [data-role="toggle"]'); await expect(trToggle).toBeVisible(); await trToggle.click(); await page.waitForTimeout(120); // Close drawer await page.locator('[data-action="close-rules-drawer"]').click(); await expect(page.locator('[data-testid="rules-drawer"]')).not.toBeVisible(); // Ensure game has started and is on white's turn const turnIndicator = page.getByTestId('turn-indicator'); await expect(turnIndicator).toHaveText("White's turn"); // 3. Assert "Actions" button is visible const actionsMenuBtn = page.getByTestId('actions-menu-button'); await expect(actionsMenuBtn).toBeVisible(); // 4. Click "Actions" -> popover opens await actionsMenuBtn.click(); // 5. Click "Transfer royalty..." item const transferBtn = page.getByTestId('action-transfer-royalty'); await expect(transferBtn).toBeVisible(); await transferBtn.click(); // 6. Hint card appears: "Select the piece whose royalty you want to transfer." await expect(page.getByText('Select the piece whose royalty you want to transfer.')).toBeVisible(); // 7. Click the white king (e1). await page.locator('[data-square="e1"]').click(); // 8. Hint updates: "Select the new royal piece." await expect(page.getByText('Select the new royal piece.')).toBeVisible(); // 9. Click a friendly piece (e.g., the queen at d1) await page.locator('[data-square="d1"]').click(); // 10. Board state updates: Turn flipped (meaning the action was accepted and consumed the turn) await expect(turnIndicator).toHaveText("Black's turn"); // 11. "Transfer royalty..." is now greyed out (already used this game by White) // Black's turn: click actions -> should be enabled for Black since Black hasn't used it. await actionsMenuBtn.click(); const transferBtn2 = page.getByTestId('action-transfer-royalty'); await expect(transferBtn2).toBeVisible(); await expect(transferBtn2).toBeEnabled(); // Play a move for Black to get back to White await drag(page, 'e7', 'e5'); await expect(turnIndicator).toHaveText("White's turn"); // Now White's action should be disabled await actionsMenuBtn.click(); const transferBtn3 = page.getByTestId('action-transfer-royalty'); await expect(transferBtn3).toBeVisible(); await expect(transferBtn3).toBeDisabled(); await expect(page.getByText('Used this game')).toBeVisible(); }); });