fae9f8dce4
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>
1789 lines
57 KiB
Markdown
1789 lines
57 KiB
Markdown
# 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 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.
|