From be8ecd96b658a4bcd157c0f13ec1cb3c19e1f139 Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Mon, 18 May 2026 19:17:23 -0400 Subject: [PATCH] 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) --- .../2026-05-18-table-fidelity-features.md | 1322 +++++++++++++++++ 1 file changed, 1322 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-table-fidelity-features.md diff --git a/docs/superpowers/plans/2026-05-18-table-fidelity-features.md b/docs/superpowers/plans/2026-05-18-table-fidelity-features.md new file mode 100644 index 0000000..343a8c3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-table-fidelity-features.md @@ -0,0 +1,1322 @@ +# 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 `