35 Commits

Author SHA1 Message Date
claude (blind_chess) d95ab2abf1 docs: refresh handoff — promotion fix shipped, both fixes deployed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:53:23 -04:00
claude (blind_chess) c01244c850 fix: promotion dialog only fires for genuine pawn promotions
The "Promote pawn" dialog popped for any pawn "moved" toward the last
rank: the commit paths checked piece type + destination rank but never
the pawn's SOURCE rank. With the phantom layer now filling ranks 7-8
with tappable phantom pieces, tapping one (which falls through to the
real-move handler) while a real pawn was armed triggered the dialog for
a move no pawn could make — and for any phantom type, not just pawns.

Root cause: incomplete promotion detection, duplicated in Game.svelte
`onCommit` and the server's `isPromotionRequired`. Replaced with one
shared `isPromotionMove(piece, from, to)` — pawn, from the rank adjacent
to promotion, to the promotion rank, at most one file over — used by
both. 7 unit tests in packages/shared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:45:42 -04:00
claude (blind_chess) 5d995eb428 docs: update handoff — deployed, test pass underway, contrast fix pending deploy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:33:46 -04:00
claude (blind_chess) d10e581243 fix(client): light outline on dark phantom glyphs for panel contrast
Opponent (black) phantom pieces rendered near-invisible on the dark
--panel background in the palette and Captures panel, and the black
drag-ghost was low-contrast over dark areas. Black piece glyphs in
those three spots now get a light text-shadow outline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:22:50 -04:00
claude (blind_chess) 077330054b docs: table-fidelity batch deployed to both instances
Deployed 2026-05-18 to CT 690 (chess.sethpc.xyz) and chess.local
(VDJ-RIG); both verified serving the new client build. CLAUDE.md and
the handoff updated to deployed state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:09:02 -04:00
claude (blind_chess) 0c0e739bd3 docs: session handoff — table-fidelity batch code-complete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:00:20 -04:00
claude (blind_chess) 2e808008b1 docs: record table-fidelity feature batch as code-complete
- 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>
2026-05-18 20:57:02 -04:00
claude (blind_chess) 59717b3b5b docs: amend plan to reflect code-review fixes
Tasks 8/10/11 received review fixes during execution; the plan's code
blocks are updated to match what shipped:
- Task 8: drag controller handles pointercancel + idempotent start.
- Task 10: palette pieces are plain spans + svelte-ignore (no
  focusable-but-not-operable role/tabindex).
- Task 11: phantom-load effect keyed on gameId; drag ghost gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:51:37 -04:00
claude (blind_chess) 82a69d8812 fix(client): key phantom-load effect on gameId, gate the drag ghost
Re-key the phantom-load effect on `loadedFor` (tracks gameId) so it
reloads if the same <Game> instance is reused for a different game
without a remount. Also gate the drag-ghost block behind
`phantomLayerEnabled` for consistency with all other phantom UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:50:13 -04:00
claude (blind_chess) 313837eb21 feat(client): wire the phantom opponent-model layer into the game view
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:45:40 -04:00
claude (blind_chess) 816f89be36 feat(client): phantom-piece palette component
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:41:43 -04:00
claude (blind_chess) c65db03cfa chore(client): suppress phantom-span a11y warning with documented svelte-ignore
Phantom pieces are a pointer-only overlay; adding role/tabindex would create
a focusable element with no keyboard equivalent (worse a11y, not better).
The real game remains fully keyboard-operable via the square <button>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:39:37 -04:00
claude (blind_chess) 599dc17f44 feat(client): render and drag phantom pieces on the board
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:35:58 -04:00
claude (blind_chess) 4b3e587f6c fix(client): handle pointercancel and make drag-start idempotent
Add onCancel handler for browser/OS gesture takeover (scroll, palm
rejection) that previously leaked window listeners and left drag stuck.
Extract detach() helper called by onUp, onCancel, and start() for
idempotency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:32:20 -04:00
claude (blind_chess) f52f7dbb8f feat(client): pointer-event drag controller for the phantom layer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:28:37 -04:00
claude (blind_chess) bd98315fe3 fix(client): guard phantom-store mutations against unset game and no-op move
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:27:25 -04:00
claude (blind_chess) 0583984723 feat(client): local-only phantom-layer store
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:24:13 -04:00
claude (blind_chess) 2ae2c8013c test(shared): cover null-valued entry in deserializePhantoms
Adds a null-valued entry under a valid square key (d3) to the
'keeps valid entries and drops invalid ones' fixture, proving the
typeof/null guard branch in deserializePhantoms is exercised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:23:17 -04:00
claude (blind_chess) a574100e25 feat(shared): pure phantom-model helpers (seed positions, deserialize)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:20:46 -04:00
claude (blind_chess) 783d85a40c feat(client): capture-tally panel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:16:18 -04:00
claude (blind_chess) 3169995d7f refactor(server): type captureTally accumulators as PieceTally
Tighten the local byYou/byOpponent accumulators from the wider
Record<string, number> to PieceTally, matching the return type's fields
and preventing silent invalid-key writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:14:26 -04:00
claude (blind_chess) ce36755a89 feat(server): per-viewer capture tally on joined and update messages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:09:26 -04:00
claude (blind_chess) 0498f1de43 feat(client): label attempted-move announcements by player
Attempted-move lines (no_such_piece, no_legal_moves, wont_help, illegal_move)
now show "White — " or "Black — " prefix derived from ply parity. Removed
alarm-red err styling; replaced with neutral dim+italic via .entry.attempt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:04:49 -04:00
claude (blind_chess) 5282237027 refactor(bot): hoist the rejection announcement to a single local
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:03:37 -04:00
claude (blind_chess) 558891ed37 feat(bot): suppress bot retry-search churn from the moderator log
Pop intermediate wont_help/illegal_move/no_such_piece/no_legal_moves
announcements produced during the bot's decision cycle before any
broadcast reaches the human opponent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:00:25 -04:00
claude (blind_chess) 76717cf52e docs(server): correct translateMove audience docs after the 'both' change
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:58:48 -04:00
claude (blind_chess) 41b3ab93bb feat(server): moderator announces every move and attempt to both players
All move-event announcements in translator.ts and all attempted-move
announcements in commit.ts now use audience 'both' so the moderator
panel is a complete shared transcript for both players.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:54:34 -04:00
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
claude (blind_chess) f8faa11b6d docs: design spec for table-fidelity feature batch
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) <noreply@anthropic.com>
2026-05-18 18:52:00 -04:00
claude (blind_chess) b01f324c3b feat(deploy): local chess.local instance for VDJ-RIG
A second, LAN-only deploy alongside the CT 690 / chess.sethpc.xyz
instance. Runs on VDJ-RIG as a persistent systemd daemon, served on
port 80 and reachable at http://chess.local via an mDNS alias.

- blind-chess-local.service: server unit; binds port 80 as the
  non-root blindchess user via CAP_NET_BIND_SERVICE.
- chess-mdns-alias{,.service}: publishes the chess.local mDNS name
  with avahi-publish -a -R (-R skips the reverse PTR, which would
  otherwise collide with the host's own <hostname>.local record).
- install-local.sh: idempotent root-side installer (Node 22 via
  NodeSource, avahi-utils, blindchess user, /opt/blind-chess, units).
- CLAUDE.md: documents the local instance under Operations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:40:02 -04:00
claude (blind_chess) e75f5fff7b docs: CLAUDE.md current state reflects blind-Casual check fix
Phase line gains the 2026-04-29 fix; test count 75→78; observability gap
narrowed to note `[bot resign]` is now grep-able while metrics/tracing
remain deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:41:42 -04:00
claude (blind_chess) 04494fcdee docs: handoff for blind Casual check-resolution fix
Captures session state: root cause, fix, verification numbers (blind 100%
-> 17% resignation, avg ply 26 -> 90), preserved view-filter invariant,
deferred Phase 2 work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 06:05:21 -04:00
claude (blind_chess) f00164ebbb chore: gitignore tmp/ for self-play transcripts
Self-play transcripts produced by `pnpm selfplay --transcripts` land in
`tmp/selfplay-runs/<timestamp>/` — operator scratch, not part of source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 05:56:14 -04:00
claude (blind_chess) dc7f8adcdf fix(bot): blind Casual no longer resigns prematurely under check
The blind-mode CasualBrain heuristic ignored the moderator's
'<own>_in_check' announcement and scored moves on capture/advance/development
signals uncorrelated with check resolution. chess.js rejected every
non-resolving attempt, BotDriver's RETRY_CAP=5 fired, and the bot resigned.
100-game blind self-play: 100% resignations at avg ply 26.

Fix:
- CasualBrain.detectOwnCheck() scans newAnnouncements for the own-color
  in_check tag; when set, heuristicPick() applies a +5000 boost to king
  moves so they're tried first. Information stays within the public
  moderator vocabulary — no oracle access, view-filter invariant intact.
- RETRY_CAP raised 5 -> 25. Vanilla never hits the cap (chess.js verbose
  moves are guaranteed legal); blind needs more budget to find a legal
  move through pseudo-legal candidates.
- BotDriver.botResign() now logs '[bot resign]' with gameId/color/mode/ply/
  reason/detail. Previously silent — operator had no signal in journald.

Verification (100-game blind Casual-vs-Casual self-play):
- avgPly: 26 -> 90 (3.5x)
- Resignations: 100% -> 17%
- Checkmates: 0% -> 42%
- Threefold draws: 0% -> 41%

Vanilla regression check (80 games combined): 0 resigns either way,
strength unchanged (Casual still wins 98% vs random).

78 tests pass (was 75; +2 new check-resolution tests, +1 retry-cap test
updated to match new cap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 05:56:02 -04:00
claude (blind_chess) 1213ec8fb1 docs: handoff reflects final merged state 2026-04-28 15:25:03 -04:00
37 changed files with 3146 additions and 66 deletions
@@ -4,7 +4,7 @@
- Created: 2026-04-28 ~19:15 UTC
- Project: /home/claude/bin/blind_chess
- Branch: `feat/ai-player-phase-1-casual` (16 commits ahead of main; pending merge as final step of this handoff)
- Branch: `feat/ai-player-phase-1-casual` merged to `main` via fast-forward at commit `1674695` and pushed.
- Repo: `git.sethpc.xyz/Seth/blind_chess`
- Live URL: **https://chess.sethpc.xyz** (Phase 1 deployed and verified)
@@ -46,7 +46,7 @@ This session executed `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casua
| Casual ≥80% vs Random, both colors | ✅ 97% as W, 96% as B |
| All unit + integration tests pass | ✅ 75/75 (21 shared + 54 server) |
| Live smoke checklist | ✅ /api/health, AI game creation, recon→503, no journald errors |
| Branch merged + deployed | ⏳ Pending merge (final step of this session) |
| Branch merged + deployed | ✅ Merged to main (`1674695`); deployed to CT 690 |
## Critical Files
@@ -72,20 +72,14 @@ This session executed `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casua
## Immediate Next Steps
1. **Merge `feat/ai-player-phase-1-casual` to `main`** (final step of this handoff).
```bash
git checkout main
git merge --ff-only feat/ai-player-phase-1-casual || git merge --no-ff feat/ai-player-phase-1-casual
git push origin main
```
2. **Soak Phase 1 for a few days of real play** before starting Phase 2. Watch for:
- Bot-driver errors in journald (`journalctl -u blind-chess | grep "bot driver error"`).
1. **Soak Phase 1 for a few days of real play** before starting Phase 2. Watch for:
- Bot-driver errors in journald (`ssh root@192.168.0.245 'journalctl -u blind-chess | grep "bot driver error"'`).
- Mid-game crashes or stuck games.
- User feedback on Casual's strength (too weak / too strong / fine).
- User feedback on Casual's strength (too weak / too strong / fine — defaults to `js-chess-engine` level 2).
3. **When ready, write Phase 2 plan** — `docs/superpowers/plans/2026-04-28-ai-player-phase-2-recon.md` against the existing spec. Phase 2 reuses the `Brain` and `BotDriver` infrastructure unchanged; new pieces are `OllamaClient`, `ollama-endpoints` (preflight + failover), `prompt`, `parse`, `ReconBrain`, plus `aiInfo` protocol field, `'ai_unavailable'` end reason, post-game reasoning reveal UI.
2. **When ready, write Phase 2 plan**`docs/superpowers/plans/YYYY-MM-DD-ai-player-phase-2-recon.md` against the existing spec. Phase 2 reuses the `Brain` and `BotDriver` infrastructure unchanged; new pieces are `OllamaClient`, `ollama-endpoints` (preflight + failover), `prompt`, `parse`, `ReconBrain`, plus `aiInfo` protocol field, `'ai_unavailable'` end reason, post-game reasoning reveal UI.
3. **Tune Casual difficulty if needed.** Single-line change in `packages/server/src/bot/casual-brain.ts``level` default in `CasualOpts` (currently 2). Drop to 1 if it feels unbeatable; raise to 3 if trivial.
## Blockers / Open Questions
@@ -0,0 +1,147 @@
# Handoff: Blind Casual check-resolution fix shipped
## Session Metadata
- Created: 2026-04-29 06:01:21 UTC
- Project: /home/claude/bin/blind_chess
- Branch: main (commits `dc7f8ad`, `f00164e` pushed)
- Session duration: ~1 hour
- Live URL: https://chess.sethpc.xyz (deployed and verified)
### Recent Commits (for context)
- `f00164e` chore: gitignore tmp/ for self-play transcripts
- `dc7f8ad` fix(bot): blind Casual no longer resigns prematurely under check
- `1213ec8` docs: handoff reflects final merged state
- `1674695` docs: AI Phase 1 shipped — context, decisions, handoff
- `7c18725` feat(bot): vanilla CasualBrain delegates to js-chess-engine
## Handoff Chain
- **Continues from**: [2026-04-28-191500-ai-phase-1-shipped.md](./2026-04-28-191500-ai-phase-1-shipped.md) — Phase 1 (Casual bot) deployed; the prior handoff predicted this exact bug as a deferred risk: *"the heuristic exhausts its retry cap (5) when the bot picks a move that can't legally proceed in blind mode... Consider raising retry cap or improving heuristic if blind Casual feels broken in real play."*
- **Supersedes**: None.
## Current State Summary
User reported: *"casual bot is resigning prematurely."* Investigation confirmed the prior handoff's prediction. Vanilla mode is rock-solid (0 resigns across 80 stress games); blind mode was 100% resign at avg ply 26 in self-play. Root cause: `CasualBrain.heuristicPick` ignored the `<own>_in_check` moderator announcement and scored moves on capture/advance signals uncorrelated with check resolution. chess.js rejected every non-resolving attempt, `BotDriver.RETRY_CAP=5` fired, and the bot resigned. Fix shipped in two commits, deployed to CT 690, smoke-tested. **Blind self-play (100 games): resigns 100% → 17%, avg ply 26 → 90.** Vanilla regression check confirmed unchanged strength.
## Architecture Overview
The fix preserves the spec's view-filter invariant — **the brain still sees only its own pieces + announcements, no oracle access added**. The data needed to detect check was already being delivered to the brain in `newAnnouncements`; the heuristic just wasn't reading it. This is a recurring shape worth recognizing: a bug that looks like "the AI is broken" often turns out to be "the AI ignored a signal the protocol already sends."
The retry-cap raise (5 → 25) is essentially free for vanilla because chess.js verbose moves are guaranteed legal — vanilla never exercises retries. Blind needs the larger budget because pseudo-legal candidates from `geometricMoves` are filtered by chess.js at commit time and many fail (pinned pieces, unresolved check).
The new `[bot resign]` log line in `BotDriver.botResign()` decouples observability from the fix. Phase 1 had silent resignations — operators couldn't grep journald for them, which is why the bug surfaced as a user report rather than an alert. Future regressions are now greppable: `journalctl -u blind-chess | grep "bot resign"`.
## Critical Files
| File | Purpose | Relevance |
|------|---------|-----------|
| `packages/server/src/bot/casual-brain.ts` | Decision logic; vanilla delegates to js-chess-engine, blind uses heuristic | New `detectOwnCheck()` and `findOwnKing()` methods; `heuristicPick` takes `inCheck` parameter and applies +5000 boost to king moves |
| `packages/server/src/bot/driver.ts` | Per-game orchestrator; mutex, retry, dispatch, dispose | `RETRY_CAP` 5 → 25; `botResign()` now takes a `BotResignReason` and logs `[bot resign]` with structured detail |
| `packages/server/test/unit/bot/casual-brain.test.ts` | Unit tests | +2 tests: check-aware king bias (20-seed determinism check), and fall-through to non-king when all king moves are rejected |
| `packages/server/test/unit/bot/driver.test.ts` | Unit tests | Retry-cap test updated for new RETRY_CAP=25 |
| `scripts/selfplay.ts` | Operator CLI for evaluation | Used heavily this session — `pnpm selfplay --white casual --black casual --games 100 --mode blind --seed 100` |
## Verification Results
| Check | Result |
|---|---|
| Blind 100-game self-play (Casual vs Casual, seed=100) | resigns 100% → 17%, avgPly 26 → 90; 42 checkmates, 41 threefolds |
| Blind 20-game self-play (seed=42, same as pre-fix benchmark) | resigns 100% → 35%, avgPly 26 → 82 |
| Vanilla 30-game self-play (Casual vs Casual, seed=42) | 0 resigns; 27 checkmates, 2 threefolds, 1 fifty-move |
| Vanilla 50-game self-play (Casual W vs Random B, seed=7) | 0 resigns; Casual wins 49/50 |
| Vanilla 50-game self-play (Random W vs Casual B, seed=7) | 0 resigns; Casual wins 49/50 |
| Test suite | 78 passing (was 75; +2 new check tests, +1 driver retry-cap test updated) |
| Live `/api/health` | `{"ok":true,"activeGames":0,"uptime":4}` |
| Live POST `/api/games` with `vsAi.brain=casual` blind mode | 200 + `joinUrl:null` |
| Live POST `/api/games` with `vsAi.brain=recon` | 503 + `ai_offline` (Phase 2 unimplemented, expected) |
| journald post-deploy | No errors/warnings |
## Decisions Made
| Decision | Options Considered | Rationale |
|----------|-------------------|-----------|
| Boost king moves in heuristic vs filter candidates by chess.js legality | (a) heuristic boost — preserves view-filter invariant; (b) chess.js pre-filter — would leak attacker info | Chose (a). Preserves "bots play through the same view filter as humans" principle from the AI spec; same information ration as a human player |
| `RETRY_CAP` 5 → 25 (single global cap) vs per-mode caps | Per-mode (5 vanilla, 25 blind) vs global 25 | Chose global. Vanilla never hits the cap, so single cap simplifies code with no regression |
| King-move boost magnitude +5000 | Smaller (e.g., +200) vs larger | +5000 is large enough to deterministically dominate all other heuristic factors plus the 0.01 random tiebreak; unit test asserts 20/20 seeds pick king moves under check |
| Add resign logging now vs defer | (a) bundled with fix; (b) separate later commit | Bundled. The handoff explicitly noted the silent-resign observability gap; fixing that gap was load-bearing for any future regression detection |
| Two commits (fix + .gitignore) vs one | One bundled commit vs split | Split. Per global homelab convention: "no batching unrelated changes" — .gitignore drift was pre-existing and orthogonal |
## Immediate Next Steps
1. **Soak the fix for a few days of real play** before declaring "blind Casual is solid". Watch for:
- `ssh root@192.168.0.245 'journalctl -u blind-chess | grep "bot resign"'` — should be rare; legitimate forced positions only.
- User feedback on whether blind Casual still feels broken (lower bar but still possible).
- Mid-game stuck states (the retry budget is now 25; with degenerate brain output that's 25× more compute per cycle — should still be sub-second).
2. **When ready, write Phase 2 plan**`docs/superpowers/plans/<DATE>-ai-player-phase-2-recon.md`. Phase 2 reuses the `Brain`/`BotDriver` infrastructure unchanged; new pieces are `OllamaClient`, `ollama-endpoints` (preflight + failover), `prompt`, `parse`, `ReconBrain`, plus `aiInfo` protocol field, `'ai_unavailable'` end reason, post-game reasoning reveal UI.
3. **(Cleanup, low priority)** `git rm --cached packages/server/tsconfig.tsbuildinfo` — file is tracked from before the `*.tsbuildinfo` rule was added to `.gitignore`. Persistent `M` noise in `git status` between any rebuilds. Not blocking.
## Blockers / Open Questions
- **Blind Casual is now noticeably stronger but still loses to careful play.** The 17% post-fix resign rate represents legitimately stuck positions (multi-piece checks with no king escape, etc.) more than blunders. A human in those positions would also struggle. If users still feel blind Casual is unbeatable-or-broken, the next lever is making the heuristic *also* prefer captures and adjacent-to-king moves under check (likely block targets).
- **Threefold draws spiked from 0% → 41% in blind self-play.** Two Casual bots with the same seed/heuristic shuffle pieces and repeat positions. This is more a self-play artifact than a real-play concern; humans don't repeat. Worth watching but not actionable yet.
## Deferred Items
All Phase 2 work, untouched:
- `ReconBrain` (gemma4:26b chat agent on steel141 RTX 3090 Ti, pve197 V100 fallback)
- Mid-game GPU failover, preflight, AI-unavailable end state
- Persistent chat history per game; post-game reasoning reveal UI
- `aiInfo` protocol field (model + GPU + host)
- Acceptance bar: Recon wins ≥60% over 50 Recon-vs-Casual self-play games
## Important Context
- **The view-filter invariant is preserved.** No oracle access was added. The brain detects check via `<own_color>_in_check` in `newAnnouncements`, which is a public moderator announcement humans receive too. Phase 2 ReconBrain will read these same announcements — the pattern is now established.
- **`BrainInput.fen` is set ONLY in vanilla mode.** Blind mode omits it so the engine path can't smuggle opponent positions past the view filter. The fix did not touch this; the security boundary holds.
- **Watermark advance only on successful dispatch** is load-bearing for the fix. On retry, the brain still sees the original `<color>_in_check` announcement from the opponent's move (because `lastSeenAnnouncementCount` doesn't advance until success). This is what makes `detectOwnCheck` robust across retries.
- **The bot still uses the heuristic in vanilla as fallback** if the engine returns a move not in the chess.js candidate list. Vanilla never exercised this path in our tests, but the new `inCheck` parameter is wired through it for safety.
- **`scripts/selfplay.ts` is the canonical evaluation tool.** Phase 2 will extend it to support `--white recon --black casual` etc. The harness sets `game.aiOpponent = undefined; game.status = 'active'` after `createGame` returns — that's how it transitions out of "waiting" without a hello.
## Assumptions Made
- The user was playing in **blind mode** when they reported premature resignation. I didn't ask, but vanilla self-play showed 0 resigns across 80 games while blind showed 100%, so blind was overwhelmingly the more likely mode. If they were actually playing vanilla, that's a different bug — though I have no evidence of one.
- The +5000 king-move boost is "large enough." Verified by 20-seed determinism test; if the heuristic ever gains another factor scoring above ~5000, this assumption breaks and the test will catch it.
- `RETRY_CAP=25` is sufficient. 100-game blind self-play showed 17% still hit the cap — those are legitimate stuck positions, not under-budgeted retry. If real-play feedback says otherwise, raise further (each retry is microseconds for the heuristic; the cap could go to 50+ without performance concern).
## Potential Gotchas
- **`packages/server/tsconfig.tsbuildinfo` shows persistent `M`** in `git status` — it was tracked before `*.tsbuildinfo` was gitignored. Don't be alarmed; it's preexisting drift, not your work.
- **The pre-commit hook is `detect-secrets-hook --baseline .secrets.baseline`** at `~/.config/git/hooks/pre-commit`. If you add a new dep and pnpm-lock hashes get flagged, run `detect-secrets scan > .secrets.baseline` to refresh.
- **Server restart drops in-memory games.** Acceptable for MVP per prior decisions, but be aware: any active player-vs-Casual game in flight at deploy time will lose state.
- **`js-chess-engine` declares `engines: { node: '>=24' }`** but works on Node 22.22.2. Engines is advisory by default. If a future Node update breaks it, pin to v1.x of the package.
## Files Modified This Session
| File | Change |
|------|--------|
| `packages/server/src/bot/casual-brain.ts` | +35 LoC: new `detectOwnCheck`, `findOwnKing`; `heuristicPick` takes `inCheck`, boosts king moves +5000 when set |
| `packages/server/src/bot/driver.ts` | `RETRY_CAP` 5 → 25; `botResign(reason, detail?)` with `console.error('[bot resign]', ...)`; `BotResignReason` union; `errString` helper |
| `packages/server/test/unit/bot/casual-brain.test.ts` | +2 tests (check-aware king preference; fall-through to non-king when king moves exhausted) |
| `packages/server/test/unit/bot/driver.test.ts` | Retry-cap test updated 5 → 25, expected calls updated |
| `.gitignore` | +`tmp/` (separate commit `f00164e`) |
## Environment State
- **CT 690 / blind-chess.service:** running. Restarted 09:54 UTC after deploy. `systemctl is-active` returns `active`.
- **Active processes:** none session-relevant. Deploy was a normal restart of the systemd unit.
- **Environment variables:** none added/changed.
- **Backups:**
- Local: `packages/server/src/bot/.backup/{casual-brain,driver}.ts.1777455623`
- CT 690: `/opt/blind-chess/.backup/server-1777456437.tar.gz`
- **Secrets:** none added; pre-commit detect-secrets hook passed both commits clean.
## Related Resources
- Live URL: https://chess.sethpc.xyz
- Repo: https://git.sethpc.xyz/Seth/blind_chess (`main` at `f00164e`)
- AI Phase 1 spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`
- Phase 1 plan: `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md`
- DECISIONS.md "AI / computer player" section
- Project identity: `CLAUDE.md`
- Prior handoffs: `2026-04-28-191500-ai-phase-1-shipped.md`, `2026-04-28-170713-ai-player-spec.md`, `2026-04-28-152000-mvp-deployed.md`, `2026-04-28-104344-spec-approved-ready-for-plan.md`, `2026-04-28-kickoff.md`
---
**Security Reminder**: This handoff describes a behavior fix; no credentials, secrets, or sensitive endpoints are exposed in the handoff or the deployed code.
@@ -0,0 +1,130 @@
# Handoff: Table-fidelity batch — deployed; manual test pass underway
## Session Metadata
- Created: 2026-05-18 20:57:36
- Project: /home/claude/bin/blind_chess
- Branch: main (all work committed and pushed to `git.sethpc.xyz/Seth/blind_chess`)
- Session duration: ~one full session (brainstorm → spec → plan → 12-task execution)
## Handoff Chain
- **Continues from**: [2026-04-29-060121-blind-casual-check-fix.md](./2026-04-29-060121-blind-casual-check-fix.md) — the prior session fixed blind Casual's premature resignation.
- **Supersedes**: None.
## Recent Commits (for context)
- `c01244c` fix: promotion dialog only fires for genuine pawn promotions — **deployed**
- `5d995eb` docs: update handoff
- `d10e581` fix(client): light outline on dark phantom glyphs for panel contrast — **deployed**
- `0773300` docs: table-fidelity batch deployed to both instances
- `2e80800` docs: record table-fidelity feature batch as code-complete
- Feature implementation range: `be8ecd9..2e80800` (20 commits); two follow-up fixes since (`d10e581`, `c01244c`), both deployed.
## Current State Summary
The "table-fidelity feature batch" (three features Andrew Freiberg — Seth's dad, a physical-blind-chess player — requested by email) is **fully implemented, reviewed, committed to `main`, and deployed** (2026-05-18) to both live instances — `chess.sethpc.xyz` (CT 690) and `chess.local` (VDJ-RIG). All 12 plan tasks were executed via subagent-driven development with two-stage review per task plus a final whole-batch review. Build, typecheck, and the 94-test suite all pass (32 shared + 62 server).
A **manual browser test pass is underway** (Seth). It has surfaced two bugs so far — both fixed and deployed:
1. **Contrast** (`d10e581`): opponent (black) phantom pieces were near-invisible on the dark `--panel` background — black glyphs in the palette, Captures panel, and drag-ghost now get a light text-shadow outline.
2. **Spurious promotion dialog** (`c01244c`): the "Promote pawn" modal fired for any pawn "moved" toward the last rank because the commit paths checked the destination rank but not the pawn's *source* rank — easy to hit once the phantom layer filled ranks 7-8 with tappable phantoms. Fixed with a new shared `isPromotionMove(piece, from, to)` (pawn, from the rank adjacent to promotion, to the promotion rank, ≤1 file over), used by both client `onCommit` and server `isPromotionRequired`.
Both fixes are live — the two instances and `main` are all at `c01244c`.
## Architecture Overview
Three features, two increments, all shipped:
1. **Announce-all (F1):** every moderator `Announcement` is now `audience: 'both'`. Previously move events went only to the opponent and attempted-move errors only to the actor. The `audience` field is retained (uniformly `'both'`) as the egress-control hook in `ws.ts`/`ModeratorPanel`. The Casual bot's *intermediate* retry-rejection announcements are popped in `BotDriver.dispatch` so its blind-mode search does not broadcast as churn — only its final move is announced.
2. **Capture tally (F2):** a server-derived per-viewer `captures: CaptureTally` field on the `joined`/`update` messages, rendered by a new `CaptureTally.svelte` panel. Must be server-side — in blind mode the capturing client cannot see what it took.
3. **Phantom layer (F3):** a client-LOCAL overlay of guessed opponent pieces, blind mode only. Seeded once with the opponent's standard army, then fully manual: pointer-drag a phantom anywhere, off-board to remove, re-add from an unlimited palette. Persisted to `localStorage` (`bc:phantoms:<gameId>`). **Never sent to the server** — it lives in its own store so the zero-leak property is auditable.
The zero-leak core (`buildView`, `geometric.ts`) was deliberately untouched.
## Critical Files
| File | Purpose | Relevance |
|------|---------|-----------|
| `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md` | The spec | Design rationale, info-leak analysis |
| `docs/superpowers/plans/2026-05-18-table-fidelity-features.md` | The 12-task plan | Amended to match shipped code |
| `packages/server/src/translator.ts`, `commit.ts` | F1 audience change | All announcements `'both'` |
| `packages/server/src/bot/driver.ts` | F1 bot-churn suppression | `dispatch` pops intermediate rejections |
| `packages/server/src/captures.ts` | F2 `captureTally` | Pure per-viewer derivation |
| `packages/shared/src/phantoms.ts` | F3 pure model | `opponentStartPosition`, `deserializePhantoms` (tested) |
| `packages/client/src/lib/stores/phantoms.svelte.ts` | F3 local store | Never read in any `send` path |
| `packages/client/src/lib/stores/phantom-drag.svelte.ts` | F3 drag controller | Pointer events, tap-vs-drag, `pointercancel`-safe |
| `packages/client/src/lib/Board.svelte`, `Game.svelte`, `PhantomPalette.svelte` | F3 UI | Phantom rendering + wiring |
## Key Patterns Discovered
- **Build ordering:** `server`/`client` resolve `@blind-chess/shared` from its built `dist/`. After editing `shared`, run `pnpm --filter @blind-chess/shared build` before downstream typecheck/build. `pnpm -r build` handles order automatically.
- **Client has no test harness** (by design). Pure logic worth testing goes to `packages/shared` (vitest); Svelte components are covered by `svelte-check` + manual.
- **`ply`-parity actor derivation:** the four attempted-move enums carry no colour; the client derives White/Black from `ply % 2` (an attempt only happens on the actor's turn).
## Work Completed
- Tasks 111 of the plan (all three features), each with implementer + spec-compliance review + code-quality review and fix loops.
- Final whole-batch code review — verdict: ready to ship, no Critical/Important issues.
- Checkpoint A and B verifications: `pnpm -r build && pnpm -r typecheck && pnpm -r test` all clean; 87 tests pass (25 shared + 62 server).
- DECISIONS.md, CLAUDE.md, and the spec updated to reflect the shipped state.
## Files Modified
See `git diff --stat be8ecd9..2e80800`. New files: `packages/server/src/captures.ts`, `packages/server/test/unit/captures.test.ts`, `packages/shared/src/phantoms.ts`, `packages/shared/test/phantoms.test.ts`, `packages/client/src/lib/{CaptureTally,PhantomPalette}.svelte`, `packages/client/src/lib/stores/{phantoms,phantom-drag}.svelte.ts`. Modified: `translator.ts`, `commit.ts`, `bot/driver.ts`, `ws.ts`, `shared/{types,protocol,index}.ts`, `client/lib/{Board,Game,ModeratorPanel}.svelte`, `client/lib/stores/game.svelte.ts`.
## Decisions Made
All recorded in `DECISIONS.md` under "Table-fidelity features (2026-05-18)" and "Deferred / Rejected". Key ones: announcements widened to `'both'` (deliberate, authorised); manual phantom model (smart-tracker rejected); phantom layer client-local only; drag-and-drop for phantoms only (real moves stay click-to-move).
## Immediate Next Steps
1. **Continue the manual browser/phone test pass.** Open https://chess.sethpc.xyz, create a **blind game vs computer**, and check: the moderator panel shows White/Black-labelled attempt lines; the Captures panel updates on a capture; the phantom layer renders 16 seeded pieces; dragging a phantom moves it / off-board removes it; the palette places phantoms; a *tap* still makes real moves; genuine pawn promotion (pawn e7→e8) still pops the dialog; phantoms persist across reload; vanilla mode shows no phantom UI; phantoms hide on game end. The phantom drag is the main mobile-risk surface — test on a phone.
2. **Check the board phantom glyph contrast.** `.phantom-b` (dark phantoms on the board) render at `opacity: 0.4` on the board squares; they have a dashed frame so the square reads, but the piece *type* may be hard to tell on dark squares. Flagged to Seth to eyeball during the test pass — if too faint, give `.phantom-b` a subtle light outline without killing the intentional translucency.
3. (Optional) Fix the `install-local.sh` redeploy gap, and reconcile the stale VDJ-RIG "no Caddy" note — see Potential Gotchas.
## Blockers / Open Questions
- **Board phantom glyph contrast — open question.** See Immediate Next Steps #2 — needs a human eye on a real board before deciding whether `.phantom-b` needs an outline.
- **Manual browser test pass is in progress, not complete.** Two bugs found and fixed+deployed so far (contrast, spurious promotion dialog); the rest of the checklist in Next Step #1 is unverified. The phantom drag-and-drop in particular (pointer events, tap-vs-drag, hit-testing) is verified only by code review, not by clicking.
## Deferred Items
- Phantom-layer `localStorage` cleanup for games abandoned mid-play (no `finished` transition) — tiny leak, add a stale-key sweep only if it matters.
- Highlighting interacting with phantoms (rays stopping at phantom pieces) — safe but out of v1 scope.
- An `ai-game-casual` WebSocket integration test for F1 — the `driver.test.ts` unit test was chosen instead (covers the same commit-path; spec updated to record this).
## Important Context
- **Everything is on `main` and pushed.** Seth explicitly chose to work directly on `main` (no feature branch). There is nothing to merge.
- **The feature batch plus both follow-up fixes are deployed and live** on both instances (`chess.sethpc.xyz` and `chess.local`) at commit `c01244c``main` and the live site match. Deployment is outward-facing and drops in-memory games — confirm timing with Seth.
- **Two deploy instances now exist** (CLAUDE.md was updated mid-project): CT 690 / `chess.sethpc.xyz`, and `chess.local` on VDJ-RIG. Both need the update.
- The final review confirmed the security invariant holds: no `phantom` token anywhere under `packages/server/src/`, `buildView`/`geometric.ts` byte-for-byte unchanged.
## Assumptions Made
- The client a11y trade-off (phantom spans and palette pieces are pointer-only, with a documented `svelte-ignore a11y_no_static_element_interactions`) is acceptable — adding `tabindex` without a keyboard drag would be worse a11y. Real gameplay stays fully keyboard-operable via the square buttons.
- 94 is the expected test count (32 shared + 62 server); the client contributes 0 (no harness).
## Potential Gotchas
- `packages/server/tsconfig.tsbuildinfo` shows persistent `M` in `git status` — pre-existing drift (tracked before `*.tsbuildinfo` was gitignored), not this session's work.
- The pre-commit hook is `detect-secrets-hook --baseline .secrets.baseline`.
- Server restart on deploy drops all in-memory games. (CT 690 had 3 "active" games at deploy time — almost certainly stale abandoned games, since active games never auto-expire and uptime was 19 days; dropped per the pre-accepted MVP policy.)
- `deploy/install-local.sh` (the `chess.local` installer) ends with `systemctl enable --now blind-chess.service`, which does NOT restart an already-running service — a redeploy via the script alone leaves the old code running. Deploys work around it with an explicit `sudo systemctl restart blind-chess` after the script. Proper fix: change the script's `enable --now` to `enable` then `restart`.
- **VDJ-RIG port 80 has a Caddy** (host-routing): `curl http://chess.local/` serves the app correctly, but `curl http://localhost/` returns a Caddy `502`. CLAUDE.md's `chess.local` operations note says "no Caddy" — that note is stale or incomplete. Doesn't affect the instance; verify rig deploys via the `chess.local` hostname, not `localhost`.
## Environment State
- **Tools/Services:** pnpm workspace; `gitea` CLI for push. Subagent-driven development for execution.
- **Active Processes:** none. No dev servers left running.
- **Environment Variables:** none added or changed.
## Related Resources
- Spec: `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md`
- Plan: `docs/superpowers/plans/2026-05-18-table-fidelity-features.md`
- `DECISIONS.md` → "Table-fidelity features (2026-05-18)"
- Live URL: https://chess.sethpc.xyz (deployed at `c01244c`) · Repo: https://git.sethpc.xyz/Seth/blind_chess (`main` at `c01244c`)
---
**Security Reminder**: No credentials or secrets are included in this handoff.
+3
View File
@@ -15,6 +15,9 @@ build/
coverage/
.vitest/
# Self-play transcripts (operator scratch)
tmp/
# Editor / OS
.DS_Store
.idea/
+17 -3
View File
@@ -9,6 +9,7 @@
Then check `DECISIONS.md` for settled choices, and the design specs:
- `docs/superpowers/specs/2026-04-28-blind-chess-design.md` — original MVP spec (data model, protocol, FSM, testing).
- `docs/superpowers/specs/2026-04-28-ai-player-design.md` — AI/computer player spec (Casual + gemma4 recon bots, two-phase plan).
- `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md` — table-fidelity batch (announce-all, capture tally, phantom opponent-piece layer).
## Project Identity
@@ -18,12 +19,12 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
## Current State
- **Phase:** MVP **deployed and live** at https://chess.sethpc.xyz (2026-04-28). **AI Phase 1 (Casual bot) deployed** (2026-04-28) — "Play vs computer" → Casual bot.
- **Phase:** MVP **deployed and live** at https://chess.sethpc.xyz (2026-04-28). **AI Phase 1 (Casual bot) deployed** (2026-04-28) — "Play vs computer" → Casual bot. **Blind Casual check-resolution fix shipped** (2026-04-29). **Table-fidelity feature batch deployed** (2026-05-18) — moderator announces every move/attempt to both players, a capture tally, and a client-local phantom opponent-piece layer for blind mode; live on both instances (CT 690 + `chess.local`). Client UI not yet manually browser-tested.
- **Repo:** `git.sethpc.xyz/Seth/blind_chess`.
- **Stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte 5 + Vite, `chess.js`, `js-chess-engine` (Casual vanilla AI). pnpm workspace with `packages/{server,client,shared}`.
- **Deploy:** LXC **CT 690 on node-241** at 192.168.0.245, behind Caddy CT 600. Systemd unit `blind-chess.service`, port 3000. In-memory state only.
- **Tests:** 75 passing — 21 in shared (geometric helper), 54 in server (FSM + view + candidates + casual brain + driver + scripted-game + ai-game-casual integration).
- **Known gaps (deferred):** drag-and-drop input (click-to-move only), full integration coverage of every endgame path, mobile-specific polish, observability beyond `/api/health`.
- **Tests:** 87 passing — 25 in shared (geometric + phantom-model helpers), 62 in server (FSM + view + candidates + casual brain + driver + captures + scripted-game + ai-game-casual integration). The client package has no test harness by design.
- **Known gaps (deferred):** drag-and-drop for *real* moves (still click-to-move; the phantom layer added pointer-drag for phantom pieces only), full integration coverage of every endgame path, mobile-specific polish, observability beyond `/api/health` and `[bot resign]` (no metrics, no per-game tracing), `localStorage` cleanup for phantom layers of abandoned games.
- **AI Phase 2 (gemma4 recon, not built):** Spec in `docs/superpowers/specs/2026-04-28-ai-player-design.md`. Will reuse the Phase 1 `Brain`/`BotDriver` infrastructure. Plan to be written when Phase 1 has soaked. Bots play through the same view filter and FSM as humans — no oracle access.
## Key files
@@ -36,8 +37,10 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
- `packages/server/src/view.ts``buildView`, the security boundary.
- `packages/server/src/commit.ts` — touch-move FSM (the spec's hierarchy decision table).
- `packages/server/src/translator.ts` — chess.js `Move` → moderator-vocabulary enum.
- `packages/server/src/captures.ts``captureTally`, the per-viewer capture-count derivation (Feature 2).
- `packages/server/src/game-end.ts` — shared `endGame` / `finalizeIfEnded` helpers used by both ws and bot driver.
- `packages/server/src/bot/` — Brain interface, BotDriver, CasualBrain, candidates. Vanilla mode delegates to `js-chess-engine` at level 2; blind mode uses a heuristic.
- `packages/client/src/lib/stores/phantoms.svelte.ts` — client-LOCAL phantom opponent-piece store (Feature 3). Never sent to the server; `phantom-drag.svelte.ts` is its pointer-drag controller.
- `scripts/selfplay.ts` — operator CLI for evaluating Casual vs Casual / Random self-play. `pnpm selfplay --help`.
- `deploy/blind-chess.service` — systemd unit (canonical at `/etc/systemd/system/blind-chess.service` on the CT).
- `deploy/Caddyfile.snippet` — block already added to `/etc/caddy/Caddyfile` on CT 600.
@@ -49,6 +52,17 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
- **Health:** `curl https://chess.sethpc.xyz/api/health`
- **Deploy update:** `pnpm -r build``pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server` → rsync server bundle to `/opt/blind-chess/server/` and client `dist/` to `/opt/blind-chess/client/dist/``chown -R blindchess:blindchess /opt/blind-chess``systemctl restart blind-chess`. Server restart drops in-memory games (acceptable for MVP).
### Local instance — `chess.local` on VDJ-RIG
A second, **LAN-only** deploy on VDJ-RIG (192.168.0.143), fully independent of the CT 690 / chess.sethpc.xyz instance (separate in-memory state). Serves on **port 80**, reached at `http://chess.local` via an mDNS alias — no Caddy, no TLS.
- **Artifacts** (`deploy/`): `blind-chess-local.service` (server unit; binds port 80 as the non-root `blindchess` user via `CAP_NET_BIND_SERVICE`), `chess-mdns-alias` + `chess-mdns-alias.service` (publishes the `chess.local` mDNS name with `avahi-publish -a -R``-R` avoids a reverse-PTR collision with the host's own `.local` name), `install-local.sh` (root-side installer).
- **On the rig:** tree at `/opt/blind-chess/{server,client/dist}`, units `blind-chess.service` + `chess-mdns-alias.service`, Node 22 via NodeSource.
- **Logs:** `ssh vdj-rig 'journalctl -u blind-chess -f'`
- **Restart:** `ssh vdj-rig 'sudo systemctl restart blind-chess'`
- **Health:** `curl http://chess.local/api/health`
- **Redeploy:** on steel141 `pnpm -r build` + `pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server`; rsync `.deploy-server/` → rig `~/blind-chess-stage/server/`, `packages/client/dist/``~/blind-chess-stage/client-dist/`, and the `deploy/` files → `~/blind-chess-stage/`; then `sudo bash ~/blind-chess-stage/install-local.sh`.
## Conventions
- Inherits global homelab conventions from `~/bin/CLAUDE.md` (gitea CLI, conventional commits, `.claude/handoffs/` for session state).
+15
View File
@@ -75,6 +75,18 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. **Phase 1 (Casual
- 2026-04-28: **Bot-slot synthetic token is randomized, not a fixed placeholder.** Using a hard-coded placeholder ("botxxxxxxxxxxxxxxxxxxxxx") would let any client knowing it claim the bot's color via `hello`. Random tokens (same shape as human tokens) close that hole. Caught in code review of Task 7.
- 2026-04-28: **`endGame` and `finalizeIfEnded` extracted from `ws.ts` to `packages/server/src/game-end.ts`.** Both `ws.ts` and `bot/driver.ts` need to set the game-finished state — duplication risk. Hoist resolves it.
## Table-fidelity features (2026-05-18)
Spec: `docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md`. Plan: `docs/superpowers/plans/2026-05-18-table-fidelity-features.md`. Three features requested by Andrew Freiberg (a physical-game player); shipped to `main` 2026-05-18, 12 tasks via subagent-driven development. 87 tests passing (25 shared + 62 server).
- 2026-05-18: **All moderator announcements are `audience: 'both'`** — every move event and every attempted-move error reaches both players, faithful to the physical game where the moderator speaks aloud. A deliberate, authorised widening of the moderator channel (it makes blind mode slightly less blind — e.g. you hear "won't help you" on the opponent's turn). The `audience` field is retained (now uniformly `'both'`) as the egress-control hook in `ws.ts` / `ModeratorPanel`.
- 2026-05-18: **Bot intermediate retry-rejection announcements are popped in `BotDriver.dispatch`** — the blind Casual bot's retry search would otherwise broadcast up to 25 churn announcements per turn. Only the bot's final committed move is announced. Human probes (13 pieces, human-paced) still broadcast — that is the feature.
- 2026-05-18: **Capture tally is a server-derived per-viewer `captures` field on `joined`/`update`**, not a `ModeratorText` enum entry — the announcement vocabulary stays a pure event enum; the tally is structured data (`CaptureTally = { byYou, byOpponent }`). Must be server-side: in blind mode the capturing client can't see what it took.
- 2026-05-18: **Phantom opponent-piece layer is 100% client-local** — never sent to the server, persisted only to `localStorage` (`bc:phantoms:<gameId>`), in its own store (`phantoms.svelte.ts`) separate from the protocol store so the zero-leak property is auditable. Blind mode only. `buildView` / `geometric.ts` untouched.
- 2026-05-18: **Manual phantom model** — seeded once with the opponent's standard starting army, then fully manual: drag anywhere, drag off-board to remove, re-add from an unlimited palette, no automation. Rejected: a "smart tracker" that auto-removes on capture and tracks promotions (Seth chose the manual model).
- 2026-05-18: **Phantom manipulation is pointer-event drag-and-drop** with a tap-vs-drag threshold so a tap still makes a real move. Real chess moves stay click-to-move — the deferred drag-and-drop decision for *real* moves still stands; F3's drag is phantom-only.
- 2026-05-18: **Client has no unit-test harness** (deliberate) — Feature 3's testable pure logic (`opponentStartPosition`, `deserializePhantoms`) lives in `packages/shared` and is unit-tested there; Svelte components/stores are covered by `svelte-check` typechecking plus manual verification.
## Deferred / Rejected
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
@@ -99,3 +111,6 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. **Phase 1 (Casual
- 2026-04-28: **Per-turn context compaction** — deferred. Spec uses `num_ctx: 32768` which covers ~128 turns; longer games would overflow but are rare in casual play. Add running-summary compaction if seen in practice.
- 2026-04-28: **Bot rating / Elo / personalities** — out of scope. Two named buttons, no scoreboard.
- 2026-04-28: **In-game chat (player ↔ player and human ↔ Gemma)** — deferred indefinitely. Two failure modes drove the deferral: (1) blind-mode chat is a side channel that bypasses the moderator-vocabulary security boundary ("knight on c3, take it" defeats the entire view-filter architecture); (2) chatting with Gemma during play leaks the bot's belief state and undermines the post-game reasoning reveal. Resolvable but expensive (two-history split for Gemma, blind-mode mute or social-variant warnings, mobile UI real estate). Revisit only if users explicitly ask. The post-game reasoning reveal already covers most of the "see what Gemma was thinking" appeal without the leak surface.
- 2026-05-18: **Smart-tracker phantom model** (auto-remove a phantom on capture, track promotions, constrain the phantom set to the opponent's surviving army) — rejected in favour of the fully-manual model. More code and more edge cases; Seth wanted the manual ritual.
- 2026-05-18: **Highlighting interacting with phantoms** (bishop/rook rays stopping at phantom pieces) — deferred. Safe to do (phantoms carry zero real opponent info) but out of scope for v1; phantoms are a pure annotation layer that highlighting ignores.
- 2026-05-18: **Phantom-layer `localStorage` cleanup for abandoned games** — deferred. `clearForGame` only fires when the game reaches `finished` while `<Game>` is mounted; a tab closed mid-game leaves a stale `bc:phantoms:<id>` key. Each entry is a tiny JSON object; add a stale-key sweep on app start only if it ever matters.
+36
View File
@@ -0,0 +1,36 @@
[Unit]
Description=blind_chess server — local LAN instance (chess.local)
Documentation=https://git.sethpc.xyz/Seth/blind_chess
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=blindchess
Group=blindchess
WorkingDirectory=/opt/blind-chess/server
ExecStart=/usr/bin/node /opt/blind-chess/server/dist/server.js
Environment=NODE_ENV=production
Environment=PORT=80
Environment=HOST=0.0.0.0
Environment=STATIC_DIR=/opt/blind-chess/client/dist
Environment=PUBLIC_BASE=http://chess.local
Environment=LOG_LEVEL=info
Restart=always
RestartSec=2s
StandardOutput=journal
StandardError=journal
# Bind privileged port 80 as a non-root user
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/blind-chess
[Install]
WantedBy=multi-user.target
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Publish "chess.local" as an mDNS alias for this host's primary IPv4 address.
# Invoked by chess-mdns-alias.service (blind_chess local deploy).
#
# avahi-daemon already advertises the host's own <hostname>.local; this adds
# the friendly "chess.local" name pointing at the same machine. Runs in the
# foreground holding the registration until the service is stopped.
set -euo pipefail
IP="$(hostname -I | awk '{print $1}')"
if [ -z "$IP" ]; then
echo "chess-mdns-alias: no IPv4 address found" >&2
exit 1
fi
echo "chess-mdns-alias: publishing chess.local -> $IP"
# -R/--no-reverse: skip the reverse (PTR) record. avahi-daemon already owns the
# PTR for this IP via the host's own <hostname>.local, so publishing chess.local
# for the same address *with* a reverse entry collides ("Local name collision").
# Clients only need the forward A record, which -a still publishes.
exec avahi-publish -a -R chess.local "$IP"
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Publish chess.local mDNS alias for the blind_chess local deploy
Requires=avahi-daemon.service
After=avahi-daemon.service network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/chess-mdns-alias
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# blind_chess — local (chess.local) deploy installer.
#
# Run as root ON THE TARGET HOST. Expects a staging directory containing:
# server/ pnpm-deploy bundle (dist/ + node_modules/)
# client-dist/ vite build output
# chess-mdns-alias mDNS alias helper script
# blind-chess-local.service systemd unit for the server
# chess-mdns-alias.service systemd unit for the mDNS alias
#
# Usage: sudo bash install-local.sh [STAGE_DIR]
# (STAGE_DIR defaults to the directory containing this script)
set -euo pipefail
STAGE="${1:-$(cd "$(dirname "$0")" && pwd)}"
echo "=== blind_chess local install (staging: $STAGE) ==="
# --- Node.js 22 (Debian trixie ships only 20; blind_chess needs >=22) ---
need_node=1
if command -v node >/dev/null 2>&1; then
major="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0)"
if [ "${major:-0}" -ge 22 ] 2>/dev/null; then need_node=0; fi
fi
if [ "$need_node" -eq 1 ]; then
echo "--- installing Node.js 22 via NodeSource ---"
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -o DPkg::Lock::Timeout=600 nodejs
fi
echo "node: $(node --version)"
# --- avahi-utils provides avahi-publish ---
command -v avahi-publish >/dev/null 2>&1 || \
apt-get install -y -o DPkg::Lock::Timeout=600 avahi-utils
# --- dedicated unprivileged service user ---
getent passwd blindchess >/dev/null 2>&1 || \
useradd --system --user-group --no-create-home --shell /usr/sbin/nologin blindchess
# --- deploy tree under /opt/blind-chess ---
install -d /opt/blind-chess
rm -rf /opt/blind-chess/server /opt/blind-chess/client
cp -a "$STAGE/server" /opt/blind-chess/server
install -d /opt/blind-chess/client
cp -a "$STAGE/client-dist" /opt/blind-chess/client/dist
chown -R blindchess:blindchess /opt/blind-chess
# --- mDNS alias helper ---
install -m 0755 "$STAGE/chess-mdns-alias" /usr/local/bin/chess-mdns-alias
# --- systemd units (the server unit installs under the canonical name) ---
install -m 0644 "$STAGE/blind-chess-local.service" /etc/systemd/system/blind-chess.service
install -m 0644 "$STAGE/chess-mdns-alias.service" /etc/systemd/system/chess-mdns-alias.service
systemctl daemon-reload
systemctl enable --now chess-mdns-alias.service
systemctl enable --now blind-chess.service
echo "=== install complete ==="
systemctl --no-pager --lines=0 status blind-chess.service chess-mdns-alias.service || true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,439 @@
# 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 | smallmedium |
| 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 13 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.
+43
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { ALL_SQUARES, FILES, RANKS, geometricMoves, type Color, type Piece, type Square } from '@blind-chess/shared';
import { pieceGlyph } from './pieces.js';
import { phantomDrag } from './stores/phantom-drag.svelte.js';
interface Props {
pieces: Partial<Record<Square, Piece>>;
@@ -8,6 +9,8 @@
toMove: Color;
mode: 'blind' | 'vanilla';
highlightingEnabled: boolean;
phantoms?: Partial<Record<Square, Piece>>;
phantomsEnabled?: boolean;
armedSquare: Square | null; // local visual arm (pre-commit)
touchedSquare: Square | null; // server-authoritative touch
onArm: (sq: Square | null) => void;
@@ -16,6 +19,7 @@
let {
pieces, you, toMove, mode, highlightingEnabled,
phantoms = {}, phantomsEnabled = false,
armedSquare, touchedSquare, onArm, onCommit,
}: Props = $props();
@@ -43,6 +47,14 @@
return new Set(moves);
});
// 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;
});
function squareColor(sq: Square): 'light' | 'dark' {
const f = sq.charCodeAt(0) - 'a'.charCodeAt(0);
const r = parseInt(sq[1]!, 10) - 1;
@@ -50,6 +62,7 @@
}
function onSquareClick(sq: Square) {
if (phantomDrag.shouldSuppressClick(sq)) return;
const piece = pieces[sq];
// If a piece is touched (server-authoritative), only commits to that square's destinations are valid.
@@ -82,6 +95,7 @@
{#each filesDisplay as f (f)}
{@const sq = `${f}${r}` as Square}
{@const piece = pieces[sq]}
{@const ph = phantomsEnabled ? phantoms[sq] : undefined}
{@const sc = squareColor(sq)}
{@const isArmed = sq === armedSquare}
{@const isTouched = sq === touchedSquare}
@@ -96,6 +110,7 @@
class:hl-cap={isHighlightCap}
onclick={() => onSquareClick(sq)}
aria-label={sq}
data-square={sq}
>
{#if r === (you === 'w' ? '1' : '8') && f === filesDisplay[0]}
<span class="coord coord-rank">{r}</span>
@@ -106,6 +121,17 @@
{#if piece}
<span class="piece piece-{piece.color}">{pieceGlyph(piece)}</span>
{/if}
{#if ph && !piece}
<!-- Phantom pieces are a pointer-only private annotation overlay;
there is no keyboard drag interaction. The real game stays fully
keyboard-operable via the square button below. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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}
{#if isHighlight && !piece}
<span class="dot"></span>
{/if}
@@ -154,6 +180,23 @@
.piece-w { color: #fafafa; }
.piece-b { color: #1a1a1a; }
.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; }
.armed { box-shadow: inset 0 0 0 4px var(--armed); }
.touched { box-shadow: inset 0 0 0 4px var(--touched); }
.hl::before {
@@ -0,0 +1,85 @@
<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; }
/* Dark pieces need a light outline to read on the dark panel background. */
.g-b { color: #1a1a1a; text-shadow: 0 0 2px var(--text), 0 0 2px var(--text); }
.n {
font-family: ui-monospace, monospace;
font-size: 13px;
color: var(--text-dim);
}
</style>
+81 -9
View File
@@ -3,8 +3,13 @@
import { game } from './stores/game.svelte.js';
import Board from './Board.svelte';
import ModeratorPanel from './ModeratorPanel.svelte';
import CaptureTally from './CaptureTally.svelte';
import PromotionDialog from './PromotionDialog.svelte';
import type { PromotionType, Square } from '@blind-chess/shared';
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';
import { isPromotionMove, type PromotionType, type Square } from '@blind-chess/shared';
interface Props { gameId: string; }
let { gameId }: Props = $props();
@@ -35,13 +40,13 @@
function onCommit(from: Square, to: Square) {
const piece = game.state.view?.pieces[from];
if (!piece) return;
// Promotion check (white pawn to rank 8, black pawn to rank 1).
if (piece.type === 'p') {
const rank = to[1];
if ((piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1')) {
pendingPromotion = { from, to };
return;
}
// A pawn promotes only from the rank adjacent to its promotion rank
// isPromotionMove checks the source rank, destination rank, and file delta,
// so a pawn elsewhere "moved" toward the last rank no longer pops this
// dialog (which a click on a phantom-occupied back-rank square could do).
if (isPromotionMove(piece, from, to)) {
pendingPromotion = { from, to };
return;
}
game.commit(from, to);
}
@@ -91,6 +96,40 @@
}
return 'Opponent thinking';
});
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 when `you` is known (blind games only). Keyed on
// gameId — like the connection effect — so it reloads if this <Game>
// instance is reused for a different game without a remount.
let loadedFor: string | null = $state(null);
$effect(() => {
const id = gameId;
const you = game.state.you;
if (loadedFor === id) return;
if (you && game.state.mode === 'blind') {
untrack(() => phantoms.loadForGame(id, you));
loadedFor = id;
}
});
// Drop the phantom layer when the game ends.
$effect(() => {
if (game.state.gameStatus === 'finished') {
untrack(() => phantoms.clearForGame(gameId));
}
});
</script>
<div class="game-layout" class:waiting={game.state.gameStatus === 'waiting'}>
@@ -128,15 +167,21 @@
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>
<aside class="side">
<ModeratorPanel announcements={game.state.announcements} you={game.state.you} />
<CaptureTally captures={game.state.captures} you={game.state.you} />
<div class="actions">
{#if game.state.gameStatus === 'active'}
@@ -168,6 +213,13 @@
{/if}
</div>
{#if phantomLayerEnabled && 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}
{#if pendingPromotion && game.state.you}
<PromotionDialog
color={game.state.you}
@@ -219,7 +271,13 @@
color: var(--text-dim);
}
.board-area { display: flex; align-items: center; justify-content: center; }
.board-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.side {
display: flex;
@@ -268,4 +326,18 @@
.waiting-card h2 { margin: 0 0 8px; font-size: 18px; }
.link-row { display: flex; gap: 8px; margin-top: 16px; }
.link { flex: 1; font-family: ui-monospace, monospace; font-size: 13px; }
.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; }
/* A dark dragged piece needs a light outline (the base dark shadow above
does nothing for it) so it reads over dark areas of the page. */
.drag-ghost.piece-b { color: #1a1a1a; text-shadow: 0 0 3px var(--text), 0 0 2px var(--text); }
</style>
@@ -24,9 +24,11 @@
<header>Moderator</header>
<div class="log" bind:this={scrollEl}>
{#each visible as a, i (i)}
<div class="entry" class:err={a.text === 'illegal_move' || a.text === 'no_such_piece' || a.text === 'no_legal_moves' || a.text === 'wont_help'}>
{@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">{moderatorText(a.text, a.payload)}</span>
<span class="text">{isAttempt ? `${actor} ` : ''}{moderatorText(a.text, a.payload)}</span>
</div>
{:else}
<div class="empty muted">The moderator is silent.</div>
@@ -67,7 +69,7 @@
border-bottom: 1px dashed rgba(255,255,255,0.05);
}
.entry:last-child { border-bottom: none; }
.entry.err .text { color: #f87171; }
.entry.attempt .text { color: var(--text-dim); font-style: italic; }
.ply {
color: var(--text-dim);
font-family: ui-monospace, monospace;
@@ -0,0 +1,51 @@
<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)}
<!-- Pointer-only drag source — same deliberate a11y trade-off as the
phantom spans in Board.svelte (no keyboard drag interaction). -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="pp pp-{oppColor}"
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; }
/* Dark pieces need a light outline to read on the dark panel background. */
.pp-b { color: #1a1a1a; text-shadow: 0 0 2px var(--text), 0 0 2px var(--text); }
</style>
@@ -1,6 +1,7 @@
import type {
Announcement,
BoardView,
CaptureTally,
ClientMessage,
Color,
ErrorCode,
@@ -29,6 +30,7 @@ interface GameStateValue {
opponentConnected: boolean;
lastError: { code: ErrorCode; message: string; at: number } | null;
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
captures: CaptureTally;
}
function makeStore() {
@@ -49,6 +51,7 @@ function makeStore() {
opponentConnected: false,
lastError: null,
aiOpponent: null,
captures: { byYou: {}, byOpponent: {} },
});
function tokenKey(gameId: string) { return `bc:${gameId}`; }
@@ -94,6 +97,7 @@ function makeStore() {
state.highlightingEnabled = m.highlightingEnabled;
state.opponentConnected = m.opponentConnected;
state.aiOpponent = m.aiOpponent ?? null;
state.captures = m.captures;
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
break;
case 'update':
@@ -107,6 +111,7 @@ function makeStore() {
state.announcements = [...state.announcements, ...m.newAnnouncements];
}
if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent;
state.captures = m.captures;
break;
case 'peer-status':
if (state.you && m.color !== state.you) {
@@ -0,0 +1,98 @@
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 detach() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
}
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;
}
}
// pointercancel fires instead of pointerup when the browser/OS takes over
// the gesture (common on touch). Abort the drag: clean up, drop nothing.
function onCancel() {
detach();
state.active = null;
state.moved = false;
}
function onUp(e: PointerEvent) {
detach();
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
// elementFromPoint returns null off-viewport — treated as an off-board drop.
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) {
detach(); // idempotency — drop any listeners from an unfinished prior drag
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);
window.addEventListener('pointercancel', onCancel);
}
/** 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();
@@ -0,0 +1,74 @@
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) {
if (!gameId) return;
state.phantoms = { ...state.phantoms, [sq]: { color: oppColor, type } };
persist();
}
function move(from: Square, to: Square) {
if (!gameId || from === to) return;
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) {
if (!gameId) return;
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();
+30 -2
View File
@@ -65,8 +65,15 @@ export class CasualBrain implements Brain {
// can't validate against the candidate list.
}
// Blind mode (or vanilla fallback): score-based heuristic.
const choice = this.heuristicPick(filtered, input.view, input.ply);
// Blind mode (or vanilla fallback): score-based heuristic. When the
// moderator says we're in check, bias toward king moves — the only
// class of moves that resolves nearly every check (king moves out of
// attack), and the only class the bot can identify without seeing the
// attacker. Without this bias the heuristic scores capture/advance
// signals uncorrelated with check resolution, the FSM rejects every
// non-resolving attempt, and the driver's retry cap fires.
const inCheck = this.detectOwnCheck(input.newAnnouncements);
const choice = this.heuristicPick(filtered, input.view, input.ply, inCheck);
return {
type: 'commit',
from: choice.from,
@@ -75,6 +82,11 @@ export class CasualBrain implements Brain {
};
}
private detectOwnCheck(announcements: BrainInput['newAnnouncements']): boolean {
const tag = this.color === 'w' ? 'white_in_check' : 'black_in_check';
return announcements.some((a) => a.text === tag);
}
/**
* Run js-chess-engine on the given FEN and return a candidate matching
* its choice, or null if no match was found.
@@ -113,19 +125,35 @@ export class CasualBrain implements Brain {
candidates: CandidateMove[],
view: BoardView,
ply: number,
inCheck: boolean,
): CandidateMove {
const kingSquare = inCheck ? this.findOwnKing(view) : null;
const scored = candidates.map((c) => {
let score = this.scoreMove(c, view, ply);
if (c.promotion === 'q') score += 1000;
else if (c.promotion === 'r') score += 500;
else if (c.promotion === 'b') score += 100;
else if (c.promotion === 'n') score += 50;
// King moves dominate when in check. The boost is large enough to
// beat any combination of other heuristic factors so the driver
// exhausts king escapes first; if all king moves are rejected the
// attemptHistory exclusion strips them and the bot falls through
// to non-king options (block / capture-attacker guesses).
if (kingSquare && c.from === kingSquare) score += 5000;
return { move: c, score: score + this.rng() * 0.01 };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]!.move;
}
private findOwnKing(view: BoardView): Square | null {
for (const sq of Object.keys(view.pieces) as Square[]) {
const p = view.pieces[sq];
if (p && p.color === this.color && p.type === 'k') return sq;
}
return null;
}
private excludeRejected(
candidates: CandidateMove[],
history: BrainInput['attemptHistory'],
+58 -9
View File
@@ -13,7 +13,23 @@ import { buildView } from '../view.js';
import { announce } from '../translator.js';
import { finalizeIfEnded } from '../game-end.js';
const RETRY_CAP = 5;
// Per-decision-cycle retry budget. In vanilla mode chess.js verbose moves are
// guaranteed legal so the cap is never exercised. In blind mode the brain
// supplies pseudo-legal candidates and chess.js may reject many (pinned pieces,
// unresolved check); we need budget to find a legal move before giving up.
const RETRY_CAP = 25;
type BotResignReason =
| 'retry_cap_exhausted'
| 'brain_threw'
| 'brain_chose_resign'
| 'commit_silent'
| 'commit_error';
function errString(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
interface BotDriverOpts {
game: Game;
@@ -76,14 +92,23 @@ export class BotDriver {
const attemptHistory: AttemptHistoryEntry[] = [];
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
// The bot makes atomic (from,to) commits — there is no touched-piece UX.
// A prior attempt that survived past `tryMove` (e.g. illegal_move,
// promotion_required) leaves `game.armed` set; a retry that picks a
// different `from` would otherwise be rejected as
// `must_move_touched_piece` and resign the bot. Clear here so each
// attempt starts from a clean FSM state.
if (this.game.armed?.color === this.color) {
this.game.armed = null;
}
const input = this.buildBrainInput(attemptHistory);
let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry };
try {
const action = await this.brain.decide(input);
outcome = this.dispatch(action);
} catch {
} catch (err) {
// Brain exception OR programming error in dispatch. Safe failure: resign.
this.botResign();
this.botResign('brain_threw', { err: errString(err) });
return;
}
if (outcome.kind === 'done') {
@@ -93,7 +118,9 @@ export class BotDriver {
attemptHistory.push(outcome.entry);
}
this.lastSeenAnnouncementCount = this.game.announcements.length;
this.botResign();
this.botResign('retry_cap_exhausted', {
attempts: attemptHistory.map((a) => `${a.move.from}-${a.move.to}:${a.rejection}`),
});
}
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
@@ -135,9 +162,19 @@ export class BotDriver {
return { kind: 'done' };
}
if (result.kind === 'announce') {
const text = result.announcements[0]!.text;
const rejection = result.announcements[0]!;
const text = rejection.text;
if (text === 'wont_help' || text === 'illegal_move'
|| text === 'no_such_piece' || text === 'no_legal_moves') {
// 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 anns = this.game.announcements;
if (anns[anns.length - 1] === rejection) anns.pop();
return {
kind: 'retry',
entry: {
@@ -150,15 +187,18 @@ export class BotDriver {
if (result.kind === 'silent') {
// Brain sent only `from` (arming). CasualBrain always commits with
// `to`; treat as a logic error and resign safely.
this.botResign();
this.botResign('commit_silent', { from: action.from, to: action.to });
return { kind: 'done' };
}
// result.kind === 'error' — bug path; resign.
this.botResign();
this.botResign('commit_error', {
code: result.kind === 'error' ? result.code : undefined,
announcement: result.kind === 'announce' ? result.announcements[0]?.text : undefined,
});
return { kind: 'done' };
}
case 'resign':
this.botResign();
this.botResign('brain_chose_resign');
return { kind: 'done' };
case 'offer-draw':
if (!this.game.drawOffer) {
@@ -185,7 +225,7 @@ export class BotDriver {
}
}
private botResign(): void {
private botResign(reason: BotResignReason, detail?: Record<string, unknown>): void {
if (this.game.status !== 'active') return;
const ply = this.game.chess.history().length;
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
@@ -195,6 +235,15 @@ export class BotDriver {
this.game.endReason = 'resign';
this.game.winner = this.color === 'w' ? 'b' : 'w';
this.game.finishedAt = Date.now();
// eslint-disable-next-line no-console
console.error('[bot resign]', {
gameId: this.game.id,
color: this.color,
mode: this.game.mode,
ply,
reason,
...detail,
});
}
private async disposeBrain(): Promise<void> {
+20
View File
@@ -0,0 +1,20 @@
import type { CaptureTally, Color, PieceTally } 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: PieceTally = {};
const byOpponent: PieceTally = {};
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 };
}
+9 -11
View File
@@ -1,6 +1,7 @@
import type { Move } from 'chess.js';
import {
geometricMoves,
isPromotionMove,
type Announcement,
type Color,
type Piece,
@@ -37,7 +38,7 @@ export function handleCommit(game: Game, color: Color, msg: CommitInput): Commit
const piece = game.chess.get(msg.from) as { color: Color; type: Piece['type'] } | false;
if (!piece || piece.color !== color) {
return announceWith(game, 'no_such_piece', color);
return announceWith(game, 'no_such_piece');
}
const pseudo = geometricMoves(
@@ -46,12 +47,12 @@ export function handleCommit(game: Game, color: Color, msg: CommitInput): Commit
ownSquares(game, color),
);
if (pseudo.length === 0) {
return announceWith(game, 'no_legal_moves', color);
return announceWith(game, 'no_legal_moves');
}
const legal = chessJsLegalFrom(game, msg.from);
if (legal.length === 0) {
return announceWith(game, 'wont_help', color);
return announceWith(game, 'wont_help');
}
game.armed = { color, from: msg.from };
@@ -77,7 +78,7 @@ function tryMove(
}
if (!move) {
return announceWith(game, 'illegal_move', color);
return announceWith(game, 'illegal_move');
}
game.armed = null;
@@ -110,10 +111,10 @@ function tryMove(
function announceWith(
game: Game,
text: 'no_such_piece' | 'no_legal_moves' | 'wont_help' | 'illegal_move',
color: Color,
): CommitResult {
const ply = game.chess.history().length;
const a = announce(text, color, ply);
// Attempted moves are part of the shared moderator transcript — both players hear them.
const a = announce(text, 'both', ply);
game.announcements.push(a);
return { kind: 'announce', announcements: [a] };
}
@@ -124,9 +125,6 @@ function chessJsLegalFrom(game: Game, from: Square): string[] {
function isPromotionRequired(game: Game, from: Square, to: Square): boolean {
const piece = game.chess.get(from);
if (!piece || piece.type !== 'p') return false;
const toRank = to[1];
if (piece.color === 'w' && toRank === '8') return true;
if (piece.color === 'b' && toRank === '1') return true;
return false;
if (!piece) return false;
return isPromotionMove({ color: piece.color, type: piece.type }, from, to);
}
+12 -11
View File
@@ -14,10 +14,11 @@ export function announce(
/**
* Translate an applied chess.js Move into the moderator vocabulary.
*
* Capturing player learns the captured piece type via their `view` update
* (their canonical board reflects the capture; the captured-pieces tray is
* populated from move history). The opponent gets only the `*_moved_captured`
* announcement.
* Every announcement is emitted with audience 'both' the moderator speaks
* each event aloud and both players hear it. Move events carry no square
* coordinates, so a player learns *that* the opponent moved / captured /
* castled, never *where*. The capturing player additionally sees the capture
* reflected in their own view update.
*/
export function translateMove(game: Game, move: Move): Announcement[] {
const out: Announcement[] = [];
@@ -33,21 +34,21 @@ export function translateMove(game: Game, move: Move): Announcement[] {
const isQueensideCastle = move.isQueensideCastle();
const isProm = !!move.promotion;
// To opponent: the move event itself.
// The move event itself — the moderator announces every move aloud, so both players hear it.
if (isKingsideCastle) {
out.push(announce(`${moverWord}_castled_kingside` as ModeratorText, opp, ply));
out.push(announce(`${moverWord}_castled_kingside` as ModeratorText, 'both', ply));
} else if (isQueensideCastle) {
out.push(announce(`${moverWord}_castled_queenside` as ModeratorText, opp, ply));
out.push(announce(`${moverWord}_castled_queenside` as ModeratorText, 'both', ply));
} else if (isCap && isEp) {
out.push(announce(`${moverWord}_moved_captured_ep` as ModeratorText, opp, ply));
out.push(announce(`${moverWord}_moved_captured_ep` as ModeratorText, 'both', ply));
} else if (isCap) {
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, opp, ply));
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, 'both', ply));
} else {
out.push(announce(`${moverWord}_moved` as ModeratorText, opp, ply));
out.push(announce(`${moverWord}_moved` as ModeratorText, 'both', ply));
}
if (isProm) {
out.push(announce(`${moverWord}_promoted` as ModeratorText, opp, ply, { promotedTo: move.promotion }));
out.push(announce(`${moverWord}_promoted` as ModeratorText, 'both', ply, { promotedTo: move.promotion }));
}
// To both: state changes.
+3
View File
@@ -19,6 +19,7 @@ import { announce } from './translator.js';
import { buildView } from './view.js';
import { consumeCommitToken } from './ratelimit.js';
import { endGame, finalizeIfEnded } from './game-end.js';
import { captureTally } from './captures.js';
async function pokeBot(game: Game): Promise<void> {
const driver = getBotDriver(game.id);
@@ -147,6 +148,7 @@ async function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hell
mode: game.mode,
highlightingEnabled: game.highlightingEnabled,
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
captures: captureTally(game, color),
aiOpponent: game.aiOpponent,
});
@@ -284,6 +286,7 @@ function sendUpdateTo(
drawOffer,
endReason: game.endReason,
winner: game.winner ?? null,
captures: captureTally(game, color),
aiOpponent: game.aiOpponent,
});
}
@@ -117,6 +117,99 @@ describe('CasualBrain', () => {
expect(aAct).toEqual(bAct);
});
it('blind mode + own_color_in_check announcement -> prefers king moves over other candidates', async () => {
// The bot only sees its own pieces in blind mode and cannot deduce the
// attacker. Per the AI spec ("Casual never resigns voluntarily"), the
// brain must use the public moderator announcement to bias toward
// check-resolving moves — most commonly, moving the king. Without this
// bias, the heuristic scores capture/advance signals that are uncorrelated
// with check resolution, the FSM rejects every non-resolving move, and
// the driver's retry cap fires => premature resignation.
const view: BoardView = {
pieces: {
e1: { color: 'w', type: 'k' },
a2: { color: 'w', type: 'p' },
h2: { color: 'w', type: 'p' },
b1: { color: 'w', type: 'n' },
},
toMove: 'w',
inCheck: true,
};
const candidates: CandidateMove[] = [
// king moves (8 possible escape squares; only some are off the board /
// off own-occupied — geometricMoves would have excluded those, but for
// the test we just enumerate a few plausible ones).
{ from: 'e1', to: 'd1' },
{ from: 'e1', to: 'f1' },
{ from: 'e1', to: 'd2' },
{ from: 'e1', to: 'e2' },
{ from: 'e1', to: 'f2' },
// non-king alternatives that the heuristic would otherwise prefer
{ from: 'a2', to: 'a4' },
{ from: 'h2', to: 'h4' },
{ from: 'b1', to: 'c3' },
{ from: 'b1', to: 'a3' },
];
let kingHits = 0;
for (let s = 0; s < 20; s++) {
const brain = new CasualBrain({ seed: s });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const action = await brain.decide({
view,
newAnnouncements: [
{ text: 'white_in_check', audience: 'both', ply: 10, at: Date.now() },
],
legalCandidates: candidates,
attemptHistory: [],
drawOfferFromOpponent: false,
ply: 10,
});
if (action.type === 'commit' && action.from === 'e1') kingHits++;
}
// Every seed should pick a king move when the boost is large enough to
// dominate the heuristic + tiebreak.
expect(kingHits).toBe(20);
});
it('blind mode + own_color_in_check + king moves all rejected -> falls through to non-king', async () => {
// Defensive: if every king move has been tried (knight check forcing
// king moves into other attacks, double check, etc.), the bot should
// still pick *something* from remaining candidates rather than throw.
const view: BoardView = {
pieces: {
e1: { color: 'w', type: 'k' },
b1: { color: 'w', type: 'n' },
},
toMove: 'w',
inCheck: true,
};
const candidates: CandidateMove[] = [
{ from: 'e1', to: 'd1' },
{ from: 'e1', to: 'e2' },
{ from: 'b1', to: 'c3' },
];
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const action = await brain.decide({
view,
newAnnouncements: [
{ text: 'white_in_check', audience: 'both', ply: 10, at: Date.now() },
],
legalCandidates: candidates,
attemptHistory: [
{ move: { from: 'e1', to: 'd1' }, rejection: 'illegal_move' },
{ move: { from: 'e1', to: 'e2' }, rejection: 'illegal_move' },
],
drawOfferFromOpponent: false,
ply: 10,
});
expect(action.type).toBe('commit');
if (action.type === 'commit') {
expect(action.from).toBe('b1');
expect(action.to).toBe('c3');
}
});
it('opening moves favor center pawns over flank pawns (e/d > a/h)', async () => {
const candidates: CandidateMove[] = [
{ from: 'a2', to: 'a3' },
+45 -3
View File
@@ -115,20 +115,44 @@ describe('BotDriver', () => {
expect(game.chess.turn()).toBe('w');
});
it('retry cap (5): after 5 wont_help, driver resigns the bot', async () => {
it('retry on illegal_move: switching from after rejection still succeeds', async () => {
// Black bishop b8 has legal moves (c7, d6, e5xpawn, a7) but b8→f4 is
// illegal because the white pawn on e5 blocks the diagonal. Black king on a1.
// The FSM ARMS the b8 piece during its first attempt then rejects with
// illegal_move; on the retry the brain switches to a king move, which used
// to trip "must_move_touched_piece" and resign the bot.
const fen = '1b6/8/8/4P3/8/8/8/k6K b - - 0 1';
game = makeGame({ mode: 'blind', fen });
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
brain.enqueue(
{ type: 'commit', from: 'b8', to: 'f4' }, // illegal: blocked by white pawn
{ type: 'commit', from: 'a1', to: 'b1' }, // legal king move
);
await driver.onStateChange();
expect(brain.decide).toHaveBeenCalledTimes(2);
expect(game.status).toBe('active');
expect(game.chess.turn()).toBe('w');
expect(game.armed).toBeNull();
});
it('retry cap (25): after RETRY_CAP rejected attempts, driver resigns the bot', async () => {
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();
for (let i = 0; i < 6; i++) {
// Enqueue more than RETRY_CAP repeated illegal moves; driver should
// exhaust the retry budget and resign.
for (let i = 0; i < 30; i++) {
brain.enqueue({ type: 'commit', from: 'e7', to: 'd6' });
}
await driver.onStateChange();
expect(game.status).toBe('finished');
expect(game.endReason).toBe('resign');
expect(game.winner).toBe('w');
expect(brain.decide).toHaveBeenCalledTimes(5);
expect(brain.decide).toHaveBeenCalledTimes(25);
});
it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {
@@ -148,6 +172,24 @@ describe('BotDriver', () => {
expect(brain.dispose).toHaveBeenCalled();
});
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');
});
it('bot move that delivers checkmate finalizes game.status', async () => {
// FEN: '1k6/8/1K6/8/8/8/8/7Q w - - 0 1'
// White king b6, white queen h1, black king b8.
@@ -0,0 +1,32 @@
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: {} });
});
});
@@ -74,6 +74,20 @@ describe('hierarchy decision table', () => {
expect(game.armed).toBeNull();
expect(game.chess.history()).toContain('e4');
});
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');
}
});
});
describe('touch-move enforcement', () => {
+2
View File
@@ -2,3 +2,5 @@ export * from './types.js';
export * from './moderator.js';
export * from './protocol.js';
export * from './geometric.js';
export * from './phantoms.js';
export * from './promotion.js';
+41
View File
@@ -0,0 +1,41 @@
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 squarepiece 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;
}
+23
View File
@@ -0,0 +1,23 @@
import { fileIndex, type Piece, type Square } from './types.js';
/**
* True iff moving `piece` fromto is a pawn promotion.
*
* A promotion is a pawn advancing from the rank adjacent to its promotion rank
* onto the promotion rank (78 for White, 21 for Black), at most one file
* over a straight push or a diagonal capture.
*
* Checking only the piece type and the destination rank as the move-commit
* paths previously did wrongly flags e.g. a pawn on the 2nd rank "moved" to
* the 8th, popping the promotion dialog for a move no pawn could ever make.
*/
export function isPromotionMove(
piece: Piece | undefined,
from: Square,
to: Square,
): boolean {
if (!piece || piece.type !== 'p') return false;
if (Math.abs(fileIndex(from) - fileIndex(to)) > 1) return false;
if (piece.color === 'w') return from[1] === '7' && to[1] === '8';
return from[1] === '2' && to[1] === '1';
}
+3 -1
View File
@@ -1,5 +1,5 @@
import type {
BoardView, Color, GameId, GameStatus, Mode, PlayerToken,
BoardView, CaptureTally, Color, GameId, GameStatus, Mode, PlayerToken,
PromotionType, Square, EndReason,
} from './types.js';
import type { Announcement } from './moderator.js';
@@ -34,6 +34,7 @@ export type ServerMessage =
mode: Mode;
highlightingEnabled: boolean;
opponentConnected: boolean;
captures: CaptureTally;
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
}
| {
@@ -45,6 +46,7 @@ export type ServerMessage =
drawOffer?: { from: Color } | null;
endReason?: EndReason;
winner?: Color | null;
captures: CaptureTally;
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
}
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
+9
View File
@@ -61,3 +61,12 @@ export function squareAt(fileIdx: number, rankIdx: number): Square | null {
const r = String.fromCharCode('1'.charCodeAt(0) + rankIdx);
return `${f}${r}` as Square;
}
/** 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;
}
+39
View File
@@ -0,0 +1,39 @@
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
d3: null, // valid square, null value
});
expect(deserializePhantoms(raw)).toEqual({ e5: { color: 'b', type: 'n' } });
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { isPromotionMove } from '../src/promotion.js';
import type { Piece } from '../src/types.js';
const wp: Piece = { color: 'w', type: 'p' };
const bp: Piece = { color: 'b', type: 'p' };
const wn: Piece = { color: 'w', type: 'n' };
describe('isPromotionMove', () => {
it('white pawn from the 7th rank to the 8th is a promotion', () => {
expect(isPromotionMove(wp, 'e7', 'e8')).toBe(true);
expect(isPromotionMove(wp, 'd7', 'e8')).toBe(true); // capture-promotion
});
it('black pawn from the 2nd rank to the 1st is a promotion', () => {
expect(isPromotionMove(bp, 'e2', 'e1')).toBe(true);
expect(isPromotionMove(bp, 'd2', 'c1')).toBe(true);
});
it('a pawn NOT on the rank adjacent to promotion is not a promotion', () => {
expect(isPromotionMove(wp, 'e2', 'e8')).toBe(false); // the reported bug
expect(isPromotionMove(wp, 'e5', 'e8')).toBe(false);
expect(isPromotionMove(bp, 'e7', 'e1')).toBe(false);
});
it('an ordinary pawn move is not a promotion', () => {
expect(isPromotionMove(wp, 'e2', 'e4')).toBe(false);
expect(isPromotionMove(bp, 'e7', 'e5')).toBe(false);
});
it('a non-pawn reaching the last rank is not a promotion', () => {
expect(isPromotionMove(wn, 'g6', 'e8')).toBe(false);
});
it('a pawn move spanning more than one file is not a promotion', () => {
expect(isPromotionMove(wp, 'a7', 'h8')).toBe(false);
});
it('an undefined piece is not a promotion', () => {
expect(isPromotionMove(undefined, 'e7', 'e8')).toBe(false);
});
});