From f8faa11b6d3f4f5f0a6c64f5b0d42a6099e9c1b9 Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Mon, 18 May 2026 18:52:00 -0400 Subject: [PATCH] docs: design spec for table-fidelity feature batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three features requested by Andrew Freiberg (physical-game player) and refined by Seth, bringing digital blind chess closer to the physical table: 1. Moderator announces every move and attempted move to both players (widen announcement audience to 'both'; suppress bot retry churn). 2. Running capture tally (server-derived per-viewer protocol field). 3. Phantom opponent pieces — a client-local, drag-and-drop opponent-model overlay, blind mode only, never sent to the server. Spec only; no implementation. Phased: F1+F2 then F3. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-05-18-table-fidelity-features-design.md | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md diff --git a/docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md b/docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md new file mode 100644 index 0000000..bb71eae --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md @@ -0,0 +1,435 @@ +# 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. +- Integration (`ai-game-casual`): the human side receives the bot's final move + announcement and no retry-rejection announcements. + +--- + +## 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>; +``` + +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>` — 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:`, 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.