Files
duplicate_chess/docs/superpowers/plans/2026-05-19-duplicate-chess-sandbox.md
claude (duplicate_chess) fae9f8dce4 docs: correct Task 4 test move data in the plan
The Task 4 test had East's and West's moves swapped relative to the
PLAYER_BOARDS mapping (E plays NE/SE, W plays NW/SW). Caught and fixed in
the shipped code during execution; this brings the plan document in line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:19:47 -04:00

1789 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<main>
<h1>Duplicate Chess</h1>
<p>Sandbox under construction.</p>
</main>
```
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<Player, PlayerResult>;
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<Player, [BoardId, BoardId]> = {
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<Player, Color> = {
N: 'w', S: 'w', E: 'b', W: 'b',
};
/** The white and black player of each board. */
export const BOARD_PLAYERS: Record<BoardId, { w: Player; b: Player }> = {
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<BoardId, number> = {
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<BoardId, Chess>;
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 d7d5, W e7e5 -> NW black has pe5 (W's), NE black has pd5 (E's).
const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
{ player: 'E', from: 'd7', to: 'd5' },
{ player: 'W', from: 'e7', to: 'e5' },
]);
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: 'd7', to: 'd5' },
{ player: 'W', from: 'e7', to: 'e5' },
]);
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<string>();
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<Square> {
const set = new Set<Square>();
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<SavedGame>;
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<BoardId, string>;
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<GameView>(buildView(this.#game));
/** The grabbed square, or null. */
selected = $state<Square | null>(null);
/** Triple-highlight for the grabbed piece, or null. */
highlight = $state<SelectionHighlight | null>(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<number | null>(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<void> {
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
<script lang="ts">
import type { BoardId, Player, Square } from '../engine/types';
import type { SelectionHighlight } from '../engine/legality';
import { BOARD_ROTATION, BOARD_PLAYERS } from '../engine/boards';
interface Props {
id: BoardId;
fen: string;
/** Player colours, e.g. { N:'#4a90d9', ... }. */
colors: Record<Player, string>;
ghosts: Square[];
/** Highlight for this board, or null if no piece is grabbed / not active. */
highlight: { playable: Square[]; local: Square[]; selected: Square | null } | null;
active: boolean;
onSquare: (square: Square) => void;
}
let { id, fen, colors, ghosts, highlight, active, onSquare }: Props = $props();
const FILES = 'abcdefgh';
const GLYPH: Record<string, string> = {
k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟',
};
interface Cell { square: Square; piece: { glyph: string; color: string } | null; }
let cells = $derived.by<Cell[]>(() => {
const placement = fen.split(' ')[0];
const white = colors[BOARD_PLAYERS[id].w];
const black = colors[BOARD_PLAYERS[id].b];
const map: Record<string, { glyph: string; color: string }> = {};
placement.split('/').forEach((row, ri) => {
const rank = 8 - ri;
let file = 0;
for (const ch of row) {
if (/\d/.test(ch)) { file += Number(ch); continue; }
const isWhite = ch === ch.toUpperCase();
map[`${FILES[file]}${rank}`] = {
glyph: GLYPH[ch.toLowerCase()],
color: isWhite ? white : black,
};
file += 1;
}
});
const out: Cell[] = [];
for (let rank = 8; rank >= 1; rank--) {
for (let f = 0; f < 8; f++) {
const square = `${FILES[f]}${rank}`;
out.push({ square, piece: map[square] ?? null });
}
}
return out;
});
function classes(cell: Cell, index: number): string {
const dark = (index + Math.floor(index / 8)) % 2 === 1;
const hl = highlight;
const list = ['sq', dark ? 'dark' : 'light'];
if (ghosts.includes(cell.square)) list.push('ghost-sq');
if (hl?.playable.includes(cell.square)) list.push(cell.piece ? 'play occ' : 'play');
if (hl?.local.includes(cell.square)) list.push('local');
if (hl?.selected === cell.square) list.push('selected');
return list.join(' ');
}
</script>
<div class="board" class:active style="--rot:{BOARD_ROTATION[id]}deg">
{#each cells as cell, i (cell.square)}
<button class={classes(cell, i)} onclick={() => onSquare(cell.square)} aria-label={cell.square}>
{#if cell.piece}
<span class="pc" class:ghost={ghosts.includes(cell.square)}
style="color:{cell.piece.color}">{cell.piece.glyph}</span>
{/if}
</button>
{/each}
</div>
<style>
.board {
display: grid;
grid-template-columns: repeat(8, var(--sq, 34px));
grid-template-rows: repeat(8, var(--sq, 34px));
transform: rotate(var(--rot));
border: 1px solid #20232b;
border-radius: 3px;
}
.board.active { box-shadow: 0 0 0 3px var(--glow, #4a90d9), 0 0 20px 2px var(--glow, #4a90d9); }
.sq {
position: relative; padding: 0; border: 0; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.sq.light { background: #cabf9f; }
.sq.dark { background: #7d6f55; }
.pc {
font-size: calc(var(--sq, 34px) * 0.76); line-height: 1;
text-shadow: -1px -1px 0 #15171c, 1px -1px 0 #15171c,
-1px 1px 0 #15171c, 1px 1px 0 #15171c;
}
.pc.ghost { opacity: 0.42; }
.ghost-sq { outline: 2px dashed #888; outline-offset: -2px; }
.sq.play::after, .sq.local::after {
content: ''; position: absolute; border-radius: 50%;
width: 32%; height: 32%;
}
.sq.play::after { background: #46c24f; box-shadow: 0 0 7px #46c24f; }
.sq.play.occ::after {
width: 84%; height: 84%; background: transparent;
border: 3px solid #46c24f; box-shadow: 0 0 7px #46c24f;
}
.sq.local::after { background: transparent; border: 2px dashed #9aa0aa; }
.sq.selected { outline: 3px solid #3fd9d9; outline-offset: -3px; }
</style>
```
- [ ] **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
<script lang="ts">
import Board from './Board.svelte';
import { gameStore } from './stores/game.svelte';
import { PLAYER_BOARDS } from '../engine/boards';
import type { BoardId, Player, Square } from '../engine/types';
const COLORS: Record<Player, string> = {
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
};
// Board centre positions inside the 744x744 compass (see spec §5.1).
const POS: Record<BoardId, { left: number; top: number }> = {
NW: { left: 200, top: 200 }, NE: { left: 544, top: 200 },
SW: { left: 200, top: 544 }, SE: { left: 544, top: 544 },
};
const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
let view = $derived(gameStore.view);
let active = $derived(gameStore.activeBoards);
/** Ghost squares for a given board. */
function ghostsFor(id: BoardId): Square[] {
return view.ghosts.filter((g) => g.board === id).map((g) => g.square);
}
/** Highlight payload for a given board, or null. */
function highlightFor(id: BoardId) {
const h = gameStore.highlight;
if (h === null) return null;
if (id !== h.boardA && id !== h.boardB) return null;
const local = id === h.boardA ? h.onlyA : h.onlyB;
const selectedHere = active.includes(id) ? gameStore.selected : null;
return { playable: h.playable, local, selected: selectedHere };
}
function handleSquare(id: BoardId, square: Square): void {
if (gameStore.isScrubbing) return;
if (!active.includes(id)) return; // only the player-to-move's boards are interactive
if (gameStore.selected === null) {
gameStore.select(square);
} else if (gameStore.highlight?.playable.includes(square)) {
gameStore.commitTo(square);
} else {
gameStore.select(square); // re-grab or cancel
}
}
</script>
<div class="compass" style="--glow:{COLORS[view.currentPlayer]}">
{#each BOARD_IDS as id (id)}
<div class="slot" style="left:{POS[id].left}px; top:{POS[id].top}px;">
<Board
{id}
fen={view.fen[id]}
colors={COLORS}
ghosts={ghostsFor(id)}
highlight={highlightFor(id)}
active={active.includes(id)}
onSquare={(sq) => handleSquare(id, sq)}
/>
</div>
{/each}
<div class="plabel" class:on={view.currentPlayer === 'N'}
style="left:372px; top:74px; background:{COLORS.N}">NORTH</div>
<div class="plabel" class:on={view.currentPlayer === 'S'}
style="left:372px; top:670px; background:{COLORS.S}">SOUTH</div>
<div class="plabel vert" class:on={view.currentPlayer === 'E'}
style="left:670px; top:372px; background:{COLORS.E}">EAST</div>
<div class="plabel vert" class:on={view.currentPlayer === 'W'}
style="left:74px; top:372px; background:{COLORS.W}">WEST</div>
</div>
<style>
.compass { position: relative; width: 744px; height: 744px; flex: none; }
.slot { position: absolute; transform: translate(-50%, -50%); }
.plabel {
position: absolute; transform: translate(-50%, -50%);
color: #fff; font-size: 12px; font-weight: 700; letter-spacing: 0.09em;
padding: 6px 12px; border-radius: 7px; white-space: nowrap;
}
.plabel.vert { writing-mode: vertical-rl; }
.plabel.on { box-shadow: 0 0 14px currentColor; }
</style>
```
- [ ] **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
<script lang="ts">
import { gameStore } from './stores/game.svelte';
import { toCoordinate } from '../engine/notation';
import { PLAYERS } from '../engine/boards';
import type { Player } from '../engine/types';
const COLORS: Record<Player, string> = {
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
};
const NAME: Record<Player, string> = { N: 'North', S: 'South', E: 'East', W: 'West' };
let view = $derived(gameStore.view);
/** Move log grouped into rounds of four (N,S,E,W). */
let rounds = $derived.by(() => {
const out: string[][] = [];
view.history.forEach((entry, i) => {
const r = Math.floor(i / 4);
(out[r] ??= [])[i % 4] = toCoordinate(entry);
});
return out;
});
let statusText = $derived.by(() => {
const s = view.status;
if (s.state === 'playing') return `${NAME[view.currentPlayer]} to move`;
if (s.state === 'checkmate') {
const winners = PLAYERS.filter((p) => s.result?.[p] === 'win').map((p) => NAME[p]);
return `Checkmate — ${NAME[view.currentPlayer]} loses; ${winners.join(' & ')} win`;
}
if (s.state === 'stalemate') return 'Stalemate — all draw';
return `Draw (${s.reason})`;
});
let fileInput: HTMLInputElement;
function onFile(e: Event): void {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) gameStore.load(file).catch((err) => alert(String(err)));
}
</script>
<aside class="panel">
<section class="card">
<div class="turn">
<span class="dot" style="background:{COLORS[view.currentPlayer]}"></span>
{statusText}
</div>
<div class="sub">Ghosts on board: {view.ghosts.length} · Checks: {view.status.checks.length}</div>
</section>
<section class="card">
<h2>Move log</h2>
<table>
<thead><tr>
{#each PLAYERS as p}<th style="color:{COLORS[p]}">{NAME[p]}</th>{/each}
</tr></thead>
<tbody>
{#each rounds as round, r (r)}
<tr>
{#each [0, 1, 2, 3] as c}<td>{round[c] ?? ''}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</section>
<section class="card">
<h2>Legend</h2>
<div class="legend">
<div><span class="ring play"></span> Playable — legal on both boards</div>
<div><span class="ring local"></span> Legal on that board only</div>
<div><span class="ring ghost"></span> Ghost — twin captured, frozen</div>
</div>
</section>
<section class="card controls">
<button onclick={() => gameStore.newGame()}>New game</button>
<button onclick={() => gameStore.undo()} disabled={view.ply === 0}>Undo</button>
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) - 1)}
disabled={view.ply === 0}>◀ Prev</button>
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) + 1)}
disabled={!gameStore.isScrubbing}>Next ▶</button>
<button onclick={() => gameStore.scrubTo(null)} disabled={!gameStore.isScrubbing}>● Live</button>
<button onclick={() => gameStore.declareDraw()}>Declare draw</button>
<button onclick={() => gameStore.save()}>Save</button>
<button onclick={() => fileInput.click()}>Load</button>
<input type="file" accept="application/json" bind:this={fileInput}
onchange={onFile} style="display:none" />
</section>
</aside>
<style>
.panel { width: 290px; display: flex; flex-direction: column; gap: 13px; }
.card {
background: #1d2027; border: 1px solid #333845;
border-radius: 9px; padding: 13px 15px;
}
.card h2 {
margin: 0 0 8px; font-size: 11px; letter-spacing: 0.07em;
text-transform: uppercase; color: #9aa0aa;
}
.turn { display: flex; align-items: center; gap: 9px; font-weight: 600; }
.dot { width: 13px; height: 13px; border-radius: 50%; }
.sub { font-size: 12px; color: #9aa0aa; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 3px 5px; text-align: left; }
th { font-size: 10px; text-transform: uppercase; }
td { font-family: ui-monospace, Menlo, monospace; color: #cdd2da; }
.legend { display: flex; flex-direction: column; gap: 7px; font-size: 12px; }
.legend div { display: flex; align-items: center; gap: 8px; }
.ring { width: 14px; height: 14px; border-radius: 50%; flex: none; }
.ring.play { background: #46c24f; }
.ring.local { border: 2px dashed #9aa0aa; }
.ring.ghost { border: 2px dashed #888; }
.controls { display: flex; flex-wrap: wrap; gap: 6px; }
.controls button {
background: #262b34; color: #e6e8ec; border: 1px solid #333845;
border-radius: 5px; padding: 5px 9px; font-size: 12px; cursor: pointer;
}
.controls button:disabled { opacity: 0.4; cursor: default; }
</style>
```
- [ ] **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
<script lang="ts">
import { gameStore } from './stores/game.svelte';
import type { PromotionPiece } from '../engine/types';
const PIECES: { code: PromotionPiece; glyph: string }[] = [
{ code: 'q', glyph: '♛' }, { code: 'r', glyph: '♜' },
{ code: 'b', glyph: '♝' }, { code: 'n', glyph: '♞' },
];
let pending = $derived(gameStore.pendingPromotion);
</script>
{#if pending}
<div class="backdrop" onclick={() => gameStore.cancelPromotion()}
role="presentation">
<div class="dialog" onclick={(e) => e.stopPropagation()} role="presentation">
<h3>Promote pawn ({pending.from}{pending.to})</h3>
<div class="row">
{#each PIECES as p}
<button onclick={() => gameStore.choosePromotion(p.code)}>{p.glyph}</button>
{/each}
</div>
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6);
display: flex; align-items: center; justify-content: center; z-index: 50;
}
.dialog {
background: #1d2027; border: 1px solid #333845;
border-radius: 10px; padding: 18px 22px;
}
.dialog h3 { margin: 0 0 12px; font-size: 14px; }
.row { display: flex; gap: 10px; }
.row button {
font-size: 38px; line-height: 1; width: 60px; height: 60px;
background: #262b34; color: #e6e8ec;
border: 1px solid #333845; border-radius: 8px; cursor: pointer;
}
.row button:hover { border-color: #46c24f; }
</style>
```
- [ ] **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
<script lang="ts">
import Compass from './lib/Compass.svelte';
import Panel from './lib/Panel.svelte';
import PromotionDialog from './lib/PromotionDialog.svelte';
</script>
<header>
<h1>Duplicate Chess</h1>
<p>Local sandbox — operator drives all four players. Click a piece on the
glowing boards to see its synchronized-legal moves.</p>
</header>
<main>
<Compass />
<Panel />
</main>
<PromotionDialog />
<style>
header { padding: 14px 22px; border-bottom: 1px solid #333845; }
header h1 { margin: 0; font-size: 17px; }
header p { margin: 4px 0 0; font-size: 12px; color: #9aa0aa; }
main { display: flex; gap: 22px; padding: 20px 22px; align-items: flex-start; }
</style>
```
- [ ] **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 36; §3 architecture → file structure;
§4 engine → Tasks 37; §5 UI → Tasks 1014; §6 provisional rules → Task 6
(`endgame.ts`, marked `PROVISIONAL`); §7 scope → no networking/AI/editor tasks;
§8 testing → Tasks 28 (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.