# 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) ` 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'}
{a.ply > 0 ? `#${a.ply}` : ''} {isAttempt ? `${actor} — ` : ''}{moderatorText(a.text, a.payload)}
``` - [ ] **Step 2: Replace the error styling with neutral styling** In the same file's ` ``` - [ ] **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 `