Files
blind_chess/docs/superpowers/plans/2026-05-18-table-fidelity-features.md
T
claude (blind_chess) be8ecd96b6 docs: implementation plan for table-fidelity feature batch
12-task TDD plan in two increments:
- Increment 1 (Tasks 1-5): announce-all-to-both + capture tally.
- Increment 2 (Tasks 6-11): client-local phantom opponent-piece layer.

Each task has exact files, complete code, and verification commands.
Server/shared tasks are TDD'd with vitest; client tasks use svelte-check
plus manual verification (no client test harness, by design).

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

1323 lines
44 KiB
Markdown

# Table-Fidelity Features 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:** Add three physical-table-faithful features to blind chess — broadcast all moderator announcements to both players, a running capture tally, and a client-local phantom-opponent-piece overlay.
**Architecture:** Two increments. Increment 1 (Features 1+2) is server-centric and independently shippable. Increment 2 (Feature 3) is a client-only phantom layer that never touches the server. The zero-leak core (`buildView`, `geometric.ts`) is untouched throughout.
**Tech Stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte 5 (runes), `chess.js`. pnpm workspace `packages/{server,client,shared}`. Tests: `vitest` in `shared` and `server` only — the client has no test harness, so client tasks use `svelte-check` typechecking plus a manual-verification step.
**Spec:** `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md`
**Conventions for every commit step below:** conventional-commit messages; append the `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>` trailer per repo convention; run `gitea push` immediately after each commit (homelab rule: never leave a commit unpushed).
**Build ordering note:** `packages/server` and `packages/client` resolve `@blind-chess/shared` from its built `dist/`. Whenever a task adds a new export to `shared`, it must run `pnpm --filter @blind-chess/shared build` before downstream typechecks/builds will see it. Build steps are included where needed.
---
## Increment 1 — Features 1 & 2
### Task 1: Widen moderator announcement audiences to `'both'`
Move events and attempted-move errors currently reach only one player. Make every announcement `audience: 'both'` so the moderator panel is a complete shared transcript.
**Files:**
- Modify: `packages/server/src/translator.ts:36-51`
- Modify: `packages/server/src/commit.ts:39-119`
- Test: `packages/server/test/unit/commit-fsm.test.ts` (append)
- [ ] **Step 1: Write the failing tests**
Append these two `it` blocks inside the `describe('hierarchy decision table', ...)` block in `packages/server/test/unit/commit-fsm.test.ts` (after the existing `row 6` test, before the closing `});` of that describe):
```ts
it('attempted-move announcements are audience: both', () => {
const r = handleCommit(game, 'w', { from: 'e4' }); // empty square -> no_such_piece
expect(r.kind).toBe('announce');
if (r.kind === 'announce') expect(r.announcements[0]!.audience).toBe('both');
});
it('applied-move announcements are audience: both', () => {
const r = handleCommit(game, 'w', { from: 'e2', to: 'e4' });
expect(r.kind).toBe('applied');
if (r.kind === 'applied') {
for (const a of r.announcements) expect(a.audience).toBe('both');
}
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `pnpm --filter @blind-chess/server test`
Expected: the two new tests FAIL — `no_such_piece` currently has `audience: 'w'` and `white_moved` currently has `audience: 'b'`, not `'both'`.
- [ ] **Step 3: Change move-event audiences in `translator.ts`**
In `packages/server/src/translator.ts`, replace the block at lines 36-51 (the `// To opponent` comment through the promotion `if`) with:
```ts
// To both players: the move event itself (Feature 1 — the moderator
// announces every move aloud; both players hear it).
if (isKingsideCastle) {
out.push(announce(`${moverWord}_castled_kingside` as ModeratorText, 'both', ply));
} else if (isQueensideCastle) {
out.push(announce(`${moverWord}_castled_queenside` as ModeratorText, 'both', ply));
} else if (isCap && isEp) {
out.push(announce(`${moverWord}_moved_captured_ep` as ModeratorText, 'both', ply));
} else if (isCap) {
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, 'both', ply));
} else {
out.push(announce(`${moverWord}_moved` as ModeratorText, 'both', ply));
}
if (isProm) {
out.push(announce(`${moverWord}_promoted` as ModeratorText, 'both', ply, { promotedTo: move.promotion }));
}
```
(The `opp` variable is still used below to derive `oppWord` for the `*_in_check` announcement — leave it.)
- [ ] **Step 4: Change attempted-move audiences in `commit.ts`**
In `packages/server/src/commit.ts`, replace the `announceWith` function (lines 110-119) with:
```ts
function announceWith(
game: Game,
text: 'no_such_piece' | 'no_legal_moves' | 'wont_help' | 'illegal_move',
): CommitResult {
const ply = game.chess.history().length;
// Feature 1: attempted moves are announced to both players.
const a = announce(text, 'both', ply);
game.announcements.push(a);
return { kind: 'announce', announcements: [a] };
}
```
Then update its four call sites in `handleCommit` to drop the now-removed `color` argument:
- `return announceWith(game, 'no_such_piece', color);``return announceWith(game, 'no_such_piece');`
- `return announceWith(game, 'no_legal_moves', color);``return announceWith(game, 'no_legal_moves');`
- `return announceWith(game, 'wont_help', color);``return announceWith(game, 'wont_help');`
- `return announceWith(game, 'illegal_move', color);``return announceWith(game, 'illegal_move');`
- [ ] **Step 5: Run the tests to verify they pass**
Run: `pnpm --filter @blind-chess/server test`
Expected: PASS — all server tests pass, including the two new ones. The existing `commit-fsm` and integration tests still pass (their predicates check announcement `.text`, not `.audience`, and opponents still receive move events).
- [ ] **Step 6: Typecheck**
Run: `pnpm --filter @blind-chess/server typecheck`
Expected: PASS — no unused-variable error (`color` is still used elsewhere in `handleCommit`).
- [ ] **Step 7: Commit**
```bash
git add packages/server/src/translator.ts packages/server/src/commit.ts packages/server/test/unit/commit-fsm.test.ts
git commit -m "feat(server): moderator announces every move and attempt to both players"
gitea push
```
---
### Task 2: Suppress the bot's intermediate retry rejections
With attempted moves now `'both'`, the Casual bot's blind-mode retry loop would broadcast up to 25 rejection announcements per turn as search churn. Discard the bot's own intermediate rejections; only its final committed move is announced.
**Files:**
- Modify: `packages/server/src/bot/driver.ts:152-189`
- Test: `packages/server/test/unit/bot/driver.test.ts` (append)
- [ ] **Step 1: Write the failing test**
Append this `it` block inside the `describe('BotDriver', ...)` block in `packages/server/test/unit/bot/driver.test.ts` (after the `retry on illegal_move` test):
```ts
it('suppresses the bot intermediate retry rejection from the moderator log', async () => {
// Pinned-bishop position: first action is rejected (wont_help), second
// is a legal king move. The wont_help must NOT survive in announcements.
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
game = makeGame({ fen });
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
brain.enqueue(
{ type: 'commit', from: 'e7', to: 'd6' }, // rejected: wont_help
{ type: 'commit', from: 'e8', to: 'f8' }, // legal king move
);
await driver.onStateChange();
const texts = game.announcements.map((a) => a.text);
expect(texts).not.toContain('wont_help');
expect(texts).toContain('black_moved');
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm --filter @blind-chess/server test`
Expected: the new test FAILS — `game.announcements` still contains the `wont_help` rejection from the retried attempt.
- [ ] **Step 3: Pop the intermediate rejection in `dispatch`**
In `packages/server/src/bot/driver.ts`, inside `dispatch`, replace the `if (result.kind === 'announce')` block (lines 164-176) with:
```ts
if (result.kind === 'announce') {
const text = result.announcements[0]!.text;
if (text === 'wont_help' || text === 'illegal_move'
|| text === 'no_such_piece' || text === 'no_legal_moves') {
// Feature 1: attempted-move announcements are audience 'both'.
// The bot's intermediate retry rejections are internal search
// churn, not deliberate probing — suppress them so they don't
// broadcast to the human. The bot tracks its own rejections via
// attemptHistory, so removing the announcement is safe. The whole
// decision cycle runs before ws.ts broadcasts, so this pop always
// happens before any broadcast.
const rejection = result.announcements[0]!;
const anns = this.game.announcements;
if (anns[anns.length - 1] === rejection) anns.pop();
return {
kind: 'retry',
entry: {
move: { from: action.from, to: action.to, promotion: action.promotion },
rejection: text,
},
};
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `pnpm --filter @blind-chess/server test`
Expected: PASS — the new test passes and all existing driver tests still pass (`retry on wont_help`, `retry cap`, `retry on illegal_move` all check retry counts / game status, not announcement contents).
- [ ] **Step 5: Commit**
```bash
git add packages/server/src/bot/driver.ts packages/server/test/unit/bot/driver.test.ts
git commit -m "feat(bot): suppress bot retry-search churn from the moderator log"
gitea push
```
---
### Task 3: Client — label attempted-move lines by player, neutral styling
The four attempted-move enums carry no colour. Derive the attempting player from `ply` parity (an attempt only happens on the actor's turn; `ply` is the pre-move history length) and drop the alarm-red styling — these lines are now shared moderator commentary.
**Files:**
- Modify: `packages/client/src/lib/ModeratorPanel.svelte`
- [ ] **Step 1: Add the actor prefix in the entry loop**
In `packages/client/src/lib/ModeratorPanel.svelte`, replace the `{#each visible ...}` block (lines 26-30) with:
```svelte
{#each visible as a, i (i)}
{@const isAttempt = a.text === 'no_such_piece' || a.text === 'no_legal_moves' || a.text === 'wont_help' || a.text === 'illegal_move'}
{@const actor = a.ply % 2 === 0 ? 'White' : 'Black'}
<div class="entry" class:attempt={isAttempt}>
<span class="ply">{a.ply > 0 ? `#${a.ply}` : ''}</span>
<span class="text">{isAttempt ? `${actor} ` : ''}{moderatorText(a.text, a.payload)}</span>
</div>
```
- [ ] **Step 2: Replace the error styling with neutral styling**
In the same file's `<style>` block, replace the rule `.entry.err .text { color: #f87171; }` with:
```css
.entry.attempt .text { color: var(--text-dim); font-style: italic; }
```
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @blind-chess/client typecheck`
Expected: PASS.
- [ ] **Step 4: Manual verification**
Start the dev servers (`pnpm dev:server` and `pnpm dev:client` in two terminals), open the client, create a **blind game vs computer**. Make a few moves; deliberately tap one of your pieces and pick a destination that is illegal. Confirm:
- The moderator panel shows your move attempts and the bot's moves.
- An illegal attempt shows e.g. `White — Illegal move.` in muted italic (not red).
- [ ] **Step 5: Commit**
```bash
git add packages/client/src/lib/ModeratorPanel.svelte
git commit -m "feat(client): label attempted-move announcements by player"
gitea push
```
---
### Task 4: Capture tally — shared type, server function, protocol field, ws wiring
The server records every capture's type in `MoveRecord`. Compute a per-viewer tally and ship it on `joined`/`update`.
**Files:**
- Modify: `packages/shared/src/types.ts` (append)
- Modify: `packages/shared/src/protocol.ts`
- Create: `packages/server/src/captures.ts`
- Modify: `packages/server/src/ws.ts`
- Test: `packages/server/test/unit/captures.test.ts`
- [ ] **Step 1: Add the `PieceTally` and `CaptureTally` types**
Append to `packages/shared/src/types.ts`:
```ts
/** Count of pieces by type — used for the capture tally. */
export type PieceTally = Partial<Record<PieceType, number>>;
/** Per-viewer capture tally: what you took, and what you lost. */
export interface CaptureTally {
byYou: PieceTally;
byOpponent: PieceTally;
}
```
- [ ] **Step 2: Add the `captures` field to the protocol messages**
In `packages/shared/src/protocol.ts`, add `CaptureTally` to the type import from `./types.js`, then add `captures: CaptureTally;` to **both** the `joined` and the `update` members of `ServerMessage`.
The import line becomes:
```ts
import type {
BoardView, CaptureTally, Color, GameId, GameStatus, Mode, PlayerToken,
PromotionType, Square, EndReason,
} from './types.js';
```
The `joined` member gains (place it after `opponentConnected: boolean;`):
```ts
captures: CaptureTally;
```
The `update` member gains (place it after `aiOpponent?: ...;`):
```ts
captures: CaptureTally;
```
- [ ] **Step 3: Build shared**
Run: `pnpm --filter @blind-chess/shared build`
Expected: PASS — `dist/` now exports `PieceTally` / `CaptureTally`.
- [ ] **Step 4: Write the failing test**
Create `packages/server/test/unit/captures.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { captureTally } from '../../src/captures.js';
import type { Game, MoveRecord } from '../../src/state.js';
import type { Color, PieceType } from '@blind-chess/shared';
function rec(by: Color, capturedPieceType?: PieceType): MoveRecord {
return {
ply: 1, by, from: 'e2', to: 'e4', san: 'e4',
capturedPieceType, flags: {}, at: 0,
};
}
describe('captureTally', () => {
it('counts captures per viewer', () => {
const moveHistory: MoveRecord[] = [
rec('w', 'p'), rec('b'), rec('w', 'n'),
rec('b', 'p'), rec('w', 'p'),
];
const game = { moveHistory } as unknown as Game;
expect(captureTally(game, 'w')).toEqual({
byYou: { p: 2, n: 1 }, byOpponent: { p: 1 },
});
expect(captureTally(game, 'b')).toEqual({
byYou: { p: 1 }, byOpponent: { p: 2, n: 1 },
});
});
it('returns empty tallies when there are no captures', () => {
const game = { moveHistory: [rec('w'), rec('b')] } as unknown as Game;
expect(captureTally(game, 'w')).toEqual({ byYou: {}, byOpponent: {} });
});
});
```
- [ ] **Step 5: Run the test to verify it fails**
Run: `pnpm --filter @blind-chess/server test`
Expected: the new test file FAILS — `../../src/captures.js` does not exist yet.
- [ ] **Step 6: Implement `captureTally`**
Create `packages/server/src/captures.ts`:
```ts
import type { CaptureTally, Color } from '@blind-chess/shared';
import type { Game } from './state.js';
/**
* Per-viewer capture tally derived from move history. `byYou` is the set of
* opponent pieces this viewer has captured; `byOpponent` is the set of this
* viewer's pieces the opponent has captured. Pure function of move history —
* no live board state, no opponent positions.
*/
export function captureTally(game: Game, viewer: Color): CaptureTally {
const byYou: Record<string, number> = {};
const byOpponent: Record<string, number> = {};
for (const rec of game.moveHistory) {
const captured = rec.capturedPieceType;
if (!captured) continue;
const bucket = rec.by === viewer ? byYou : byOpponent;
bucket[captured] = (bucket[captured] ?? 0) + 1;
}
return { byYou, byOpponent };
}
```
- [ ] **Step 7: Run the test to verify it passes**
Run: `pnpm --filter @blind-chess/server test`
Expected: PASS — the two new `captureTally` tests pass.
- [ ] **Step 8: Wire `captures` into the `joined` and `update` messages**
In `packages/server/src/ws.ts`:
Add the import near the other server imports (after the `import { endGame, ... }` line):
```ts
import { captureTally } from './captures.js';
```
In `onHello`, the `send(ctx.socket, { type: 'joined', ... })` call gains a field (add it after `aiOpponent: game.aiOpponent,`):
```ts
captures: captureTally(game, color),
```
In `sendUpdateTo`, the `send(slot.socket, { type: 'update', ... })` call gains a field (add it after `aiOpponent: game.aiOpponent,`):
```ts
captures: captureTally(game, color),
```
- [ ] **Step 9: Build and test the server**
Run: `pnpm --filter @blind-chess/server build && pnpm --filter @blind-chess/server test`
Expected: PASS — server compiles (every `joined`/`update` construction site now provides the required `captures` field) and all tests pass.
- [ ] **Step 10: Commit**
```bash
git add packages/shared/src/types.ts packages/shared/src/protocol.ts packages/server/src/captures.ts packages/server/src/ws.ts packages/server/test/unit/captures.test.ts
git commit -m "feat(server): per-viewer capture tally on joined and update messages"
gitea push
```
---
### Task 5: Client — capture-tally store field and panel
**Files:**
- Modify: `packages/client/src/lib/stores/game.svelte.ts`
- Create: `packages/client/src/lib/CaptureTally.svelte`
- Modify: `packages/client/src/lib/Game.svelte`
- [ ] **Step 1: Add `captures` to the client game store**
In `packages/client/src/lib/stores/game.svelte.ts`:
Add `CaptureTally` to the type import from `@blind-chess/shared`.
Add to the `GameStateValue` interface (after `aiOpponent: ...;`):
```ts
captures: CaptureTally;
```
Add to the `$state<GameStateValue>({ ... })` initializer (after `aiOpponent: null,`):
```ts
captures: { byYou: {}, byOpponent: {} },
```
In `onServerMessage`, in the `case 'joined':` block (after `state.aiOpponent = m.aiOpponent ?? null;`):
```ts
state.captures = m.captures;
```
In the `case 'update':` block (after the `aiOpponent` line):
```ts
state.captures = m.captures;
```
- [ ] **Step 2: Create the `CaptureTally` component**
Create `packages/client/src/lib/CaptureTally.svelte`:
```svelte
<script lang="ts">
import type { CaptureTally, Color, PieceTally, PieceType } from '@blind-chess/shared';
import { pieceGlyph } from './pieces.js';
interface Props {
captures: CaptureTally;
you: Color;
}
let { captures, you }: Props = $props();
const ORDER: PieceType[] = ['q', 'r', 'b', 'n', 'p'];
const oppColor = $derived<Color>(you === 'w' ? 'b' : 'w');
function glyphs(tally: PieceTally, color: Color): { glyph: string; key: string }[] {
const out: { glyph: string; key: string }[] = [];
for (const t of ORDER) {
const n = tally[t] ?? 0;
for (let i = 0; i < n; i++) out.push({ glyph: pieceGlyph({ color, type: t }), key: `${t}${i}` });
}
return out;
}
function total(tally: PieceTally): number {
return Object.values(tally).reduce((a, b) => a + b, 0);
}
const youTook = $derived(glyphs(captures.byYou, oppColor));
const youLost = $derived(glyphs(captures.byOpponent, you));
</script>
<div class="panel">
<header>Captures</header>
<div class="row">
<span class="label">You took</span>
<span class="pieces">
{#each youTook as g (g.key)}<span class="g g-{oppColor}">{g.glyph}</span>{:else}<span class="muted"></span>{/each}
</span>
<span class="n">{total(captures.byYou)}</span>
</div>
<div class="row lost">
<span class="label">Lost</span>
<span class="pieces">
{#each youLost as g (g.key)}<span class="g g-{you}">{g.glyph}</span>{:else}<span class="muted"></span>{/each}
</span>
<span class="n">{total(captures.byOpponent)}</span>
</div>
</div>
<style>
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
}
header {
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--border);
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
}
.row.lost { opacity: 0.7; }
.label {
font-size: 12px;
color: var(--text-dim);
width: 56px;
flex-shrink: 0;
}
.pieces { flex: 1; font-size: 20px; line-height: 1; }
.g-w { color: #fafafa; }
.g-b { color: #1a1a1a; }
.n {
font-family: ui-monospace, monospace;
font-size: 13px;
color: var(--text-dim);
}
</style>
```
- [ ] **Step 3: Mount the panel in `Game.svelte`**
In `packages/client/src/lib/Game.svelte`:
Add the import after the `ModeratorPanel` import:
```ts
import CaptureTally from './CaptureTally.svelte';
```
In the `<aside class="side">` block, immediately after the `<ModeratorPanel ... />` line, add:
```svelte
<CaptureTally captures={game.state.captures} you={game.state.you} />
```
- [ ] **Step 4: Typecheck**
Run: `pnpm --filter @blind-chess/client typecheck`
Expected: PASS.
- [ ] **Step 5: Manual verification**
With dev servers running, create a **blind game vs computer**, play until you capture a bot piece (and until it captures one of yours). Confirm the Captures panel shows the right glyphs and counts in "You took" and "Lost".
- [ ] **Step 6: Commit**
```bash
git add packages/client/src/lib/stores/game.svelte.ts packages/client/src/lib/CaptureTally.svelte packages/client/src/lib/Game.svelte
git commit -m "feat(client): capture-tally panel"
gitea push
```
---
### Checkpoint A — Increment 1 complete
- [ ] **Full build, typecheck, and test:**
```bash
pnpm -r build && pnpm -r typecheck && pnpm -r test
```
Expected: all packages build; all tests pass — was 78, plus 5 new (2 in `commit-fsm`, 1 in `driver`, 2 in `captures`) = 83.
- [ ] **Deploy Increment 1.** Deploy per the `Operations` section of `CLAUDE.md` — there are now two instances (CT 690 / `chess.sethpc.xyz` and `chess.local` on VDJ-RIG). Smoke-test: `curl https://chess.sethpc.xyz/api/health`, then play a quick vs-computer blind game and confirm the moderator transcript and capture panel.
- [ ] **STOP for review before starting Increment 2.**
---
## Increment 2 — Feature 3: Phantom opponent pieces
> Client-only. The phantom layer never reaches the server — no `ClientMessage` carries phantom data, and the phantom store is never read in any `send`/`commit` path.
### Task 6: Shared pure phantom model
**Files:**
- Create: `packages/shared/src/phantoms.ts`
- Modify: `packages/shared/src/index.ts`
- Test: `packages/shared/test/phantoms.test.ts`
- [ ] **Step 1: Write the failing test**
Create `packages/shared/test/phantoms.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { opponentStartPosition, deserializePhantoms } from '../src/phantoms.js';
describe('opponentStartPosition', () => {
it('seeds 16 black pieces on ranks 7-8', () => {
const p = opponentStartPosition('b');
expect(Object.keys(p).length).toBe(16);
expect(p.e8).toEqual({ color: 'b', type: 'k' });
expect(p.d8).toEqual({ color: 'b', type: 'q' });
expect(p.a8).toEqual({ color: 'b', type: 'r' });
expect(p.h7).toEqual({ color: 'b', type: 'p' });
});
it('seeds 16 white pieces on ranks 1-2', () => {
const p = opponentStartPosition('w');
expect(Object.keys(p).length).toBe(16);
expect(p.e1).toEqual({ color: 'w', type: 'k' });
expect(p.a2).toEqual({ color: 'w', type: 'p' });
});
});
describe('deserializePhantoms', () => {
it('returns {} for null or invalid JSON', () => {
expect(deserializePhantoms(null)).toEqual({});
expect(deserializePhantoms('not json')).toEqual({});
expect(deserializePhantoms('[]')).toEqual({});
});
it('keeps valid entries and drops invalid ones', () => {
const raw = JSON.stringify({
e5: { color: 'b', type: 'n' },
zz: { color: 'b', type: 'p' }, // invalid square
a1: { color: 'x', type: 'p' }, // invalid colour
b2: { color: 'b', type: 'z' }, // invalid type
});
expect(deserializePhantoms(raw)).toEqual({ e5: { color: 'b', type: 'n' } });
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm --filter @blind-chess/shared test`
Expected: FAIL — `../src/phantoms.js` does not exist.
- [ ] **Step 3: Implement the phantom model**
Create `packages/shared/src/phantoms.ts`:
```ts
import { FILES, isSquare, type Color, type Piece, type PieceType, type Square } from './types.js';
const BACK_RANK: PieceType[] = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'];
const COLORS: Color[] = ['w', 'b'];
const TYPES: PieceType[] = ['p', 'n', 'b', 'r', 'q', 'k'];
/**
* The standard starting position for one colour, as a square→piece map.
* Used to seed the phantom opponent-model layer with the opponent's army.
*/
export function opponentStartPosition(opponentColor: Color): Partial<Record<Square, Piece>> {
const backRank = opponentColor === 'w' ? '1' : '8';
const pawnRank = opponentColor === 'w' ? '2' : '7';
const out: Partial<Record<Square, Piece>> = {};
FILES.forEach((file, i) => {
out[`${file}${backRank}` as Square] = { color: opponentColor, type: BACK_RANK[i]! };
out[`${file}${pawnRank}` as Square] = { color: opponentColor, type: 'p' };
});
return out;
}
/**
* Parse a persisted phantom map (from localStorage). Tolerant: returns {} on
* any structural failure and silently drops individual invalid entries.
*/
export function deserializePhantoms(raw: string | null): Partial<Record<Square, Piece>> {
if (!raw) return {};
let parsed: unknown;
try { parsed = JSON.parse(raw); } catch { return {}; }
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
const out: Partial<Record<Square, Piece>> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (!isSquare(k)) continue;
if (typeof v !== 'object' || v === null) continue;
const { color, type } = v as { color?: unknown; type?: unknown };
if (!COLORS.includes(color as Color)) continue;
if (!TYPES.includes(type as PieceType)) continue;
out[k] = { color: color as Color, type: type as PieceType };
}
return out;
}
```
- [ ] **Step 4: Export from the shared index**
In `packages/shared/src/index.ts`, append:
```ts
export * from './phantoms.js';
```
- [ ] **Step 5: Run the test and build**
Run: `pnpm --filter @blind-chess/shared test && pnpm --filter @blind-chess/shared build`
Expected: PASS — the new tests pass and `dist/` exports the phantom model.
- [ ] **Step 6: Commit**
```bash
git add packages/shared/src/phantoms.ts packages/shared/src/index.ts packages/shared/test/phantoms.test.ts
git commit -m "feat(shared): pure phantom-model helpers (seed positions, deserialize)"
gitea push
```
---
### Task 7: Client phantom store
A local-only Svelte store, persisted to `localStorage`, seeded once per game.
**Files:**
- Create: `packages/client/src/lib/stores/phantoms.svelte.ts`
- [ ] **Step 1: Create the store**
Create `packages/client/src/lib/stores/phantoms.svelte.ts`:
```ts
import {
opponentStartPosition,
deserializePhantoms,
type Color,
type Piece,
type PieceType,
type Square,
} from '@blind-chess/shared';
/**
* Client-LOCAL store for the player's phantom opponent-model layer.
* This data NEVER reaches the server — it is the player's private guess.
* Do not read this store in any `send`/`commit` path.
*/
function makeStore() {
const state = $state<{ phantoms: Partial<Record<Square, Piece>> }>({ phantoms: {} });
let gameId: string | null = null;
let oppColor: Color = 'b';
function key(id: string) { return `bc:phantoms:${id}`; }
function persist() {
if (gameId) localStorage.setItem(key(gameId), JSON.stringify(state.phantoms));
}
/** Load (or first-time seed) the phantom layer for a blind game. */
function loadForGame(id: string, you: Color) {
gameId = id;
oppColor = you === 'w' ? 'b' : 'w';
const raw = localStorage.getItem(key(id));
if (raw === null) {
// First load — seed with the opponent's starting army.
state.phantoms = opponentStartPosition(oppColor);
persist();
} else {
state.phantoms = deserializePhantoms(raw);
}
}
function place(sq: Square, type: PieceType) {
state.phantoms = { ...state.phantoms, [sq]: { color: oppColor, type } };
persist();
}
function move(from: Square, to: Square) {
const p = state.phantoms[from];
if (!p) return;
const next = { ...state.phantoms };
delete next[from];
next[to] = p;
state.phantoms = next;
persist();
}
function remove(sq: Square) {
const next = { ...state.phantoms };
delete next[sq];
state.phantoms = next;
persist();
}
/** Drop the layer when a game ends — avoids unbounded localStorage growth. */
function clearForGame(id: string) {
localStorage.removeItem(key(id));
if (gameId === id) { state.phantoms = {}; gameId = null; }
}
return { state, loadForGame, place, move, remove, clearForGame };
}
export const phantoms = makeStore();
```
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @blind-chess/client typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/client/src/lib/stores/phantoms.svelte.ts
git commit -m "feat(client): local-only phantom-layer store"
gitea push
```
---
### Task 8: Client drag controller
Pointer-event drag shared by the board (phantom move/remove) and the palette (phantom place). Distinguishes a tap from a drag so a tap on a phantom still passes through to a real move.
**Files:**
- Create: `packages/client/src/lib/stores/phantom-drag.svelte.ts`
- [ ] **Step 1: Create the drag controller**
Create `packages/client/src/lib/stores/phantom-drag.svelte.ts`:
```ts
import type { PieceType, Square } from '@blind-chess/shared';
import { phantoms } from './phantoms.svelte.js';
import { game } from './game.svelte.js';
export type DragSource =
| { kind: 'palette'; type: PieceType }
| { kind: 'board'; from: Square; type: PieceType };
/**
* Pointer-event drag controller for the phantom layer. A drag past THRESHOLD
* px places/moves/removes a phantom; a sub-threshold press is a tap and is
* left for the board's normal click handler. Real moves are unaffected.
*/
function makeDrag() {
const state = $state<{
active: DragSource | null;
x: number;
y: number;
moved: boolean;
}>({ active: null, x: 0, y: 0, moved: false });
let startX = 0;
let startY = 0;
// Set only when a board-phantom drag ends back on its origin square — the
// browser then fires a spurious `click` on that square's button which must
// be swallowed so it doesn't trigger a real move.
let suppressClickOn: Square | null = null;
const THRESHOLD = 6;
function onMove(e: PointerEvent) {
if (!state.active) return;
state.x = e.clientX;
state.y = e.clientY;
if (!state.moved && Math.hypot(e.clientX - startX, e.clientY - startY) > THRESHOLD) {
state.moved = true;
}
}
function onUp(e: PointerEvent) {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
const src = state.active;
const wasDrag = state.moved;
state.active = null;
state.moved = false;
if (!src || !wasDrag) return; // a tap — the board click handler deals with it
const el = document.elementFromPoint(e.clientX, e.clientY);
const sqEl = el?.closest('[data-square]') as HTMLElement | null;
const target = sqEl?.dataset.square as Square | undefined;
if (src.kind === 'board') {
if (target === src.from) { suppressClickOn = src.from; return; }
if (!target) { phantoms.remove(src.from); return; } // dropped off the board
if (game.state.view?.pieces[target]) return; // your own real piece — reject
phantoms.move(src.from, target);
return;
}
// palette → board
if (target && !game.state.view?.pieces[target]) phantoms.place(target, src.type);
}
function start(src: DragSource, e: PointerEvent) {
suppressClickOn = null;
state.active = src;
state.x = startX = e.clientX;
state.y = startY = e.clientY;
state.moved = false;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
/** The board calls this first in its square-click handler. */
function shouldSuppressClick(sq: Square): boolean {
if (suppressClickOn === sq) { suppressClickOn = null; return true; }
return false;
}
return { state, start, shouldSuppressClick };
}
export const phantomDrag = makeDrag();
```
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @blind-chess/client typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/client/src/lib/stores/phantom-drag.svelte.ts
git commit -m "feat(client): pointer-event drag controller for the phantom layer"
gitea push
```
---
### Task 9: Render phantoms and drag handles in `Board.svelte`
**Files:**
- Modify: `packages/client/src/lib/Board.svelte`
- [ ] **Step 1: Import the drag controller**
In the `<script>` block of `packages/client/src/lib/Board.svelte`, after the `import { pieceGlyph }` line, add:
```ts
import { phantomDrag } from './stores/phantom-drag.svelte.js';
```
- [ ] **Step 2: Add the new props (optional, with defaults)**
In the `Props` interface, after `highlightingEnabled: boolean;`, add two **optional** props (optional so `Board.svelte` typechecks on its own before `Game.svelte` is updated in Task 11):
```ts
phantoms?: Partial<Record<Square, Piece>>;
phantomsEnabled?: boolean;
```
In the `let { ... }: Props = $props();` destructuring, add them with defaults (e.g. after `highlightingEnabled,`):
```ts
phantoms = {}, phantomsEnabled = false,
```
- [ ] **Step 3: Add the dragged-origin derived value**
After the `highlights` `$derived.by(...)` block, add:
```ts
// The board square a phantom is currently being dragged out of (so it can
// be dimmed while the drag ghost is shown). Bind `active` to a local first
// so TypeScript narrows the discriminated union reliably.
const dragOrigin = $derived.by(() => {
const a = phantomDrag.state.active;
return a?.kind === 'board' && phantomDrag.state.moved ? a.from : null;
});
```
- [ ] **Step 4: Suppress the post-drag click in `onSquareClick`**
Make the first line of the `onSquareClick` function body:
```ts
function onSquareClick(sq: Square) {
if (phantomDrag.shouldSuppressClick(sq)) return;
const piece = pieces[sq];
```
- [ ] **Step 5: Render the phantom layer**
In the markup, the square loop currently has these `{@const}` lines:
```svelte
{@const sq = `${f}${r}` as Square}
{@const piece = pieces[sq]}
```
Add a third after them:
```svelte
{@const ph = phantomsEnabled ? phantoms[sq] : undefined}
```
On the `<button>` element add a `data-square` attribute (next to `aria-label={sq}`):
```svelte
data-square={sq}
```
Inside the button, immediately after the `{#if piece}...{/if}` block and before the `{#if isHighlight && !piece}` block, add:
```svelte
{#if ph && !piece}
<span
class="phantom phantom-{ph.color}"
class:dragging={sq === dragOrigin}
onpointerdown={(e) => { e.preventDefault(); phantomDrag.start({ kind: 'board', from: sq, type: ph.type }, e); }}
>{pieceGlyph(ph)}</span>
{/if}
```
- [ ] **Step 6: Add phantom styles**
In the `<style>` block, after the `.piece-b { ... }` rule, add:
```css
.phantom {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
touch-action: none;
cursor: grab;
opacity: 0.4;
outline: 2px dashed var(--text-dim);
outline-offset: -5px;
}
.phantom-w { color: #fafafa; }
.phantom-b { color: #1a1a1a; }
.phantom.dragging { opacity: 0.12; }
```
(The translucency plus the dashed inset frame make a phantom unmistakable from a real piece — tune to taste during manual verification.)
- [ ] **Step 7: Typecheck**
Run: `pnpm --filter @blind-chess/client typecheck`
Expected: PASS — the new props are optional with defaults, so `Board.svelte` typechecks even though `Game.svelte` does not pass them yet (Task 11 wires them).
- [ ] **Step 8: Commit**
```bash
git add packages/client/src/lib/Board.svelte
git commit -m "feat(client): render and drag phantom pieces on the board"
gitea push
```
---
### Task 10: `PhantomPalette` component
A row of the six opponent piece types; drag one onto the board to place a phantom.
**Files:**
- Create: `packages/client/src/lib/PhantomPalette.svelte`
- [ ] **Step 1: Create the component**
Create `packages/client/src/lib/PhantomPalette.svelte`:
```svelte
<script lang="ts">
import type { Color, PieceType } from '@blind-chess/shared';
import { pieceGlyph } from './pieces.js';
import { phantomDrag } from './stores/phantom-drag.svelte.js';
interface Props { oppColor: Color; }
let { oppColor }: Props = $props();
const TYPES: PieceType[] = ['q', 'r', 'b', 'n', 'p', 'k'];
</script>
<div class="palette">
<span class="hint muted">Drag onto your board — your guess of where the opponent is.</span>
<div class="pieces">
{#each TYPES as t (t)}
<span
class="pp pp-{oppColor}"
role="button"
tabindex="0"
aria-label={`place ${t}`}
onpointerdown={(e) => { e.preventDefault(); phantomDrag.start({ kind: 'palette', type: t }, e); }}
>{pieceGlyph({ color: oppColor, type: t })}</span>
{/each}
</div>
</div>
<style>
.palette {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
}
.hint { font-size: 12px; }
.pieces { display: flex; gap: 6px; }
.pp {
font-size: 30px;
line-height: 1;
cursor: grab;
touch-action: none;
opacity: 0.75;
user-select: none;
}
.pp:hover { opacity: 1; }
.pp-w { color: #fafafa; }
.pp-b { color: #1a1a1a; }
</style>
```
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @blind-chess/client typecheck`
Expected: PASS — `PhantomPalette.svelte` is self-contained.
- [ ] **Step 3: Commit**
```bash
git add packages/client/src/lib/PhantomPalette.svelte
git commit -m "feat(client): phantom-piece palette component"
gitea push
```
---
### Task 11: Wire the phantom layer into `Game.svelte`
Load/clear the phantom store, pass phantoms to the board, mount the palette, and render the drag ghost.
**Files:**
- Modify: `packages/client/src/lib/Game.svelte`
- [ ] **Step 1: Add imports**
In the `<script>` block of `packages/client/src/lib/Game.svelte`, after the existing component imports, add:
```ts
import PhantomPalette from './PhantomPalette.svelte';
import { pieceGlyph } from './pieces.js';
import { phantoms } from './stores/phantoms.svelte.js';
import { phantomDrag } from './stores/phantom-drag.svelte.js';
```
- [ ] **Step 2: Add derived gating values**
After the existing `const turnLabel = ...` derived block, add:
```ts
const oppColor = $derived<'w' | 'b'>(game.state.you === 'w' ? 'b' : 'w');
// Phantom layer is blind-mode-only and shown only during active play.
const phantomLayerEnabled = $derived(
game.state.mode === 'blind' && game.state.gameStatus === 'active',
);
// The piece type currently being dragged (for the floating ghost), or null.
const dragGhost = $derived.by(() => {
const a = phantomDrag.state.active;
return a && phantomDrag.state.moved ? a.type : null;
});
// Load the phantom layer once `you` is known (blind games only).
let phantomsLoaded = $state(false);
$effect(() => {
if (phantomsLoaded) return;
const you = game.state.you;
if (you && game.state.mode === 'blind') {
untrack(() => phantoms.loadForGame(gameId, you));
phantomsLoaded = true;
}
});
// Drop the phantom layer when the game ends.
$effect(() => {
if (game.state.gameStatus === 'finished') {
untrack(() => phantoms.clearForGame(gameId));
}
});
```
(`untrack` is already imported at the top of `Game.svelte`.)
- [ ] **Step 3: Pass phantoms to `Board` and mount the palette**
Replace the `<div class="board-area">...</div>` block with:
```svelte
<div class="board-area">
<Board
pieces={game.state.view.pieces}
you={game.state.you}
toMove={game.state.view.toMove}
mode={game.state.mode ?? 'blind'}
highlightingEnabled={game.state.highlightingEnabled}
phantoms={phantomLayerEnabled ? phantoms.state.phantoms : {}}
phantomsEnabled={phantomLayerEnabled}
armedSquare={armedSquare}
touchedSquare={game.state.touchedPiece}
{onArm}
{onCommit}
/>
{#if phantomLayerEnabled}
<PhantomPalette {oppColor} />
{/if}
</div>
```
- [ ] **Step 4: Render the drag ghost**
Immediately after the closing `</div>` of `<div class="game-layout" ...>` (and before the `{#if pendingPromotion ...}` block), add:
```svelte
{#if dragGhost}
<div
class="drag-ghost piece-{oppColor}"
style="left: {phantomDrag.state.x}px; top: {phantomDrag.state.y}px;"
>{pieceGlyph({ color: oppColor, type: dragGhost })}</div>
{/if}
```
- [ ] **Step 5: Add layout styles for the palette and ghost**
In the `<style>` block, replace the `.board-area { ... }` rule with:
```css
.board-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
```
Then append:
```css
.drag-ghost {
position: fixed;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 1000;
font-size: 44px;
line-height: 1;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
}
.drag-ghost.piece-w { color: #fafafa; }
.drag-ghost.piece-b { color: #1a1a1a; }
```
- [ ] **Step 6: Typecheck**
Run: `pnpm --filter @blind-chess/client typecheck`
Expected: PASS — the `Board.svelte` props are now satisfied.
- [ ] **Step 7: Manual verification**
With dev servers running, create a **blind game vs computer**. Confirm:
- The board shows 16 translucent phantom pieces on the opponent's home ranks.
- A palette of six piece types appears below the board.
- Dragging a phantom to another square moves it; dragging it off the board removes it.
- Dragging a palette piece onto a board square places a phantom there.
- A phantom cannot be dropped onto a square holding one of your own real pieces (the drop is rejected).
- Tapping (not dragging) a square still arms/commits real moves normally, including onto a square that has a phantom.
- Reload the page mid-game — phantoms persist (loaded from `localStorage`, not re-seeded).
- Create a **vanilla** vs-computer game — no phantom layer or palette appears.
- When a game ends, the phantom layer and palette disappear and the full board is revealed cleanly.
- [ ] **Step 8: Commit**
```bash
git add packages/client/src/lib/Game.svelte
git commit -m "feat(client): wire the phantom opponent-model layer into the game view"
gitea push
```
---
### Checkpoint B — Increment 2 complete
- [ ] **Full build, typecheck, and test:**
```bash
pnpm -r build && pnpm -r typecheck && pnpm -r test
```
Expected: all packages build; all tests pass (Increment 1's 83 + 4 new phantom-model tests = 87).
- [ ] **Deploy Increment 2** per the `Operations` section of `CLAUDE.md` (both instances: CT 690 / `chess.sethpc.xyz` and `chess.local` on VDJ-RIG). Smoke-test the phantom layer on a phone and on desktop in a vs-computer blind game.
- [ ] **Update `DECISIONS.md`** with the locked choices from this work: announcement audience widened to `'both'`; manual phantom model (seeded once, no automation); drag-and-drop interaction for phantoms only (real moves stay click-to-move); phantom layer is client-local and never serialized to the server.
- [ ] **Write a session handoff** via the `/session-handoff` skill.
---
## Notes on testing scope
`packages/shared` and `packages/server` have `vitest`; `packages/client` does not, by deliberate decision (the spec left this to the plan). Feature 3's genuinely-testable logic — start positions and deserialization — lives in `packages/shared/src/phantoms.ts` and is unit-tested there. The client store, drag controller, and Svelte components are covered by `svelte-check` typechecking plus the manual-verification steps. Standing up a client test harness is out of scope for this plan.