diff --git a/DECISIONS.md b/DECISIONS.md index 83b0944..ddf1ba6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -89,3 +89,4 @@ Spec: `docs/superpowers/specs/2026-04-28-ai-player-design.md`. All decisions bel - 2026-04-28: **AI vs AI public spectate-able games** — rejected for MVP. Self-play harness is CLI-only (`scripts/selfplay.ts`). - 2026-04-28: **Per-turn context compaction** — deferred. Spec uses `num_ctx: 32768` which covers ~128 turns; longer games would overflow but are rare in casual play. Add running-summary compaction if seen in practice. - 2026-04-28: **Bot rating / Elo / personalities** — out of scope. Two named buttons, no scoreboard. +- 2026-04-28: **In-game chat (player ↔ player and human ↔ Gemma)** — deferred indefinitely. Two failure modes drove the deferral: (1) blind-mode chat is a side channel that bypasses the moderator-vocabulary security boundary ("knight on c3, take it" defeats the entire view-filter architecture); (2) chatting with Gemma during play leaks the bot's belief state and undermines the post-game reasoning reveal. Resolvable but expensive (two-history split for Gemma, blind-mode mute or social-variant warnings, mobile UI real estate). Revisit only if users explicitly ask. The post-game reasoning reveal already covers most of the "see what Gemma was thinking" appeal without the leak surface. diff --git a/docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md b/docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md new file mode 100644 index 0000000..f7aecbb --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md @@ -0,0 +1,2561 @@ +# AI/Computer Player — Phase 1 (Casual Bot) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a Casual algorithmic bot opponent end-to-end on https://chess.sethpc.xyz — humans can play a legal blind-chess or vanilla-chess game alone, on demand, against an in-process bot that picks moves with simple heuristics. + +**Architecture:** A `Brain` strategy interface + a per-game `BotDriver` orchestrator live under `packages/server/src/bot/`. The driver subscribes to game state changes by being poked from `ws.ts` after each state-mutating handler. `CasualBrain` is pure TypeScript with no I/O. Bots are virtual in-process players: their `PlayerSlot` is filled with no socket; they consume only `buildView(game, botColor)` + announcements; they dispatch moves through the same `handleCommit` FSM humans use. Phase 2 will swap in `ReconBrain` against the same driver. + +**Tech Stack:** Node 22 + TypeScript, Fastify + `ws`, `chess.js` v1.4.0, Svelte 5 + Vite, vitest, pnpm workspace. No new runtime dependencies. + +**Source spec:** [`docs/superpowers/specs/2026-04-28-ai-player-design.md`](../specs/2026-04-28-ai-player-design.md) + +**Phase 1 scope:** §"Components" / `Brain`, `CasualBrain`, `BotDriver`, bot registry, candidate computation. §"Touches in existing code" except `aiInfo` and post-game thoughts log (those are Phase 2). §"Data flow / Game creation (vs Casual)". §"Testing" Casual layers + 1 integration test. §Phase-1 acceptance bars. + +**Phase 2 (Recon, deferred):** `ReconBrain`, `OllamaClient`, `ollama-endpoints`, `prompt`, `parse`, GPU preflight + failover, `aiInfo` protocol field, post-game reasoning reveal. Will get its own plan after Phase 1 ships and self-play results inform the Recon target. + +--- + +## File Structure + +**New files (all under `packages/server/src/bot/` unless noted):** + +| File | Responsibility | +|---|---| +| `brain.ts` | `Brain` interface, `BrainInput`, `BrainAction`, `CandidateMove` types. No logic. | +| `candidates.ts` | `legalCandidates(game, color): CandidateMove[]` — vanilla path uses `chess.js .moves({verbose: true})`, blind path uses `geometricMoves` over own pieces (+ promotion expansion). | +| `casual-brain.ts` | `CasualBrain` class: scoring heuristics, `attemptHistory` exclusion, queen-default promotion, draw auto-response. | +| `driver.ts` | `BotDriver` class: per-game, mutex, retry cap, dispose-on-end. Imports `handleCommit` and dispatches actions through it. | +| `index.ts` | Public re-exports (`createBotDriver`, types). | +| `packages/server/test/unit/bot/candidates.test.ts` | Unit tests for `legalCandidates`. | +| `packages/server/test/unit/bot/casual-brain.test.ts` | Unit tests for `CasualBrain`. | +| `packages/server/test/unit/bot/driver.test.ts` | Unit tests for `BotDriver` (with a `StubBrain` defined in the test file). | +| `packages/server/test/integration/ai-game-casual.test.ts` | Real-WS integration test: human + Casual bot play a scripted game end-to-end. | +| `scripts/selfplay.ts` | Operator CLI. NOT in CI. Runs Casual-vs-Casual N times, reports stats. | + +**Modified files:** + +| File | Why | +|---|---| +| `packages/shared/src/protocol.ts` | Add `vsAi?: { brain: 'casual' \| 'recon' }` to `CreateGameRequest`. (Phase 1 implements only `'casual'`; `'recon'` accepted but rejected at runtime in Phase 1.) | +| `packages/server/src/validation.ts` | Validate the new `vsAi` field. | +| `packages/server/src/state.ts` | Add `Game.aiOpponent?: { color: Color; brain: 'casual' \| 'recon' }` (informational). | +| `packages/server/src/games.ts` | `createGame` accepts `vsAi`; if set, fills the bot slot with a synthetic `PlayerSlot` (no socket). Add bot driver registry: `attachBotDriver`, `getBotDriver`, `disposeBotDriver`. | +| `packages/server/src/server.ts` | `POST /api/games` reads `vsAi`, instantiates `CasualBrain` + `BotDriver`, attaches to registry. Returns `joinUrl: null` when AI game (not shareable). | +| `packages/server/src/ws.ts` | After every state-mutating handler (`onHello`-activates-game, `onCommit`-applied, `onResign`, `onOfferDraw`, `onRespondDraw`, `endGame`), call `pokeBot(game)`. New helper `pokeBot(game)` looks up the driver and fires `onStateChange()`. | +| `packages/client/src/lib/Landing.svelte` | Two-section layout: "Play with a friend" (existing) + "Play vs Computer" with two buttons (Casual = wired in Phase 1, Recon = disabled placeholder for Phase 2). | +| `packages/client/src/lib/Game.svelte` | (Read first to find opponent indicator.) Show "Casual bot" badge on bot's slot. Show "Casual bot is moving..." during bot turns. Source flag from a new `aiOpponent` field on `joined`/`update` payloads. | +| `packages/server/src/server.ts` (response shape) and `packages/shared/src/protocol.ts` (server msgs) | Add `aiOpponent?: { brain: 'casual' \| 'recon'; color: Color }` to `joined` and `update` so client knows. (`aiInfo` with model/GPU details is Phase 2.) | +| `package.json` (root) | Add `"selfplay": "tsx scripts/selfplay.ts"` script. | +| `DECISIONS.md` | Append Phase 1 outcome. | +| `CLAUDE.md` | Update "Current State" line from "designed, not built" to "Phase 1 deployed". | + +--- + +## Pre-flight (Task 0) + +- [ ] **Step 0.1: Verify clean tree, on `main`, MVP tests pass** + +Run: `git status && git rev-parse --abbrev-ref HEAD && pnpm -r test` +Expected: clean tree, on `main`, `43 passing` (21 shared + 22 server). + +- [ ] **Step 0.2: Create implementation branch** + +Run: `git checkout -b feat/ai-player-phase-1-casual` +Expected: branch created and checked out. + +- [ ] **Step 0.3: Backup files that will be edited** + +Per global safety rule. The `.backup/` directory is gitignored. + +```bash +mkdir -p .backup/p1 +ts=$(date +%s) +for f in packages/shared/src/protocol.ts \ + packages/server/src/validation.ts \ + packages/server/src/state.ts \ + packages/server/src/games.ts \ + packages/server/src/server.ts \ + packages/server/src/ws.ts \ + packages/client/src/lib/Landing.svelte \ + DECISIONS.md \ + CLAUDE.md \ + package.json; do + cp "$f" ".backup/p1/$(basename $f).$ts" +done +ls -la .backup/p1/ +``` + +Expected: 10 files copied. + +--- + +## Task 1: `Brain` interface + types + +**Files:** +- Create: `packages/server/src/bot/brain.ts` +- Create: `packages/server/src/bot/index.ts` + +- [ ] **Step 1.1: Write the type declarations** + +Create `packages/server/src/bot/brain.ts`: + +```typescript +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; +} + +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; + decide(input: BrainInput): Promise; + dispose?(): Promise; +} +``` + +- [ ] **Step 1.2: Create the bot module index** + +Create `packages/server/src/bot/index.ts`: + +```typescript +export type { + Brain, BrainInput, BrainAction, BrainInitArgs, + CandidateMove, AttemptHistoryEntry, +} from './brain.js'; +``` + +- [ ] **Step 1.3: Typecheck** + +Run: `pnpm --filter @blind-chess/server typecheck` +Expected: no errors. (Imports are types only, no runtime code.) + +- [ ] **Step 1.4: Commit** + +```bash +git add packages/server/src/bot/brain.ts packages/server/src/bot/index.ts +git commit -m "feat(bot): scaffold Brain interface and types" +``` + +--- + +## Task 2: `legalCandidates` — candidate move computation + +**Files:** +- Create: `packages/server/src/bot/candidates.ts` +- Create: `packages/server/test/unit/bot/candidates.test.ts` + +The function takes a `Game` and a `Color` and returns the bot's legal-from-its-perspective candidates. In vanilla mode, that's `chess.js .moves({verbose: true})` (truly legal). In blind mode, it's the union of `geometricMoves` over each own piece, with promotion expansion. Blind candidates may include moves the FSM later rejects with `wont_help` (pin / unresolved check) — that's expected; the driver will retry. + +- [ ] **Step 2.1: Write failing tests** + +Create `packages/server/test/unit/bot/candidates.test.ts`: + +```typescript +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: {}, + }; +} + +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: 20 geometric candidates for white', () => { + // 16 pawn moves (8 single + 8 double) + 4 knight moves = 20. + const game = makeGame('blind'); + const candidates = legalCandidates(game, 'w'); + expect(candidates.length).toBe(20); + }); + + 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'); + 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'); + expect(candidates.length).toBe(20); + }); + + 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); + }); +}); +``` + +Run: `pnpm --filter @blind-chess/server test -- candidates.test.ts` +Expected: FAIL — `Cannot find module '../../../src/bot/candidates.js'`. + +- [ ] **Step 2.2: Implement `legalCandidates`** + +Create `packages/server/src/bot/candidates.ts`: + +```typescript +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'); +} +``` + +- [ ] **Step 2.3: Run tests** + +Run: `pnpm --filter @blind-chess/server test -- candidates.test.ts` +Expected: 7 tests pass. + +- [ ] **Step 2.4: Commit** + +```bash +git add packages/server/src/bot/candidates.ts packages/server/test/unit/bot/candidates.test.ts +git commit -m "feat(bot): legalCandidates for vanilla and blind modes" +``` + +--- + +## Task 3: `CasualBrain` — algorithmic strategy + +**Files:** +- Create: `packages/server/src/bot/casual-brain.ts` +- Create: `packages/server/test/unit/bot/casual-brain.test.ts` + +`CasualBrain` is pure: receives `BrainInput`, returns `BrainAction`. No I/O, deterministic when seeded. Scoring per spec: + +- `+50` if destination is reachable but not own-occupied (capture proxy in blind mode; explicit-capture in vanilla via `chess.js Move.captured`). +- `+30` if first 8 plies and the move develops a knight or bishop from rank 1 (white) / rank 8 (black). +- `+25` if pawn move toward center (e/d files preferred). +- `+15` for rank advancement toward opponent. +- `-40` anti-shuffling penalty on a queen/rook/minor that hasn't moved yet *if* a knight or bishop on its starting square is also a candidate (i.e., we'd rather develop a minor first). +- Tiny seedable random tiebreak (epsilon ~0.01). + +Promotion default: queen. Draw response: accept at material parity (counted from `view.pieces` only — biased and weak by design), decline at lead. Casual never resigns voluntarily. On `attemptHistory` rejection, re-score and pick a different top. + +- [ ] **Step 3.1: Write failing tests** + +Create `packages/server/test/unit/bot/casual-brain.test.ts`: + +```typescript +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 { + 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' }); + // View shows white has 1 queen, 1 rook. Material counter doesn't see opponent. + // Casual heuristic: parity inferred from "I have N pieces, assume opponent has N". + // For unit test we fix a view + drawOfferFromOpponent and assert accept. + 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' }); + // 50 random plies; assert never resigns. + 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 brain = new CasualBrain({ seed: 1 }); + await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' }); + 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. + 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); + }); +}); +``` + +Run: `pnpm --filter @blind-chess/server test -- casual-brain.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3.2: Implement `CasualBrain`** + +Create `packages/server/src/bot/casual-brain.ts`: + +```typescript +import type { BoardView, Color, PieceType, Square } from '@blind-chess/shared'; +import type { + Brain, + BrainAction, + BrainInitArgs, + BrainInput, + CandidateMove, +} from './brain.js'; + +interface CasualOpts { + seed?: number; +} + +const PIECE_VALUE: Record = { + 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 rng: () => number; + + constructor(opts: CasualOpts = {}) { + this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff)); + } + + async init(args: BrainInitArgs): Promise { + this.color = args.color; + this.mode = args.mode; + } + + async decide(input: BrainInput): Promise { + 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'); + } + + const scored = filtered.map((c) => ({ + move: c, + score: this.scoreMove(c, input.view, input.ply) + this.rng() * 0.01, + })); + scored.sort((a, b) => b.score - a.score); + const choice = scored[0]!.move; + + return { + type: 'commit', + from: choice.from, + to: choice.to, + promotion: choice.promotion, + }; + } + + 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; + + // Capture proxy: destination not own-occupied. (In view, we only see own + // pieces; if dest has a piece, it's ours -> not a capture. If empty, may + // be a capture or just empty — guess.) + const destPiece = view.pieces[move.to]; + if (!destPiece) score += 50; + + const piece = view.pieces[move.from]; + if (!piece) return score; // shouldn't happen, but safe. + + const ownStartingRank = this.color === 'w' ? '1' : '8'; + const ownPawnStartingRank = this.color === 'w' ? '2' : '7'; + + // Development bonus for first 16 plies (8 moves per side). + if (ply < 16 && (piece.type === 'n' || piece.type === 'b') + && move.from[1] === ownStartingRank) { + score += 30; + } + + // Center pawn bonus. + 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; + } + + // Rank-advance bonus toward opponent. + 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; + + // Anti-shuffling: penalize moving major pieces from start before knights/bishops. + if (move.from[1] === ownStartingRank && (piece.type === 'q' || piece.type === 'r')) { + score -= 40; + } + + // Promotion bias toward queen. + if (move.promotion === 'q') score += 100; + else if (move.promotion) score += 50; + + return score; + } + + private acceptDraw(view: BoardView): boolean { + // Crude material count from own view only. Accept if "low material" + // (assume opponent symmetric). Decline if "high material". + 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]; + } + // Accept if own material < 15 (rough endgame threshold). + return own < 15; + } +} + +function moveKey(m: CandidateMove): string { + return `${m.from}-${m.to}${m.promotion ?? ''}`; +} + +// Mulberry32 PRNG: seedable, fast, good enough for tiebreaks. +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; + }; +} +``` + +- [ ] **Step 3.3: Run tests** + +Run: `pnpm --filter @blind-chess/server test -- casual-brain.test.ts` +Expected: 9 tests pass. + +- [ ] **Step 3.4: Commit** + +```bash +git add packages/server/src/bot/casual-brain.ts packages/server/test/unit/bot/casual-brain.test.ts +git commit -m "feat(bot): CasualBrain with capture/development/center heuristics" +``` + +--- + +## Task 4: `BotDriver` — per-game orchestration + +**Files:** +- Create: `packages/server/src/bot/driver.ts` +- Create: `packages/server/test/unit/bot/driver.test.ts` + +The driver wires a `Brain` to a `Game`: + +- **Mutex:** `decideInFlight: boolean` — second `onStateChange` while one is in flight is a no-op. +- **Trigger:** caller invokes `driver.onStateChange()` after every state mutation. Driver decides whether to fire `decide()`. +- **Decision loop:** computes `BrainInput` from current `Game`, `await brain.decide()`, dispatches the action through the same `handleCommit` (and `endGame`/`onResign`-equivalents) the WS layer uses. On `wont_help`/`illegal_move` rejection, append to `attemptHistory` and call `decide` again. Bounded retry **5**; on cap-hit, dispatch `{type: 'resign'}`. +- **Dispose:** when `game.status === 'finished'`, dispose brain (call `brain.dispose?.()`) and stop accepting `onStateChange`. + +The driver depends on small dispatch helpers from existing code paths. To avoid duplicating logic, expose tiny pure helpers in a new `packages/server/src/bot/dispatch.ts`. Or just have the driver call `handleCommit`, `endGame`-equivalent, and `translator.announce` directly — they're already pure-ish. We do that to keep modules focused. + +**Important:** the driver must NOT call `ws.ts` broadcast functions directly (circular import risk and ws.ts owns socket state). Instead, it pokes the same state-mutating helpers the WS layer pokes, and the WS layer does its own broadcasting after the driver returns. Sequence: human commits → ws.ts handles → ws.ts broadcasts to humans → ws.ts pokes driver → driver runs `decide()` → driver calls `handleCommit` (mutates state, returns announcements/move) → driver records announcements onto `game.announcements` already (handleCommit does this) → driver returns → ws.ts broadcasts the new state to all humans (a follow-up broadcastUpdate call after `pokeBot`). + +So the contract is: + +> `pokeBot(game)` is called by ws.ts. `pokeBot` returns `Promise` that resolves after the driver has finished any synchronous chain of actions (e.g., bot moves, then game ends, OR bot moves, then it's bot's turn again — wait, bots only play one color, so no chained turns). The caller (ws.ts) then broadcasts the resulting state to all sockets. + +For Phase 1 the chain is at most 1 deep: +- Bot moves → human's turn (ws.ts broadcasts → human moves → ws.ts pokes again). +- Bot resigns → game ends (ws.ts broadcasts the resignation announcement and end state). + +For draw offers: +- Human offers draw → ws.ts pokes bot → bot calls `respondDraw(true|false)` → game ends (accept) or drawOffer cleared (decline). + +- [ ] **Step 4.1: Write failing tests** + +Create `packages/server/test/unit/bot/driver.test.ts`: + +```typescript +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: {}, + 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 => { + this.decideCalls++; + if (this.script.length === 0) { + // Default: trivial commit on any 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 () => { + // Make a move so it is black's turn. + game.chess.move('e4'); + await driver.onStateChange(); + expect(brain.decide).toHaveBeenCalledTimes(1); + // Stub commits the first candidate; chess.js should advance to white. + expect(game.chess.turn()).toBe('w'); + }); + + it('mutex: second onStateChange while in-flight is a no-op', async () => { + game.chess.move('e4'); + let release: () => void; + const gate = new Promise((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-to-move version of the pinned-piece test: + // 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(); + // Stub will return a pinned-bishop move first; FSM rejects with wont_help; + // driver should retry with attemptHistory and a fresh decide call returns a + // legal king move. + brain.enqueue( + { type: 'commit', from: 'e7', to: 'd6' }, // pinned, rejected + { type: 'commit', from: 'h8', to: 'g8' }, // king sidestep, accepted + ); + await driver.onStateChange(); + expect(brain.decide).toHaveBeenCalledTimes(2); + expect(game.chess.turn()).toBe('w'); // turn advanced + }); + + 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(); + // 6 pinned attempts in a row — driver should resign on the 6th instead. + 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'); + }); + + 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(); + }); +}); +``` + +Run: `pnpm --filter @blind-chess/server test -- driver.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 4.2: Implement `BotDriver`** + +Create `packages/server/src/bot/driver.ts`: + +```typescript +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'; + +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 { + await this.brain.init({ + color: this.color, + mode: this.game.mode, + gameId: this.game.id, + }); + this.lastSeenAnnouncementCount = this.game.announcements.length; + } + + async onStateChange(): Promise { + 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; + } + } + + /** True if the brain should be invoked given current game state. */ + private shouldDecide(): boolean { + if (this.game.status !== 'active') return false; + 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 { + const attemptHistory: AttemptHistoryEntry[] = []; + + for (let attempt = 0; attempt < RETRY_CAP; attempt++) { + const input = this.buildBrainInput(attemptHistory); + let action: BrainAction; + try { + action = await this.brain.decide(input); + } catch (e) { + // Brain exception => bot resigns. CasualBrain only throws on zero + // candidates (impossible if shouldDecide passed). Phase 2 ReconBrain + // has its own retry/fallback layer before reaching here. + this.botResign(`brain_error: ${(e as Error).message}`); + return; + } + + const outcome = this.dispatch(action); + if (outcome.kind === 'done') return; + // outcome.kind === 'retry': record the rejection and loop. + attemptHistory.push(outcome.entry); + } + this.botResign('retry_cap'); + } + + private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput { + const view = buildView(this.game, this.color); + const sliceStart = this.lastSeenAnnouncementCount; + this.lastSeenAnnouncementCount = this.game.announcements.length; + 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, + }; + } + + /** Dispatch a brain action. `done` = cycle complete; `retry` = loop again. */ + 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') 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') { + // Bot sent `from` only (arming). CasualBrain always commits with + // `to`; treat as a logic error and resign safely. + this.botResign('bot_armed_only'); + return { kind: 'done' }; + } + // result.kind === 'error' (not_your_turn etc.) — bug path; resign. + this.botResign('commit_error'); + return { kind: 'done' }; + } + case 'resign': + this.botResign('voluntary'); + return { kind: 'done' }; + case 'offer-draw': + if (!this.game.drawOffer) { + this.game.drawOffer = { from: this.color, at: Date.now() }; + } + return { kind: 'done' }; + case 'respond-draw': + if (!this.game.drawOffer || this.game.drawOffer.from === this.color) { + return { kind: 'done' }; + } + if (action.accept) { + const ply = this.game.chess.history().length; + const a = announce('draw_agreed', 'both', ply); + this.game.announcements.push(a); + this.game.drawOffer = null; + this.game.status = 'finished'; + this.game.endReason = 'draw_agreed'; + this.game.winner = null; + this.game.finishedAt = Date.now(); + } else { + this.game.drawOffer = null; + } + return { kind: 'done' }; + } + } + + private botResign(_reason: string): 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 { + if (this.disposed) return; + this.disposed = true; + try { + await this.brain.dispose?.(); + } catch {/* ignore */} + } +} +``` + +- [ ] **Step 4.3: Run tests** + +Run: `pnpm --filter @blind-chess/server test -- driver.test.ts` +Expected: 8 tests pass. + +- [ ] **Step 4.4: Re-export driver from index** + +Edit `packages/server/src/bot/index.ts`: + +```typescript +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'; +``` + +- [ ] **Step 4.5: Commit** + +```bash +git add packages/server/src/bot/driver.ts packages/server/src/bot/index.ts \ + packages/server/test/unit/bot/driver.test.ts +git commit -m "feat(bot): BotDriver with mutex, retry cap, and dispatch" +``` + +--- + +## Task 5: `Game.aiOpponent`, bot-driver registry, protocol additions + +**Files:** +- Modify: `packages/server/src/state.ts` +- Modify: `packages/server/src/games.ts` +- Modify: `packages/shared/src/protocol.ts` +- Modify: `packages/server/src/validation.ts` + +- [ ] **Step 5.1: Add `aiOpponent` to `Game` type** + +Edit `packages/server/src/state.ts`. Insert into the `Game` interface (right after `disconnectAt`): + +```typescript + aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; +``` + +- [ ] **Step 5.2: Add bot-driver registry to `games.ts`** + +Edit `packages/server/src/games.ts`. After the existing `games` Map declaration, add: + +```typescript +import type { BotDriver } from './bot/driver.js'; + +const botDrivers = new Map(); + +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); +} +``` + +Also extend `pruneFinished` to clean orphan drivers: + +```typescript +export function pruneFinished(): number { + const now = Date.now(); + let removed = 0; + for (const [id, g] of games) { + if (g.status === 'finished' && g.finishedAt && now - g.finishedAt > PRUNE_AFTER_FINISHED_MS) { + games.delete(id); + botDrivers.delete(id); + removed++; + } + } + return removed; +} +``` + +- [ ] **Step 5.3: Extend `createGame` to optionally fill bot slot** + +Edit `packages/server/src/games.ts` `createGame`: + +```typescript +export function createGame(opts: { + mode: Mode; + creatorSide: Color; + highlightingEnabled: boolean; + vsAi?: { brain: 'casual' | 'recon' }; +}): { game: Game; creatorToken: PlayerToken } { + const id = newGameId(); + const creatorToken = newPlayerToken(); + const now = Date.now(); + + const botColor: Color | null = opts.vsAi + ? (opts.creatorSide === 'w' ? 'b' : 'w') + : null; + + const game: Game = { + id, + mode: opts.mode, + highlightingEnabled: opts.highlightingEnabled, + status: 'waiting', + createdAt: now, + chess: new Chess(), + moveHistory: [], + announcements: [], + players: { + w: opts.creatorSide === 'w' ? makeSlot(creatorToken, now) + : (botColor === 'w' ? makeBotSlot(now) : null), + b: opts.creatorSide === 'b' ? makeSlot(creatorToken, now) + : (botColor === 'b' ? makeBotSlot(now) : null), + }, + armed: null, + drawOffer: null, + disconnectAt: {}, + aiOpponent: opts.vsAi && botColor + ? { color: botColor, brain: opts.vsAi.brain } + : undefined, + }; + + games.set(id, game); + return { game, creatorToken }; +} + +function makeBotSlot(now: number) { + return { + token: 'bot' + 'x'.repeat(21), // 24-char placeholder; never matched by real client. + socket: null, + joinedAt: now, + rateBucket: { tokens: RATE_LIMIT.capacity, last: now }, + }; +} +``` + +- [ ] **Step 5.4: Protocol additions** + +Edit `packages/shared/src/protocol.ts`: + +```typescript +export interface CreateGameRequest { + mode: Mode; + side: Color | 'random'; + highlightingEnabled: boolean; + vsAi?: { brain: 'casual' | 'recon' }; +} + +export interface CreateGameResponse { + gameId: GameId; + creatorToken: PlayerToken; + joinUrl: string | null; +} +``` + +Also extend the `joined` and `update` server messages to optionally include `aiOpponent`: + +```typescript +export type ServerMessage = + | { + type: 'joined'; + you: Color | 'spectator-rejected'; + token: PlayerToken; + view: BoardView; + announcements: Announcement[]; + gameStatus: GameStatus; + mode: Mode; + highlightingEnabled: boolean; + opponentConnected: boolean; + aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; + } + | { + type: 'update'; + view: BoardView; + newAnnouncements: Announcement[]; + gameStatus: GameStatus; + touchedPiece?: Square; + drawOffer?: { from: Color } | null; + endReason?: EndReason; + winner?: Color | null; + aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; + } + // ... rest unchanged +``` + +(Note: `'ai_unavailable'` is in spec but only emitted in Phase 2; deferring its addition to `EndReason` until Phase 2.) + +- [ ] **Step 5.5: Validate `vsAi` on `POST /api/games`** + +Edit `packages/server/src/validation.ts`: + +```typescript +export const createGameSchema = z.object({ + mode: z.union([z.literal('blind'), z.literal('vanilla')]), + side: z.union([colorSchema, z.literal('random')]), + highlightingEnabled: z.boolean(), + vsAi: z.object({ + brain: z.union([z.literal('casual'), z.literal('recon')]), + }).optional(), +}); +``` + +- [ ] **Step 5.6: Typecheck and rebuild shared** + +Run: `pnpm --filter @blind-chess/shared build && pnpm --filter @blind-chess/server typecheck` +Expected: shared builds; server typecheck passes. + +- [ ] **Step 5.7: Run all tests (regression)** + +Run: `pnpm -r test` +Expected: all 43 + new tests pass. (Existing FSM/view/integration tests should still pass since `Game` only got an optional field.) + +- [ ] **Step 5.8: Commit** + +```bash +git add packages/shared/src/protocol.ts \ + packages/server/src/state.ts \ + packages/server/src/games.ts \ + packages/server/src/validation.ts +git commit -m "feat(bot): protocol vsAi/aiOpponent fields, bot-slot synthesis, driver registry" +``` + +--- + +## Task 6: Wire `POST /api/games` to instantiate the driver + +**Files:** +- Modify: `packages/server/src/server.ts` + +- [ ] **Step 6.1: Read the current `POST /api/games` handler** + +Already read in pre-flight — `server.ts` lines 42-56. + +- [ ] **Step 6.2: Update the handler** + +Edit `packages/server/src/server.ts`. Replace the existing `POST /api/games` handler with: + +```typescript +fastify.post('/api/games', async (req, reply) => { + const parsed = createGameSchema.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'malformed', detail: parsed.error.issues }; + } + const { mode, side, highlightingEnabled, vsAi } = parsed.data; + + // Phase 1: only 'casual' is implemented. 'recon' returns 503. + if (vsAi && vsAi.brain === 'recon') { + reply.code(503); + return { error: 'ai_offline', detail: 'recon bot not yet implemented' }; + } + + const creatorSide = chooseSide(side); + const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi }); + + // For AI games, wire the bot. + if (vsAi && game.aiOpponent) { + const brain = new CasualBrain({}); + const driver = new BotDriver({ game, brain, color: game.aiOpponent.color }); + await driver.init(); + attachBotDriver(game.id, driver); + } + + const publicBase = PUBLIC_BASE + || (req.headers.host ? `${req.protocol}://${req.headers.host}` : ''); + const joinUrl = vsAi ? null : `${publicBase}/g/${game.id}`; + return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl }; +}); +``` + +Add the imports near the top of `server.ts`: + +```typescript +import { CasualBrain, BotDriver } from './bot/index.js'; +import { attachBotDriver } from './games.js'; +``` + +- [ ] **Step 6.3: Typecheck** + +Run: `pnpm --filter @blind-chess/server typecheck` +Expected: no errors. + +- [ ] **Step 6.4: Commit** + +```bash +git add packages/server/src/server.ts +git commit -m "feat(bot): POST /api/games instantiates CasualBrain + BotDriver" +``` + +--- + +## Task 7: `ws.ts` state-change hooks + +**Files:** +- Modify: `packages/server/src/ws.ts` + +The driver needs to be poked after each state-mutating action. The simplest way: introduce a single `pokeBot(game)` helper that looks up the driver and awaits its `onStateChange()`. Call it after: + +- `onHello` if game became active. +- `onCommit` after `applied`. +- `onCommit` after `announce` only if it was the bot's announcement (it isn't — bots dispatch via the driver, not via `onMessage`); skip. +- `onResign` after `endGame`. +- `onOfferDraw` after offer registered. +- `onRespondDraw` after responded. +- `maybeAbandon` after `endGame`. + +After the bot acts, the bot's actions may have modified game state (a move applied, a draw accepted). We then need to broadcast the resulting state to all human sockets. The simplest pattern: every place we currently call `broadcastNewAnnouncements` or `broadcastUpdate`, we instead call `pokeAndBroadcast(game, newAnnouncements?)` which: +1. Pokes the bot. +2. After bot returns, broadcasts the *current* announcements queue (everything since the last broadcast point). +3. If the game ended during bot action, broadcasts that too. + +This needs care because `broadcastNewAnnouncements` currently takes "the new announcements just produced" as an arg. After the bot moves, the bot's announcements have already been pushed onto `game.announcements` by `handleCommit` / `botResign`. We need to broadcast those too. + +Cleaner approach: track `lastBroadcastIdx` per-color. On every broadcast, send the slice from each color's `lastBroadcastIdx` to current end. This makes the bot's contribution naturally included. + +Simpler still for Phase 1: after handler completes, after pokeBot, just call a fresh `broadcastUpdate(game)` (no `newAnnouncements`) followed by sending the full new-announcements slice. But that would re-send announcements clients already have. + +Cleanest minimal change: add a `lastBroadcastAt` tracking on the game, slice `announcements` from there each time we broadcast. + +Edit `state.ts`: + +```typescript +export interface Game { + // ... existing fields + lastBroadcastIdx?: { w: number; b: number }; +} +``` + +In `createGame`, initialize: `lastBroadcastIdx: { w: 0, b: 0 }`. + +- [ ] **Step 7.1: Add `lastBroadcastIdx` to Game** + +Edit `packages/server/src/state.ts` — add `lastBroadcastIdx: { w: number; b: number }` to the `Game` interface. + +Edit `packages/server/src/games.ts` `createGame` to initialize: + +```typescript + lastBroadcastIdx: { w: 0, b: 0 }, +``` + +(Insert into the Game literal.) + +- [ ] **Step 7.2: Refactor `ws.ts` to slice-from-idx broadcasting** + +Edit `packages/server/src/ws.ts`. Replace `broadcastNewAnnouncements` with `broadcastSinceLast`: + +```typescript +function broadcastSinceLast(game: Game, extra?: { touchedPieceFor?: Color; touchedPiece?: string }): void { + for (const c of ['w', 'b'] as const) { + const lastIdx = game.lastBroadcastIdx?.[c] ?? 0; + const all = game.announcements; + const slice = all.slice(lastIdx).filter((a) => a.audience === 'both' || a.audience === c); + sendUpdateTo(game, c, slice, extra?.touchedPieceFor === c ? { touchedPiece: extra.touchedPiece } : undefined); + if (game.lastBroadcastIdx) game.lastBroadcastIdx[c] = all.length; + } +} +``` + +Replace existing call sites: + +```typescript +// onCommit applied: +finalizeIfEnded(game, result.announcements); +await pokeBot(game); +broadcastSinceLast(game); + +// onCommit silent: +sendUpdateTo(game, color, [], { touchedPiece: msg.from }); + +// onCommit announce: +broadcastSinceLast(game); + +// onResign: +endGame(game, 'resign', color === 'w' ? 'b' : 'w'); +await pokeBot(game); // not strictly needed (game ended), but consistent. +broadcastSinceLast(game); + +// onOfferDraw: +game.drawOffer = { from: color, at: Date.now() }; +await pokeBot(game); +broadcastUpdate(game); // existing helper, sends full update with no new announcements +broadcastSinceLast(game); // pick up any draw_agreed announcement the bot may have added + +// onRespondDraw: +// (after the existing logic) +await pokeBot(game); +broadcastSinceLast(game); + +// onHello (when game becomes active): +if (game.status === 'active') { + await pokeBot(game); + broadcastSinceLast(game); // covers bot-as-white opening move +} + +// maybeAbandon (after endGame): +broadcastSinceLast(game); +``` + +And add the helper: + +```typescript +async function pokeBot(game: Game): Promise { + const driver = getBotDriver(game.id); + if (!driver) return; + try { + await driver.onStateChange(); + } catch (err) { + fastify.log?.error?.({ err, gameId: game.id }, 'bot driver error'); + } +} +``` + +Add the import at the top of `ws.ts`: + +```typescript +import { getBotDriver } from './games.js'; +``` + +(There's no `fastify` in scope inside `ws.ts` — drop the log call or use `console.error`. Use `console.error({ err, gameId: game.id }, 'bot driver error')` for now; structured logging cleanup is out of scope.) + +The signatures of `onCommit`, `onResign`, `onOfferDraw`, `onRespondDraw`, `onHello` need to become `async` (or `void` returning a Promise). The existing handlers are synchronous. Easiest path: have the message router (`onMessage`) `void`-fire them but ensure each handler awaits internally. + +Replace each handler signature `function onCommit(ctx, msg): void` → `async function onCommit(ctx, msg): Promise`. The router doesn't need to change — `void`ing a Promise is fine because the WS callbacks don't care about completion. + +- [ ] **Step 7.3: Run all tests** + +Run: `pnpm -r test` +Expected: all existing 43 tests still pass (the broadcast-since-last refactor is internal; per-message protocol unchanged). New driver/casual-brain/candidates tests still pass. + +- [ ] **Step 7.4: Commit** + +```bash +git add packages/server/src/state.ts \ + packages/server/src/games.ts \ + packages/server/src/ws.ts +git commit -m "feat(bot): ws.ts pokes BotDriver after state-mutating handlers" +``` + +--- + +## Task 8: Integration test — Casual vs scripted human + +**Files:** +- Create: `packages/server/test/integration/ai-game-casual.test.ts` + +The harness is the same pattern as `scripted-game.test.ts` (real Fastify, ephemeral port, real `ws` clients), but only one human client connects — the other side is the bot. + +- [ ] **Step 8.1: Write the integration test** + +Create `packages/server/test/integration/ai-game-casual.test.ts`: + +```typescript +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, getBotDriver, +} 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; +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); + } + return { gameId: game.id, creatorToken, creatorColor: creatorSide }; + }); + 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; + send: (m: unknown) => void; + close: () => void; +} + +function makeClient(gameId: string): Promise { + 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((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'): Promise<{ gameId: string; creatorToken: string }> { + const res = await fetch(`${baseUrl}/api/games`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'vanilla', 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?.color).toBe('w'); + + // Bot's opening move should arrive as an update. + const botMoved = await human.waitFor((m) => + m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'), + ); + 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('full short game: scholar\'s mate setup completes without errors', async () => { + // Vanilla. Human plays as white. Run 8 plies; bot must not crash. + 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'); + + const playerMoves = [ + ['e2', 'e4'], ['f1', 'c4'], ['d1', 'h5'], ['h5', 'f7'], + ]; + for (const [from, to] of playerMoves) { + human.send({ type: 'commit', from }); + await human.waitFor((m) => m.type === 'update' && m.touchedPiece === from); + human.send({ type: 'commit', from, to }); + // Wait either for game-end or for bot's reply. + await human.waitFor((m) => + m.type === 'update' && + (m.gameStatus === 'finished' || m.view.toMove === 'w'), + 3000, + ); + } + 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 }; + // Test harness server doesn't include joinUrl; assert undefined or null. + expect(json.joinUrl ?? null).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); + }); +}); +``` + +- [ ] **Step 8.2: Run integration tests** + +Run: `pnpm --filter @blind-chess/server test -- ai-game-casual` +Expected: 5 tests pass. + +- [ ] **Step 8.3: Run full suite** + +Run: `pnpm -r test` +Expected: 43 + ~26 new = 69 tests pass. + +- [ ] **Step 8.4: Commit** + +```bash +git add packages/server/test/integration/ai-game-casual.test.ts +git commit -m "test(bot): integration test Casual vs scripted human" +``` + +--- + +## Task 9: Client landing two-section layout + +**Files:** +- Modify: `packages/client/src/lib/Landing.svelte` + +Two sections: "Play with a friend" (the existing flow) and "Play vs Computer" (new: Casual button enabled, Recon button disabled with "coming soon" tooltip). Both sections share mode/side/highlight controls; selections are independent so user can configure differently per side. + +- [ ] **Step 9.1: Refactor Landing.svelte** + +Edit `packages/client/src/lib/Landing.svelte`. Full file content: + +```svelte + + +
+
+

blind chess

+

A two-player chess variant where each player sees only their own pieces. The server is the moderator.

+
+ +
+

Play with a friend

+

Get a shareable link, send it to someone, play together.

+ +
+ Mode +
+ + +
+
+ +
+ You play as +
+ + + +
+
+ +
+ +
+ + + {#if friendError}

Error: {friendError}

{/if} +
+ +
+

Play vs computer

+

Always-available opponent. No link to share — game starts immediately.

+ +
+ Mode +
+ + +
+
+ +
+ You play as +
+ + + +
+
+ +
+ +
+ +
+ + +
+

+ Casual: fast, plays simple moves, makes mistakes. Good for a quick game. +

+ {#if aiError}

Error: {aiError}

{/if} +
+ +
+ git.sethpc.xyz/Seth/blind_chess +
+
+ + +``` + +- [ ] **Step 9.2: Build and visually verify** + +Run: `pnpm --filter @blind-chess/client build` +Expected: build succeeds. + +- [ ] **Step 9.3: Run dev server and check landing page** + +Run: `pnpm --filter @blind-chess/client dev` (and in another shell `pnpm --filter @blind-chess/server dev`). +Visit http://localhost:5173 (or whatever Vite reports). +Expected: two cards visible, "Casual bot" button creates an AI game, navigates to game URL, bot plays first if user picked black. The "gemma4 recon (coming soon)" button is disabled. + +If UI works, kill the dev servers. + +- [ ] **Step 9.4: Commit** + +```bash +git add packages/client/src/lib/Landing.svelte +git commit -m "feat(client): two-section landing — friend vs Casual bot" +``` + +--- + +## Task 10: Client AI badge + thinking indicator + +**Files:** +- Modify: `packages/client/src/lib/Game.svelte` +- Modify: `packages/client/src/lib/stores/game.svelte.ts` + +The store needs to track `aiOpponent` from server messages. The Game component needs to show: +- "Casual bot" badge under the opponent's slot. +- "Casual bot is moving..." indicator when `view.toMove === aiOpponent.color` and game is active. + +- [ ] **Step 10.1: Read `Game.svelte` to find opponent indicator location** + +Run: `wc -l packages/client/src/lib/Game.svelte` + +Read the file. Identify where the opponent indicator / status lives. The exact placement of the badge is a small UX call; aim for: under the opponent's name/status row (top of the board on mobile). + +- [ ] **Step 10.2: Update store to track `aiOpponent`** + +Edit `packages/client/src/lib/stores/game.svelte.ts`. In the `GameStateValue` interface add: + +```typescript + aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null; +``` + +In the initial state add: `aiOpponent: null,`. + +In `onServerMessage` for both `'joined'` and `'update'` cases, propagate: + +```typescript + if ('aiOpponent' in m && m.aiOpponent) state.aiOpponent = m.aiOpponent; +``` + +(Add to both `case 'joined':` and `case 'update':` blocks.) + +- [ ] **Step 10.3: Surface in `Game.svelte`** + +Edit `packages/client/src/lib/Game.svelte` to add the AI badge and thinking indicator. Find the existing opponent-status block (likely near the top of the board, alongside `opponentConnected`) and add adjacent: + +```svelte +{#if game.state.aiOpponent} +
+ {#if game.state.aiOpponent.brain === 'casual'} + Casual bot + {:else} + gemma4 recon + {/if} + {#if isBotTurn()} + moving + {/if} +
+{/if} +``` + +with the helper function in the script: + +```typescript +function isBotTurn(): boolean { + const ai = game.state.aiOpponent; + if (!ai) return false; + if (game.state.gameStatus !== 'active') return false; + return game.state.view?.toMove === ai.color; +} +``` + +Add styles within the existing `