# Duplicate Chess Sandbox — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a local, single-operator browser sandbox for "duplicate chess" — a four-player coupled-board chess variant — that enforces the synchronized-move rule, renders ghosts, shows the move-legality intersection, and detects the endgame. **Architecture:** A single Vite + Svelte 5 + TypeScript app, no server. A pure, DOM-free engine (`src/engine/`) holds four `chess.js` games and derives a player's legal moves as the intersection of the moves legal on their two boards. The Svelte UI (`src/lib/`) renders the four boards in a pinwheel "compass" and is driven entirely by the engine. **Tech Stack:** Vite, Svelte 5 (runes), TypeScript, `chess.js` (per-board orthodox chess), `vitest` (engine tests). Full design: `docs/superpowers/specs/2026-05-19-duplicate-chess-design.md`. --- ## File structure ``` duplicate_chess/ package.json, tsconfig.json, vite.config.ts, svelte.config.js, index.html src/ engine/ types.ts shared engine types (no runtime code) boards.ts board/player/turn-order constant maps game.ts DuplicateGame: 4 chess.js, history, move application, counters legality.ts legalSyncedMoves + selectionHighlight (the intersection) ghosts.ts ghost derivation endgame.ts checkmate / stalemate / threefold / fifty-move detection notation.ts coordinate notation + save/load JSON test-helpers.ts playSymmetric() shared by tests *.test.ts co-located vitest specs lib/ stores/game.svelte.ts reactive store wrapping the engine Board.svelte one rotatable board (pieces, highlights, ghosts, clicks) Compass.svelte the four-board pinwheel + player labels Panel.svelte turn indicator, move log, legend, controls PromotionDialog.svelte promotion piece picker App.svelte app.css global styles + CSS variables main.ts ``` Dependency direction (no cycles): `legality`/`ghosts`/`endgame`/`notation` → `game` → `boards` → `types`. --- ## Task 1: Project scaffold **Files:** - Create: `package.json`, `vite.config.ts`, `tsconfig.json`, `svelte.config.js`, `index.html`, `src/main.ts`, `src/App.svelte`, `src/app.css`, `src/vite-env.d.ts` - [ ] **Step 1: Scaffold the Vite + Svelte + TS project** Run from `/home/claude/bin/duplicate_chess`: ```bash pnpm create vite@latest . --template svelte-ts pnpm add chess.js pnpm add -D vitest ``` If `pnpm create vite` refuses because the directory is not empty, scaffold into a temp subdir and move files: ```bash pnpm create vite@latest .vite-tmp --template svelte-ts cp -r .vite-tmp/. . && rm -rf .vite-tmp pnpm add chess.js && pnpm add -D vitest ``` - [ ] **Step 2: Add a vitest script and config** Edit `package.json` — add to `"scripts"`: `"test": "vitest run"`, `"test:watch": "vitest"`. Create `vitest.config.ts`: ```ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'] }, }); ``` - [ ] **Step 3: Replace the scaffold's demo content with a placeholder** Replace `src/App.svelte` entirely: ```svelte

Duplicate Chess

Sandbox under construction.

``` Replace `src/app.css` with an empty file for now (real styles arrive in Task 14). Ensure `src/main.ts` imports `./app.css` and mounts `App` (the scaffold already does this — leave it). - [ ] **Step 4: Verify build, typecheck, and test all run clean** ```bash pnpm run build pnpm exec svelte-check --tsconfig ./tsconfig.json pnpm test ``` Expected: build succeeds; `svelte-check` reports 0 errors; `vitest` reports "No test files found" (exit 0) — that is fine, tests arrive next. - [ ] **Step 5: Commit** ```bash git add -A git commit -m "chore: scaffold Vite + Svelte 5 + TS project with chess.js and vitest" ``` --- ## Task 2: Engine types and constants **Files:** - Create: `src/engine/types.ts`, `src/engine/boards.ts` - Test: `src/engine/boards.test.ts` - [ ] **Step 1: Write `src/engine/types.ts`** (pure types — no test needed) ```ts export type BoardId = 'NW' | 'NE' | 'SW' | 'SE'; export type Player = 'N' | 'S' | 'E' | 'W'; export type Color = 'w' | 'b'; export type Square = string; export type PromotionPiece = 'q' | 'r' | 'b' | 'n'; export interface SyncMove { from: Square; to: Square; promotion?: PromotionPiece; } export interface HistoryEntry extends SyncMove { player: Player; } export interface GhostMarker { board: BoardId; square: Square; } export type PlayerResult = 'win' | 'draw' | 'loss'; export type GameResult = Record; export type GameState = 'playing' | 'checkmate' | 'stalemate' | 'draw'; export type DrawReason = 'stalemate' | 'threefold' | 'fifty-move' | 'manual'; export interface GameStatus { state: GameState; /** Present when state !== 'playing'. */ result?: GameResult; /** Present for a draw/stalemate. */ reason?: DrawReason; /** Boards on which the player to move is currently in check. */ checks: BoardId[]; } ``` - [ ] **Step 2: Write the failing test `src/engine/boards.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { BOARD_IDS, PLAYERS, PLAYER_BOARDS, PLAYER_COLOR, BOARD_PLAYERS, BOARD_ROTATION } from './boards'; describe('boards constants', () => { it('lists four boards and four players in turn order', () => { expect(BOARD_IDS).toEqual(['NW', 'NE', 'SW', 'SE']); expect(PLAYERS).toEqual(['N', 'S', 'E', 'W']); }); it('each player controls exactly two boards', () => { for (const p of PLAYERS) expect(PLAYER_BOARDS[p]).toHaveLength(2); expect(PLAYER_BOARDS.N).toEqual(['NW', 'NE']); expect(PLAYER_BOARDS.W).toEqual(['NW', 'SW']); }); it('board players are consistent with player boards', () => { for (const b of BOARD_IDS) { const { w, b: black } = BOARD_PLAYERS[b]; expect(PLAYER_BOARDS[w]).toContain(b); expect(PLAYER_BOARDS[black]).toContain(b); expect(PLAYER_COLOR[w]).toBe('w'); expect(PLAYER_COLOR[black]).toBe('b'); } }); it('has a rotation for every board', () => { expect(BOARD_ROTATION).toEqual({ NW: 225, NE: 135, SW: 315, SE: 45 }); }); }); ``` - [ ] **Step 3: Run the test to verify it fails** Run: `pnpm test` Expected: FAIL — `boards.ts` does not exist. - [ ] **Step 4: Write `src/engine/boards.ts`** ```ts import type { BoardId, Player, Color } from './types'; export const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE']; /** Turn order. */ export const PLAYERS: Player[] = ['N', 'S', 'E', 'W']; /** The two boards each player controls (order is stable: [boardA, boardB]). */ export const PLAYER_BOARDS: Record = { N: ['NW', 'NE'], S: ['SW', 'SE'], E: ['NE', 'SE'], W: ['NW', 'SW'], }; /** The colour each player plays on both their boards. */ export const PLAYER_COLOR: Record = { N: 'w', S: 'w', E: 'b', W: 'b', }; /** The white and black player of each board. */ export const BOARD_PLAYERS: Record = { NW: { w: 'N', b: 'W' }, NE: { w: 'N', b: 'E' }, SW: { w: 'S', b: 'W' }, SE: { w: 'S', b: 'E' }, }; /** Compass rotation in degrees for rendering each board (see spec §5.1). */ export const BOARD_ROTATION: Record = { NW: 225, NE: 135, SW: 315, SE: 45, }; ``` - [ ] **Step 5: Run the test to verify it passes, then commit** Run: `pnpm test` Expected: PASS (4 tests). ```bash git add src/engine/types.ts src/engine/boards.ts src/engine/boards.test.ts git commit -m "feat(engine): board/player constant maps and shared types" ``` --- ## Task 3: DuplicateGame core **Files:** - Create: `src/engine/game.ts`, `src/engine/test-helpers.ts` - Test: `src/engine/game.test.ts` `DuplicateGame` holds four `chess.js` games, the move history, and the two draw-clock counters. It is the single owner of game state. - [ ] **Step 1: Write `src/engine/test-helpers.ts`** (shared by later tests) ```ts import type { DuplicateGame } from './game'; import type { SyncMove } from './types'; /** * Apply a list of [whiteMove, blackMove?] underlying ply-pairs symmetrically: * North then South play the white move, East then West play the black move. * While every move is symmetric all four boards stay identical, so each board * behaves as an ordinary chess game — useful for reaching ordinary checkmate / * stalemate / repetition positions. Omit blackMove for a final unanswered white move. */ export function playSymmetric( game: DuplicateGame, pairs: Array<[SyncMove, SyncMove?]>, ): void { for (const [white, black] of pairs) { game.applyMove(white); // N game.applyMove(white); // S if (black) { game.applyMove(black); // E game.applyMove(black); // W } } } ``` - [ ] **Step 2: Write the failing test `src/engine/game.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { DuplicateGame } from './game'; const START = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR'; function placement(fen: string) { return fen.split(' ')[0]; } describe('DuplicateGame', () => { it('starts with four boards in the standard position, North to move', () => { const g = new DuplicateGame(); expect(g.ply).toBe(0); expect(g.currentPlayer).toBe('N'); for (const id of ['NW', 'NE', 'SW', 'SE'] as const) { expect(placement(g.boards[id].fen())).toBe(START); } }); it('applies a synchronized move to the current player\'s two boards only', () => { const g = new DuplicateGame(); g.applyMove({ from: 'e2', to: 'e4' }); // North expect(g.ply).toBe(1); expect(g.currentPlayer).toBe('S'); expect(placement(g.boards.NW.fen())).toContain('4P3'); // pawn advanced expect(placement(g.boards.NE.fen())).toContain('4P3'); expect(placement(g.boards.SW.fen())).toBe(START); // untouched expect(placement(g.boards.SE.fen())).toBe(START); }); it('cycles the current player N -> S -> E -> W -> N', () => { const g = new DuplicateGame(); g.applyMove({ from: 'e2', to: 'e4' }); // N g.applyMove({ from: 'e2', to: 'e4' }); // S g.applyMove({ from: 'e7', to: 'e5' }); // E g.applyMove({ from: 'e7', to: 'e5' }); // W expect(g.currentPlayer).toBe('N'); expect(g.ply).toBe(4); }); it('throws on a move not legal on both of the player\'s boards', () => { const g = new DuplicateGame(); expect(() => g.applyMove({ from: 'e2', to: 'e5' })).toThrow(); }); it('undo removes the last move and restores the position', () => { const g = new DuplicateGame(); g.applyMove({ from: 'e2', to: 'e4' }); g.undo(); expect(g.ply).toBe(0); expect(g.currentPlayer).toBe('N'); expect(placement(g.boards.NW.fen())).toBe(START); }); it('rebuilds from a history array passed to the constructor', () => { const g = new DuplicateGame([ { player: 'N', from: 'e2', to: 'e4' }, { player: 'S', from: 'e2', to: 'e4' }, ]); expect(g.ply).toBe(2); expect(g.currentPlayer).toBe('E'); }); it('resets the progress clock on a pawn move or capture', () => { const g = new DuplicateGame(); g.applyMove({ from: 'e2', to: 'e4' }); // pawn move -> clock stays 0 expect(g.pliesSinceProgress).toBe(0); g.applyMove({ from: 'e2', to: 'e4' }); // pawn move expect(g.pliesSinceProgress).toBe(0); g.applyMove({ from: 'g8', to: 'f6' }); // knight move -> clock increments expect(g.pliesSinceProgress).toBe(1); }); }); ``` - [ ] **Step 3: Run the test to verify it fails** Run: `pnpm test` Expected: FAIL — `game.ts` does not exist. - [ ] **Step 4: Write `src/engine/game.ts`** ```ts import { Chess } from 'chess.js'; import type { BoardId, Player, SyncMove, HistoryEntry } from './types'; import { BOARD_IDS, PLAYERS, PLAYER_BOARDS } from './boards'; /** A chess.js move result has these fields we rely on. */ interface ChessMoveResult { piece: string; captured?: string; } export class DuplicateGame { readonly boards: Record; readonly history: HistoryEntry[] = []; /** Plies since the last capture or pawn move on any board (for the 50-move rule). */ pliesSinceProgress = 0; /** Repetition keys of the whole 4-board system, one per position incl. the start. */ readonly repetitionKeys: string[] = []; constructor(history: HistoryEntry[] = []) { this.boards = { NW: new Chess(), NE: new Chess(), SW: new Chess(), SE: new Chess(), }; this.repetitionKeys.push(this.systemKey()); for (const entry of history) this.applyMove(entry, entry.player); } get ply(): number { return this.history.length; } get currentPlayer(): Player { return PLAYERS[this.history.length % 4]; } /** Whether `move` is a legal chess move on `board` in the current position. */ isLegalOnBoard(board: BoardId, move: SyncMove): boolean { return this.boards[board].moves({ verbose: true }).some( (m) => m.from === move.from && m.to === move.to && (m.promotion ?? undefined) === (move.promotion ?? undefined), ); } /** Apply one synchronized move to a player's two boards. Throws if illegal on either. */ applyMove(move: SyncMove, player: Player = this.currentPlayer): void { const [a, b] = PLAYER_BOARDS[player]; if (!this.isLegalOnBoard(a, move) || !this.isLegalOnBoard(b, move)) { throw new Error( `Illegal synchronized move ${move.from}${move.to} for ${player}`, ); } const ra = this.boards[a].move(move) as unknown as ChessMoveResult; const rb = this.boards[b].move(move) as unknown as ChessMoveResult; this.history.push({ ...move, player }); const progress = isProgress(ra) || isProgress(rb); this.pliesSinceProgress = progress ? 0 : this.pliesSinceProgress + 1; this.repetitionKeys.push(this.systemKey()); } /** Remove the last move by replaying the truncated history. */ undo(): void { if (this.history.length === 0) return; const replay = this.history.slice(0, -1); for (const id of BOARD_IDS) this.boards[id].reset(); this.history.length = 0; this.pliesSinceProgress = 0; this.repetitionKeys.length = 0; this.repetitionKeys.push(this.systemKey()); for (const e of replay) this.applyMove(e, e.player); } /** A repetition key for the whole system: each board's placement+castling+ep, plus the side to move. */ systemKey(): string { return ( BOARD_IDS.map((id) => { const parts = this.boards[id].fen().split(' '); return `${parts[0]}${parts[2]}${parts[3]}`; }).join('|') + ':' + this.currentPlayer ); } /** How many times the current system position has occurred. */ repetitionCount(): number { const current = this.repetitionKeys[this.repetitionKeys.length - 1]; return this.repetitionKeys.filter((k) => k === current).length; } } function isProgress(result: ChessMoveResult): boolean { return result.piece === 'p' || result.captured != null; } ``` - [ ] **Step 5: Run the test to verify it passes, then commit** Run: `pnpm test` Expected: PASS (game tests + boards tests). ```bash git add src/engine/game.ts src/engine/test-helpers.ts src/engine/game.test.ts git commit -m "feat(engine): DuplicateGame core — boards, history, undo, draw clocks" ``` --- ## Task 4: Synchronized-move legality **Files:** - Create: `src/engine/legality.ts` - Test: `src/engine/legality.test.ts` - [ ] **Step 1: Write the failing test `src/engine/legality.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { DuplicateGame } from './game'; import { legalSyncedMoves, selectionHighlight } from './legality'; describe('legalSyncedMoves', () => { it('returns all 20 white opening moves when a player\'s two boards are identical', () => { const g = new DuplicateGame(); expect(legalSyncedMoves(g)).toHaveLength(20); }); it('excludes a move legal on only one of the player\'s boards', () => { // N e2e4, S e2e4, E e7e5, W d7d5 -> NW black has pe5, NE black has pd5. const g = new DuplicateGame([ { player: 'N', from: 'e2', to: 'e4' }, { player: 'S', from: 'e2', to: 'e4' }, { player: 'E', from: 'e7', to: 'e5' }, { player: 'W', from: 'd7', to: 'd5' }, ]); expect(g.currentPlayer).toBe('N'); const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`); // e4-d5 is a capture on NE but illegal on NW (d5 empty there): not synced. expect(keys).not.toContain('e4d5'); // e4-e5 is legal on NE but blocked on NW (black pawn on e5): not synced. expect(keys).not.toContain('e4e5'); }); }); describe('selectionHighlight', () => { it('marks every destination playable when the two boards agree', () => { const g = new DuplicateGame(); const h = selectionHighlight(g, 'e2'); expect(h.playable.sort()).toEqual(['e3', 'e4']); expect(h.onlyA).toEqual([]); expect(h.onlyB).toEqual([]); }); it('splits destinations into playable vs board-local-only on divergence', () => { const g = new DuplicateGame([ { player: 'N', from: 'e2', to: 'e4' }, { player: 'S', from: 'e2', to: 'e4' }, { player: 'E', from: 'e7', to: 'e5' }, { player: 'W', from: 'd7', to: 'd5' }, ]); const h = selectionHighlight(g, 'e4'); // North's e4 pawn expect(h.boardA).toBe('NW'); expect(h.boardB).toBe('NE'); expect(h.playable).toEqual([]); // nothing legal on both expect(h.onlyA).toEqual([]); // e4 is blocked on NW expect(h.onlyB.sort()).toEqual(['d5', 'e5']); // capture + advance on NE only }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm test` Expected: FAIL — `legality.ts` does not exist. - [ ] **Step 3: Write `src/engine/legality.ts`** ```ts import type { DuplicateGame } from './game'; import type { SyncMove, Square, BoardId, PromotionPiece } from './types'; import { PLAYER_BOARDS } from './boards'; function key(m: { from: string; to: string; promotion?: string }): string { return `${m.from}${m.to}${m.promotion ?? ''}`; } /** Every synchronized-legal move for the player to move (the intersection). */ export function legalSyncedMoves(game: DuplicateGame): SyncMove[] { const [a, b] = PLAYER_BOARDS[game.currentPlayer]; const movesA = game.boards[a].moves({ verbose: true }); const keysB = new Set(game.boards[b].moves({ verbose: true }).map(key)); const seen = new Set(); const result: SyncMove[] = []; for (const m of movesA) { const k = key(m); if (keysB.has(k) && !seen.has(k)) { seen.add(k); result.push({ from: m.from, to: m.to, promotion: (m.promotion as PromotionPiece) || undefined, }); } } return result; } export interface SelectionHighlight { /** The current player's first board. */ boardA: BoardId; /** The current player's second board. */ boardB: BoardId; /** Destinations legal on BOTH boards (actually playable). */ playable: Square[]; /** Destinations legal on board A only. */ onlyA: Square[]; /** Destinations legal on board B only. */ onlyB: Square[]; } /** Triple-highlight data for the current player's piece grabbed at `from`. */ export function selectionHighlight( game: DuplicateGame, from: Square, ): SelectionHighlight { const [a, b] = PLAYER_BOARDS[game.currentPlayer]; const destA = new Set( game.boards[a].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to), ); const destB = new Set( game.boards[b].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to), ); const playable: Square[] = []; const onlyA: Square[] = []; const onlyB: Square[] = []; for (const sq of destA) (destB.has(sq) ? playable : onlyA).push(sq); for (const sq of destB) if (!destA.has(sq)) onlyB.push(sq); return { boardA: a, boardB: b, playable, onlyA, onlyB }; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `pnpm test` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/engine/legality.ts src/engine/legality.test.ts git commit -m "feat(engine): synchronized-move intersection and selection highlight" ``` --- ## Task 5: Ghost derivation **Files:** - Create: `src/engine/ghosts.ts` - Test: `src/engine/ghosts.test.ts` - [ ] **Step 1: Write the failing test `src/engine/ghosts.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { DuplicateGame } from './game'; import { playSymmetric } from './test-helpers'; import { ghosts } from './ghosts'; describe('ghosts', () => { it('reports no ghosts at the start', () => { expect(ghosts(new DuplicateGame())).toEqual([]); }); it('forms a ghost when a piece is captured on one board but not its twin', () => { const g = new DuplicateGame(); // Symmetric opening so all four boards stay identical... playSymmetric(g, [ [{ from: 'e2', to: 'e4' }, { from: 'e7', to: 'e5' }], [{ from: 'g1', to: 'f3' }, { from: 'b8', to: 'c6' }], ]); // ...then North & South each play Nxe5 (capturing the e5 pawn), // and East captures that knight only on its boards (NE, SE). g.applyMove({ from: 'f3', to: 'e5' }); // N: Nf3xe5 on NW, NE g.applyMove({ from: 'f3', to: 'e5' }); // S: Nf3xe5 on SW, SE g.applyMove({ from: 'c6', to: 'e5' }); // E: Nc6xe5 on NE, SE — captures the white knight // North's knight on e5 survives on NW but was captured on NE -> NW/e5 is a ghost. // South's knight on e5 survives on SW but was captured on SE -> SW/e5 is a ghost. const result = ghosts(g).sort((x, y) => (x.board < y.board ? -1 : 1)); expect(result).toEqual([ { board: 'NW', square: 'e5' }, { board: 'SW', square: 'e5' }, ]); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm test` Expected: FAIL — `ghosts.ts` does not exist. - [ ] **Step 3: Write `src/engine/ghosts.ts`** ```ts import type { DuplicateGame } from './game'; import type { GhostMarker, BoardId, Color, Square } from './types'; import { PLAYERS, PLAYER_BOARDS, PLAYER_COLOR } from './boards'; /** Squares occupied by a piece of `color` on `board`. */ function colorSquares(game: DuplicateGame, board: BoardId, color: Color): Set { const set = new Set(); for (const row of game.boards[board].board()) { for (const cell of row) { if (cell && cell.color === color) set.add(cell.square); } } return set; } /** * Ghosts across all four players. A player's non-ghost pieces always occupy * identical squares on both their boards (they move in lockstep), so a piece is * a ghost iff the player's other board has no same-colour piece on that square. */ export function ghosts(game: DuplicateGame): GhostMarker[] { const markers: GhostMarker[] = []; for (const player of PLAYERS) { const [a, b] = PLAYER_BOARDS[player]; const color = PLAYER_COLOR[player]; const sqA = colorSquares(game, a, color); const sqB = colorSquares(game, b, color); for (const sq of sqA) if (!sqB.has(sq)) markers.push({ board: a, square: sq }); for (const sq of sqB) if (!sqA.has(sq)) markers.push({ board: b, square: sq }); } return markers; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `pnpm test` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/engine/ghosts.ts src/engine/ghosts.test.ts git commit -m "feat(engine): ghost derivation from cross-board piece comparison" ``` --- ## Task 6: Endgame detection **Files:** - Create: `src/engine/endgame.ts` - Test: `src/engine/endgame.test.ts` Implements the provisional rules from spec §6. - [ ] **Step 1: Write the failing test `src/engine/endgame.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { DuplicateGame } from './game'; import { playSymmetric } from './test-helpers'; import { evaluateStatus } from './endgame'; describe('evaluateStatus', () => { it('reports an ongoing game at the start', () => { const s = evaluateStatus(new DuplicateGame()); expect(s.state).toBe('playing'); expect(s.checks).toEqual([]); }); it('detects a double-board checkmate (Fool\'s mate, played symmetrically)', () => { const g = new DuplicateGame(); playSymmetric(g, [ [{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }], [{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }], ]); expect(g.currentPlayer).toBe('N'); // North (White) is mated const s = evaluateStatus(g); expect(s.state).toBe('checkmate'); expect(s.checks.sort()).toEqual(['NE', 'NW']); expect(s.result).toEqual({ N: 'loss', S: 'draw', E: 'win', W: 'win' }); }); it('detects threefold repetition of the whole system', () => { const g = new DuplicateGame(); const cycle: Array<[{ from: string; to: string }, { from: string; to: string }]> = [ [{ from: 'g1', to: 'f3' }, { from: 'g8', to: 'f6' }], [{ from: 'f3', to: 'g1' }, { from: 'f6', to: 'g8' }], ]; playSymmetric(g, cycle); // back to start (occurrence 2) playSymmetric(g, cycle); // back to start (occurrence 3) const s = evaluateStatus(g); expect(s.state).toBe('draw'); expect(s.reason).toBe('threefold'); expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' }); }); it('detects a stalemate as an all-draw game end (provisional rule)', () => { const g = new DuplicateGame(); // The known fastest stalemate, played symmetrically on all four boards. playSymmetric(g, [ [{ from: 'e2', to: 'e3' }, { from: 'a7', to: 'a5' }], [{ from: 'd1', to: 'h5' }, { from: 'a8', to: 'a6' }], [{ from: 'h5', to: 'a5' }, { from: 'h7', to: 'h5' }], [{ from: 'a5', to: 'c7' }, { from: 'a6', to: 'h6' }], [{ from: 'h2', to: 'h4' }, { from: 'f7', to: 'f6' }], [{ from: 'c7', to: 'd7' }, { from: 'e8', to: 'f7' }], [{ from: 'd7', to: 'b7' }, { from: 'd8', to: 'd3' }], [{ from: 'b7', to: 'b8' }, { from: 'd3', to: 'h7' }], [{ from: 'b8', to: 'c8' }, { from: 'f7', to: 'g6' }], [{ from: 'c8', to: 'e6' }], // no black reply — Black is stalemated ]); expect(g.currentPlayer).toBe('E'); // a Black player, with no move const s = evaluateStatus(g); expect(s.state).toBe('stalemate'); expect(s.reason).toBe('stalemate'); expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' }); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm test` Expected: FAIL — `endgame.ts` does not exist. - [ ] **Step 3: Write `src/engine/endgame.ts`** ```ts import type { DuplicateGame } from './game'; import type { GameStatus, GameResult, BoardId } from './types'; import { PLAYERS, PLAYER_BOARDS, BOARD_PLAYERS } from './boards'; import { legalSyncedMoves } from './legality'; /** PROVISIONAL (spec §6): the 50-move rule fires after this many rounds. */ const FIFTY_MOVE_ROUNDS = 50; const FIFTY_MOVE_PLIES = FIFTY_MOVE_ROUNDS * 4; function allDraw(): GameResult { return { N: 'draw', S: 'draw', E: 'draw', W: 'draw' }; } /** Evaluate the game from the perspective of the player to move. */ export function evaluateStatus(game: DuplicateGame): GameStatus { const player = game.currentPlayer; const [a, b] = PLAYER_BOARDS[player]; const checks: BoardId[] = []; if (game.boards[a].inCheck()) checks.push(a); if (game.boards[b].inCheck()) checks.push(b); const synced = legalSyncedMoves(game); if (synced.length === 0) { if (checks.length > 0) { // Checkmate. PROVISIONAL (spec §6): every opponent delivering a check wins. const winners = checks.map((board) => BOARD_PLAYERS[board].w === player ? BOARD_PLAYERS[board].b : BOARD_PLAYERS[board].w, ); const result = {} as GameResult; for (const p of PLAYERS) { result[p] = p === player ? 'loss' : winners.includes(p) ? 'win' : 'draw'; } return { state: 'checkmate', result, checks }; } // PROVISIONAL (spec §6): a no-synchronized-move stalemate ends the game, all draw. return { state: 'stalemate', result: allDraw(), reason: 'stalemate', checks }; } if (game.repetitionCount() >= 3) { return { state: 'draw', result: allDraw(), reason: 'threefold', checks }; } if (game.pliesSinceProgress >= FIFTY_MOVE_PLIES) { return { state: 'draw', result: allDraw(), reason: 'fifty-move', checks }; } return { state: 'playing', checks }; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `pnpm test` Expected: PASS. If the stalemate test fails, re-verify the move sequence is the canonical fastest stalemate (10 White moves, 9 Black moves) before changing code. - [ ] **Step 5: Commit** ```bash git add src/engine/endgame.ts src/engine/endgame.test.ts git commit -m "feat(engine): endgame detection with provisional rules" ``` --- ## Task 7: Coordinate notation and save/load **Files:** - Create: `src/engine/notation.ts` - Test: `src/engine/notation.test.ts` - [ ] **Step 1: Write the failing test `src/engine/notation.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { toCoordinate, serialize, deserialize } from './notation'; import type { HistoryEntry } from './types'; describe('notation', () => { it('renders a move as a coordinate token', () => { expect(toCoordinate({ from: 'e2', to: 'e4' })).toBe('e2e4'); expect(toCoordinate({ from: 'e7', to: 'e8', promotion: 'q' })).toBe('e7e8q'); }); it('round-trips a game through serialize/deserialize', () => { const history: HistoryEntry[] = [ { player: 'N', from: 'e2', to: 'e4' }, { player: 'S', from: 'e2', to: 'e4' }, ]; const restored = deserialize(serialize(history)); expect(restored).toEqual(history); }); it('rejects a file that is not a duplicate-chess save', () => { expect(() => deserialize('{"variant":"chess","version":1,"moves":[]}')).toThrow(); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm test` Expected: FAIL — `notation.ts` does not exist. - [ ] **Step 3: Write `src/engine/notation.ts`** ```ts import type { SyncMove, HistoryEntry } from './types'; /** Coordinate notation, e.g. "e2e4" or "e7e8q". */ export function toCoordinate(move: SyncMove): string { return `${move.from}${move.to}${move.promotion ?? ''}`; } export interface SavedGame { variant: 'duplicate-chess'; version: 1; moves: HistoryEntry[]; } export function serialize(history: HistoryEntry[]): string { const data: SavedGame = { variant: 'duplicate-chess', version: 1, moves: history }; return JSON.stringify(data, null, 2); } export function deserialize(json: string): HistoryEntry[] { const data = JSON.parse(json) as Partial; if (data.variant !== 'duplicate-chess' || !Array.isArray(data.moves)) { throw new Error('Not a duplicate-chess save file'); } return data.moves; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `pnpm test` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/engine/notation.ts src/engine/notation.test.ts git commit -m "feat(engine): coordinate notation and JSON save/load" ``` --- ## Task 8: Engine integration test — a scripted full game **Files:** - Test: `src/engine/integration.test.ts` - [ ] **Step 1: Write the integration test `src/engine/integration.test.ts`** ```ts import { describe, it, expect } from 'vitest'; import { DuplicateGame } from './game'; import { playSymmetric } from './test-helpers'; import { legalSyncedMoves } from './legality'; import { ghosts } from './ghosts'; import { evaluateStatus } from './endgame'; import { serialize, deserialize } from './notation'; describe('integration: a scripted game played to checkmate', () => { it('plays Fool\'s mate, stays consistent throughout, and ends correctly', () => { const g = new DuplicateGame(); // The game is live and ongoing until the mate. expect(evaluateStatus(g).state).toBe('playing'); expect(legalSyncedMoves(g).length).toBeGreaterThan(0); playSymmetric(g, [ [{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }], [{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }], ]); // No captures occurred, so there are no ghosts. expect(ghosts(g)).toEqual([]); // North is checkmated. const status = evaluateStatus(g); expect(status.state).toBe('checkmate'); expect(status.result?.N).toBe('loss'); expect(legalSyncedMoves(g)).toEqual([]); // The game round-trips through save/load and reproduces the same outcome. const restored = new DuplicateGame(deserialize(serialize(g.history))); expect(evaluateStatus(restored).state).toBe('checkmate'); expect(restored.history).toEqual(g.history); }); it('undo from the mated position restores a playable game', () => { const g = new DuplicateGame(); playSymmetric(g, [ [{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }], [{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }], ]); g.undo(); // take back W's d8h4 expect(evaluateStatus(g).state).toBe('playing'); expect(g.currentPlayer).toBe('W'); }); }); ``` - [ ] **Step 2: Run the test to verify it passes** Run: `pnpm test` Expected: PASS — all engine suites green. No new implementation needed; this test exercises the finished engine. If it fails, the failure points to a real engine bug — fix the implicated module and re-run. - [ ] **Step 3: Commit** ```bash git add src/engine/integration.test.ts git commit -m "test(engine): scripted full-game integration test" ``` --- ## Task 9: Reactive game store **Files:** - Create: `src/lib/stores/game.svelte.ts` The store keeps the `DuplicateGame` as a plain (non-reactive) field — `chess.js` objects do not belong inside a Svelte proxy — and exposes a plain-data `view` snapshot in a `$state` rune. Components read `store.view`. - [ ] **Step 1: Write `src/lib/stores/game.svelte.ts`** ```ts import { DuplicateGame } from '../../engine/game'; import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '../../engine/legality'; import { ghosts } from '../../engine/ghosts'; import { evaluateStatus } from '../../engine/endgame'; import { serialize, deserialize } from '../../engine/notation'; import { PLAYER_BOARDS } from '../../engine/boards'; import type { BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus, } from '../../engine/types'; /** A plain, reactivity-friendly snapshot of everything the UI renders. */ export interface GameView { /** Piece-placement FEN field per board. */ fen: Record; currentPlayer: Player; ply: number; ghosts: GhostMarker[]; status: GameStatus; history: HistoryEntry[]; } function buildView(game: DuplicateGame): GameView { return { fen: { NW: game.boards.NW.fen(), NE: game.boards.NE.fen(), SW: game.boards.SW.fen(), SE: game.boards.SE.fen(), }, currentPlayer: game.currentPlayer, ply: game.ply, ghosts: ghosts(game), status: evaluateStatus(game), history: [...game.history], }; } class GameStore { /** The authoritative live game — deliberately NOT a $state proxy. */ #game = new DuplicateGame(); /** Snapshot the UI renders. While scrubbing it reflects a past ply. */ view = $state(buildView(this.#game)); /** The grabbed square, or null. */ selected = $state(null); /** Triple-highlight for the grabbed piece, or null. */ highlight = $state(null); /** A pawn move awaiting a promotion choice, or null. */ pendingPromotion = $state<{ from: Square; to: Square } | null>(null); /** Ply currently being viewed; null means the live position. */ scrubPly = $state(null); get isScrubbing(): boolean { return this.scrubPly !== null; } /** Which boards belong to the player to move (for the turn glow). */ get activeBoards(): [BoardId, BoardId] { return PLAYER_BOARDS[this.#game.currentPlayer]; } /** Grab a piece: must be the current player's turn and a live (non-scrub) view. */ select(square: Square): void { if (this.isScrubbing) return; if (this.selected === square) { this.clearSelection(); return; } this.selected = square; this.highlight = selectionHighlight(this.#game, square); } clearSelection(): void { this.selected = null; this.highlight = null; } /** Attempt to play the grabbed piece to `to`. Opens the promotion dialog if needed. */ commitTo(to: Square): void { const from = this.selected; if (from === null || this.highlight === null) return; if (!this.highlight.playable.includes(to)) return; // not a synchronized-legal square const moves = legalSyncedMoves(this.#game).filter((m) => m.from === from && m.to === to); if (moves.length === 0) return; if (moves.some((m) => m.promotion)) { this.pendingPromotion = { from, to }; return; } this.#apply(moves[0]); } /** Finish a promotion started by commitTo. */ choosePromotion(piece: SyncMove['promotion']): void { if (this.pendingPromotion === null) return; this.#apply({ ...this.pendingPromotion, promotion: piece }); this.pendingPromotion = null; } cancelPromotion(): void { this.pendingPromotion = null; } #apply(move: SyncMove): void { this.#game.applyMove(move); this.clearSelection(); this.scrubPly = null; this.view = buildView(this.#game); } undo(): void { this.#game.undo(); this.clearSelection(); this.scrubPly = null; this.view = buildView(this.#game); } newGame(): void { this.#game = new DuplicateGame(); this.clearSelection(); this.pendingPromotion = null; this.scrubPly = null; this.view = buildView(this.#game); } /** Scrub the move history; null returns to the live position. */ scrubTo(ply: number | null): void { this.clearSelection(); if (ply === null || ply >= this.#game.ply) { this.scrubPly = null; this.view = buildView(this.#game); return; } this.scrubPly = ply; this.view = buildView(new DuplicateGame(this.#game.history.slice(0, ply))); } /** Manually declare a draw (provisional: insufficient material is not auto-detected). */ declareDraw(): void { this.view = { ...this.view, status: { state: 'draw', reason: 'manual', checks: [], result: { N: 'draw', S: 'draw', E: 'draw', W: 'draw' } }, }; } save(): void { const blob = new Blob([serialize(this.#game.history)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `duplicate-chess-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); } async load(file: File): Promise { const history = deserialize(await file.text()); this.#game = new DuplicateGame(history); this.clearSelection(); this.pendingPromotion = null; this.scrubPly = null; this.view = buildView(this.#game); } } export const gameStore = new GameStore(); ``` - [ ] **Step 2: Typecheck** Run: `pnpm exec svelte-check --tsconfig ./tsconfig.json` Expected: 0 errors. (Runes in a `.svelte.ts` file are valid; Vite's Svelte plugin compiles them.) - [ ] **Step 3: Commit** ```bash git add src/lib/stores/game.svelte.ts git commit -m "feat(ui): reactive game store wrapping the engine" ``` --- ## Task 10: Board component **Files:** - Create: `src/lib/Board.svelte` Renders one board: an 8×8 grid, pieces coloured per owning player, the board rotated per the compass, ghost markers, and the triple-highlight. Click-to-move. - [ ] **Step 1: Write `src/lib/Board.svelte`** ```svelte
{#each cells as cell, i (cell.square)} {/each}
``` - [ ] **Step 2: Typecheck** Run: `pnpm exec svelte-check --tsconfig ./tsconfig.json` Expected: 0 errors. (`Board.svelte` is not yet rendered anywhere — that is fine.) - [ ] **Step 3: Commit** ```bash git add src/lib/Board.svelte git commit -m "feat(ui): Board component — rotated grid, coloured pieces, highlights, ghosts" ``` --- ## Task 11: Compass component **Files:** - Create: `src/lib/Compass.svelte` Arranges the four `Board`s in the pinwheel X with the four player labels in the V-notches, and wires clicks/highlights from the store. - [ ] **Step 1: Write `src/lib/Compass.svelte`** ```svelte
{#each BOARD_IDS as id (id)}
handleSquare(id, sq)} />
{/each}
NORTH
SOUTH
EAST
WEST
``` - [ ] **Step 2: Typecheck** Run: `pnpm exec svelte-check --tsconfig ./tsconfig.json` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash git add src/lib/Compass.svelte git commit -m "feat(ui): Compass component — pinwheel layout, click-to-move wiring" ``` --- ## Task 12: Panel component **Files:** - Create: `src/lib/Panel.svelte` Turn indicator, coordinate move log, legend, and controls. - [ ] **Step 1: Write `src/lib/Panel.svelte`** ```svelte ``` - [ ] **Step 2: Typecheck** Run: `pnpm exec svelte-check --tsconfig ./tsconfig.json` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash git add src/lib/Panel.svelte git commit -m "feat(ui): Panel component — turn, move log, legend, controls" ``` --- ## Task 13: Promotion dialog **Files:** - Create: `src/lib/PromotionDialog.svelte` - [ ] **Step 1: Write `src/lib/PromotionDialog.svelte`** ```svelte {#if pending}
gameStore.cancelPromotion()} role="presentation">
e.stopPropagation()} role="presentation">

Promote pawn ({pending.from}→{pending.to})

{#each PIECES as p} {/each}
{/if} ``` - [ ] **Step 2: Typecheck** Run: `pnpm exec svelte-check --tsconfig ./tsconfig.json` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash git add src/lib/PromotionDialog.svelte git commit -m "feat(ui): promotion dialog" ``` --- ## Task 14: Assemble the app and smoke-test **Files:** - Modify: `src/App.svelte`, `src/app.css` - Create: nothing new - [ ] **Step 1: Write `src/App.svelte`** ```svelte

Duplicate Chess

Local sandbox — operator drives all four players. Click a piece on the glowing boards to see its synchronized-legal moves.

``` - [ ] **Step 2: Write `src/app.css`** ```css :root { color-scheme: dark; } * { box-sizing: border-box; } body { margin: 0; background: #15171c; color: #e6e8ec; font-family: -apple-system, "Segoe UI", Roboto, sans-serif; } ``` - [ ] **Step 3: Build and typecheck** ```bash pnpm run build pnpm exec svelte-check --tsconfig ./tsconfig.json pnpm test ``` Expected: build succeeds; `svelte-check` 0 errors; all engine tests pass. - [ ] **Step 4: Manual smoke test** Run: `pnpm dev`, open the printed URL, and verify: 1. Four boards render in the pinwheel; North's two boards glow. 2. Clicking a North piece shows green dots (playable) and, after divergence, grey dashed dots on one board only. 3. Clicking a green dot plays the move on both North boards; the turn advances to South and the glow moves. 4. Playing into a capture on one board produces a ghost (faded, dashed) on the twin board. 5. Promoting a pawn (advance one to its last rank) opens the dialog; choosing a piece promotes on both boards. 6. The move log fills with coordinate tokens; Undo and Prev/Next/Live work. 7. Save downloads a JSON file; Load restores it. 8. Play a quick Fool's mate (f3/e5/g4/Qh4 symmetrically) and confirm the panel reports the checkmate result. Fix any issue found, re-running `svelte-check` after each change. - [ ] **Step 5: Commit** ```bash git add src/App.svelte src/app.css git commit -m "feat(ui): assemble the duplicate chess sandbox app" ``` --- ## Self-review notes - **Spec coverage:** §2 rules → engine Tasks 3–6; §3 architecture → file structure; §4 engine → Tasks 3–7; §5 UI → Tasks 10–14; §6 provisional rules → Task 6 (`endgame.ts`, marked `PROVISIONAL`); §7 scope → no networking/AI/editor tasks; §8 testing → Tasks 2–8 (vitest) + Task 14 step 4 (manual). All sections covered. - **Type consistency:** `SyncMove`, `HistoryEntry`, `GameStatus`, `SelectionHighlight`, `GameView` are each defined once and imported everywhere; `selectionHighlight` returns `{boardA,boardB,playable,onlyA,onlyB}` and `Compass` maps `onlyA/onlyB` to the `Board`'s `local` prop consistently. - **Provisional rules** live only in `endgame.ts` and are commented `PROVISIONAL (spec §6)` so a future ruling from Andrew is easy to locate. - **Deferred (spec §9):** rotated-board ergonomics and the 50-move counting unit are out of scope here; `FIFTY_MOVE_ROUNDS` is a single named constant so the unit can be retuned trivially.