Compare commits

55 Commits

Author SHA1 Message Date
claude (blind_chess) 5d3ac69cbb docs: session handoff — duplicate-chess landing card and cross-repo deploy 2026-05-19 18:16:09 -04:00
claude (blind_chess) 33a7cef656 docs: record duplicate-chess sub-app sharing the chess.sethpc.xyz origin
The Caddy block for chess.sethpc.xyz now has a handle_path /duplicate/*
that serves the duplicate_chess static bundle from /var/www/duplicate-chess
on CT 600. The new 3rd landing card surfaces it as "under development".
2026-05-19 18:11:55 -04:00
claude (blind_chess) fef6dcf095 feat(client): third landing card linking to duplicate chess
Adds a "Duplicate Chess (under development)" card below the friend/AI
cards, pointing at /duplicate/ — a sibling sandbox (Andrew Freiberg's
four-player variant) served as a static sub-app at chess.sethpc.xyz/duplicate/
via a separate Caddy handler. The card is a plain anchor so it survives
through any future static-fallback rewriting.
2026-05-19 18:08:11 -04:00
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
claude (blind_chess) 1674695eef docs: AI Phase 1 shipped — context, decisions, handoff
- CLAUDE.md: phase line moved to "Phase 1 deployed"; key files lists
  the new bot module, game-end extraction, and selfplay harness.
- DECISIONS.md: new "Phase 1 implementation outcomes" subsection records
  the CasualBrain-engine reversal, the FEN-vanilla-only invariant, why
  blind keeps heuristic, and the bot-slot token randomization. The
  earlier "Stockfish deferred" entry is partially superseded.
- .claude/handoffs/: handoff document for the next session.
2026-04-28 15:20:24 -04:00
claude (blind_chess) 7c18725586 feat(bot): vanilla CasualBrain delegates to js-chess-engine
The hand-rolled scoring heuristic lost to a random-move baseline 7-7 in
self-play — far below the spec's >=80% acceptance bar. Swap in a real
chess engine (js-chess-engine, MIT, ~400KB, no native deps) for vanilla
mode at level 2 with randomness=30 to break threefold cycles.

- BrainInput.fen added; driver populates it ONLY in vanilla mode.
  Blind mode omits the FEN so the engine path can't smuggle opponent
  positions past the view filter.
- CasualBrain in vanilla: convert FEN -> EngineGame -> ai({level: 2});
  validate the engine's move is in legalCandidates; fall back to
  heuristic on miss.
- Blind mode unchanged (engine isn't useful when only own pieces are
  visible — that's Phase 2 Recon's territory).

Self-play vs RandomBrain (100 games each direction, vanilla):
  - Casual(W) vs Random(B): W=97%
  - Random(W) vs Casual(B): B=96%
Casual-vs-Casual vanilla balanced, ~5-30ms/move. All 54 tests still pass.

Refresh .secrets.baseline (stale) to allow new pnpm-lock.yaml hashes.
2026-04-28 15:14:12 -04:00
claude (blind_chess) dc5e6678b9 feat(bot): self-play harness with Casual and random baselines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:52:10 -04:00
claude (blind_chess) 06bd144f7c feat(client): AI badge and bot-moving turn indicator
Track aiOpponent in game store; show a pill badge in the topbar for AI
games, update turn label to "<Brain> is moving…" on the bot's turn,
and suppress the disconnected-opponent banner when the opponent is a bot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:26:25 -04:00
claude (blind_chess) 31f68db654 feat(client): two-section landing — friend vs Casual bot 2026-04-28 14:24:06 -04:00
claude (blind_chess) cb8e017792 fix(bot): wire aiOpponent into joined and update server messages 2026-04-28 14:22:41 -04:00
claude (blind_chess) 73d5d0cb93 test(bot): integration tests for Casual vs human 2026-04-28 14:21:27 -04:00
claude (blind_chess) 88bc23b0d0 fix(bot): harden ws.ts integration seam
- maybeAbandon Promise no longer floats from setTimeout
- broadcastSinceLast loses dead extra parameter
- bot-slot token is randomized so a third party can't hijack the
  bot's color by guessing a fixed placeholder
2026-04-28 14:17:46 -04:00
claude (blind_chess) a9660c0694 feat(bot): pokeBot + broadcastSinceLast hooks into ws.ts handlers
Replace broadcastNewAnnouncements/broadcastUpdate with watermark-based
broadcastSinceLast; add pokeBot helper; make all state-mutating handlers
async; hook pokeBot after every mutation so the CasualBrain fires on
each turn without oracle access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:13:24 -04:00
claude (blind_chess) 58e1fc5bd8 feat(bot): POST /api/games instantiates CasualBrain + BotDriver 2026-04-28 14:10:19 -04:00
claude (blind_chess) 9a837ec319 feat(bot): vsAi/aiOpponent protocol fields and bot-driver registry 2026-04-28 14:07:01 -04:00
claude (blind_chess) 4407110147 fix(bot): finalize game on bot checkmate; harden driver dispatch
Extract endGame/finalizeIfEnded to game-end.ts so driver.ts can call
finalizeIfEnded after an applied move (fix: bot checkmate was not
setting game.status='finished'). Wrap entire dispatch() call in
try/catch for exception safety. Move lastSeenAnnouncementCount advance
to after successful dispatch so retry attempts see FSM rejection
announcements. Add checkmate-finalize test; lock retry-cap at 5 calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:04:22 -04:00
claude (blind_chess) 3798b9c00d feat(bot): BotDriver with mutex, retry cap, and dispatch
Wires Brain to Game: init/onStateChange/dispose lifecycle, in-flight
mutex, 5-attempt retry loop with attemptHistory, resign-on-cap. Also
adds Game.aiOpponent? field to state.ts for Task 5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:56:28 -04:00
claude (blind_chess) ebd1463b0a docs(bot): clarify when scoreMove early-return fires 2026-04-28 13:52:22 -04:00
claude (blind_chess) aa7bc30ee1 feat(bot): CasualBrain with capture/development/center heuristics 2026-04-28 13:48:34 -04:00
claude (blind_chess) f48e0a9cdf feat(bot): legalCandidates for vanilla and blind modes 2026-04-28 13:42:37 -04:00
claude (blind_chess) bc954f4748 feat(bot): scaffold Brain interface and types 2026-04-28 13:38:16 -04:00
56 changed files with 5407 additions and 4467 deletions
@@ -0,0 +1,143 @@
# Handoff: AI Phase 1 (Casual bot) shipped
## Session Metadata
- Created: 2026-04-28 ~19:15 UTC
- Project: /home/claude/bin/blind_chess
- 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)
## Handoff Chain
- **Continues from**: [2026-04-28-170713-ai-player-spec.md](./2026-04-28-170713-ai-player-spec.md) — AI player spec written and approved.
- **Supersedes**: None.
## Current State Summary
Phase 1 of the AI player feature (Casual bot) is **deployed and live**. Playing vs a Casual bot is now an option from the landing page, alongside the existing "play with a friend" flow.
This session executed `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` via subagent-driven development: 13 tasks, dispatched as fresh subagents per task with two-stage review (spec compliance + code quality). Several tasks surfaced real plan bugs that subagents fixed inline; the most consequential reversal was during Task 11 (self-play harness): the hand-rolled scoring algorithm in `CasualBrain` lost to a random-move baseline 7-7 in 100-game self-play, far below the spec's ≥80% acceptance bar. Solution: swapped vanilla-mode CasualBrain to delegate to `js-chess-engine` (level 2, randomness=30); blind mode kept the heuristic. Casual now wins 96-97% vs Random in vanilla, in both colors.
## Architecture Overview (what's deployed)
- **`packages/server/src/bot/`** — new module:
- `brain.ts``Brain` interface, `BrainInput`/`BrainAction`/`CandidateMove`/`AttemptHistoryEntry` types. `BrainInput.fen` set ONLY in vanilla mode (preserves view-filter invariant).
- `candidates.ts``legalCandidates(game, color)`. Vanilla: `chess.js .moves({verbose: true})`. Blind: `geometricMoves` over own pieces + promotion expansion.
- `casual-brain.ts``CasualBrain implements Brain`. Vanilla: delegates to `js-chess-engine` at level 2; blind: heuristic scoring (capture proxy / development / center / advance). Promotion default: queen. Draw response based on own material count.
- `driver.ts``BotDriver` per-game orchestrator. Mutex via `decideInFlight`, retry cap of 5, dispatches via `handleCommit`/announce, on game end calls `brain.dispose?.()`.
- `index.ts` — public re-exports.
- **`packages/server/src/game-end.ts`** — extracted from `ws.ts`: `endGame`/`finalizeIfEnded`. Both `ws.ts` and `bot/driver.ts` use it.
- **`packages/server/src/games.ts`** — bot driver registry (`attachBotDriver`, `getBotDriver`, `disposeBotDriver`). `createGame` accepts optional `vsAi: { brain }` and fills the bot's slot with a synthetic player slot (random token, no socket). `pruneFinished` cleans the registry.
- **`packages/server/src/state.ts`** — `Game` gains optional `aiOpponent?: { color; brain }` (informational) and required `lastBroadcastIdx: { w: number; b: number }` (per-color watermark for slice broadcasting).
- **`packages/server/src/ws.ts`** — refactored: `pokeBot(game)` helper called after every state-mutating handler; `broadcastSinceLast(game)` replaces the old `broadcastNewAnnouncements` (slices `game.announcements` from each color's watermark). Handlers are async; router uses `void` casts to discard handler Promises.
- **`packages/server/src/server.ts`** — `POST /api/games` handles `vsAi: { brain: 'casual' }`: instantiates `CasualBrain` + `BotDriver`, attaches to registry. `vsAi.brain === 'recon'` returns 503 (Phase 2 not implemented). `joinUrl: null` for AI games.
- **`packages/shared/src/protocol.ts`** — `CreateGameRequest.vsAi`, `CreateGameResponse.joinUrl: string | null`, `aiOpponent` on `joined` and `update` server messages.
- **`packages/server/src/validation.ts`** — Zod schema for `vsAi`.
- **Client (`packages/client/`)** — landing page split into two sections (friend / vs computer). In-game UI shows a "Casual bot" badge in the topbar; turn label says "Casual bot is moving…" when bot's turn. The "Opponent disconnected" banner is suppressed for AI games.
- **`scripts/selfplay.ts`** — operator CLI. `pnpm selfplay --white casual --black random --games 100 --mode vanilla`. Reports W/B/D/MaxPly/Err and end-reason histogram. Supports `--transcripts` for per-game logs.
## Phase 1 Acceptance — Met
| Check | Result |
|---|---|
| 100 Casual self-play vanilla games complete | ✅ Err=0 across all runs |
| Median ply 20-200 in self-play | ✅ avgPly~52 (engine vs random), ~116 (Casual vs Casual) |
| 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 | ✅ Merged to main (`1674695`); deployed to CT 690 |
## Critical Files
| File | Status | Notes |
|---|---|---|
| `docs/superpowers/specs/2026-04-28-ai-player-design.md` | Unchanged | Original spec; still the source of truth for Phase 2. |
| `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` | Unchanged | Phase 1 plan; can be archived or marked "executed" if useful. |
| `CLAUDE.md` | ✅ Updated | "Current State" reflects Phase 1 deployed; "Key files" lists new bot module. |
| `DECISIONS.md` | ✅ Updated | New "Phase 1 implementation outcomes" section; the previous "Stockfish deferred" entry is now strikethrough (partial supersede — using `js-chess-engine` instead). |
| `packages/server/src/bot/` | ✅ New | Brain, BotDriver, CasualBrain, candidates, index. |
| `packages/server/src/game-end.ts` | ✅ New | Extracted endGame/finalizeIfEnded. |
| `scripts/selfplay.ts` | ✅ New | Self-play harness. Run via `pnpm selfplay`. |
| `.secrets.baseline` | ✅ Refreshed | The previous baseline was stale (~6087 lines → 8196 after refresh). pnpm-lock.yaml integrity hashes for js-chess-engine were tripping the secret-detection hook. |
## Decisions Made (highlights — full list in DECISIONS.md)
- **CasualBrain reversal**: vanilla mode now delegates to `js-chess-engine` at level 2. Hand-rolled scorer lost to random — empirically broken. Engine swap brought it to 96-97% vs random.
- **`BrainInput.fen` is vanilla-only**: blind mode omits the FEN to preserve the view-filter invariant. The engine cannot smuggle opponent positions past the security boundary.
- **Blind mode keeps the heuristic**: a chess engine isn't useful when the bot only sees its own pieces. That gap is what Phase 2 (Recon) addresses with belief-state-from-announcements.
- **Bot-slot tokens are randomized**: not a fixed placeholder. Closes a hijack vector caught in code review.
- **`endGame`/`finalizeIfEnded` extracted to `game-end.ts`**: both ws and driver need to set finished state; duplication risk eliminated.
- **`pokeBot → broadcastSinceLast` order is load-bearing**: the bot's response (move + announcements) must be in `game.announcements` before broadcasting, so the human sees the bot's reply in the same WS message they receive after their own move.
## Immediate Next Steps
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 — defaults to `js-chess-engine` level 2).
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
- **Casual at level 2 may be too strong for some users.** Beats random 96-97% which is the intended acceptance bar, but a careful human is supposed to win against Casual. If users report Casual is unbeatable, drop to level 1. If users report it's trivial, raise to level 3. (`packages/server/src/bot/casual-brain.ts:33` — change the default in `CasualOpts`.)
- **Blind mode self-play games are very short** (avgPly=16, all resignations). The heuristic exhausts its retry cap (5) when the bot picks a move that can't legally proceed in blind mode. This is functional but observation: blind Casual is much weaker than vanilla Casual. Consider raising retry cap or improving heuristic if blind Casual feels broken in real play.
- **`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 (`npm i js-chess-engine@^1.0.0`) — older API but compatible.
## Deferred Items (Phase 2 work)
All from the original AI spec, 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 for Future Sessions
- **The bot's `BoardView` is the only egress to the engine, in vanilla mode.** This invariant is preserved structurally: the FEN is set in `BrainInput` only when `mode === 'vanilla'`. Phase 2 ReconBrain will not need this field at all (it gets the view + announcements only — same input shape as a human player who can't see the FEN of the actual game).
- **`Casual` and `Recon` brains are both architecturally instances of `Brain`.** Phase 2 just adds another `Brain` implementation against the same `BotDriver`. The driver's mutex / retry / dispatch / dispose lifecycle does NOT need changes.
- **Watermark advance only on successful dispatch** (in `BotDriver.runDecisionCycle`). On retry, the brain still sees the FSM's rejection announcement in `newAnnouncements`. This matters for ReconBrain (Phase 2) which uses announcements as evidence; CasualBrain ignores them.
- **`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.
- **The pre-commit hook is `detect-secrets-hook --baseline .secrets.baseline`** in `/home/claude/.config/git/hooks/pre-commit`. If you add a new dep and pnpm-lock.yaml hashes get flagged, run `detect-secrets scan > .secrets.baseline` to refresh.
## Files Modified / Added This Session
| File | Change |
|---|---|
| (new) `packages/server/src/bot/{brain,candidates,casual-brain,driver,index}.ts` | The bot module (~600 LoC). |
| (new) `packages/server/src/game-end.ts` | Extracted from ws.ts. |
| (new) `packages/server/test/unit/bot/{candidates,casual-brain,driver}.test.ts` | 27 unit tests. |
| (new) `packages/server/test/integration/ai-game-casual.test.ts` | 5 integration tests. |
| (new) `scripts/selfplay.ts` | Operator CLI. |
| (new) `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md` | The plan. |
| `packages/server/src/state.ts`, `games.ts`, `validation.ts`, `server.ts`, `ws.ts` | Wired up. |
| `packages/shared/src/protocol.ts` | Added `vsAi`, `aiOpponent`, nullable `joinUrl`. |
| `packages/client/src/lib/Landing.svelte`, `Game.svelte`, `stores/game.svelte.ts` | UI. |
| `package.json`, `pnpm-lock.yaml`, `packages/server/package.json` | Added `js-chess-engine`, `tsx`. |
| `CLAUDE.md`, `DECISIONS.md` | Context updates. |
| `.secrets.baseline` | Refreshed. |
## Environment State
- **CT 690 / blind-chess.service:** running. `systemctl is-active` returns `active`. Uptime measured from the deploy-restart at 2026-04-28 ~19:14 UTC.
- **Active processes:** none session-relevant. The deploy was a normal restart of the systemd unit.
- **Environment variables:** none added/changed.
- **Secrets:** none added; `.secrets.baseline` was refreshed to a clean state (the old one had ~4500 lines of stale per-file entries).
## Related Resources
- Live URL: https://chess.sethpc.xyz — Phase 1 live.
- Repo: https://git.sethpc.xyz/Seth/blind_chess — `feat/ai-player-phase-1-casual` branch (pending merge to main).
- Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`.
- Plan: `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md`.
- Decisions: `DECISIONS.md` "AI / computer player" section + new "Phase 1 implementation outcomes" subsection.
- Project identity: `CLAUDE.md`.
- Prior handoffs: `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 Phase 1 deployment; no credentials, secrets, or sensitive endpoints are exposed in the handoff or the deployed code. The bot uses no external services in Phase 1 (Phase 2 will add Ollama endpoints).
@@ -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.
@@ -0,0 +1,135 @@
# Handoff: 3rd landing card linking to duplicate-chess (deployed)
## Session Metadata
- Created: 2026-05-19 18:14:48
- Project: /home/claude/bin/blind_chess
- Branch: main (commits pushed)
- Session duration: ~30 minutes; the bulk of this session's substantive work
happened in the sibling `duplicate_chess` repo — see that repo's handoff for
the full deploy story.
### Recent Commits (for context)
- `33a7cef` docs: record duplicate-chess sub-app sharing the chess.sethpc.xyz origin
- `fef6dcf` feat(client): third landing card linking to duplicate chess
- `d95ab2a` docs: refresh handoff — promotion fix shipped, both fixes deployed
- `c01244c` fix: promotion dialog only fires for genuine pawn promotions
## Handoff Chain
- **Continues from**: [2026-05-18-205736-table-fidelity-features.md](./2026-05-18-205736-table-fidelity-features.md)
— that handoff's state is otherwise still current; the manual browser test
pass on the table-fidelity batch is still pending.
- **Cross-repo companion**: `~/bin/duplicate_chess/.claude/handoffs/2026-05-19-181212-deployed-as-chess-sethpc-xyz-duplicate.md`
— the full story of the deploy lives there.
## Current State Summary
The blind_chess landing now shows a 3rd card — "Duplicate Chess (under
development)" — that links to `/duplicate/`. The page-level deploy on
`chess.sethpc.xyz` is live and verified: the served bundle contains the new
strings ("Duplicate Chess", "under development", "/duplicate/") and CSS classes
(`.card-link`, `.badge`, `.open-cue`); curling `/duplicate/` returns the
duplicate_chess static index from a separate `handle_path` block in the same
Caddy site. blind_chess's server was restarted (1 in-memory game dropped per
the pre-accepted MVP policy).
State of blind_chess proper is otherwise unchanged from the table-fidelity
handoff — the same open items (manual browser test pass, board phantom glyph
contrast eyeball) apply.
## Architecture Overview
The `chess.sethpc.xyz` Caddy block on CT 600 now routes:
- `/duplicate/*` → static `/var/www/duplicate-chess/` (`handle_path` strips
the prefix; Vite was built with `base: '/duplicate/'`).
- Everything else → reverse_proxy to CT 690:3000 (blind_chess Fastify, as
before).
This means duplicate_chess deploys never restart blind_chess. The blind_chess
client just contains a link to `/duplicate/` — no SPA-level integration.
## Critical Files
| File | Purpose | Relevance |
|------|---------|-----------|
| `packages/client/src/lib/Landing.svelte` | 3rd card + `.card-link`, `.badge`, `.open-cue` CSS | The visible entry point |
| `CLAUDE.md` (deploy line) | Notes the `/duplicate/*` Caddy block | Resume reference |
| Caddy on CT 600 (`/etc/caddy/Caddyfile` lines ~11121124) | `handle_path /duplicate/*` sub-block | Backup at `/etc/caddy/Caddyfile.bak.duplicate-chess-1779228542` |
## Files Modified
- `packages/client/src/lib/Landing.svelte` — added a 3rd `<a class="card card-link" href="/duplicate/">` card under the existing two cards (friend/AI). New CSS for the link-shaped card, the "under development" pill badge, and the `Open →` cue.
- `CLAUDE.md` — Deploy bullet extended with one sentence about the `/duplicate/*` handler.
## Decisions Made
| Decision | Why |
|----------|-----|
| 3rd card as a plain `<a href>`, not an SPA-internal route | Honest about the architecture: duplicate is a separate static app behind a Caddy `handle_path`, not a route within blind_chess. Plain anchor survives any future static-fallback rewriting. |
| Visibly tagged "under development" | Sets Andrew's (the inventor's) expectations that duplicate isn't at parity with blind/vanilla. |
| Drop the 1 active in-memory game on restart | Acceptable per pre-existing MVP policy in DECISIONS.md. |
## Immediate Next Steps
1. **The table-fidelity manual browser test pass is still pending** (from the
prior handoff — Step 1 there). Now an even bigger reason to do it: Andrew
will be looking at the landing and seeing 3 cards; if the layout breaks at
a phone width or the card looks broken, fix it before he tests duplicate.
2. **Visually verify the new 3rd card** in a real browser at
https://chess.sethpc.xyz/ — colours, badge contrast, hover state, mobile
layout. Only confirmed via curl + JS/CSS string grep so far.
3. **Decide whether chess.local should also serve duplicate.** Currently the
LAN-only VDJ-RIG instance is on the previous blind_chess client (no 3rd
card) and has no `/duplicate/*` handler. If Seth wants LAN parity, redeploy
blind_chess client there AND copy duplicate's dist to the rig with a Caddy
block of its own.
## Important Context
- **The bulk of the work is in `duplicate_chess`**, not here. See its handoff
for the deploy details (Vite base, Caddy `handle_path`, `tar`-pipe transfer,
Caddyfile backup name).
- **`fef6dcf` + `33a7cef` are the only two commits this session in blind_chess.**
Everything else (vite.config, page title, CLAUDE.md, DECISIONS.md, handoff)
is in `duplicate_chess`.
- **The table-fidelity batch state in the prior handoff is unchanged.** The
manual browser test pass is still in progress. The deploy on CT 690 is now
at the new commit `fef6dcf` (client only — server code is byte-identical
to `c01244c`).
## Potential Gotchas
- **The blind_chess server WAS restarted** even though server code didn't
change (Fastify-static reads `STATIC_DIR` at startup, so the new
`index.html` + bundle hashes only get picked up after a service restart).
Took uptime back to 0; the 1 in-memory game at restart was dropped.
- **The Caddyfile edit was on CT 600 only.** chess.local on VDJ-RIG was not
touched and has no `/duplicate/*` route.
## Assumptions Made
- That "incorporate as a 3rd game mode 'under development'" meant a 3rd
top-level landing card (the existing "mode" was a radio inside each card
for Blind/Vanilla, but duplicate is 4-player so it can't fit that radio).
- That a plain link out to `/duplicate/` was the right level of integration
(vs iframe or source merge).
## Environment State
- `caddy.service` on CT 600 — reloaded.
- `blind-chess.service` on CT 690 — restarted (1 in-memory game dropped).
- chess.local on VDJ-RIG — not touched.
- No dev servers running locally.
## Related Resources
- Cross-repo companion handoff: `~/bin/duplicate_chess/.claude/handoffs/2026-05-19-181212-deployed-as-chess-sethpc-xyz-duplicate.md`
- Live URLs:
- https://chess.sethpc.xyz/ — blind_chess landing (3rd card visible).
- https://chess.sethpc.xyz/duplicate/ — duplicate_chess sandbox.
- https://chess.sethpc.xyz/api/health — blind_chess API health.
---
**Security Reminder**: No credentials or secrets in this handoff.
+3
View File
@@ -15,6 +15,9 @@ build/
coverage/
.vitest/
# Self-play transcripts (operator scratch)
tmp/
# Editor / OS
.DS_Store
.idea/
+299 -4331
View File
File diff suppressed because it is too large Load Diff
+23 -6
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,13 +19,13 @@ 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/computer player feature spec written and approved** (2026-04-28); implementation pending.
- **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`. 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:** 43 passing — 21 in shared (geometric helper), 22 in server (FSM + view + 4 real-WS 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`.
- **AI player (designed, not built):** Two-phase plan in `docs/superpowers/specs/2026-04-28-ai-player-design.md`. Phase 1 = Casual bot (algorithmic, ~200 LoC). Phase 2 = gemma4 recon bot (`gemma4:26b` chat agent on steel141 RTX 3090 Ti primary, pve197 V100 fallback). Bots play through the same view filter and FSM as humans — no oracle access.
- **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. The `chess.sethpc.xyz` Caddy block also serves a sibling app at `/duplicate/*` (the `duplicate_chess` static bundle from `/var/www/duplicate-chess/` on CT 600) — see Landing.svelte for the 3rd "under development" card.
- **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,6 +37,11 @@ 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.
@@ -46,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).
+27 -3
View File
@@ -50,9 +50,9 @@ Format: `YYYY-MM-DD: <decision> — <why>`
- 2026-04-28: **WS path through Caddy**`wss://chess.sethpc.xyz/ws?game=<id>` works without explicit `transport ws` config. Caddy's reverse_proxy handles upgrade transparently.
- 2026-04-28: **Public DNS** — relies on existing `*.sethpc.xyz` wildcard pointing at the WAN IP; no Pi-hole entry was needed. Caddy host-routes `chess.sethpc.xyz` to 192.168.0.245:3000.
## AI / computer player (designed 2026-04-28, not yet implemented)
## AI / computer player
Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions below are settled at spec-approval time; revisit if implementation surfaces something the spec didn't anticipate.
Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. **Phase 1 (Casual bot) deployed 2026-04-28** — live at https://chess.sethpc.xyz "Play vs computer". Phase 2 (Recon) deferred until Phase 1 has soaked.
- 2026-04-28: **Two AI bots, phased delivery**`CasualBrain` (Phase 1, algorithmic, in-process) ships first; `ReconBrain` (Phase 2, `gemma4:26b` chat agent) ships second. Phased to keep research uncertainty (Recon's actual playing strength) from blocking shipping anything. Rejected: combined launch, single difficulty-dial UX, throwaway Casual-as-stub.
- 2026-04-28: **Bots use the same view filter as humans**`BotDriver` calls `buildView(game, botColor)`; bot input is filtered `BoardView` + `Announcement[]`. No oracle access. Preserves the architectural invariant: the view filter is the only egress for board state, even for in-process bots. Rejected: "easy mode" oracle access for Casual to keep it simple.
@@ -66,6 +66,27 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions bel
- 2026-04-28: **Reasoning hidden during play, revealed post-game** — Gemma's chat history is private during the game; on game end, the chat history is copied to `Game.aiThoughtsLog` and the post-game screen shows a collapsible "View gemma4's reasoning" section. Rejected: live streaming "thinking tokens" to user (leaks strategy), permanent hiding (loses showcase value of the project).
- 2026-04-28: **`vsAi` field added to `CreateGameRequest`; `aiInfo` field added to `joined`/`update` server messages; `'ai_unavailable'` added to `EndReason`** — minimal protocol surface for the feature. AI metadata is NOT in `ModeratorText` enum (kept clean). UI-system messages for game-start info and failover events are style-distinct from `Announcement` entries.
### Phase 1 implementation outcomes (2026-04-28)
- 2026-04-28: **Phase 1 shipped to https://chess.sethpc.xyz.** 13 implementation tasks executed via subagent-driven development against `docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md`. 75 tests passing (21 shared + 54 server). Live smoke checklist passed.
- 2026-04-28: **CasualBrain reversal — vanilla mode now uses `js-chess-engine` (level 2, randomness=30), not the hand-rolled scorer.** The original heuristic lost to a random-move baseline 7-7 in 100-game self-play (target was ≥80%). After swap-in: Casual wins 97% as white and 96% as black vs Random, ~5-30ms/move. Supersedes the spec's "no Stockfish" decision in spirit — `js-chess-engine` is MIT-licensed, ~400KB, no native deps, and at level 2 plays "Casual" strength (beats random comfortably, loses to a careful human). Originally rejected "Stockfish for strong vanilla AI" was about *strength*, not about *using a pre-made engine*. Documented and pushed; accepted as a learning.
- 2026-04-28: **Bot's BoardView is the only egress to the engine.** `BrainInput.fen` is set ONLY in vanilla mode (where the view is full reveal); blind mode omits it. Engine cannot smuggle opponent positions past the view filter — same architectural invariant the brainstorming session established for human-played blind chess.
- 2026-04-28: **Blind mode keeps the heuristic (not engine).** Architecturally Stockfish/js-chess-engine can't usefully play blind chess — they need a full board to evaluate, and giving them one would be oracle access. Building a belief-state from announcements is the Recon bot's design (Phase 2). Self-play confirmed blind heuristic completes games (avgPly=16, 0 errors, all decisive) — short games but functional.
- 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 -->
@@ -83,10 +104,13 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions bel
- 2026-04-28: **Pre-deploy "server restarting" warning to active players** — stretch goal, not MVP. Mitigation for now: deploy during low-usage windows.
- 2026-04-28: ~~**Client-side AI / hint generation** — explicitly out of scope. Human vs. human only.~~ **Partially superseded 2026-04-28** by AI-player spec. Reversal applies *only* to the human-vs-AI path; client-side AI / hint generation in human-vs-human games remains rejected.
- 2026-04-28: **Difficulty slider for AI** — rejected. Two named buttons (Casual, Recon) only. No continuum; the two bots are architecturally different, not tuneable strengths of the same engine.
- 2026-04-28: **Stockfish for vanilla-mode AI strength** — deferred. Vanilla is a side-effect, not a feature target. Revisit if users explicitly ask for strong vanilla AI.
- 2026-04-28: ~~**Stockfish for vanilla-mode AI strength** — deferred. Vanilla is a side-effect, not a feature target. Revisit if users explicitly ask for strong vanilla AI.~~ **Partially superseded 2026-04-28** during Phase 1 implementation — using `js-chess-engine` (smaller, MIT, no GPL concerns) at level 2 for Casual vanilla, capped at ~30ms/move. The original rejection was about not making Casual *strong*; the engine at level 2 is genuinely casual-strength while still beating random comfortably. Stockfish itself remains rejected (GPL, 7MB+ wasm, overkill for the strength target).
- 2026-04-28: **Live token streaming during Gemma's thinking** — rejected for MVP. Static "AI is thinking..." indicator only. Streaming would leak strategic intent and adds protocol complexity.
- 2026-04-28: **Mid-game GPU flap-back** — rejected. Once failed over to V100, stays there for the rest of the game even if steel141 recovers. Simpler, more predictable, and chat-history is mid-flight.
- 2026-04-28: **AI vs AI public spectate-able games** — rejected for MVP. Self-play harness is CLI-only (`scripts/selfplay.ts`).
- 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.
+3 -1
View File
@@ -12,9 +12,11 @@
"test": "pnpm -r test",
"dev:server": "pnpm --filter @blind-chess/server dev",
"dev:client": "pnpm --filter @blind-chess/client dev",
"typecheck": "pnpm -r typecheck"
"typecheck": "pnpm -r typecheck",
"selfplay": "tsx scripts/selfplay.ts"
},
"devDependencies": {
"tsx": "^4.21.0",
"typescript": "^5.6.0",
"vitest": "^3.0.0"
}
+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>
+118 -10
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);
}
@@ -61,6 +66,17 @@
setTimeout(() => copied = false, 1500);
}
const isBotTurn = $derived(
!!game.state.aiOpponent
&& game.state.gameStatus === 'active'
&& game.state.view?.toMove === game.state.aiOpponent.color,
);
const aiBadgeText = $derived.by(() => {
if (!game.state.aiOpponent) return null;
return game.state.aiOpponent.brain === 'casual' ? 'Casual bot' : 'gemma4 recon';
});
const turnLabel = $derived.by(() => {
if (game.state.gameStatus === 'finished') {
const reason = game.state.endReason;
@@ -74,8 +90,46 @@
if (game.state.gameStatus === 'waiting') return 'Waiting for opponent…';
if (!game.state.you) return '…';
if (game.state.view?.toMove === game.state.you) return 'Your turn';
if (game.state.aiOpponent) {
const name = game.state.aiOpponent.brain === 'casual' ? 'Casual bot' : 'gemma4 recon';
return `${name} is moving…`;
}
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'}>
@@ -89,6 +143,11 @@
· You: {game.state.you === 'w' ? 'White' : 'Black'}
{/if}
</span>
{#if aiBadgeText}
<span class="ai-badge" class:thinking={isBotTurn}>
{aiBadgeText}
</span>
{/if}
</div>
{#if game.state.gameStatus === 'waiting'}
@@ -108,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'}
@@ -141,13 +206,20 @@
<div class="banner err">{game.state.lastError.code}: {game.state.lastError.message}</div>
{/if}
{#if !game.state.opponentConnected && game.state.gameStatus === 'active'}
{#if !game.state.opponentConnected && game.state.gameStatus === 'active' && !game.state.aiOpponent}
<div class="banner muted">Opponent disconnected — 5 minute grace window.</div>
{/if}
</aside>
{/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}
@@ -199,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;
@@ -221,6 +299,22 @@
.banner .row { display: flex; gap: 8px; margin-top: 8px; }
.banner .row button { flex: 1; }
.ai-badge {
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel);
color: var(--text-dim);
white-space: nowrap;
}
.ai-badge.thinking {
color: var(--accent);
border-color: var(--accent);
background: rgba(211, 84, 0, 0.07);
}
.waiting-card {
background: var(--panel);
border: 1px solid var(--border);
@@ -232,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>
+163 -29
View File
@@ -1,30 +1,60 @@
<script lang="ts">
import type { Mode, Color, CreateGameResponse } from '@blind-chess/shared';
let mode: Mode = $state('blind');
let side: Color | 'random' = $state('random');
let highlightingEnabled = $state(false);
let creating = $state(false);
let error: string | null = $state(null);
// Friend section state.
let friendMode: Mode = $state('blind');
let friendSide: Color | 'random' = $state('random');
let friendHighlight = $state(false);
let friendCreating = $state(false);
let friendError: string | null = $state(null);
async function create() {
creating = true;
error = null;
// AI section state (separate so user can configure each independently).
let aiMode: Mode = $state('blind');
let aiSide: Color | 'random' = $state('random');
let aiHighlight = $state(false);
let aiCreating = $state(false);
let aiError: string | null = $state(null);
async function createWithFriend() {
friendCreating = true; friendError = null;
try {
const res = await fetch('/api/games', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode, side, highlightingEnabled }),
body: JSON.stringify({
mode: friendMode, side: friendSide, highlightingEnabled: friendHighlight,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
// store creator token before navigating
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
location.hash = `#/g/${json.gameId}`;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
friendError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
friendCreating = false;
}
}
async function createVsCasual() {
aiCreating = true; aiError = null;
try {
const res = await fetch('/api/games', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
mode: aiMode, side: aiSide, highlightingEnabled: aiHighlight,
vsAi: { brain: 'casual' },
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
location.hash = `#/g/${json.gameId}`;
} catch (e) {
aiError = e instanceof Error ? e.message : String(e);
} finally {
aiCreating = false;
}
}
</script>
@@ -36,18 +66,19 @@
</div>
<div class="card">
<h2>Create a game</h2>
<h2>Play with a friend</h2>
<p class="card-sub muted">Get a shareable link, send it to someone, play together.</p>
<div class="field">
<span class="lbl">Mode</span>
<div class="opts">
<label class="opt" class:active={mode === 'blind'}>
<input type="radio" bind:group={mode} value="blind" />
<label class="opt" class:active={friendMode === 'blind'}>
<input type="radio" bind:group={friendMode} value="blind" />
<span class="opt-title">Blind</span>
<span class="opt-sub">Each player sees only their own pieces.</span>
</label>
<label class="opt" class:active={mode === 'vanilla'}>
<input type="radio" bind:group={mode} value="vanilla" />
<label class="opt" class:active={friendMode === 'vanilla'}>
<input type="radio" bind:group={friendMode} value="vanilla" />
<span class="opt-title">Vanilla</span>
<span class="opt-sub">Normal chess. Both players see everything.</span>
</label>
@@ -57,31 +88,93 @@
<div class="field">
<span class="lbl">You play as</span>
<div class="row">
<label><input type="radio" bind:group={side} value="w" /> White</label>
<label><input type="radio" bind:group={side} value="b" /> Black</label>
<label><input type="radio" bind:group={side} value="random" /> Random</label>
<label><input type="radio" bind:group={friendSide} value="w" /> White</label>
<label><input type="radio" bind:group={friendSide} value="b" /> Black</label>
<label><input type="radio" bind:group={friendSide} value="random" /> Random</label>
</div>
</div>
<div class="field">
<label class="toggle">
<input type="checkbox" bind:checked={highlightingEnabled} />
<input type="checkbox" bind:checked={friendHighlight} />
<span>Highlight reachable squares</span>
{#if mode === 'blind'}
{#if friendMode === 'blind'}
<span class="hint muted">(geometric only — no opponent info)</span>
{/if}
</label>
</div>
<button class="primary big" disabled={creating} onclick={create}>
{creating ? 'Creating…' : 'Create game'}
<button class="primary big" disabled={friendCreating} onclick={createWithFriend}>
{friendCreating ? 'Creating…' : 'Create game'}
</button>
{#if error}
<p class="error">Error: {error}</p>
{/if}
{#if friendError}<p class="error">Error: {friendError}</p>{/if}
</div>
<div class="card">
<h2>Play vs computer</h2>
<p class="card-sub muted">Always-available opponent. No link to share — game starts immediately.</p>
<div class="field">
<span class="lbl">Mode</span>
<div class="opts">
<label class="opt" class:active={aiMode === 'blind'}>
<input type="radio" bind:group={aiMode} value="blind" />
<span class="opt-title">Blind</span>
<span class="opt-sub">Each player sees only their own pieces.</span>
</label>
<label class="opt" class:active={aiMode === 'vanilla'}>
<input type="radio" bind:group={aiMode} value="vanilla" />
<span class="opt-title">Vanilla</span>
<span class="opt-sub">Normal chess. Both players see everything.</span>
</label>
</div>
</div>
<div class="field">
<span class="lbl">You play as</span>
<div class="row">
<label><input type="radio" bind:group={aiSide} value="w" /> White</label>
<label><input type="radio" bind:group={aiSide} value="b" /> Black</label>
<label><input type="radio" bind:group={aiSide} value="random" /> Random</label>
</div>
</div>
<div class="field">
<label class="toggle">
<input type="checkbox" bind:checked={aiHighlight} />
<span>Highlight reachable squares</span>
{#if aiMode === 'blind'}
<span class="hint muted">(geometric only — no opponent info)</span>
{/if}
</label>
</div>
<div class="ai-buttons">
<button class="primary" disabled={aiCreating} onclick={createVsCasual}>
{aiCreating ? 'Creating…' : 'Casual bot'}
</button>
<button class="secondary" disabled title="Coming soon">
gemma4 recon (coming soon)
</button>
</div>
<p class="card-sub muted small">
Casual: fast, plays simple moves, makes mistakes. Good for a quick game.
</p>
{#if aiError}<p class="error">Error: {aiError}</p>{/if}
</div>
<a class="card card-link" href="/duplicate/">
<h2>
Duplicate Chess
<span class="badge">under development</span>
</h2>
<p class="card-sub muted">
A four-player chess variant invented by Andrew Freiberg. Perfect information —
every player sees all four boards. Local sandbox; open it and play around.
</p>
<span class="open-cue">Open →</span>
</a>
<footer class="muted">
<span class="mono">git.sethpc.xyz/Seth/blind_chess</span>
</footer>
@@ -114,8 +207,11 @@
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px;
margin-bottom: 20px;
}
h2 { font-size: 18px; margin: 0 0 16px; }
.card-sub { font-size: 13px; margin: -10px 0 16px; }
.card-sub.small { margin-top: 12px; font-size: 12px; }
h2 { font-size: 18px; margin: 0 0 8px; }
.field { margin-bottom: 20px; }
.lbl {
@@ -157,6 +253,44 @@
margin-top: 8px;
}
.ai-buttons { display: grid; gap: 8px; grid-template-columns: 1fr 1fr; }
@media (max-width: 480px) { .ai-buttons { grid-template-columns: 1fr; } }
.secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
}
.secondary:disabled { cursor: not-allowed; opacity: 0.6; }
.error { color: #f87171; margin-top: 12px; }
footer { text-align: center; margin-top: 24px; font-size: 12px; }
.card-link {
display: block;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: border 0.15s, background 0.15s;
}
.card-link:hover { border-color: var(--accent-dim); background: rgba(211,84,0,0.05); }
.card-link h2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 999px;
color: var(--accent);
background: rgba(211,84,0,0.10);
border: 1px solid var(--accent-dim);
}
.open-cue {
display: inline-block;
margin-top: 8px;
font-size: 13px;
color: var(--accent);
font-weight: 600;
}
</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,
@@ -28,6 +29,8 @@ interface GameStateValue {
winner: Color | null;
opponentConnected: boolean;
lastError: { code: ErrorCode; message: string; at: number } | null;
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
captures: CaptureTally;
}
function makeStore() {
@@ -47,6 +50,8 @@ function makeStore() {
winner: null,
opponentConnected: false,
lastError: null,
aiOpponent: null,
captures: { byYou: {}, byOpponent: {} },
});
function tokenKey(gameId: string) { return `bc:${gameId}`; }
@@ -91,6 +96,8 @@ function makeStore() {
state.mode = m.mode;
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':
@@ -103,6 +110,8 @@ function makeStore() {
if (m.newAnnouncements.length) {
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();
+1
View File
@@ -17,6 +17,7 @@
"@fastify/websocket": "^11.0.0",
"chess.js": "^1.4.0",
"fastify": "^5.2.0",
"js-chess-engine": "^2.4.6",
"pino": "^9.5.0",
"ws": "^8.18.0",
"zod": "^3.24.0"
+54
View File
@@ -0,0 +1,54 @@
import type {
Announcement,
BoardView,
Color,
PromotionType,
Square,
} from '@blind-chess/shared';
import type { ModeratorText } from '@blind-chess/shared';
export interface CandidateMove {
from: Square;
to: Square;
promotion?: PromotionType;
}
export interface AttemptHistoryEntry {
move: CandidateMove;
rejection: ModeratorText;
}
export interface BrainInput {
view: BoardView;
newAnnouncements: Announcement[];
legalCandidates: CandidateMove[];
attemptHistory: AttemptHistoryEntry[];
drawOfferFromOpponent: boolean;
ply: number;
/**
* Full position FEN. Set only in vanilla mode where `view` is already a
* full reveal — omitted in blind mode, since passing the FEN there would
* leak opponent positions and violate the view-filter invariant. Brains
* with an internal chess engine rely on this; brains that don't can
* ignore it.
*/
fen?: string;
}
export type BrainAction =
| { type: 'commit'; from: Square; to: Square; promotion?: PromotionType }
| { type: 'resign' }
| { type: 'offer-draw' }
| { type: 'respond-draw'; accept: boolean };
export interface BrainInitArgs {
color: Color;
mode: 'blind' | 'vanilla';
gameId: string;
}
export interface Brain {
init(args: BrainInitArgs): Promise<void>;
decide(input: BrainInput): Promise<BrainAction>;
dispose?(): Promise<void>;
}
+68
View File
@@ -0,0 +1,68 @@
import {
geometricMoves,
type Color,
type Piece,
type PieceType,
type PromotionType,
type Square,
} from '@blind-chess/shared';
import type { Game } from '../state.js';
import { ownSquares } from '../view.js';
import type { CandidateMove } from './brain.js';
const PROMOTION_TYPES: PromotionType[] = ['q', 'r', 'b', 'n'];
export function legalCandidates(game: Game, color: Color): CandidateMove[] {
if (game.mode === 'vanilla') return vanillaCandidates(game, color);
return blindCandidates(game, color);
}
function vanillaCandidates(game: Game, color: Color): CandidateMove[] {
// chess.js only returns moves for the side to move via `.moves()`. To get a
// hypothetical move list for the other color we'd need to rotate — but the
// bot driver only invokes legalCandidates when it's the bot's turn, so this
// is fine in practice. Tests for "wrong color" use blind mode.
if (game.chess.turn() !== color) return [];
const moves = game.chess.moves({ verbose: true }) as Array<{
from: Square; to: Square; promotion?: PromotionType;
}>;
const out: CandidateMove[] = [];
for (const m of moves) {
out.push({ from: m.from, to: m.to, promotion: m.promotion });
}
return out;
}
function blindCandidates(game: Game, color: Color): CandidateMove[] {
const own = ownSquares(game, color);
const board = game.chess.board();
const out: CandidateMove[] = [];
for (const row of board) {
if (!row) continue;
for (const cell of row) {
if (!cell) continue;
if (cell.color !== color) continue;
const piece: Piece = { color: cell.color, type: cell.type as PieceType };
const from = cell.square as Square;
const tos = geometricMoves(piece, from, own);
for (const to of tos) {
if (isPromotionSquare(piece, to)) {
for (const promo of PROMOTION_TYPES) {
out.push({ from, to, promotion: promo });
}
} else {
out.push({ from, to });
}
}
}
}
return out;
}
function isPromotionSquare(piece: Piece, to: Square): boolean {
if (piece.type !== 'p') return false;
const rank = to[1];
return (piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1');
}
+223
View File
@@ -0,0 +1,223 @@
import { Game as EngineGame } from 'js-chess-engine';
import type { BoardView, Color, PieceType, PromotionType, Square } from '@blind-chess/shared';
import type {
Brain,
BrainAction,
BrainInitArgs,
BrainInput,
CandidateMove,
} from './brain.js';
interface CasualOpts {
seed?: number;
/**
* Engine difficulty for vanilla mode (1-5; 1 is weakest).
* `js-chess-engine` level 1 plays at roughly beginner strength —
* crushes random moves but loses to a careful human. Higher levels
* raise both strength and per-move latency.
*/
level?: 1 | 2 | 3 | 4 | 5;
}
const PIECE_VALUE: Record<PieceType, number> = {
p: 1, n: 3, b: 3, r: 5, q: 9, k: 0,
};
export class CasualBrain implements Brain {
private color: Color = 'w';
private mode: 'blind' | 'vanilla' = 'blind';
private level: 1 | 2 | 3 | 4 | 5;
private rng: () => number;
constructor(opts: CasualOpts = {}) {
this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff));
this.level = opts.level ?? 2;
}
async init(args: BrainInitArgs): Promise<void> {
this.color = args.color;
this.mode = args.mode;
}
async decide(input: BrainInput): Promise<BrainAction> {
if (input.drawOfferFromOpponent) {
return { type: 'respond-draw', accept: this.acceptDraw(input.view) };
}
const filtered = this.excludeRejected(input.legalCandidates, input.attemptHistory);
if (filtered.length === 0) {
throw new Error('CasualBrain: zero candidates after exclusion');
}
// Vanilla mode: delegate to a real chess engine. The driver supplies
// a FEN only in vanilla mode, so this branch is naturally gated.
if (this.mode === 'vanilla' && input.fen) {
const engineMove = this.engineMove(input.fen, filtered);
if (engineMove) {
return {
type: 'commit',
from: engineMove.from,
to: engineMove.to,
promotion: engineMove.promotion,
};
}
// Fall through to heuristic if the engine produced something we
// can't validate against the candidate list.
}
// 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,
to: choice.to,
promotion: choice.promotion,
};
}
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.
*/
private engineMove(fen: string, candidates: CandidateMove[]): CandidateMove | null {
let result: { move: Record<string, string> };
try {
const g = new EngineGame(fen);
// randomness=30 picks among moves within 30 centipawns of best; this
// breaks threefold-repetition draws when the bot is clearly winning
// but doesn't see the conversion path.
result = g.ai({ level: this.level, play: false, randomness: 30 }) as { move: Record<string, string> };
} catch {
return null;
}
const entry = Object.entries(result.move ?? {})[0];
if (!entry) return null;
const [fromUC, toUC] = entry;
const from = (fromUC as string).toLowerCase() as Square;
const to = (toUC as string).toLowerCase() as Square;
// Find a candidate matching this from-to. If the move is a promotion,
// js-chess-engine emits the destination square (e.g., {E7: 'E8'}) but
// doesn't separately surface the promotion piece — default to queen.
const matches = candidates.filter((c) => c.from === from && c.to === to);
if (matches.length === 0) return null;
const queen = matches.find((c) => c.promotion === 'q');
if (queen) return queen;
return matches[0]!;
}
/**
* Score-based fallback used for blind mode and any vanilla case where
* the engine's pick wasn't in the candidate list. Plays badly on purpose.
*/
private heuristicPick(
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'],
): CandidateMove[] {
if (history.length === 0) return candidates;
const rejected = new Set(history.map((h) => moveKey(h.move)));
return candidates.filter((c) => !rejected.has(moveKey(c)));
}
private scoreMove(move: CandidateMove, view: BoardView, ply: number): number {
let score = 0;
const destPiece = view.pieces[move.to];
if (!destPiece) score += 50;
const piece = view.pieces[move.from];
if (!piece) return score;
const ownStartingRank = this.color === 'w' ? '1' : '8';
const ownPawnStartingRank = this.color === 'w' ? '2' : '7';
if (ply < 16 && (piece.type === 'n' || piece.type === 'b')
&& move.from[1] === ownStartingRank) {
score += 30;
}
if (piece.type === 'p' && move.from[1] === ownPawnStartingRank) {
const file = move.from[0];
if (file === 'd' || file === 'e') score += 25;
else if (file === 'c' || file === 'f') score += 10;
}
const fromRank = parseInt(move.from[1]!, 10);
const toRank = parseInt(move.to[1]!, 10);
const advance = this.color === 'w' ? toRank - fromRank : fromRank - toRank;
if (advance > 0) score += 15 * advance;
if (move.from[1] === ownStartingRank && (piece.type === 'q' || piece.type === 'r')) {
score -= 40;
}
return score;
}
private acceptDraw(view: BoardView): boolean {
let own = 0;
for (const sq of Object.keys(view.pieces) as Square[]) {
const p = view.pieces[sq];
if (p) own += PIECE_VALUE[p.type];
}
return own < 15;
}
}
function moveKey(m: CandidateMove): string {
return `${m.from}-${m.to}${m.promotion ?? ''}`;
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
+256
View File
@@ -0,0 +1,256 @@
import type { Color } from '@blind-chess/shared';
import type { Game } from '../state.js';
import type {
AttemptHistoryEntry,
Brain,
BrainAction,
BrainInput,
CandidateMove,
} from './brain.js';
import { legalCandidates } from './candidates.js';
import { handleCommit } from '../commit.js';
import { buildView } from '../view.js';
import { announce } from '../translator.js';
import { finalizeIfEnded } from '../game-end.js';
// 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;
brain: Brain;
color: Color;
}
export class BotDriver {
private game: Game;
private brain: Brain;
private color: Color;
private decideInFlight = false;
private disposed = false;
private lastSeenAnnouncementCount = 0;
constructor(opts: BotDriverOpts) {
this.game = opts.game;
this.brain = opts.brain;
this.color = opts.color;
}
async init(): Promise<void> {
await this.brain.init({
color: this.color,
mode: this.game.mode,
gameId: this.game.id,
});
this.lastSeenAnnouncementCount = this.game.announcements.length;
}
async onStateChange(): Promise<void> {
if (this.disposed) return;
if (this.game.status === 'finished') {
await this.disposeBrain();
return;
}
if (this.decideInFlight) return;
if (!this.shouldDecide()) return;
this.decideInFlight = true;
try {
await this.runDecisionCycle();
} finally {
this.decideInFlight = false;
}
}
private shouldDecide(): boolean {
if (this.game.status !== 'active') return false;
// Respond to a draw offer from opponent even when it's not our turn.
if (this.game.drawOffer && this.game.drawOffer.from !== this.color) return true;
if (this.game.chess.turn() === this.color) return true;
return false;
}
private async runDecisionCycle(): Promise<void> {
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 (err) {
// Brain exception OR programming error in dispatch. Safe failure: resign.
this.botResign('brain_threw', { err: errString(err) });
return;
}
if (outcome.kind === 'done') {
this.lastSeenAnnouncementCount = this.game.announcements.length;
return;
}
attemptHistory.push(outcome.entry);
}
this.lastSeenAnnouncementCount = this.game.announcements.length;
this.botResign('retry_cap_exhausted', {
attempts: attemptHistory.map((a) => `${a.move.from}-${a.move.to}:${a.rejection}`),
});
}
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
const view = buildView(this.game, this.color);
const sliceStart = this.lastSeenAnnouncementCount;
// NOTE: do NOT advance lastSeenAnnouncementCount here. The caller advances
// it once the decision cycle terminates successfully — otherwise retried
// attempts would not see the FSM's rejection announcements in their input.
const newAnnouncements = this.game.announcements
.slice(sliceStart)
.filter((a) => a.audience === 'both' || a.audience === this.color);
const candidates: CandidateMove[] = legalCandidates(this.game, this.color);
return {
view,
newAnnouncements,
legalCandidates: candidates,
attemptHistory,
drawOfferFromOpponent: !!(this.game.drawOffer && this.game.drawOffer.from !== this.color),
ply: this.game.chess.history().length,
// Vanilla mode: full reveal, FEN exposes nothing the brain can't already
// see. Blind mode: omit FEN so the engine path can't smuggle opponent
// positions past the view filter.
fen: this.game.mode === 'vanilla' ? this.game.chess.fen() : undefined,
};
}
private dispatch(
action: BrainAction,
): { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry } {
switch (action.type) {
case 'commit': {
const result = handleCommit(this.game, this.color, {
from: action.from, to: action.to, promotion: action.promotion,
});
if (result.kind === 'applied') {
finalizeIfEnded(this.game, result.announcements);
return { kind: 'done' };
}
if (result.kind === 'announce') {
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: {
move: { from: action.from, to: action.to, promotion: action.promotion },
rejection: text,
},
};
}
}
if (result.kind === 'silent') {
// Brain sent only `from` (arming). CasualBrain always commits with
// `to`; treat as a logic error and resign safely.
this.botResign('commit_silent', { from: action.from, to: action.to });
return { kind: 'done' };
}
// result.kind === 'error' — bug path; resign.
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('brain_chose_resign');
return { kind: 'done' };
case 'offer-draw':
if (!this.game.drawOffer) {
this.game.drawOffer = { from: this.color, at: Date.now() };
}
return { kind: 'done' };
case 'respond-draw':
if (!this.game.drawOffer || this.game.drawOffer.from === this.color) {
return { kind: 'done' };
}
if (action.accept) {
const ply = this.game.chess.history().length;
const a = announce('draw_agreed', 'both', ply);
this.game.announcements.push(a);
this.game.drawOffer = null;
this.game.status = 'finished';
this.game.endReason = 'draw_agreed';
this.game.winner = null;
this.game.finishedAt = Date.now();
} else {
this.game.drawOffer = null;
}
return { kind: 'done' };
}
}
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';
const a = announce(text, 'both', ply);
this.game.announcements.push(a);
this.game.status = 'finished';
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> {
if (this.disposed) return;
this.disposed = true;
try {
await this.brain.dispose?.();
} catch {/* ignore */}
}
}
+7
View File
@@ -0,0 +1,7 @@
export type {
Brain, BrainInput, BrainAction, BrainInitArgs,
CandidateMove, AttemptHistoryEntry,
} from './brain.js';
export { CasualBrain } from './casual-brain.js';
export { BotDriver } from './driver.js';
export { legalCandidates } from './candidates.js';
+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);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Color } from '@blind-chess/shared';
import type { Game } from './state.js';
export function endGame(game: Game, reason: Game['endReason'], winner: Color | null): void {
game.status = 'finished';
game.endReason = reason;
game.winner = winner;
game.finishedAt = Date.now();
}
export function finalizeIfEnded(game: Game, announcements: ReadonlyArray<{ text: string }>): void {
// Detect terminal moderator announcements.
const lastTexts = new Set(announcements.map((a) => a.text));
if (lastTexts.has('white_checkmate')) endGame(game, 'checkmate', 'w');
else if (lastTexts.has('black_checkmate')) endGame(game, 'checkmate', 'b');
else if (lastTexts.has('stalemate')) endGame(game, 'stalemate', null);
else if (lastTexts.has('draw_insufficient')) endGame(game, 'insufficient', null);
else if (lastTexts.has('draw_threefold')) endGame(game, 'threefold', null);
else if (lastTexts.has('draw_fifty')) endGame(game, 'fifty_move', null);
}
+40 -2
View File
@@ -3,9 +3,23 @@ import { randomBytes } from 'node:crypto';
import type {
Color, GameId, Mode, PlayerToken,
} from '@blind-chess/shared';
import type { BotDriver } from './bot/driver.js';
import { type Game, PRUNE_AFTER_FINISHED_MS, RATE_LIMIT } from './state.js';
const games = new Map<GameId, Game>();
const botDrivers = new Map<GameId, BotDriver>();
export function attachBotDriver(id: GameId, driver: BotDriver): void {
botDrivers.set(id, driver);
}
export function getBotDriver(id: GameId): BotDriver | undefined {
return botDrivers.get(id);
}
export function disposeBotDriver(id: GameId): void {
botDrivers.delete(id);
}
export function newGameId(): GameId {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
@@ -31,11 +45,16 @@ export function createGame(opts: {
mode: Mode;
creatorSide: Color;
highlightingEnabled: boolean;
vsAi?: { brain: 'casual' | 'recon' };
}): { game: Game; creatorToken: PlayerToken } {
const id = newGameId();
const creatorToken = newPlayerToken();
const now = Date.now();
const botColor: Color | null = opts.vsAi
? (opts.creatorSide === 'w' ? 'b' : 'w')
: null;
const game: Game = {
id,
mode: opts.mode,
@@ -46,12 +65,18 @@ export function createGame(opts: {
moveHistory: [],
announcements: [],
players: {
w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now) : null,
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now) : null,
w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now)
: (botColor === 'w' ? makeBotSlot(now) : null),
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now)
: (botColor === 'b' ? makeBotSlot(now) : null),
},
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
aiOpponent: opts.vsAi && botColor
? { color: botColor, brain: opts.vsAi.brain }
: undefined,
};
games.set(id, game);
@@ -67,6 +92,18 @@ function makeSlot(token: PlayerToken, now: number) {
};
}
function makeBotSlot(now: number) {
// Synthetic slot: occupies the player's color but never connects. The token
// is randomized (same shape as a real client token) so a third party can't
// hijack the bot's color by guessing a fixed placeholder.
return {
token: newPlayerToken(),
socket: null,
joinedAt: now,
rateBucket: { tokens: RATE_LIMIT.capacity, last: now },
};
}
export function getGame(id: GameId): Game | undefined {
return games.get(id);
}
@@ -115,6 +152,7 @@ export function pruneFinished(): number {
for (const [id, g] of games) {
if (g.status === 'finished' && g.finishedAt && now - g.finishedAt > PRUNE_AFTER_FINISHED_MS) {
games.delete(id);
botDrivers.delete(id);
removed++;
}
}
+20 -3
View File
@@ -8,9 +8,11 @@ import {
chooseSide,
createGame,
pruneFinished,
attachBotDriver,
} from './games.js';
import { attachSocket } from './ws.js';
import { createGameSchema } from './validation.js';
import { CasualBrain, BotDriver } from './bot/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -45,13 +47,28 @@ fastify.post('/api/games', async (req, reply) => {
reply.code(400);
return { error: 'malformed', detail: parsed.error.issues };
}
const { mode, side, highlightingEnabled } = parsed.data;
const { mode, side, highlightingEnabled, vsAi } = parsed.data;
// Phase 1: only 'casual' is implemented. 'recon' returns 503.
if (vsAi && vsAi.brain === 'recon') {
reply.code(503);
return { error: 'ai_offline', detail: 'recon bot not yet implemented' };
}
const creatorSide = chooseSide(side);
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled });
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
// For AI games, wire the bot.
if (vsAi && game.aiOpponent) {
const brain = new CasualBrain({});
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
await driver.init();
attachBotDriver(game.id, driver);
}
const publicBase = PUBLIC_BASE
|| (req.headers.host ? `${req.protocol}://${req.headers.host}` : '');
const joinUrl = `${publicBase}/g/${game.id}`;
const joinUrl = vsAi ? null : `${publicBase}/g/${game.id}`;
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
});
+2
View File
@@ -51,6 +51,8 @@ export interface Game {
armed: { color: Color; from: Square } | null;
drawOffer: { from: Color; at: number } | null;
disconnectAt: { w?: number; b?: number };
lastBroadcastIdx: { w: number; b: number };
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
}
export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 };
+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
@@ -41,4 +41,7 @@ export const createGameSchema = z.object({
mode: z.union([z.literal('blind'), z.literal('vanilla')]),
side: z.union([colorSchema, z.literal('random')]),
highlightingEnabled: z.boolean(),
vsAi: z.object({
brain: z.union([z.literal('casual'), z.literal('recon')]),
}).optional(),
});
+59 -55
View File
@@ -10,6 +10,7 @@ import {
claimSlot,
findTokenInGame,
getGame,
getBotDriver,
} from './games.js';
import type { Game } from './state.js';
import { GRACE_MS } from './state.js';
@@ -17,6 +18,30 @@ import { handleCommit } from './commit.js';
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);
if (!driver) return;
try {
await driver.onStateChange();
} catch (err) {
// Don't throw out of message handlers — log and continue.
// eslint-disable-next-line no-console
console.error('[bot driver error]', { gameId: game.id, err });
}
}
function broadcastSinceLast(game: Game): void {
for (const c of ['w', 'b'] as const) {
const lastIdx = game.lastBroadcastIdx[c];
const all = game.announcements;
const slice = all.slice(lastIdx).filter((a) => a.audience === 'both' || a.audience === c);
sendUpdateTo(game, c, slice);
game.lastBroadcastIdx[c] = all.length;
}
}
interface SocketCtx {
socket: WebSocket;
@@ -60,7 +85,7 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
}
const msg = result.data as ClientMessage;
if (msg.type === 'hello') return onHello(ctx, msg);
if (msg.type === 'hello') { void onHello(ctx, msg); return; }
if (msg.type === 'pong') return;
if (!ctx.game || !ctx.color) {
@@ -68,14 +93,14 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
}
switch (msg.type) {
case 'commit': return onCommit(ctx, msg);
case 'resign': return onResign(ctx);
case 'offer-draw': return onOfferDraw(ctx);
case 'respond-draw': return onRespondDraw(ctx, msg.accept);
case 'commit': void onCommit(ctx, msg); return;
case 'resign': void onResign(ctx); return;
case 'offer-draw': void onOfferDraw(ctx); return;
case 'respond-draw': void onRespondDraw(ctx, msg.accept); return;
}
}
function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): void {
async function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>): Promise<void> {
const game = getGame(msg.gameId);
if (!game) return sendError(ctx.socket, 'game_not_found');
@@ -123,17 +148,20 @@ function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>)
mode: game.mode,
highlightingEnabled: game.highlightingEnabled,
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
captures: captureTally(game, color),
aiOpponent: game.aiOpponent,
});
// Notify peer that we're connected.
notifyPeer(game, color, true);
// If activation just happened, push update to both.
// If activation just happened, poke bot then broadcast.
if (game.status === 'active') {
broadcastUpdate(game);
await pokeBot(game);
broadcastSinceLast(game);
}
}
function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): void {
async function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }>): Promise<void> {
const game = ctx.game!;
const color = ctx.color!;
@@ -153,17 +181,19 @@ function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }
return;
case 'announce':
// Announcement to actor; opponent is unaffected unless audience=both.
broadcastNewAnnouncements(game, result.announcements);
// Non-turn-ending — no bot poke.
broadcastSinceLast(game);
return;
case 'applied':
// Move applied. Check end conditions.
// Move applied. Check end conditions, then poke bot.
finalizeIfEnded(game, result.announcements);
broadcastNewAnnouncements(game, result.announcements);
await pokeBot(game);
broadcastSinceLast(game);
return;
}
}
function onResign(ctx: SocketCtx): void {
async function onResign(ctx: SocketCtx): Promise<void> {
const game = ctx.game!;
const color = ctx.color!;
if (game.status !== 'active') return;
@@ -172,19 +202,22 @@ function onResign(ctx: SocketCtx): void {
const a = announce(color === 'w' ? 'white_resigned' : 'black_resigned', 'both', ply);
game.announcements.push(a);
endGame(game, 'resign', color === 'w' ? 'b' : 'w');
broadcastNewAnnouncements(game, [a]);
await pokeBot(game); // bot.onStateChange will see status=finished and dispose
broadcastSinceLast(game);
}
function onOfferDraw(ctx: SocketCtx): void {
async function onOfferDraw(ctx: SocketCtx): Promise<void> {
const game = ctx.game!;
const color = ctx.color!;
if (game.status !== 'active') return;
game.drawOffer = { from: color, at: Date.now() };
// Push update to both so opponent sees the drawOffer field.
broadcastUpdate(game);
// Poke bot — it may auto-respond to the draw offer.
await pokeBot(game);
// broadcastSinceLast sends drawOffer field via sendUpdateTo's existing logic.
broadcastSinceLast(game);
}
function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
async function onRespondDraw(ctx: SocketCtx, accept: boolean): Promise<void> {
const game = ctx.game!;
const color = ctx.color!;
if (!game.drawOffer || game.drawOffer.from === color) return;
@@ -194,11 +227,11 @@ function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
game.announcements.push(a);
game.drawOffer = null;
endGame(game, 'draw_agreed', null);
broadcastNewAnnouncements(game, [a]);
} else {
game.drawOffer = null;
broadcastUpdate(game);
}
await pokeBot(game);
broadcastSinceLast(game);
}
function onClose(ctx: SocketCtx): void {
@@ -211,13 +244,13 @@ function onClose(ctx: SocketCtx): void {
if (game.status === 'active') {
game.disconnectAt[color] = Date.now();
// Schedule grace timer.
setTimeout(() => maybeAbandon(game, color), GRACE_MS + 100);
setTimeout(() => { void maybeAbandon(game, color); }, GRACE_MS + 100);
}
notifyPeer(game, color, false, Date.now() + GRACE_MS);
}
}
function maybeAbandon(game: Game, color: Color): void {
async function maybeAbandon(game: Game, color: Color): Promise<void> {
if (game.status !== 'active') return;
const slot = game.players[color];
if (!slot) return;
@@ -228,42 +261,11 @@ function maybeAbandon(game: Game, color: Color): void {
game.announcements.push(a);
const winner = game.players[color === 'w' ? 'b' : 'w']?.socket ? (color === 'w' ? 'b' : 'w') : null;
endGame(game, 'abandoned', winner);
broadcastNewAnnouncements(game, [a]);
await pokeBot(game); // dispose bot if game ended
broadcastSinceLast(game);
}
function endGame(game: Game, reason: Game['endReason'], winner: Color | null): void {
game.status = 'finished';
game.endReason = reason;
game.winner = winner;
game.finishedAt = Date.now();
}
function finalizeIfEnded(game: Game, announcements: ReadonlyArray<{ text: string }>): void {
// Detect terminal moderator announcements.
const lastTexts = new Set(announcements.map((a) => a.text));
if (lastTexts.has('white_checkmate')) endGame(game, 'checkmate', 'w');
else if (lastTexts.has('black_checkmate')) endGame(game, 'checkmate', 'b');
else if (lastTexts.has('stalemate')) endGame(game, 'stalemate', null);
else if (lastTexts.has('draw_insufficient')) endGame(game, 'insufficient', null);
else if (lastTexts.has('draw_threefold')) endGame(game, 'threefold', null);
else if (lastTexts.has('draw_fifty')) endGame(game, 'fifty_move', null);
}
function broadcastNewAnnouncements(
game: Game,
newAnnouncements: ReadonlyArray<import('@blind-chess/shared').Announcement>,
): void {
for (const c of ['w', 'b'] as const) {
const filtered = newAnnouncements.filter((a) => a.audience === 'both' || a.audience === c);
sendUpdateTo(game, c, filtered);
}
}
function broadcastUpdate(game: Game): void {
for (const c of ['w', 'b'] as const) {
sendUpdateTo(game, c, []);
}
}
function sendUpdateTo(
game: Game,
@@ -284,6 +286,8 @@ function sendUpdateTo(
drawOffer,
endReason: game.endReason,
winner: game.winner ?? null,
captures: captureTally(game, color),
aiOpponent: game.aiOpponent,
});
}
@@ -0,0 +1,184 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebSocket } from 'ws';
import Fastify from 'fastify';
import websocketPlugin from '@fastify/websocket';
import {
activeGameCount, chooseSide, createGame, attachBotDriver,
} from '../../src/games.js';
import { attachSocket } from '../../src/ws.js';
import { createGameSchema } from '../../src/validation.js';
import { CasualBrain, BotDriver } from '../../src/bot/index.js';
import type { ServerMessage } from '@blind-chess/shared';
let app: ReturnType<typeof Fastify>;
let baseUrl = '';
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(websocketPlugin);
app.get('/api/health', async () => ({ ok: true, activeGames: activeGameCount() }));
app.post('/api/games', async (req, reply) => {
const parsed = createGameSchema.safeParse(req.body);
if (!parsed.success) { reply.code(400); return { error: 'malformed' }; }
const { mode, side, highlightingEnabled, vsAi } = parsed.data;
if (vsAi && vsAi.brain === 'recon') {
reply.code(503); return { error: 'ai_offline' };
}
const creatorSide = chooseSide(side);
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
if (vsAi && game.aiOpponent) {
const brain = new CasualBrain({ seed: 1 });
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
await driver.init();
attachBotDriver(game.id, driver);
}
const joinUrl = vsAi ? null : `http://placeholder/g/${game.id}`;
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
});
app.get('/ws', { websocket: true }, (socket) => {
const raw = (socket as unknown as { socket?: unknown }).socket ?? socket;
attachSocket(raw as never);
});
await app.listen({ port: 0, host: '127.0.0.1' });
const addr = app.server.address();
if (typeof addr !== 'object' || !addr) throw new Error('no address');
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => { await app.close(); });
interface Client {
ws: WebSocket;
msgs: ServerMessage[];
waitFor: (pred: (m: ServerMessage) => boolean, timeoutMs?: number) => Promise<ServerMessage>;
send: (m: unknown) => void;
close: () => void;
}
function makeClient(gameId: string): Promise<Client> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(baseUrl.replace('http', 'ws') + `/ws?game=${gameId}`);
const msgs: ServerMessage[] = [];
const waiters: Array<{ pred: (m: ServerMessage) => boolean;
resolve: (m: ServerMessage) => void;
reject: (e: Error) => void;
timer: NodeJS.Timeout }> = [];
ws.on('message', (data) => {
const m = JSON.parse(data.toString()) as ServerMessage;
msgs.push(m);
for (const w of [...waiters]) {
if (w.pred(m)) {
clearTimeout(w.timer);
waiters.splice(waiters.indexOf(w), 1);
w.resolve(m);
}
}
});
ws.on('open', () => resolve({
ws, msgs,
waitFor: (pred, timeoutMs = 2000) => new Promise<ServerMessage>((res, rej) => {
const existing = msgs.find(pred);
if (existing) return res(existing);
const timer = setTimeout(() => rej(new Error('waitFor timeout')), timeoutMs);
waiters.push({ pred, resolve: res, reject: rej, timer });
}),
send: (m) => ws.send(JSON.stringify(m)),
close: () => ws.close(),
}));
ws.on('error', reject);
});
}
async function createAiGame(side: 'w' | 'b', mode: 'blind' | 'vanilla' = 'vanilla'): Promise<{ gameId: string; creatorToken: string }> {
const res = await fetch(`${baseUrl}/api/games`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode, side, highlightingEnabled: false, vsAi: { brain: 'casual' } }),
});
return await res.json();
}
describe('AI game / Casual', () => {
it('human as black: bot moves first as white', async () => {
const { gameId, creatorToken } = await createAiGame('b');
const human = await makeClient(gameId);
human.send({ type: 'hello', gameId, token: creatorToken });
const joined = await human.waitFor((m) => m.type === 'joined');
expect(joined.type === 'joined' && joined.you).toBe('b');
if (joined.type === 'joined') {
expect(joined.aiOpponent).toEqual({ color: 'w', brain: 'casual' });
}
// Bot's opening move should arrive as an update (bot moves first as white).
const botMoved = await human.waitFor((m) =>
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'),
2000,
);
expect(botMoved.type).toBe('update');
human.close();
});
it('human as white: human moves first, bot replies', async () => {
const { gameId, creatorToken } = await createAiGame('w');
const human = await makeClient(gameId);
human.send({ type: 'hello', gameId, token: creatorToken });
await human.waitFor((m) => m.type === 'joined');
// Human plays e2e4 (arm + commit).
human.send({ type: 'commit', from: 'e2' });
await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
human.send({ type: 'commit', from: 'e2', to: 'e4' });
// Bot replies as black.
const botMoved = await human.waitFor((m) =>
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'black_moved'),
);
expect(botMoved.type).toBe('update');
// After bot reply, it's white's turn again.
if (botMoved.type === 'update') {
expect(botMoved.view.toMove).toBe('w');
}
human.close();
});
it('bot alternate exchanges: game doesn\'t end prematurely', async () => {
// One human-bot exchange: human plays white e2-e4, bot replies as black.
const { gameId, creatorToken } = await createAiGame('w');
const human = await makeClient(gameId);
human.send({ type: 'hello', gameId, token: creatorToken });
const joined = await human.waitFor((m) => m.type === 'joined');
expect(joined.type === 'joined' && joined.gameStatus).toBe('active');
human.send({ type: 'commit', from: 'e2' });
await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
human.send({ type: 'commit', from: 'e2', to: 'e4' });
const botReplied = await human.waitFor((m) =>
m.type === 'update' &&
(m.gameStatus === 'finished' || m.view.toMove === 'w'),
2000,
);
// Game should still be active after one exchange.
expect(botReplied.type === 'update' && botReplied.gameStatus).toBe('active');
human.close();
});
it('joinUrl is null for AI games', async () => {
const res = await fetch(`${baseUrl}/api/games`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'casual' } }),
});
const json = await res.json() as { joinUrl: string | null };
expect(json.joinUrl).toBeNull();
});
it('recon brain returns 503 in Phase 1', async () => {
const res = await fetch(`${baseUrl}/api/games`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'recon' } }),
});
expect(res.status).toBe(503);
});
});
@@ -26,6 +26,7 @@ beforeAll(async () => {
mode: parsed.data.mode,
creatorSide,
highlightingEnabled: parsed.data.highlightingEnabled,
vsAi: parsed.data.vsAi,
});
return { gameId: game.id, creatorToken, creatorColor: creatorSide };
});
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { Chess } from 'chess.js';
import { legalCandidates } from '../../../src/bot/candidates.js';
import type { Game } from '../../../src/state.js';
import { RATE_LIMIT } from '../../../src/state.js';
function makeGame(mode: 'blind' | 'vanilla', fen?: string): Game {
return {
id: 'cand0001',
mode,
highlightingEnabled: false,
status: 'active',
createdAt: Date.now(),
chess: fen ? new Chess(fen) : new Chess(),
moveHistory: [],
announcements: [],
players: {
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
},
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
};
}
describe('legalCandidates / vanilla', () => {
it('starting position: 20 candidates for white', () => {
const game = makeGame('vanilla');
const candidates = legalCandidates(game, 'w');
expect(candidates.length).toBe(20);
});
it('returns from/to on each candidate', () => {
const game = makeGame('vanilla');
const candidates = legalCandidates(game, 'w');
expect(candidates.every((c) => c.from && c.to)).toBe(true);
});
it('vanilla excludes pinned-piece moves (chess.js filters self-check)', () => {
// White king e1, white bishop e2, black rook e8. Bishop is pinned.
const game = makeGame('vanilla', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w');
// Bishop on e2 has zero legal moves (any move drops the king to check).
expect(candidates.find((c) => c.from === 'e2')).toBeUndefined();
});
it('vanilla expands all 4 promotion options', () => {
// White pawn on a7, ready to promote.
const game = makeGame('vanilla', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7');
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
});
});
describe('legalCandidates / blind', () => {
it('starting position: 34 geometric candidates for white', () => {
// 8 pawns: edge pawns (a2, h2) return 3 moves each (forward 1, forward 2, 1 diagonal),
// interior pawns (b2-g2) return 4 moves each (forward 1, forward 2, 2 diagonals).
// Total pawn: 2*3 + 6*4 = 30.
// 2 knights: 2 moves each = 4.
// Total: 34.
const game = makeGame('blind');
const candidates = legalCandidates(game, 'w');
expect(candidates.length).toBe(34);
});
it('blind INCLUDES pinned-piece moves (geometric does not know about pins)', () => {
// Same pinned-bishop position. Geometric move-gen sees no own piece blocking;
// bishop can geometrically reach d3, c4, b5, a6, f3, etc.
const game = makeGame('blind', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w');
expect(candidates.some((c) => c.from === 'e2')).toBe(true);
});
it('blind expands all 4 promotion options for own pawn', () => {
const game = makeGame('blind', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7' && c.to === 'a8');
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
});
it('blind ignores whose turn it is (returns moves for either color)', () => {
// Vanilla path filters by chess.js .moves() which respects toMove. Blind
// path iterates own pieces directly, so black candidates exist on move 0.
const game = makeGame('blind');
const candidates = legalCandidates(game, 'b');
// Same geometric move count as white: 34.
expect(candidates.length).toBe(34);
});
it('zero own pieces = zero candidates (degenerate)', () => {
// FEN with only black king + pieces — but FEN must be valid, kings required.
const game = makeGame('blind', '4k3/8/8/8/8/8/8/4K3 w - - 0 1');
const black = legalCandidates(game, 'b');
// Black king on e8 has 5 geometric king moves (d8, f8, d7, e7, f7).
expect(black.length).toBe(5);
});
});
@@ -0,0 +1,232 @@
import { describe, it, expect } from 'vitest';
import { CasualBrain } from '../../../src/bot/casual-brain.js';
import type { BrainInput, CandidateMove } from '../../../src/bot/brain.js';
import type { BoardView } from '@blind-chess/shared';
function makeInput(overrides: Partial<BrainInput> = {}): BrainInput {
const view: BoardView = {
pieces: { e2: { color: 'w', type: 'p' } },
toMove: 'w',
inCheck: false,
};
return {
view,
newAnnouncements: [],
legalCandidates: [{ from: 'e2', to: 'e4' }],
attemptHistory: [],
drawOfferFromOpponent: false,
ply: 0,
...overrides,
};
}
describe('CasualBrain', () => {
it('init() resolves', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'casualab' });
});
it('single candidate -> picks it', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const action = await brain.decide(makeInput());
expect(action.type).toBe('commit');
if (action.type === 'commit') {
expect(action.from).toBe('e2');
expect(action.to).toBe('e4');
}
});
it('zero candidates -> throws', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
await expect(brain.decide(makeInput({ legalCandidates: [] }))).rejects.toThrow();
});
it('attemptHistory excludes the rejected move', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const input = makeInput({
legalCandidates: [
{ from: 'e2', to: 'e4' },
{ from: 'd2', to: 'd4' },
],
attemptHistory: [{ move: { from: 'e2', to: 'e4' }, rejection: 'wont_help' }],
});
const action = await brain.decide(input);
expect(action.type).toBe('commit');
if (action.type === 'commit') {
expect(action.from).toBe('d2');
expect(action.to).toBe('d4');
}
});
it('promotion: when multiple candidates differ only by promotion, picks queen', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const candidates: CandidateMove[] = [
{ from: 'a7', to: 'a8', promotion: 'q' },
{ from: 'a7', to: 'a8', promotion: 'r' },
{ from: 'a7', to: 'a8', promotion: 'b' },
{ from: 'a7', to: 'a8', promotion: 'n' },
];
const action = await brain.decide(makeInput({ legalCandidates: candidates }));
if (action.type === 'commit') expect(action.promotion).toBe('q');
});
it('draw offer at material parity -> accept', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
// White has 1 king + 1 rook = 5 material; Casual heuristic accepts when own material < 15.
const view: BoardView = {
pieces: {
e1: { color: 'w', type: 'k' },
a1: { color: 'w', type: 'r' },
},
toMove: 'w', inCheck: false,
};
const action = await brain.decide({
view, newAnnouncements: [], legalCandidates: [{ from: 'e1', to: 'e2' }],
attemptHistory: [], drawOfferFromOpponent: true, ply: 30,
});
expect(action.type).toBe('respond-draw');
if (action.type === 'respond-draw') expect(action.accept).toBe(true);
});
it('never voluntarily offers resign', async () => {
const brain = new CasualBrain({ seed: 1 });
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
for (let i = 0; i < 50; i++) {
const action = await brain.decide(makeInput({ ply: i }));
expect(action.type).not.toBe('resign');
}
});
it('seeded determinism: same seed + same input -> same move', async () => {
const candidates: CandidateMove[] = [
{ from: 'e2', to: 'e4' },
{ from: 'd2', to: 'd4' },
{ from: 'g1', to: 'f3' },
];
const a = new CasualBrain({ seed: 42 });
await a.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const b = new CasualBrain({ seed: 42 });
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const aAct = await a.decide(makeInput({ legalCandidates: candidates }));
const bAct = await b.decide(makeInput({ legalCandidates: candidates }));
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' },
{ from: 'h2', to: 'h3' },
{ from: 'e2', to: 'e4' },
{ from: 'd2', to: 'd4' },
];
// Many seeds → assert e2 or d2 wins majority. The score gap (center pawn
// scores ~25 + 15*2 = 55 over flank pawn ~15*1 = 15) is well over the
// 0.01 random tiebreak, so center should win nearly always.
let centerHits = 0;
for (let s = 0; s < 20; s++) {
const b = new CasualBrain({ seed: s });
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
const a = await b.decide(makeInput({ legalCandidates: candidates, ply: 0 }));
if (a.type === 'commit' && (a.from === 'e2' || a.from === 'd2')) centerHits++;
}
expect(centerHits).toBeGreaterThan(15);
});
});
@@ -0,0 +1,212 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Chess } from 'chess.js';
import { BotDriver } from '../../../src/bot/driver.js';
import type { Brain, BrainAction, BrainInput } from '../../../src/bot/brain.js';
import type { Game } from '../../../src/state.js';
import { RATE_LIMIT } from '../../../src/state.js';
function makeGame(opts: { mode?: 'blind' | 'vanilla'; fen?: string; status?: Game['status'] } = {}): Game {
return {
id: 'gabcd123',
mode: opts.mode ?? 'blind',
highlightingEnabled: false,
status: opts.status ?? 'active',
createdAt: Date.now(),
chess: opts.fen ? new Chess(opts.fen) : new Chess(),
moveHistory: [],
announcements: [],
players: {
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
},
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
aiOpponent: { color: 'b', brain: 'casual' },
};
}
class StubBrain implements Brain {
public decideCalls = 0;
private script: BrainAction[] = [];
init = vi.fn(async () => {});
dispose = vi.fn(async () => {});
decide = vi.fn(async (input: BrainInput): Promise<BrainAction> => {
this.decideCalls++;
if (this.script.length === 0) {
// Default: trivial commit on the first legal candidate.
if (input.legalCandidates.length === 0) throw new Error('no candidates');
const c = input.legalCandidates[0]!;
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
}
return this.script.shift()!;
});
enqueue(...actions: BrainAction[]) { this.script.push(...actions); }
}
describe('BotDriver', () => {
let game: Game;
let brain: StubBrain;
let driver: BotDriver;
beforeEach(async () => {
game = makeGame();
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
});
it('init() invokes brain.init with correct args', async () => {
expect(brain.init).toHaveBeenCalledWith({
color: 'b',
mode: 'blind',
gameId: 'gabcd123',
});
});
it('onStateChange does nothing when not bot turn', async () => {
// White to move (start). Bot is black.
await driver.onStateChange();
expect(brain.decide).not.toHaveBeenCalled();
});
it('onStateChange fires decide when it is bot turn', async () => {
game.chess.move('e4');
await driver.onStateChange();
expect(brain.decide).toHaveBeenCalledTimes(1);
expect(game.chess.turn()).toBe('w'); // turn advanced
});
it('mutex: second onStateChange while in-flight is a no-op', async () => {
game.chess.move('e4');
let release: () => void;
const gate = new Promise<void>((r) => { release = r; });
brain.decide.mockImplementationOnce(async (input) => {
await gate;
const c = input.legalCandidates[0]!;
return { type: 'commit', from: c.from, to: c.to };
});
const p1 = driver.onStateChange();
const p2 = driver.onStateChange();
release!();
await Promise.all([p1, p2]);
expect(brain.decide).toHaveBeenCalledTimes(1);
});
it('retry on wont_help: pinned bishop scenario', async () => {
// Black king h8, black bishop e7 pinned by white rook on e1.
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();
// First action: pinned-bishop move (FSM rejects with wont_help)
// Second action: legal king move (black king at e8, not h8 which is white)
brain.enqueue(
{ type: 'commit', from: 'e7', to: 'd6' },
{ type: 'commit', from: 'e8', to: 'f8' },
);
await driver.onStateChange();
expect(brain.decide).toHaveBeenCalledTimes(2);
expect(game.chess.turn()).toBe('w');
});
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();
// 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(25);
});
it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {
game.drawOffer = { from: 'w', at: Date.now() };
brain.enqueue({ type: 'respond-draw', accept: true });
await driver.onStateChange();
expect(brain.decide).toHaveBeenCalledTimes(1);
expect(game.status).toBe('finished');
expect(game.endReason).toBe('draw_agreed');
});
it('dispose on game finished: subsequent onStateChange is a no-op', async () => {
game.chess.move('e4');
game.status = 'finished';
await driver.onStateChange();
expect(brain.decide).not.toHaveBeenCalled();
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.
// Qh8# is mate: queen moves h1→h8, covers h8; white king b6 covers a7,b7,c7,a5,b5,c5.
// Black king b8 escape squares (a7,b7,c7,a8,c8) are all covered. Verified with chess.js.
const fen = '1k6/8/1K6/8/8/8/8/7Q w - - 0 1';
game = makeGame({ fen });
game.aiOpponent = { color: 'w', brain: 'casual' };
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'w' });
await driver.init();
brain.enqueue({ type: 'commit', from: 'h1', to: 'h8' });
await driver.onStateChange();
expect(game.status).toBe('finished');
expect(game.endReason).toBe('checkmate');
expect(game.winner).toBe('w');
});
});
@@ -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: {} });
});
});
@@ -24,6 +24,7 @@ function makeGame(fen?: string): Game {
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
};
}
@@ -73,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', () => {
+1
View File
@@ -21,6 +21,7 @@ function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'f
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
};
}
+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 square→piece 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` from→to is a pawn promotion.
*
* A promotion is a pawn advancing from the rank adjacent to its promotion rank
* onto the promotion rank (7→8 for White, 2→1 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';
}
+7 -2
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,8 @@ export type ServerMessage =
mode: Mode;
highlightingEnabled: boolean;
opponentConnected: boolean;
captures: CaptureTally;
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
}
| {
type: 'update';
@@ -44,6 +46,8 @@ 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 }
| { type: 'error'; code: ErrorCode; message: string }
@@ -53,10 +57,11 @@ export interface CreateGameRequest {
mode: Mode;
side: Color | 'random';
highlightingEnabled: boolean;
vsAi?: { brain: 'casual' | 'recon' };
}
export interface CreateGameResponse {
gameId: GameId;
creatorToken: PlayerToken;
joinUrl: string;
joinUrl: string | null;
}
+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);
});
});
+12
View File
@@ -8,6 +8,9 @@ importers:
.:
devDependencies:
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.6.0
version: 5.9.3
@@ -54,6 +57,9 @@ importers:
fastify:
specifier: ^5.2.0
version: 5.8.5
js-chess-engine:
specifier: ^2.4.6
version: 2.4.6
pino:
specifier: ^9.5.0
version: 9.14.0
@@ -933,6 +939,10 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-chess-engine@2.4.6:
resolution: {integrity: sha512-OKvWKICifXLjUilGzT5RstUv9iGpk04PjGpTyVT0lMlxX2HptoXZ2Q9hNicidnYjFcR7FHpnXFVwreDSF6a5Ng==}
engines: {node: '>=24'}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
@@ -2075,6 +2085,8 @@ snapshots:
joycon@3.1.1: {}
js-chess-engine@2.4.6: {}
js-tokens@9.0.1: {}
json-schema-ref-resolver@3.0.0:
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env tsx
/**
* Self-play harness for the Casual bot.
*
* Runs N games in-process (no HTTP). Reports stats and optionally writes a
* transcript per game. Supports CasualBrain on either color and a
* RandomBrain baseline for measuring Casual's strength.
*
* Usage:
* pnpm selfplay --games 100 --mode vanilla
* pnpm selfplay --white casual --black random --games 100 --mode vanilla
* pnpm selfplay --white random --black casual --games 100 --mode vanilla
* pnpm selfplay --games 50 --mode blind --transcripts
* pnpm selfplay --games 10 --seed 42
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CasualBrain, BotDriver } from '../packages/server/src/bot/index.js';
import type { Brain, BrainAction, BrainInitArgs, BrainInput }
from '../packages/server/src/bot/brain.js';
import { createGame } from '../packages/server/src/games.js';
interface Args {
white: 'casual' | 'random';
black: 'casual' | 'random';
games: number;
mode: 'blind' | 'vanilla';
seed: number;
transcripts: boolean;
maxPly: number;
}
function parseArgs(): Args {
const args: Args = {
white: 'casual', black: 'casual',
games: 10, mode: 'blind', seed: 1, transcripts: false, maxPly: 400,
};
const a = process.argv.slice(2);
for (let i = 0; i < a.length; i++) {
const k = a[i]!;
const v = a[i + 1]!;
if (k === '--white') { args.white = v as 'casual' | 'random'; i++; }
else if (k === '--black') { args.black = v as 'casual' | 'random'; i++; }
else if (k === '--games') { args.games = parseInt(v, 10); i++; }
else if (k === '--mode') { args.mode = v as 'blind' | 'vanilla'; i++; }
else if (k === '--seed') { args.seed = parseInt(v, 10); i++; }
else if (k === '--max-ply') { args.maxPly = parseInt(v, 10); i++; }
else if (k === '--transcripts') { args.transcripts = true; }
else if (k === '--help' || k === '-h') {
console.log('Usage: pnpm selfplay [--white casual|random] [--black casual|random]');
console.log(' [--games N] [--mode blind|vanilla]');
console.log(' [--seed N] [--max-ply N] [--transcripts]');
process.exit(0);
}
}
return args;
}
class RandomBrain implements Brain {
private rng: () => number;
constructor(seed: number) {
let a = seed >>> 0;
this.rng = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
async init(_args: BrainInitArgs): Promise<void> {}
async decide(input: BrainInput): Promise<BrainAction> {
const cs = input.legalCandidates;
if (cs.length === 0) throw new Error('no candidates');
const i = Math.floor(this.rng() * cs.length);
const c = cs[i]!;
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
}
}
function makeBrain(kind: 'casual' | 'random', seed: number): Brain {
return kind === 'casual' ? new CasualBrain({ seed }) : new RandomBrain(seed);
}
interface GameResult {
result: 'w' | 'b' | 'draw' | 'maxply' | 'error';
endReason: string;
ply: number;
ms: number;
transcript: string[];
}
async function runOneGame(args: Args, gameIdx: number): Promise<GameResult> {
const startMs = Date.now();
const transcript: string[] = [];
const { game } = createGame({
mode: args.mode, creatorSide: 'w', highlightingEnabled: false,
vsAi: { brain: 'casual' },
});
// createGame already filled both slots when vsAi is set. Clear the
// aiOpponent tag (this is a self-play game, not a vs-AI game) and flip
// status to 'active' (no hello will arrive in self-play).
game.aiOpponent = undefined;
game.status = 'active';
const wBrain = makeBrain(args.white, args.seed + gameIdx * 2);
const bBrain = makeBrain(args.black, args.seed + gameIdx * 2 + 1);
const wDriver = new BotDriver({ game, brain: wBrain, color: 'w' });
const bDriver = new BotDriver({ game, brain: bBrain, color: 'b' });
await wDriver.init();
await bDriver.init();
let ply = 0;
while (game.status === 'active' && ply < args.maxPly) {
const turn = game.chess.turn() as 'w' | 'b';
const driver = turn === 'w' ? wDriver : bDriver;
try {
await driver.onStateChange();
} catch (err) {
transcript.push(`!! error at ply ${ply}: ${(err as Error).message}`);
return { result: 'error', endReason: (err as Error).message,
ply, ms: Date.now() - startMs, transcript };
}
const newPly = game.chess.history().length;
if (newPly === ply && game.status === 'active') {
// Driver didn't move and game didn't end — defensive break.
transcript.push(`!! stuck at ply ${ply} (${turn} to move)`);
return { result: 'error', endReason: 'stuck',
ply, ms: Date.now() - startMs, transcript };
}
if (newPly > ply) {
const lastSan = game.chess.history()[newPly - 1];
transcript.push(`${newPly}. ${turn === 'w' ? 'W' : 'B'}: ${lastSan}`);
}
ply = newPly;
}
const ms = Date.now() - startMs;
if (game.status !== 'finished') {
return { result: 'maxply', endReason: 'max_ply', ply, ms, transcript };
}
const result: 'w' | 'b' | 'draw' = game.winner ?? 'draw';
return { result, endReason: game.endReason ?? 'unknown', ply, ms, transcript };
}
function summarize(rs: GameResult[]): string {
const w = rs.filter((r) => r.result === 'w').length;
const b = rs.filter((r) => r.result === 'b').length;
const d = rs.filter((r) => r.result === 'draw').length;
const mp = rs.filter((r) => r.result === 'maxply').length;
const er = rs.filter((r) => r.result === 'error').length;
const avgPly = rs.reduce((s, r) => s + r.ply, 0) / Math.max(rs.length, 1);
const avgMs = rs.reduce((s, r) => s + r.ms, 0) / Math.max(rs.length, 1);
return `W=${w} B=${b} D=${d} MaxPly=${mp} Err=${er} avgPly=${avgPly.toFixed(0)} avgMs=${avgMs.toFixed(0)}`;
}
async function main(): Promise<void> {
const args = parseArgs();
console.log(`selfplay: ${args.games} game(s), mode=${args.mode}, white=${args.white}, black=${args.black}, seed=${args.seed}`);
const results: GameResult[] = [];
let outDir: string | null = null;
if (args.transcripts) {
outDir = resolve('tmp', 'selfplay-runs', String(Date.now()));
mkdirSync(outDir, { recursive: true });
console.log(`transcripts -> ${outDir}`);
}
for (let i = 0; i < args.games; i++) {
const r = await runOneGame(args, i);
results.push(r);
if (outDir) {
writeFileSync(
resolve(outDir, `game-${String(i + 1).padStart(4, '0')}.txt`),
`result=${r.result} reason=${r.endReason} ply=${r.ply} ms=${r.ms}\n${r.transcript.join('\n')}\n`,
);
}
if ((i + 1) % 10 === 0 || i === args.games - 1) {
console.log(`[${i + 1}/${args.games}] ${summarize(results)}`);
}
}
console.log('\n=== summary ===');
console.log(summarize(results));
const reasons = new Map<string, number>();
for (const r of results) reasons.set(r.endReason, (reasons.get(r.endReason) ?? 0) + 1);
console.log('end reasons:');
for (const [k, v] of [...reasons.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${k}: ${v}`);
}
console.log('errors: ' + results.filter((r) => r.result === 'error').length);
}
main().catch((err) => { console.error(err); process.exit(1); });