diff --git a/docs/superpowers/plans/2026-05-19-duplicate-chess-sandbox.md b/docs/superpowers/plans/2026-05-19-duplicate-chess-sandbox.md new file mode 100644 index 0000000..cb65956 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-duplicate-chess-sandbox.md @@ -0,0 +1,1788 @@ +# 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.