2e808008b1
- DECISIONS.md: new "Table-fidelity features" section + deferred items (smart-tracker rejected, highlight/phantom coupling deferred, abandoned-game localStorage cleanup deferred). - CLAUDE.md: current state, test count 78->87, key files, known gaps. - spec: record that the driver unit test covers the bot-suppression path in place of the considered-and-dropped ai-game-casual integration test (resolves a spec/implementation drift the final review flagged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
440 lines
20 KiB
Markdown
440 lines
20 KiB
Markdown
# Table-Fidelity Features — Design Spec
|
||
|
||
> Three features that bring digital blind chess closer to the physical-table
|
||
> experience, requested by Andrew Freiberg (an experienced physical-game
|
||
> player) and refined by Seth.
|
||
|
||
- **Date:** 2026-05-18
|
||
- **Status:** Approved (brainstorm), pending spec review
|
||
- **Project:** blind_chess
|
||
- **Supersedes/extends:** `2026-04-28-blind-chess-design.md` (moderator vocabulary,
|
||
view filter, FSM). No prior decision is reversed; one (`audience` filtering of
|
||
move events) is deliberately widened — see Feature 1.
|
||
|
||
## Motivation
|
||
|
||
From Andrew's email (the physical game uses a human moderator and three people):
|
||
|
||
1. *"Can you make it so the moderator announces all moves or attempted moves,
|
||
white and black? One of the things a player listens for is what the moderator
|
||
says when it is the other player's turn."*
|
||
2. *"A running tabulation so you can see how many pieces you have captured.
|
||
Normally when you play, you set up the opponent's pieces on your board and
|
||
remove them as you make a capture to keep track."*
|
||
|
||
And Seth's framing of the third: *"You move the other players' pieces at will to
|
||
create a model 'guess' of where their pieces are... move the opponent's pieces
|
||
anywhere you want, including off the board."*
|
||
|
||
These map to three features. They are independent in code but share one theme:
|
||
making the digital game as faithful to the physical table as Andrew's
|
||
real-world experience of it.
|
||
|
||
## Scope at a glance
|
||
|
||
| # | Feature | Where | Size |
|
||
|---|---------|-------|------|
|
||
| 1 | Moderator announces every move & attempted move, to both players | server (+ tiny client) | small |
|
||
| 2 | Running capture tally | server + client panel | small–medium |
|
||
| 3 | Phantom opponent pieces (private opponent-model overlay) | client only | medium |
|
||
|
||
**Phasing:** one spec, one plan, implemented in two increments —
|
||
*Increment 1* = Features 1 + 2 (server-centric, shippable alone);
|
||
*Increment 2* = Feature 3 (the larger client build).
|
||
|
||
---
|
||
|
||
## Feature 1 — Moderator announces everything, to both players
|
||
|
||
### Current behaviour
|
||
|
||
`Announcement` carries an `audience` field (`'w' | 'b' | 'both'`), filtered in
|
||
exactly two places: the server's `broadcastSinceLast` (`ws.ts`) and the
|
||
client's `ModeratorPanel`.
|
||
|
||
- **Move events** (`white_moved`, `*_moved_captured`, `*_castled_*`,
|
||
`*_promoted`) — emitted with `audience: opp`. Only the opponent of the mover
|
||
sees them.
|
||
- **Attempted-move errors** (`no_such_piece`, `no_legal_moves`, `wont_help`,
|
||
`illegal_move`) — emitted with `audience: actor`. Only the player who made
|
||
the attempt sees them.
|
||
- **State changes** (`*_in_check`, `*_checkmate`, draws) and
|
||
resign/draw/abandon — already `audience: 'both'`.
|
||
|
||
So a player does **not** hear what the moderator says about the opponent's
|
||
*failed* attempts — exactly the channel Andrew listens for at the table.
|
||
|
||
### Change
|
||
|
||
Both move events and attempted-move errors become `audience: 'both'`. After
|
||
this change **every** announcement is `'both'`; the `audience` field becomes
|
||
uniformly `'both'` but is **retained** as the moderator-channel egress-control
|
||
hook (the filtering code in `ws.ts` and `ModeratorPanel` stays — it is the
|
||
security boundary, now a pass-through).
|
||
|
||
Concretely:
|
||
|
||
- `translator.ts` `translateMove` — the six move-event `announce(...)` calls
|
||
change their audience argument from `opp` to `'both'`.
|
||
- `commit.ts` `announceWith` — `announce(text, color, ply)` becomes
|
||
`announce(text, 'both', ply)`.
|
||
|
||
No protocol change, no enum change.
|
||
|
||
### Client: labelling attempted-move lines
|
||
|
||
The four error enums carry **no colour** (`wont_help`, not `white_wont_help`).
|
||
With the announcement now shared, the panel must say *whose* attempt it was.
|
||
|
||
It can be derived for free: an attempted move only ever happens on the actor's
|
||
turn, and an error announcement's `ply` is `chess.history().length` captured
|
||
*before* the move applies. Therefore **`ply` parity is the actor**:
|
||
`ply % 2 === 0` → White attempted, odd → Black. (Move events already encode
|
||
colour in the enum text and need no parity prefix.)
|
||
|
||
The client (`ModeratorPanel` / `moderator-strings`) prefixes the four error
|
||
texts with `"White — "` / `"Black — "` derived from `ply` parity. The current
|
||
alarm-red `.err` styling on those four entries is replaced with a neutral/muted
|
||
"moderator info" style — with a shared transcript they are commentary, not
|
||
"you did something wrong".
|
||
|
||
### Bot retry suppression
|
||
|
||
The blind Casual bot searches for a legal move by retrying rejected candidates
|
||
inside one decision cycle (`BotDriver`, `RETRY_CAP = 25`). Each rejection runs
|
||
through `announceWith`, which pushes one error announcement onto
|
||
`game.announcements`. With those now `'both'`, the human opponent would see the
|
||
bot's internal search as up to 25 lines of moderator spam.
|
||
|
||
Fix, entirely within `bot/driver.ts`: in `dispatch`, on the `'announce'` retry
|
||
branch, pop the just-pushed announcement off `game.announcements` before
|
||
returning `{ kind: 'retry' }`. This is safe because:
|
||
|
||
- `announceWith` pushes exactly one announcement and the driver receives it as
|
||
`result.announcements[0]`; nothing mutates `game.announcements` in between,
|
||
so it is the last element. Pop with an identity guard
|
||
(`if (last === result.announcements[0]) pop()`).
|
||
- The whole bot decision cycle runs inside `pokeBot(game)` and completes
|
||
*before* `ws.ts` calls `broadcastSinceLast` — intermediate announcements are
|
||
removed before any broadcast ever runs.
|
||
- The bot tracks its own rejections via `attemptHistory` (explicitly passed),
|
||
not via `newAnnouncements`, so popping does not affect the bot's logic.
|
||
|
||
Only the bot's **final committed move** announcement survives and is broadcast
|
||
(that is Feature 1 working: the human hears "Black has moved"). A bot that
|
||
exhausts retries still resigns; the human sees the resignation, not the 25
|
||
fumbles.
|
||
|
||
Human probes are **not** suppressed: a person tries 1–3 pieces per turn,
|
||
naturally bounded — broadcasting those is the feature.
|
||
|
||
### Information-channel note (deliberate)
|
||
|
||
Feature 1 genuinely widens what blind mode reveals. Hearing "won't help you" on
|
||
the opponent's turn tells you they are pinned or otherwise constrained;
|
||
`no_legal_moves` tells you they touched a fully-boxed-in piece. This is the
|
||
*intended* moderator channel and it is faithful to the physical game (Andrew,
|
||
an experienced physical-game player, explicitly asked for it). It is recorded
|
||
here as a conscious, authorised reduction in blindness — **not** a leak through
|
||
an illegitimate side channel. `buildView` and `geometric.ts` (the zero-leak
|
||
core) are untouched.
|
||
|
||
### Files
|
||
|
||
- `packages/server/src/translator.ts` — audience of move events.
|
||
- `packages/server/src/commit.ts` — audience in `announceWith`.
|
||
- `packages/server/src/bot/driver.ts` — pop intermediate retry rejections.
|
||
- `packages/client/src/lib/moderator-strings.ts` /
|
||
`packages/client/src/lib/ModeratorPanel.svelte` — actor prefix via `ply`
|
||
parity; neutral styling for attempted-move lines.
|
||
|
||
### Tests
|
||
|
||
- Unit (`translator`): move events carry `audience: 'both'`.
|
||
- Unit (`commit-fsm`): `no_such_piece` / `no_legal_moves` / `wont_help` /
|
||
`illegal_move` carry `audience: 'both'`.
|
||
- Unit (`driver`): after a decision cycle that incurs ≥1 retry, only the final
|
||
move's announcement(s) remain in `game.announcements`; intermediate
|
||
rejections are absent.
|
||
- The `driver` unit test is the chosen coverage for the bot-suppression path:
|
||
it drives the real `BotDriver` → `handleCommit` → `announceWith` pipeline, so
|
||
it verifies suppression at the commit-path level. A separate `ai-game-casual`
|
||
WebSocket integration test was considered and dropped — it would only
|
||
additionally exercise the trivial `broadcastSinceLast` pass-through filter,
|
||
for materially more harness complexity.
|
||
|
||
---
|
||
|
||
## Feature 2 — Running capture tally
|
||
|
||
### What the player sees
|
||
|
||
A read-only panel beside the board:
|
||
|
||
- Primary line — **"You've captured:"** followed by glyphs of the opponent
|
||
pieces you have taken, with a count, e.g. `♟ ♟ ♞ (3)`.
|
||
- Secondary muted line — **"Lost:"** followed by glyphs of your pieces the
|
||
opponent has taken, e.g. `♙ ♗ (2)`.
|
||
|
||
(Andrew asked specifically for captures; losses are free to compute and
|
||
complete the picture. Seth may drop the "Lost" line at spec review.)
|
||
|
||
### Why this needs the server
|
||
|
||
In blind mode the capturing client cannot see what it captured — opponent
|
||
pieces are filtered out of its `BoardView`. The captured piece's *type* must
|
||
come from the server. This is the same single-piece-of-history reveal the
|
||
physical moderator gives you when you take a piece.
|
||
|
||
### Data model
|
||
|
||
`MoveRecord` already records `by: Color` and `capturedPieceType?: PieceType`
|
||
for every move (`state.ts`). The tally is a pure derivation of
|
||
`game.moveHistory`.
|
||
|
||
New shared type (`packages/shared/src/types.ts`):
|
||
|
||
```ts
|
||
export type PieceTally = Partial<Record<PieceType, number>>;
|
||
```
|
||
|
||
New field on the `joined` and `update` server messages
|
||
(`packages/shared/src/protocol.ts`):
|
||
|
||
```ts
|
||
captures: { byYou: PieceTally; byOpponent: PieceTally };
|
||
```
|
||
|
||
`captures` is sent on **every** `update` (it is tiny and keeps `update`
|
||
idempotent — replaying the latest `update` still renders correctly, matching
|
||
the existing protocol decision).
|
||
|
||
### Server computation
|
||
|
||
New module `packages/server/src/captures.ts`:
|
||
|
||
```ts
|
||
function captureTally(game, viewer): { byYou: PieceTally; byOpponent: PieceTally }
|
||
```
|
||
|
||
Iterates `game.moveHistory`; for each record with `capturedPieceType`,
|
||
increments `byYou` if `by === viewer`, else `byOpponent`. En-passant captures
|
||
are included automatically (`capturedPieceType` is `'p'`).
|
||
|
||
`ws.ts` includes `captures: captureTally(game, color)` in the `joined` payload
|
||
(`onHello`) and in `update` payloads (`sendUpdateTo`).
|
||
|
||
### Client
|
||
|
||
- `game.svelte.ts` store gains a `captures` field, set from `joined` and
|
||
`update`.
|
||
- New component `packages/client/src/lib/CaptureTally.svelte`, rendered in the
|
||
side panel of `Game.svelte` near `ModeratorPanel`. Reuses `pieces.ts`
|
||
`pieceGlyph`. `byYou` glyphs render in the opponent's colour (they are
|
||
opponent pieces); `byOpponent` glyphs render in your colour.
|
||
|
||
### Modes
|
||
|
||
Built for both modes. In vanilla it is a simple scoreboard; in blind it is the
|
||
load-bearing feature. The bot ignores `captures`; a future ReconBrain may
|
||
consume it (Phase 2, out of scope here).
|
||
|
||
### Files
|
||
|
||
- `packages/shared/src/types.ts` — `PieceTally`.
|
||
- `packages/shared/src/protocol.ts` — `captures` on `joined` / `update`.
|
||
- `packages/server/src/captures.ts` — new, `captureTally`.
|
||
- `packages/server/src/ws.ts` — include `captures` in `joined` / `update`.
|
||
- `packages/client/src/lib/stores/game.svelte.ts` — store field.
|
||
- `packages/client/src/lib/CaptureTally.svelte` — new component.
|
||
- `packages/client/src/lib/Game.svelte` — mount the panel.
|
||
|
||
### Tests
|
||
|
||
- Unit (`captures`): `captureTally` returns correct per-viewer counts for a
|
||
`moveHistory` containing captures by both colours, including en passant.
|
||
- Client component verified manually (no client test harness — see Feature 3).
|
||
|
||
---
|
||
|
||
## Feature 3 — Phantom opponent pieces
|
||
|
||
A private opponent-model overlay on the player's own board: the digital form of
|
||
"set up the opponent's pieces on your board and move/remove them to keep
|
||
track." **Blind mode only** — pointless in vanilla, where real opponent pieces
|
||
are visible.
|
||
|
||
### Behaviour (the manual model, per Seth's decision)
|
||
|
||
- **Seeded once** at game start with the opponent's 16 pieces on their standard
|
||
home squares — saves the manual initial setup.
|
||
- **Fully manual thereafter.** Drag any phantom anywhere; drag it off the board
|
||
to remove it; place fresh phantoms from an always-available palette of the
|
||
six piece types. **No count limits, no automation, no auto-removal on
|
||
capture.** Editable at any time, including the opponent's turn.
|
||
- The capture tally (Feature 2) is a **separate** read-only counter — it is
|
||
*not* coupled to the phantom layer.
|
||
- Phantoms are visually distinct from real pieces (translucent, dashed
|
||
outline) and use the opponent's colour.
|
||
- A phantom **cannot** occupy a square holding one of your real pieces (your
|
||
real pieces are known truth). Other squares are all valid — overlapping where
|
||
the opponent might really be is the entire point.
|
||
- On game-over the board reveals all real pieces; the phantom overlay and
|
||
palette are **hidden** so the reveal is clean.
|
||
|
||
### The security invariant (load-bearing)
|
||
|
||
The phantom layer is **100% client-local**. It is never serialized to the wire,
|
||
never sent to the server, never seen by the opponent. It contains zero real
|
||
opponent information by construction — it is the player's own fiction.
|
||
|
||
To make this auditable, the phantom layer gets its **own store**, separate from
|
||
the server-synced `game.svelte.ts`:
|
||
|
||
> New store `packages/client/src/lib/stores/phantoms.svelte.ts`. A reviewer
|
||
> confirms "phantoms never leak" by verifying this store is never read in any
|
||
> `send(...)` / `commit(...)` path. No `ClientMessage` variant carries phantom
|
||
> data.
|
||
|
||
`buildView` and `geometric.ts` are untouched.
|
||
|
||
### Data model
|
||
|
||
- Phantom state: `Partial<Record<Square, Piece>>` — at most one phantom per
|
||
square; `Piece.color` is always the opponent's colour. Placing on an occupied
|
||
phantom square replaces.
|
||
- Store operations: `place(sq, type)`, `move(from, to)`, `remove(sq)`,
|
||
`clear()`, `loadForGame(gameId, you)`.
|
||
- **Persistence:** `localStorage`, key `bc:phantoms:<gameId>`, value = JSON of
|
||
the phantom map. Survives reload / reconnect (important on phones).
|
||
- **Seeding:** on first load of a blind game (no `localStorage` key present),
|
||
seed the 16 opponent pieces at standard start squares for the opponent
|
||
colour (`you === 'w' ? 'b' : 'w'`) and persist immediately. The presence of
|
||
the key thereafter means "already seeded — load, do not re-seed", so a reload
|
||
never wipes the player's edits.
|
||
- On `gameStatus === 'finished'`, clear the `localStorage` key (avoids
|
||
unbounded accumulation across games).
|
||
|
||
The pure transformation logic — standard start squares for a colour,
|
||
place/move/remove on a map, (de)serialization — is extracted into a plain
|
||
(non-`.svelte`) module so it can be unit-tested. `phantoms.svelte.ts` is the
|
||
thin reactive wrapper. (The plan decides whether to stand up a `vitest` config
|
||
in the client package, which currently has none, or host the pure module in
|
||
`packages/shared`.)
|
||
|
||
### Interaction — drag-and-drop (approved option A)
|
||
|
||
Drag-and-drop via **pointer events** (`pointerdown` / `pointermove` /
|
||
`pointerup`) so it works for both mouse and touch. Real moves stay
|
||
click-to-move (the touch-move FSM is unchanged — the deferred decision against
|
||
drag-and-drop for *real* moves still stands; F3's drag is phantom-only).
|
||
|
||
- **Move a phantom:** `pointerdown` on a phantom → once the pointer moves past a
|
||
small threshold (~6 px) it is a drag; a drag image follows the pointer.
|
||
`pointerup` over another square → move the phantom there; over a square with
|
||
your real piece → invalid, snap back; outside the board → remove the phantom.
|
||
- **Place from palette:** `pointerdown` on a palette piece → drag → `pointerup`
|
||
over a board square free of your real piece → place a phantom of that type.
|
||
- **Tap vs drag:** a `pointerdown`+`pointerup` with no movement past the
|
||
threshold is **not** a phantom action — it is forwarded to the board's normal
|
||
`onSquareClick`, so you can still arm/commit a real move onto a square you
|
||
have a phantom guess on (e.g. to capture there). A drag past the threshold
|
||
`stopPropagation`s so the underlying square click does not also fire.
|
||
|
||
This isolation — tap → real move, drag → phantom — means phantom editing never
|
||
blocks the live game's move path and needs no mode toggle.
|
||
|
||
### Rendering & components
|
||
|
||
- The phantom layer is rendered **within `Board.svelte`** as an additional
|
||
styled layer in each grid cell (alignment with real squares is then free).
|
||
`Board.svelte` owns the pointer-event handling and the tap-vs-drag
|
||
disambiguation, because that decision cannot be cleanly split across
|
||
components. `Board.svelte` remains prop-driven: it receives phantom data and
|
||
`onPhantomMove` / `onPhantomPlace` / `onPhantomRemove` callbacks.
|
||
- New component `packages/client/src/lib/PhantomPalette.svelte` — the
|
||
six-type palette, rendered beside the board on desktop / below on mobile;
|
||
source of palette→board drags.
|
||
- `Game.svelte` wires the `phantoms` store to `Board` and `PhantomPalette`,
|
||
and gates the whole phantom UI on `mode === 'blind' && gameStatus ===
|
||
'active'`.
|
||
- Phantom styling lives in `app.css` / component styles (translucent, dashed).
|
||
|
||
### Out of scope for v1
|
||
|
||
Highlighting ignores phantoms — the geometric highlight stays a function of
|
||
your real pieces only. Letting bishop/rook rays stop at phantom pieces would be
|
||
information-safe (phantoms hold zero real opponent data) and is a reasonable
|
||
future enhancement, but it is not in this spec.
|
||
|
||
### Files
|
||
|
||
- New: `packages/client/src/lib/stores/phantoms.svelte.ts`,
|
||
`packages/client/src/lib/PhantomPalette.svelte`, a pure phantom-model module
|
||
(location per plan), optionally a small pointer-drag helper.
|
||
- Modified: `packages/client/src/lib/Board.svelte` (phantom layer + drag +
|
||
tap-vs-drag), `packages/client/src/lib/Game.svelte` (mount palette, wire
|
||
store, blind+active gating), `packages/client/src/app.css` (phantom styles).
|
||
- No server or shared changes for Feature 3 (unless the pure model module is
|
||
hosted in `packages/shared`).
|
||
|
||
### Tests
|
||
|
||
Feature 3 is client-only and the project currently has no client test harness
|
||
(the 78 existing tests are all `shared` + `server`). The pure phantom-model
|
||
logic (seed squares, place/move/remove, (de)serialization) is unit-tested; the
|
||
drag interaction and rendering are verified manually on phone + desktop. The
|
||
plan decides the test-infra approach.
|
||
|
||
---
|
||
|
||
## Architecture & invariants summary
|
||
|
||
- **Feature 1** widens the moderator channel: every `Announcement` becomes
|
||
`audience: 'both'`. The field and its filtering are retained as the egress
|
||
control. This is a deliberate, authorised increase in shared information,
|
||
faithful to the physical game.
|
||
- **Feature 2** adds one server-derived, per-viewer protocol field
|
||
(`captures`). Capture *types* stay out of the `ModeratorText` enum —
|
||
announcements remain a pure event vocabulary; the tally is structured data.
|
||
- **Feature 3** adds a client-local-only layer that never reaches the wire,
|
||
isolated in its own store for auditability.
|
||
- The zero-leak core — `buildView` and `geometric.ts` — is **not touched** by
|
||
any of the three features.
|
||
|
||
## Phasing
|
||
|
||
| Increment | Contents | Independently shippable |
|
||
|-----------|----------|--------------------------|
|
||
| 1 | Features 1 + 2 | Yes — server + a read-only client panel |
|
||
| 2 | Feature 3 | Yes — client phantom layer |
|
||
|
||
One implementation plan; tasks ordered so Increment 1 completes (and can
|
||
deploy) before Increment 2 begins.
|
||
|
||
## Out of scope / explicitly rejected
|
||
|
||
- **Smart-tracker phantom model** (auto-removal on capture, promotion
|
||
bookkeeping, constrained opponent army) — rejected by Seth in favour of the
|
||
manual model.
|
||
- **Phantom layer in vanilla mode** — pointless; excluded.
|
||
- **Drag-and-drop for real moves** — still deferred (DECISIONS.md). F3's drag
|
||
is phantom-only.
|
||
- **Highlighting interacting with phantoms** — safe future enhancement, not
|
||
v1.
|
||
- **Capture tally feeding bot decisions** — bot ignores it; possible Phase-2
|
||
ReconBrain input.
|
||
- **Sending phantom state to the server / persisting it server-side** — would
|
||
break the security invariant; never.
|
||
|
||
## Open questions
|
||
|
||
None outstanding. Resolved during brainstorm:
|
||
|
||
- Phantom model: manual, seeded once, no automation, unlimited placement,
|
||
editable anytime (Seth).
|
||
- Phantom interaction: drag-and-drop, tap-vs-drag disambiguation, no mode
|
||
toggle (Seth — option A).
|
||
- Feature 2 "Lost" secondary line: included by default; Seth may drop it at
|
||
spec review.
|