# 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 `