Compare commits

..

17 Commits

Author SHA1 Message Date
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
30 changed files with 2113 additions and 4429 deletions
@@ -0,0 +1,149 @@
# 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` (16 commits ahead of main; pending merge as final step of this handoff)
- 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 | ⏳ Pending merge (final step of this session) |
## 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. **Merge `feat/ai-player-phase-1-casual` to `main`** (final step of this handoff).
```bash
git checkout main
git merge --ff-only feat/ai-player-phase-1-casual || git merge --no-ff feat/ai-player-phase-1-casual
git push origin main
```
2. **Soak Phase 1 for a few days of real play** before starting Phase 2. Watch for:
- Bot-driver errors in journald (`journalctl -u blind-chess | grep "bot driver error"`).
- Mid-game crashes or stuck games.
- User feedback on Casual's strength (too weak / too strong / fine).
3. **When ready, write Phase 2 plan** — `docs/superpowers/plans/2026-04-28-ai-player-phase-2-recon.md` against the existing spec. Phase 2 reuses the `Brain` and `BotDriver` infrastructure unchanged; new pieces are `OllamaClient`, `ollama-endpoints` (preflight + failover), `prompt`, `parse`, `ReconBrain`, plus `aiInfo` protocol field, `'ai_unavailable'` end reason, post-game reasoning reveal UI.
## 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).
+299 -4331
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -18,13 +18,13 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
## Current State ## 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.
- **Repo:** `git.sethpc.xyz/Seth/blind_chess`. - **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}`. - **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. - **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). - **Tests:** 75 passing — 21 in shared (geometric helper), 54 in server (FSM + view + candidates + casual brain + driver + scripted-game + ai-game-casual integration).
- **Known gaps (deferred):** drag-and-drop input (click-to-move only), full integration coverage of every endgame path, mobile-specific polish, observability beyond `/api/health`. - **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. - **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 ## Key files
@@ -36,6 +36,9 @@ The system's most distinctive property: highlighting in blind mode reveals **zer
- `packages/server/src/view.ts``buildView`, the security boundary. - `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/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/translator.ts` — chess.js `Move` → moderator-vocabulary enum.
- `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.
- `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/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. - `deploy/Caddyfile.snippet` — block already added to `/etc/caddy/Caddyfile` on CT 600.
+12 -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: **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. - 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: **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. - 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,15 @@ 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: **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. - 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.
## Deferred / Rejected ## Deferred / Rejected
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas --> <!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
@@ -83,7 +92,7 @@ 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: **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: ~~**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: **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: **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: **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: **AI vs AI public spectate-able games** — rejected for MVP. Self-play harness is CLI-only (`scripts/selfplay.ts`).
+3 -1
View File
@@ -12,9 +12,11 @@
"test": "pnpm -r test", "test": "pnpm -r test",
"dev:server": "pnpm --filter @blind-chess/server dev", "dev:server": "pnpm --filter @blind-chess/server dev",
"dev:client": "pnpm --filter @blind-chess/client dev", "dev:client": "pnpm --filter @blind-chess/client dev",
"typecheck": "pnpm -r typecheck" "typecheck": "pnpm -r typecheck",
"selfplay": "tsx scripts/selfplay.ts"
}, },
"devDependencies": { "devDependencies": {
"tsx": "^4.21.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
} }
+37 -1
View File
@@ -61,6 +61,17 @@
setTimeout(() => copied = false, 1500); 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(() => { const turnLabel = $derived.by(() => {
if (game.state.gameStatus === 'finished') { if (game.state.gameStatus === 'finished') {
const reason = game.state.endReason; const reason = game.state.endReason;
@@ -74,6 +85,10 @@
if (game.state.gameStatus === 'waiting') return 'Waiting for opponent…'; if (game.state.gameStatus === 'waiting') return 'Waiting for opponent…';
if (!game.state.you) return '…'; if (!game.state.you) return '…';
if (game.state.view?.toMove === game.state.you) return 'Your turn'; 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'; return 'Opponent thinking';
}); });
</script> </script>
@@ -89,6 +104,11 @@
· You: {game.state.you === 'w' ? 'White' : 'Black'} · You: {game.state.you === 'w' ? 'White' : 'Black'}
{/if} {/if}
</span> </span>
{#if aiBadgeText}
<span class="ai-badge" class:thinking={isBotTurn}>
{aiBadgeText}
</span>
{/if}
</div> </div>
{#if game.state.gameStatus === 'waiting'} {#if game.state.gameStatus === 'waiting'}
@@ -141,7 +161,7 @@
<div class="banner err">{game.state.lastError.code}: {game.state.lastError.message}</div> <div class="banner err">{game.state.lastError.code}: {game.state.lastError.message}</div>
{/if} {/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> <div class="banner muted">Opponent disconnected — 5 minute grace window.</div>
{/if} {/if}
</aside> </aside>
@@ -221,6 +241,22 @@
.banner .row { display: flex; gap: 8px; margin-top: 8px; } .banner .row { display: flex; gap: 8px; margin-top: 8px; }
.banner .row button { flex: 1; } .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 { .waiting-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
+121 -28
View File
@@ -1,30 +1,60 @@
<script lang="ts"> <script lang="ts">
import type { Mode, Color, CreateGameResponse } from '@blind-chess/shared'; import type { Mode, Color, CreateGameResponse } from '@blind-chess/shared';
let mode: Mode = $state('blind'); // Friend section state.
let side: Color | 'random' = $state('random'); let friendMode: Mode = $state('blind');
let highlightingEnabled = $state(false); let friendSide: Color | 'random' = $state('random');
let creating = $state(false); let friendHighlight = $state(false);
let error: string | null = $state(null); let friendCreating = $state(false);
let friendError: string | null = $state(null);
async function create() { // AI section state (separate so user can configure each independently).
creating = true; let aiMode: Mode = $state('blind');
error = null; 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 { try {
const res = await fetch('/api/games', { const res = await fetch('/api/games', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, 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}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: CreateGameResponse & { creatorColor: Color } = await res.json(); const json: CreateGameResponse & { creatorColor: Color } = await res.json();
// store creator token before navigating
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken); localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
location.hash = `#/g/${json.gameId}`; location.hash = `#/g/${json.gameId}`;
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : String(e); friendError = e instanceof Error ? e.message : String(e);
} finally { } 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> </script>
@@ -36,18 +66,19 @@
</div> </div>
<div class="card"> <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"> <div class="field">
<span class="lbl">Mode</span> <span class="lbl">Mode</span>
<div class="opts"> <div class="opts">
<label class="opt" class:active={mode === 'blind'}> <label class="opt" class:active={friendMode === 'blind'}>
<input type="radio" bind:group={mode} value="blind" /> <input type="radio" bind:group={friendMode} value="blind" />
<span class="opt-title">Blind</span> <span class="opt-title">Blind</span>
<span class="opt-sub">Each player sees only their own pieces.</span> <span class="opt-sub">Each player sees only their own pieces.</span>
</label> </label>
<label class="opt" class:active={mode === 'vanilla'}> <label class="opt" class:active={friendMode === 'vanilla'}>
<input type="radio" bind:group={mode} value="vanilla" /> <input type="radio" bind:group={friendMode} value="vanilla" />
<span class="opt-title">Vanilla</span> <span class="opt-title">Vanilla</span>
<span class="opt-sub">Normal chess. Both players see everything.</span> <span class="opt-sub">Normal chess. Both players see everything.</span>
</label> </label>
@@ -57,29 +88,79 @@
<div class="field"> <div class="field">
<span class="lbl">You play as</span> <span class="lbl">You play as</span>
<div class="row"> <div class="row">
<label><input type="radio" bind:group={side} value="w" /> White</label> <label><input type="radio" bind:group={friendSide} value="w" /> White</label>
<label><input type="radio" bind:group={side} value="b" /> Black</label> <label><input type="radio" bind:group={friendSide} value="b" /> Black</label>
<label><input type="radio" bind:group={side} value="random" /> Random</label> <label><input type="radio" bind:group={friendSide} value="random" /> Random</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="toggle"> <label class="toggle">
<input type="checkbox" bind:checked={highlightingEnabled} /> <input type="checkbox" bind:checked={friendHighlight} />
<span>Highlight reachable squares</span> <span>Highlight reachable squares</span>
{#if mode === 'blind'} {#if friendMode === 'blind'}
<span class="hint muted">(geometric only — no opponent info)</span> <span class="hint muted">(geometric only — no opponent info)</span>
{/if} {/if}
</label> </label>
</div> </div>
<button class="primary big" disabled={creating} onclick={create}> <button class="primary big" disabled={friendCreating} onclick={createWithFriend}>
{creating ? 'Creating…' : 'Create game'} {friendCreating ? 'Creating…' : 'Create game'}
</button> </button>
{#if friendError}<p class="error">Error: {friendError}</p>{/if}
</div>
{#if error} <div class="card">
<p class="error">Error: {error}</p> <h2>Play vs computer</h2>
{/if} <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> </div>
<footer class="muted"> <footer class="muted">
@@ -114,8 +195,11 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 22px; 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; } .field { margin-bottom: 20px; }
.lbl { .lbl {
@@ -157,6 +241,15 @@
margin-top: 8px; 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; } .error { color: #f87171; margin-top: 12px; }
footer { text-align: center; margin-top: 24px; font-size: 12px; } footer { text-align: center; margin-top: 24px; font-size: 12px; }
</style> </style>
@@ -28,6 +28,7 @@ interface GameStateValue {
winner: Color | null; winner: Color | null;
opponentConnected: boolean; opponentConnected: boolean;
lastError: { code: ErrorCode; message: string; at: number } | null; lastError: { code: ErrorCode; message: string; at: number } | null;
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
} }
function makeStore() { function makeStore() {
@@ -47,6 +48,7 @@ function makeStore() {
winner: null, winner: null,
opponentConnected: false, opponentConnected: false,
lastError: null, lastError: null,
aiOpponent: null,
}); });
function tokenKey(gameId: string) { return `bc:${gameId}`; } function tokenKey(gameId: string) { return `bc:${gameId}`; }
@@ -91,6 +93,7 @@ function makeStore() {
state.mode = m.mode; state.mode = m.mode;
state.highlightingEnabled = m.highlightingEnabled; state.highlightingEnabled = m.highlightingEnabled;
state.opponentConnected = m.opponentConnected; state.opponentConnected = m.opponentConnected;
state.aiOpponent = m.aiOpponent ?? null;
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token); if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
break; break;
case 'update': case 'update':
@@ -103,6 +106,7 @@ function makeStore() {
if (m.newAnnouncements.length) { if (m.newAnnouncements.length) {
state.announcements = [...state.announcements, ...m.newAnnouncements]; state.announcements = [...state.announcements, ...m.newAnnouncements];
} }
if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent;
break; break;
case 'peer-status': case 'peer-status':
if (state.you && m.color !== state.you) { if (state.you && m.color !== state.you) {
+1
View File
@@ -17,6 +17,7 @@
"@fastify/websocket": "^11.0.0", "@fastify/websocket": "^11.0.0",
"chess.js": "^1.4.0", "chess.js": "^1.4.0",
"fastify": "^5.2.0", "fastify": "^5.2.0",
"js-chess-engine": "^2.4.6",
"pino": "^9.5.0", "pino": "^9.5.0",
"ws": "^8.18.0", "ws": "^8.18.0",
"zod": "^3.24.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');
}
+195
View File
@@ -0,0 +1,195 @@
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.
const choice = this.heuristicPick(filtered, input.view, input.ply);
return {
type: 'commit',
from: choice.from,
to: choice.to,
promotion: choice.promotion,
};
}
/**
* 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,
): CandidateMove {
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;
return { move: c, score: score + this.rng() * 0.01 };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]!.move;
}
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;
};
}
+207
View File
@@ -0,0 +1,207 @@
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';
const RETRY_CAP = 5;
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++) {
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 {
// Brain exception OR programming error in dispatch. Safe failure: resign.
this.botResign();
return;
}
if (outcome.kind === 'done') {
this.lastSeenAnnouncementCount = this.game.announcements.length;
return;
}
attemptHistory.push(outcome.entry);
}
this.lastSeenAnnouncementCount = this.game.announcements.length;
this.botResign();
}
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 text = result.announcements[0]!.text;
if (text === 'wont_help' || text === 'illegal_move'
|| text === 'no_such_piece' || text === 'no_legal_moves') {
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();
return { kind: 'done' };
}
// result.kind === 'error' — bug path; resign.
this.botResign();
return { kind: 'done' };
}
case 'resign':
this.botResign();
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(): 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();
}
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 { 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 { import type {
Color, GameId, Mode, PlayerToken, Color, GameId, Mode, PlayerToken,
} from '@blind-chess/shared'; } from '@blind-chess/shared';
import type { BotDriver } from './bot/driver.js';
import { type Game, PRUNE_AFTER_FINISHED_MS, RATE_LIMIT } from './state.js'; import { type Game, PRUNE_AFTER_FINISHED_MS, RATE_LIMIT } from './state.js';
const games = new Map<GameId, Game>(); 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 { export function newGameId(): GameId {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
@@ -31,11 +45,16 @@ export function createGame(opts: {
mode: Mode; mode: Mode;
creatorSide: Color; creatorSide: Color;
highlightingEnabled: boolean; highlightingEnabled: boolean;
vsAi?: { brain: 'casual' | 'recon' };
}): { game: Game; creatorToken: PlayerToken } { }): { game: Game; creatorToken: PlayerToken } {
const id = newGameId(); const id = newGameId();
const creatorToken = newPlayerToken(); const creatorToken = newPlayerToken();
const now = Date.now(); const now = Date.now();
const botColor: Color | null = opts.vsAi
? (opts.creatorSide === 'w' ? 'b' : 'w')
: null;
const game: Game = { const game: Game = {
id, id,
mode: opts.mode, mode: opts.mode,
@@ -46,12 +65,18 @@ export function createGame(opts: {
moveHistory: [], moveHistory: [],
announcements: [], announcements: [],
players: { players: {
w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now) : null, w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now)
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now) : null, : (botColor === 'w' ? makeBotSlot(now) : null),
b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now)
: (botColor === 'b' ? makeBotSlot(now) : null),
}, },
armed: null, armed: null,
drawOffer: null, drawOffer: null,
disconnectAt: {}, disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
aiOpponent: opts.vsAi && botColor
? { color: botColor, brain: opts.vsAi.brain }
: undefined,
}; };
games.set(id, game); 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 { export function getGame(id: GameId): Game | undefined {
return games.get(id); return games.get(id);
} }
@@ -115,6 +152,7 @@ export function pruneFinished(): number {
for (const [id, g] of games) { for (const [id, g] of games) {
if (g.status === 'finished' && g.finishedAt && now - g.finishedAt > PRUNE_AFTER_FINISHED_MS) { if (g.status === 'finished' && g.finishedAt && now - g.finishedAt > PRUNE_AFTER_FINISHED_MS) {
games.delete(id); games.delete(id);
botDrivers.delete(id);
removed++; removed++;
} }
} }
+20 -3
View File
@@ -8,9 +8,11 @@ import {
chooseSide, chooseSide,
createGame, createGame,
pruneFinished, pruneFinished,
attachBotDriver,
} from './games.js'; } from './games.js';
import { attachSocket } from './ws.js'; import { attachSocket } from './ws.js';
import { createGameSchema } from './validation.js'; import { createGameSchema } from './validation.js';
import { CasualBrain, BotDriver } from './bot/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -45,13 +47,28 @@ fastify.post('/api/games', async (req, reply) => {
reply.code(400); reply.code(400);
return { error: 'malformed', detail: parsed.error.issues }; 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 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 const publicBase = PUBLIC_BASE
|| (req.headers.host ? `${req.protocol}://${req.headers.host}` : ''); || (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 }; return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
}); });
+2
View File
@@ -51,6 +51,8 @@ export interface Game {
armed: { color: Color; from: Square } | null; armed: { color: Color; from: Square } | null;
drawOffer: { from: Color; at: number } | null; drawOffer: { from: Color; at: number } | null;
disconnectAt: { w?: number; b?: number }; disconnectAt: { w?: number; b?: number };
lastBroadcastIdx: { w: number; b: number };
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
} }
export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 }; export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 };
+3
View File
@@ -41,4 +41,7 @@ export const createGameSchema = z.object({
mode: z.union([z.literal('blind'), z.literal('vanilla')]), mode: z.union([z.literal('blind'), z.literal('vanilla')]),
side: z.union([colorSchema, z.literal('random')]), side: z.union([colorSchema, z.literal('random')]),
highlightingEnabled: z.boolean(), highlightingEnabled: z.boolean(),
vsAi: z.object({
brain: z.union([z.literal('casual'), z.literal('recon')]),
}).optional(),
}); });
+56 -55
View File
@@ -10,6 +10,7 @@ import {
claimSlot, claimSlot,
findTokenInGame, findTokenInGame,
getGame, getGame,
getBotDriver,
} from './games.js'; } from './games.js';
import type { Game } from './state.js'; import type { Game } from './state.js';
import { GRACE_MS } from './state.js'; import { GRACE_MS } from './state.js';
@@ -17,6 +18,29 @@ import { handleCommit } from './commit.js';
import { announce } from './translator.js'; import { announce } from './translator.js';
import { buildView } from './view.js'; import { buildView } from './view.js';
import { consumeCommitToken } from './ratelimit.js'; import { consumeCommitToken } from './ratelimit.js';
import { endGame, finalizeIfEnded } from './game-end.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 { interface SocketCtx {
socket: WebSocket; socket: WebSocket;
@@ -60,7 +84,7 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
} }
const msg = result.data as ClientMessage; 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 (msg.type === 'pong') return;
if (!ctx.game || !ctx.color) { if (!ctx.game || !ctx.color) {
@@ -68,14 +92,14 @@ function onMessage(ctx: SocketCtx, data: unknown): void {
} }
switch (msg.type) { switch (msg.type) {
case 'commit': return onCommit(ctx, msg); case 'commit': void onCommit(ctx, msg); return;
case 'resign': return onResign(ctx); case 'resign': void onResign(ctx); return;
case 'offer-draw': return onOfferDraw(ctx); case 'offer-draw': void onOfferDraw(ctx); return;
case 'respond-draw': return onRespondDraw(ctx, msg.accept); 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); const game = getGame(msg.gameId);
if (!game) return sendError(ctx.socket, 'game_not_found'); if (!game) return sendError(ctx.socket, 'game_not_found');
@@ -123,17 +147,19 @@ function onHello(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'hello' }>)
mode: game.mode, mode: game.mode,
highlightingEnabled: game.highlightingEnabled, highlightingEnabled: game.highlightingEnabled,
opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket, opponentConnected: !!game.players[color === 'w' ? 'b' : 'w']?.socket,
aiOpponent: game.aiOpponent,
}); });
// Notify peer that we're connected. // Notify peer that we're connected.
notifyPeer(game, color, true); notifyPeer(game, color, true);
// If activation just happened, push update to both. // If activation just happened, poke bot then broadcast.
if (game.status === 'active') { 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 game = ctx.game!;
const color = ctx.color!; const color = ctx.color!;
@@ -153,17 +179,19 @@ function onCommit(ctx: SocketCtx, msg: Extract<ClientMessage, { type: 'commit' }
return; return;
case 'announce': case 'announce':
// Announcement to actor; opponent is unaffected unless audience=both. // Announcement to actor; opponent is unaffected unless audience=both.
broadcastNewAnnouncements(game, result.announcements); // Non-turn-ending — no bot poke.
broadcastSinceLast(game);
return; return;
case 'applied': case 'applied':
// Move applied. Check end conditions. // Move applied. Check end conditions, then poke bot.
finalizeIfEnded(game, result.announcements); finalizeIfEnded(game, result.announcements);
broadcastNewAnnouncements(game, result.announcements); await pokeBot(game);
broadcastSinceLast(game);
return; return;
} }
} }
function onResign(ctx: SocketCtx): void { async function onResign(ctx: SocketCtx): Promise<void> {
const game = ctx.game!; const game = ctx.game!;
const color = ctx.color!; const color = ctx.color!;
if (game.status !== 'active') return; if (game.status !== 'active') return;
@@ -172,19 +200,22 @@ function onResign(ctx: SocketCtx): void {
const a = announce(color === 'w' ? 'white_resigned' : 'black_resigned', 'both', ply); const a = announce(color === 'w' ? 'white_resigned' : 'black_resigned', 'both', ply);
game.announcements.push(a); game.announcements.push(a);
endGame(game, 'resign', color === 'w' ? 'b' : 'w'); 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 game = ctx.game!;
const color = ctx.color!; const color = ctx.color!;
if (game.status !== 'active') return; if (game.status !== 'active') return;
game.drawOffer = { from: color, at: Date.now() }; game.drawOffer = { from: color, at: Date.now() };
// Push update to both so opponent sees the drawOffer field. // Poke bot — it may auto-respond to the draw offer.
broadcastUpdate(game); 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 game = ctx.game!;
const color = ctx.color!; const color = ctx.color!;
if (!game.drawOffer || game.drawOffer.from === color) return; if (!game.drawOffer || game.drawOffer.from === color) return;
@@ -194,11 +225,11 @@ function onRespondDraw(ctx: SocketCtx, accept: boolean): void {
game.announcements.push(a); game.announcements.push(a);
game.drawOffer = null; game.drawOffer = null;
endGame(game, 'draw_agreed', null); endGame(game, 'draw_agreed', null);
broadcastNewAnnouncements(game, [a]);
} else { } else {
game.drawOffer = null; game.drawOffer = null;
broadcastUpdate(game);
} }
await pokeBot(game);
broadcastSinceLast(game);
} }
function onClose(ctx: SocketCtx): void { function onClose(ctx: SocketCtx): void {
@@ -211,13 +242,13 @@ function onClose(ctx: SocketCtx): void {
if (game.status === 'active') { if (game.status === 'active') {
game.disconnectAt[color] = Date.now(); game.disconnectAt[color] = Date.now();
// Schedule grace timer. // 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); 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; if (game.status !== 'active') return;
const slot = game.players[color]; const slot = game.players[color];
if (!slot) return; if (!slot) return;
@@ -228,42 +259,11 @@ function maybeAbandon(game: Game, color: Color): void {
game.announcements.push(a); game.announcements.push(a);
const winner = game.players[color === 'w' ? 'b' : 'w']?.socket ? (color === 'w' ? 'b' : 'w') : null; const winner = game.players[color === 'w' ? 'b' : 'w']?.socket ? (color === 'w' ? 'b' : 'w') : null;
endGame(game, 'abandoned', winner); 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( function sendUpdateTo(
game: Game, game: Game,
@@ -284,6 +284,7 @@ function sendUpdateTo(
drawOffer, drawOffer,
endReason: game.endReason, endReason: game.endReason,
winner: game.winner ?? null, winner: game.winner ?? null,
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, mode: parsed.data.mode,
creatorSide, creatorSide,
highlightingEnabled: parsed.data.highlightingEnabled, highlightingEnabled: parsed.data.highlightingEnabled,
vsAi: parsed.data.vsAi,
}); });
return { gameId: game.id, creatorToken, creatorColor: creatorSide }; 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,139 @@
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('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,170 @@
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 cap (5): after 5 wont_help, driver resigns the bot', async () => {
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
game = makeGame({ fen });
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
for (let i = 0; i < 6; i++) {
brain.enqueue({ type: 'commit', from: 'e7', to: 'd6' });
}
await driver.onStateChange();
expect(game.status).toBe('finished');
expect(game.endReason).toBe('resign');
expect(game.winner).toBe('w');
expect(brain.decide).toHaveBeenCalledTimes(5);
});
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('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');
});
});
@@ -24,6 +24,7 @@ function makeGame(fen?: string): Game {
armed: null, armed: null,
drawOffer: null, drawOffer: null,
disconnectAt: {}, disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
}; };
} }
+1
View File
@@ -21,6 +21,7 @@ function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'f
armed: null, armed: null,
drawOffer: null, drawOffer: null,
disconnectAt: {}, disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
}; };
} }
+4 -1
View File
@@ -34,6 +34,7 @@ export type ServerMessage =
mode: Mode; mode: Mode;
highlightingEnabled: boolean; highlightingEnabled: boolean;
opponentConnected: boolean; opponentConnected: boolean;
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
} }
| { | {
type: 'update'; type: 'update';
@@ -44,6 +45,7 @@ export type ServerMessage =
drawOffer?: { from: Color } | null; drawOffer?: { from: Color } | null;
endReason?: EndReason; endReason?: EndReason;
winner?: Color | null; winner?: Color | null;
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
} }
| { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number } | { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number }
| { type: 'error'; code: ErrorCode; message: string } | { type: 'error'; code: ErrorCode; message: string }
@@ -53,10 +55,11 @@ export interface CreateGameRequest {
mode: Mode; mode: Mode;
side: Color | 'random'; side: Color | 'random';
highlightingEnabled: boolean; highlightingEnabled: boolean;
vsAi?: { brain: 'casual' | 'recon' };
} }
export interface CreateGameResponse { export interface CreateGameResponse {
gameId: GameId; gameId: GameId;
creatorToken: PlayerToken; creatorToken: PlayerToken;
joinUrl: string; joinUrl: string | null;
} }
+12
View File
@@ -8,6 +8,9 @@ importers:
.: .:
devDependencies: devDependencies:
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript: typescript:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.9.3 version: 5.9.3
@@ -54,6 +57,9 @@ importers:
fastify: fastify:
specifier: ^5.2.0 specifier: ^5.2.0
version: 5.8.5 version: 5.8.5
js-chess-engine:
specifier: ^2.4.6
version: 2.4.6
pino: pino:
specifier: ^9.5.0 specifier: ^9.5.0
version: 9.14.0 version: 9.14.0
@@ -933,6 +939,10 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
js-chess-engine@2.4.6:
resolution: {integrity: sha512-OKvWKICifXLjUilGzT5RstUv9iGpk04PjGpTyVT0lMlxX2HptoXZ2Q9hNicidnYjFcR7FHpnXFVwreDSF6a5Ng==}
engines: {node: '>=24'}
js-tokens@9.0.1: js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
@@ -2075,6 +2085,8 @@ snapshots:
joycon@3.1.1: {} joycon@3.1.1: {}
js-chess-engine@2.4.6: {}
js-tokens@9.0.1: {} js-tokens@9.0.1: {}
json-schema-ref-resolver@3.0.0: 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); });