6d457a2321
- DECISIONS.md: in-game chat (player↔player and human↔Gemma) deferred indefinitely. Blind-mode chat is a side channel that defeats the moderator-vocabulary security boundary; chat with Gemma leaks belief state mid-game. Resolvable but expensive — revisit only on demand. - Spec: same deferral noted in "Out of scope". - New plan: docs/superpowers/plans/2026-04-28-ai-player-phase-1-casual.md — 13 tasks, 80 sub-steps. Phase 1 only (Casual bot end-to-end). Phase 2 (Recon) gets its own plan once Phase 1 outcomes inform Recon's target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2562 lines
89 KiB
Markdown
2562 lines
89 KiB
Markdown
# 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<void>;
|
||
decide(input: BrainInput): Promise<BrainAction>;
|
||
dispose?(): Promise<void>;
|
||
}
|
||
```
|
||
|
||
- [ ] **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> = {}): 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<PieceType, number> = {
|
||
p: 1, n: 3, b: 3, r: 5, q: 9, k: 0,
|
||
};
|
||
|
||
export class CasualBrain implements Brain {
|
||
private color: Color = 'w';
|
||
private mode: 'blind' | 'vanilla' = 'blind';
|
||
private rng: () => number;
|
||
|
||
constructor(opts: CasualOpts = {}) {
|
||
this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff));
|
||
}
|
||
|
||
async init(args: BrainInitArgs): Promise<void> {
|
||
this.color = args.color;
|
||
this.mode = args.mode;
|
||
}
|
||
|
||
async decide(input: BrainInput): Promise<BrainAction> {
|
||
if (input.drawOfferFromOpponent) {
|
||
return { type: 'respond-draw', accept: this.acceptDraw(input.view) };
|
||
}
|
||
|
||
const filtered = this.excludeRejected(input.legalCandidates, input.attemptHistory);
|
||
if (filtered.length === 0) {
|
||
throw new Error('CasualBrain: zero candidates after exclusion');
|
||
}
|
||
|
||
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<void>` 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<BrainAction> => {
|
||
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<void>((r) => { release = r; });
|
||
brain.decide.mockImplementationOnce(async (input) => {
|
||
await gate;
|
||
const c = input.legalCandidates[0]!;
|
||
return { type: 'commit', from: c.from, to: c.to };
|
||
});
|
||
const p1 = driver.onStateChange();
|
||
const p2 = driver.onStateChange();
|
||
release!();
|
||
await Promise.all([p1, p2]);
|
||
expect(brain.decide).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('retry on wont_help: pinned bishop scenario', async () => {
|
||
// Black-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<void> {
|
||
await this.brain.init({
|
||
color: this.color,
|
||
mode: this.game.mode,
|
||
gameId: this.game.id,
|
||
});
|
||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||
}
|
||
|
||
async onStateChange(): Promise<void> {
|
||
if (this.disposed) return;
|
||
|
||
if (this.game.status === 'finished') {
|
||
await this.disposeBrain();
|
||
return;
|
||
}
|
||
|
||
if (this.decideInFlight) return;
|
||
if (!this.shouldDecide()) return;
|
||
|
||
this.decideInFlight = true;
|
||
try {
|
||
await this.runDecisionCycle();
|
||
} finally {
|
||
this.decideInFlight = false;
|
||
}
|
||
}
|
||
|
||
/** 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<void> {
|
||
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<void> {
|
||
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<GameId, BotDriver>();
|
||
|
||
export function attachBotDriver(id: GameId, driver: BotDriver): void {
|
||
botDrivers.set(id, driver);
|
||
}
|
||
|
||
export function getBotDriver(id: GameId): BotDriver | undefined {
|
||
return botDrivers.get(id);
|
||
}
|
||
|
||
export function disposeBotDriver(id: GameId): void {
|
||
botDrivers.delete(id);
|
||
}
|
||
```
|
||
|
||
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<void> {
|
||
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<void>`. 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<typeof Fastify>;
|
||
let baseUrl = '';
|
||
|
||
beforeAll(async () => {
|
||
app = Fastify({ logger: false });
|
||
await app.register(websocketPlugin);
|
||
app.get('/api/health', async () => ({ ok: true, activeGames: activeGameCount() }));
|
||
app.post('/api/games', async (req, reply) => {
|
||
const parsed = createGameSchema.safeParse(req.body);
|
||
if (!parsed.success) { reply.code(400); return { error: 'malformed' }; }
|
||
const { mode, side, highlightingEnabled, vsAi } = parsed.data;
|
||
if (vsAi && vsAi.brain === 'recon') {
|
||
reply.code(503); return { error: 'ai_offline' };
|
||
}
|
||
const creatorSide = chooseSide(side);
|
||
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi });
|
||
if (vsAi && game.aiOpponent) {
|
||
const brain = new CasualBrain({ seed: 1 });
|
||
const driver = new BotDriver({ game, brain, color: game.aiOpponent.color });
|
||
await driver.init();
|
||
attachBotDriver(game.id, driver);
|
||
}
|
||
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<ServerMessage>;
|
||
send: (m: unknown) => void;
|
||
close: () => void;
|
||
}
|
||
|
||
function makeClient(gameId: string): Promise<Client> {
|
||
return new Promise((resolve, reject) => {
|
||
const ws = new WebSocket(baseUrl.replace('http', 'ws') + `/ws?game=${gameId}`);
|
||
const msgs: ServerMessage[] = [];
|
||
const waiters: Array<{ pred: (m: ServerMessage) => boolean;
|
||
resolve: (m: ServerMessage) => void;
|
||
reject: (e: Error) => void;
|
||
timer: NodeJS.Timeout }> = [];
|
||
ws.on('message', (data) => {
|
||
const m = JSON.parse(data.toString()) as ServerMessage;
|
||
msgs.push(m);
|
||
for (const w of [...waiters]) {
|
||
if (w.pred(m)) {
|
||
clearTimeout(w.timer);
|
||
waiters.splice(waiters.indexOf(w), 1);
|
||
w.resolve(m);
|
||
}
|
||
}
|
||
});
|
||
ws.on('open', () => resolve({
|
||
ws, msgs,
|
||
waitFor: (pred, timeoutMs = 2000) => new Promise<ServerMessage>((res, rej) => {
|
||
const existing = msgs.find(pred);
|
||
if (existing) return res(existing);
|
||
const timer = setTimeout(() => rej(new Error('waitFor timeout')), timeoutMs);
|
||
waiters.push({ pred, resolve: res, reject: rej, timer });
|
||
}),
|
||
send: (m) => ws.send(JSON.stringify(m)),
|
||
close: () => ws.close(),
|
||
}));
|
||
ws.on('error', reject);
|
||
});
|
||
}
|
||
|
||
async function createAiGame(side: 'w' | 'b'): 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
|
||
<script lang="ts">
|
||
import type { Mode, Color, CreateGameResponse } from '@blind-chess/shared';
|
||
|
||
// Friend section state.
|
||
let friendMode: Mode = $state('blind');
|
||
let friendSide: Color | 'random' = $state('random');
|
||
let friendHighlight = $state(false);
|
||
let friendCreating = $state(false);
|
||
let friendError: string | null = $state(null);
|
||
|
||
// AI section state (separate so user can configure each independently).
|
||
let aiMode: Mode = $state('blind');
|
||
let aiSide: Color | 'random' = $state('random');
|
||
let aiHighlight = $state(false);
|
||
let aiCreating = $state(false);
|
||
let aiError: string | null = $state(null);
|
||
|
||
async function createWithFriend() {
|
||
friendCreating = true; friendError = null;
|
||
try {
|
||
const res = await fetch('/api/games', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
mode: friendMode, side: friendSide, highlightingEnabled: friendHighlight,
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
|
||
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
|
||
location.hash = `#/g/${json.gameId}`;
|
||
} catch (e) {
|
||
friendError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
friendCreating = false;
|
||
}
|
||
}
|
||
|
||
async function createVsCasual() {
|
||
aiCreating = true; aiError = null;
|
||
try {
|
||
const res = await fetch('/api/games', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
mode: aiMode, side: aiSide, highlightingEnabled: aiHighlight,
|
||
vsAi: { brain: 'casual' },
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
|
||
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
|
||
location.hash = `#/g/${json.gameId}`;
|
||
} catch (e) {
|
||
aiError = e instanceof Error ? e.message : String(e);
|
||
} finally {
|
||
aiCreating = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="page">
|
||
<div class="hero">
|
||
<h1>blind <span class="accent">chess</span></h1>
|
||
<p class="tagline">A two-player chess variant where each player sees only their own pieces. The server is the moderator.</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Play with a friend</h2>
|
||
<p class="card-sub muted">Get a shareable link, send it to someone, play together.</p>
|
||
|
||
<div class="field">
|
||
<span class="lbl">Mode</span>
|
||
<div class="opts">
|
||
<label class="opt" class:active={friendMode === 'blind'}>
|
||
<input type="radio" bind:group={friendMode} value="blind" />
|
||
<span class="opt-title">Blind</span>
|
||
<span class="opt-sub">Each player sees only their own pieces.</span>
|
||
</label>
|
||
<label class="opt" class:active={friendMode === 'vanilla'}>
|
||
<input type="radio" bind:group={friendMode} value="vanilla" />
|
||
<span class="opt-title">Vanilla</span>
|
||
<span class="opt-sub">Normal chess. Both players see everything.</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<span class="lbl">You play as</span>
|
||
<div class="row">
|
||
<label><input type="radio" bind:group={friendSide} value="w" /> White</label>
|
||
<label><input type="radio" bind:group={friendSide} value="b" /> Black</label>
|
||
<label><input type="radio" bind:group={friendSide} value="random" /> Random</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="toggle">
|
||
<input type="checkbox" bind:checked={friendHighlight} />
|
||
<span>Highlight reachable squares</span>
|
||
{#if friendMode === 'blind'}
|
||
<span class="hint muted">(geometric only — no opponent info)</span>
|
||
{/if}
|
||
</label>
|
||
</div>
|
||
|
||
<button class="primary big" disabled={friendCreating} onclick={createWithFriend}>
|
||
{friendCreating ? 'Creating…' : 'Create game'}
|
||
</button>
|
||
{#if friendError}<p class="error">Error: {friendError}</p>{/if}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Play vs computer</h2>
|
||
<p class="card-sub muted">Always-available opponent. No link to share — game starts immediately.</p>
|
||
|
||
<div class="field">
|
||
<span class="lbl">Mode</span>
|
||
<div class="opts">
|
||
<label class="opt" class:active={aiMode === 'blind'}>
|
||
<input type="radio" bind:group={aiMode} value="blind" />
|
||
<span class="opt-title">Blind</span>
|
||
<span class="opt-sub">Each player sees only their own pieces.</span>
|
||
</label>
|
||
<label class="opt" class:active={aiMode === 'vanilla'}>
|
||
<input type="radio" bind:group={aiMode} value="vanilla" />
|
||
<span class="opt-title">Vanilla</span>
|
||
<span class="opt-sub">Normal chess. Both players see everything.</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<span class="lbl">You play as</span>
|
||
<div class="row">
|
||
<label><input type="radio" bind:group={aiSide} value="w" /> White</label>
|
||
<label><input type="radio" bind:group={aiSide} value="b" /> Black</label>
|
||
<label><input type="radio" bind:group={aiSide} value="random" /> Random</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="toggle">
|
||
<input type="checkbox" bind:checked={aiHighlight} />
|
||
<span>Highlight reachable squares</span>
|
||
{#if aiMode === 'blind'}
|
||
<span class="hint muted">(geometric only — no opponent info)</span>
|
||
{/if}
|
||
</label>
|
||
</div>
|
||
|
||
<div class="ai-buttons">
|
||
<button class="primary" disabled={aiCreating} onclick={createVsCasual}>
|
||
{aiCreating ? 'Creating…' : 'Casual bot'}
|
||
</button>
|
||
<button class="secondary" disabled title="Coming soon">
|
||
gemma4 recon (coming soon)
|
||
</button>
|
||
</div>
|
||
<p class="card-sub muted small">
|
||
Casual: fast, plays simple moves, makes mistakes. Good for a quick game.
|
||
</p>
|
||
{#if aiError}<p class="error">Error: {aiError}</p>{/if}
|
||
</div>
|
||
|
||
<footer class="muted">
|
||
<span class="mono">git.sethpc.xyz/Seth/blind_chess</span>
|
||
</footer>
|
||
</div>
|
||
|
||
<style>
|
||
.page {
|
||
max-width: 540px;
|
||
margin: 0 auto;
|
||
padding: 32px 20px 80px;
|
||
min-height: 100%;
|
||
}
|
||
.hero { text-align: center; margin-bottom: 32px; }
|
||
h1 {
|
||
font-size: 48px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.02em;
|
||
margin: 0 0 12px;
|
||
}
|
||
.accent { color: var(--accent); }
|
||
.tagline {
|
||
color: var(--text-dim);
|
||
font-size: 15px;
|
||
line-height: 1.5;
|
||
max-width: 420px;
|
||
margin: 0 auto;
|
||
}
|
||
.card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 22px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.card-sub { font-size: 13px; margin: -10px 0 16px; }
|
||
.card-sub.small { margin-top: 12px; font-size: 12px; }
|
||
h2 { font-size: 18px; margin: 0 0 8px; }
|
||
|
||
.field { margin-bottom: 20px; }
|
||
.lbl {
|
||
display: block;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--text-dim);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.opts { display: grid; gap: 8px; }
|
||
.opt {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: border 0.15s, background 0.15s;
|
||
}
|
||
.opt:hover { border-color: var(--accent-dim); }
|
||
.opt.active { border-color: var(--accent); background: rgba(211,84,0,0.07); }
|
||
.opt input { display: none; }
|
||
.opt-title { font-weight: 600; }
|
||
.opt-sub { color: var(--text-dim); font-size: 13px; margin-top: 2px; }
|
||
|
||
.row { display: flex; gap: 16px; flex-wrap: wrap; }
|
||
.row label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||
|
||
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; flex-wrap: wrap; }
|
||
.hint { font-size: 13px; }
|
||
|
||
button.big {
|
||
width: 100%;
|
||
padding: 14px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.ai-buttons { display: grid; gap: 8px; grid-template-columns: 1fr 1fr; }
|
||
@media (max-width: 480px) { .ai-buttons { grid-template-columns: 1fr; } }
|
||
.secondary {
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
color: var(--text-dim);
|
||
}
|
||
.secondary:disabled { cursor: not-allowed; opacity: 0.6; }
|
||
|
||
.error { color: #f87171; margin-top: 12px; }
|
||
footer { text-align: center; margin-top: 24px; font-size: 12px; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **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}
|
||
<div class="ai-badge" class:thinking={isBotTurn()}>
|
||
{#if game.state.aiOpponent.brain === 'casual'}
|
||
<span class="badge-name">Casual bot</span>
|
||
{:else}
|
||
<span class="badge-name">gemma4 recon</span>
|
||
{/if}
|
||
{#if isBotTurn()}
|
||
<span class="thinking-text">moving<span class="dots">…</span></span>
|
||
{/if}
|
||
</div>
|
||
{/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 `<style>` block:
|
||
|
||
```css
|
||
.ai-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 4px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
background: var(--panel);
|
||
color: var(--text-dim);
|
||
}
|
||
.ai-badge.thinking { color: var(--accent); border-color: var(--accent-dim); }
|
||
.badge-name { font-weight: 600; }
|
||
.thinking-text { font-style: italic; }
|
||
.dots { animation: pulse 1.4s ease-in-out infinite; }
|
||
@keyframes pulse {
|
||
0%, 80%, 100% { opacity: 0.3; }
|
||
40% { opacity: 1; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10.4: Build and visually verify**
|
||
|
||
Run: `pnpm --filter @blind-chess/client build`
|
||
Expected: succeeds.
|
||
|
||
Run dev servers; create an AI game; observe the badge + thinking indicator switches when it's the bot's turn.
|
||
|
||
- [ ] **Step 10.5: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/lib/Game.svelte \
|
||
packages/client/src/lib/stores/game.svelte.ts
|
||
git commit -m "feat(client): AI badge and thinking indicator"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Self-play harness
|
||
|
||
**Files:**
|
||
- Create: `scripts/selfplay.ts`
|
||
- Modify: `package.json` (root)
|
||
|
||
CLI tool that runs N Casual-vs-Casual games in-process (no HTTP, no WS) and prints summary stats: wins/losses/draws by color, average move count, average per-move time, top reasons for game end.
|
||
|
||
- [ ] **Step 11.1: Create the script**
|
||
|
||
Create `scripts/selfplay.ts`:
|
||
|
||
```typescript
|
||
#!/usr/bin/env tsx
|
||
/**
|
||
* Self-play harness for the Casual bot.
|
||
*
|
||
* Runs N games of Casual vs Casual in-process (no HTTP). Reports stats and
|
||
* (optionally) writes a transcript per game to tmp/selfplay-runs/<ts>/.
|
||
*
|
||
* Usage:
|
||
* pnpm selfplay --white casual --black casual --games 100
|
||
* pnpm selfplay --games 50 --mode blind
|
||
* pnpm selfplay --games 10 --mode vanilla --seed 42 --transcripts
|
||
*/
|
||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { Chess } from 'chess.js';
|
||
import { CasualBrain, BotDriver } from '../packages/server/src/bot/index.js';
|
||
import { createGame } from '../packages/server/src/games.js';
|
||
import type { Game } from '../packages/server/src/state.js';
|
||
|
||
interface Args {
|
||
white: 'casual';
|
||
black: 'casual';
|
||
games: number;
|
||
mode: 'blind' | 'vanilla';
|
||
seed: number;
|
||
transcripts: boolean;
|
||
maxPly: number;
|
||
}
|
||
|
||
function parseArgs(): Args {
|
||
const args: Args = {
|
||
white: 'casual', black: 'casual',
|
||
games: 10, mode: 'blind', seed: 1, transcripts: false, maxPly: 400,
|
||
};
|
||
const a = process.argv.slice(2);
|
||
for (let i = 0; i < a.length; i++) {
|
||
const k = a[i]!;
|
||
const v = a[i + 1]!;
|
||
if (k === '--white') { args.white = v as 'casual'; i++; }
|
||
else if (k === '--black') { args.black = v as 'casual'; i++; }
|
||
else if (k === '--games') { args.games = parseInt(v, 10); i++; }
|
||
else if (k === '--mode') { args.mode = v as 'blind' | 'vanilla'; i++; }
|
||
else if (k === '--seed') { args.seed = parseInt(v, 10); i++; }
|
||
else if (k === '--max-ply') { args.maxPly = parseInt(v, 10); i++; }
|
||
else if (k === '--transcripts') { args.transcripts = true; }
|
||
else if (k === '--help' || k === '-h') {
|
||
console.log('Usage: pnpm selfplay [--games N] [--mode blind|vanilla] [--seed N] [--transcripts]');
|
||
process.exit(0);
|
||
}
|
||
}
|
||
return args;
|
||
}
|
||
|
||
async function runOneGame(args: Args, gameIdx: number): Promise<{
|
||
result: 'w' | 'b' | 'draw' | 'maxply' | 'error';
|
||
endReason: string;
|
||
ply: number;
|
||
ms: number;
|
||
transcript: string[];
|
||
}> {
|
||
const startMs = Date.now();
|
||
const transcript: string[] = [];
|
||
|
||
const { game } = createGame({
|
||
mode: args.mode, creatorSide: 'w', highlightingEnabled: false,
|
||
vsAi: { brain: 'casual' },
|
||
});
|
||
// createGame already filled both slots when vsAi is set. We just need to
|
||
// flip the game out of `waiting` (no hello will arrive in self-play) and
|
||
// clear the aiOpponent tag so neither side is treated as "the human's bot".
|
||
game.aiOpponent = undefined;
|
||
game.status = 'active';
|
||
|
||
const wBrain = new CasualBrain({ seed: args.seed + gameIdx * 2 });
|
||
const bBrain = new CasualBrain({ seed: args.seed + gameIdx * 2 + 1 });
|
||
const wDriver = new BotDriver({ game, brain: wBrain, color: 'w' });
|
||
const bDriver = new BotDriver({ game, brain: bBrain, color: 'b' });
|
||
await wDriver.init();
|
||
await bDriver.init();
|
||
|
||
let ply = 0;
|
||
while (game.status === 'active' && ply < args.maxPly) {
|
||
const turn = game.chess.turn() as 'w' | 'b';
|
||
const driver = turn === 'w' ? wDriver : bDriver;
|
||
try {
|
||
await driver.onStateChange();
|
||
} catch (err) {
|
||
transcript.push(`!! error at ply ${ply}: ${(err as Error).message}`);
|
||
return { result: 'error', endReason: (err as Error).message, ply, ms: Date.now() - startMs, transcript };
|
||
}
|
||
const newPly = game.chess.history().length;
|
||
if (newPly === ply && game.status === 'active') {
|
||
// Driver didn't move and game didn't end — defensive break.
|
||
transcript.push(`!! stuck at ply ${ply} (${turn} to move)`);
|
||
return { result: 'error', endReason: 'stuck', ply, ms: Date.now() - startMs, transcript };
|
||
}
|
||
if (newPly > ply) {
|
||
const lastSan = game.chess.history()[newPly - 1];
|
||
transcript.push(`${newPly}. ${turn === 'w' ? 'W' : 'B'}: ${lastSan}`);
|
||
}
|
||
ply = newPly;
|
||
}
|
||
|
||
const ms = Date.now() - startMs;
|
||
if (game.status !== 'finished') {
|
||
return { result: 'maxply', endReason: 'max_ply', ply, ms, transcript };
|
||
}
|
||
const result: 'w' | 'b' | 'draw' = game.winner ?? 'draw';
|
||
return { result, endReason: game.endReason ?? 'unknown', ply, ms, transcript };
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const args = parseArgs();
|
||
console.log(`selfplay: ${args.games} game(s), mode=${args.mode}, seed=${args.seed}`);
|
||
const results: Awaited<ReturnType<typeof runOneGame>>[] = [];
|
||
|
||
let outDir: string | null = null;
|
||
if (args.transcripts) {
|
||
outDir = resolve('tmp', 'selfplay-runs', String(Date.now()));
|
||
mkdirSync(outDir, { recursive: true });
|
||
console.log(`transcripts -> ${outDir}`);
|
||
}
|
||
|
||
for (let i = 0; i < args.games; i++) {
|
||
const r = await runOneGame(args, i);
|
||
results.push(r);
|
||
if (outDir) {
|
||
writeFileSync(
|
||
resolve(outDir, `game-${String(i + 1).padStart(4, '0')}.txt`),
|
||
`result=${r.result} reason=${r.endReason} ply=${r.ply} ms=${r.ms}\n${r.transcript.join('\n')}\n`,
|
||
);
|
||
}
|
||
if ((i + 1) % 10 === 0 || i === args.games - 1) {
|
||
const summary = summarize(results);
|
||
console.log(`[${i + 1}/${args.games}] ${summary}`);
|
||
}
|
||
}
|
||
|
||
// Final summary.
|
||
console.log('\n=== summary ===');
|
||
console.log(summarize(results));
|
||
const reasons = new Map<string, number>();
|
||
for (const r of results) reasons.set(r.endReason, (reasons.get(r.endReason) ?? 0) + 1);
|
||
console.log('end reasons:');
|
||
for (const [k, v] of [...reasons.entries()].sort((a, b) => b[1] - a[1])) {
|
||
console.log(` ${k}: ${v}`);
|
||
}
|
||
console.log('errors: ' + results.filter((r) => r.result === 'error').length);
|
||
}
|
||
|
||
function summarize(rs: { result: 'w' | 'b' | 'draw' | 'maxply' | 'error'; ply: number; ms: number }[]): string {
|
||
const w = rs.filter((r) => r.result === 'w').length;
|
||
const b = rs.filter((r) => r.result === 'b').length;
|
||
const d = rs.filter((r) => r.result === 'draw').length;
|
||
const mp = rs.filter((r) => r.result === 'maxply').length;
|
||
const er = rs.filter((r) => r.result === 'error').length;
|
||
const avgPly = rs.reduce((s, r) => s + r.ply, 0) / Math.max(rs.length, 1);
|
||
const avgMs = rs.reduce((s, r) => s + r.ms, 0) / Math.max(rs.length, 1);
|
||
return `W=${w} B=${b} D=${d} MaxPly=${mp} Err=${er} avgPly=${avgPly.toFixed(0)} avgMs=${avgMs.toFixed(0)}`;
|
||
}
|
||
|
||
main().catch((err) => { console.error(err); process.exit(1); });
|
||
```
|
||
|
||
- [ ] **Step 11.2: Add npm script**
|
||
|
||
Edit `package.json` (root). In `scripts`:
|
||
|
||
```json
|
||
"selfplay": "tsx scripts/selfplay.ts"
|
||
```
|
||
|
||
Add `tsx` to root devDependencies if not already present (it's in `packages/server/devDependencies`; root may also need it, OR run via `pnpm --filter @blind-chess/server tsx ../../../scripts/selfplay.ts`):
|
||
|
||
```bash
|
||
pnpm add -Dw tsx
|
||
```
|
||
|
||
- [ ] **Step 11.3: Run a 100-game self-play**
|
||
|
||
Run: `pnpm selfplay --games 100 --mode vanilla --seed 1`
|
||
Expected:
|
||
- All 100 games complete (no `Err` count).
|
||
- Median ply count between 20 and 200.
|
||
- W + B + D + MaxPly = 100.
|
||
- Per-move time on the order of milliseconds.
|
||
|
||
Run: `pnpm selfplay --games 100 --mode blind --seed 1`
|
||
Expected:
|
||
- All 100 complete.
|
||
- Blind games may have higher MaxPly count (Casual is rejection-noisy in blind mode); that's OK as long as Err=0.
|
||
|
||
- [ ] **Step 11.4: Run 100 vs random-mover baseline**
|
||
|
||
For the Phase 1 acceptance bar "Casual reliably beats a random-mover ≥80%", we need a `RandomBrain`. Add a small one to the harness file (NOT to the production bot module):
|
||
|
||
In `scripts/selfplay.ts`, just below the imports, add an inline helper:
|
||
|
||
```typescript
|
||
import type { Brain, BrainAction, BrainInitArgs, BrainInput } from '../packages/server/src/bot/brain.js';
|
||
|
||
class RandomBrain implements Brain {
|
||
private rng: () => number;
|
||
constructor(seed: number) {
|
||
let a = seed >>> 0;
|
||
this.rng = () => {
|
||
a = (a + 0x6d2b79f5) >>> 0;
|
||
let t = a;
|
||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
async init(_a: BrainInitArgs): Promise<void> {}
|
||
async decide(input: BrainInput): Promise<BrainAction> {
|
||
const cs = input.legalCandidates;
|
||
if (cs.length === 0) throw new Error('no candidates');
|
||
const i = Math.floor(this.rng() * cs.length);
|
||
const c = cs[i]!;
|
||
return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion };
|
||
}
|
||
}
|
||
```
|
||
|
||
Extend `Args.white` / `Args.black` to accept `'random'`:
|
||
|
||
```typescript
|
||
white: 'casual' | 'random';
|
||
black: 'casual' | 'random';
|
||
```
|
||
|
||
In `runOneGame`, pick brain by name:
|
||
|
||
```typescript
|
||
function makeBrain(kind: 'casual' | 'random', seed: number): Brain {
|
||
return kind === 'casual' ? new CasualBrain({ seed }) : new RandomBrain(seed);
|
||
}
|
||
// ...
|
||
const wBrain = makeBrain(args.white, args.seed + gameIdx * 2);
|
||
const bBrain = makeBrain(args.black, args.seed + gameIdx * 2 + 1);
|
||
```
|
||
|
||
Run:
|
||
```bash
|
||
pnpm selfplay --white casual --black random --games 100 --mode vanilla
|
||
pnpm selfplay --white random --black casual --games 100 --mode vanilla
|
||
```
|
||
|
||
Expected: Casual wins ≥80% in each color.
|
||
|
||
If under 80%: tune `casual-brain.ts` scoring (likely the rank-advance weight or capture bonus). Re-run.
|
||
|
||
- [ ] **Step 11.5: Commit**
|
||
|
||
```bash
|
||
git add scripts/selfplay.ts package.json pnpm-lock.yaml
|
||
git commit -m "feat(bot): self-play harness with Casual and random baselines"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Build, full test suite, deploy
|
||
|
||
**Files:**
|
||
- None modified by this task; deploy artifacts go to CT 690.
|
||
|
||
- [ ] **Step 12.1: Full clean build**
|
||
|
||
Run: `pnpm -r build`
|
||
Expected: clean build of all three packages.
|
||
|
||
- [ ] **Step 12.2: Full test run**
|
||
|
||
Run: `pnpm -r test`
|
||
Expected: all tests pass (43 pre-existing + new shared tests if any + ~26 new server tests = ~69 total).
|
||
|
||
- [ ] **Step 12.3: Typecheck**
|
||
|
||
Run: `pnpm -r typecheck`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 12.4: Deploy bundle**
|
||
|
||
Use the same flow as the MVP deploy (per `CLAUDE.md` "Operations" section).
|
||
|
||
```bash
|
||
# Build server-only deploy bundle:
|
||
pnpm --filter @blind-chess/server deploy --prod --legacy .deploy-server
|
||
|
||
# Rsync to CT 690.
|
||
rsync -avz --delete .deploy-server/ root@192.168.0.245:/opt/blind-chess/server/
|
||
rsync -avz --delete packages/client/dist/ root@192.168.0.245:/opt/blind-chess/client/dist/
|
||
ssh root@192.168.0.245 'chown -R blindchess:blindchess /opt/blind-chess && systemctl restart blind-chess'
|
||
```
|
||
|
||
- [ ] **Step 12.5: Health check**
|
||
|
||
Run: `curl https://chess.sethpc.xyz/api/health`
|
||
Expected: `{"ok":true,"activeGames":0,"uptime":<small>}`
|
||
|
||
- [ ] **Step 12.6: Live smoke checklist (Casual)**
|
||
|
||
Manually:
|
||
- [ ] Open https://chess.sethpc.xyz on a phone. See two cards (friend / vs computer).
|
||
- [ ] Click "Casual bot" with `mode=blind, side=white`. Game opens with you as white.
|
||
- [ ] See "Casual bot" badge under the opponent's slot.
|
||
- [ ] Make a move (e2-e4). Bot replies within 1 second; "moving..." indicator appears briefly.
|
||
- [ ] Play to completion (resign or 30+ plies). No errors in console; game ends cleanly.
|
||
- [ ] Repeat with `mode=vanilla, side=black` — bot moves first.
|
||
- [ ] Check journald: `ssh root@192.168.0.245 'journalctl -u blind-chess --since "5 min ago" | grep -i error'` — expect no errors.
|
||
|
||
- [ ] **Step 12.7: Commit any lockfile changes that surfaced from deploy build**
|
||
|
||
```bash
|
||
git status
|
||
# If pnpm-lock.yaml changed from `pnpm deploy`, commit:
|
||
git add pnpm-lock.yaml
|
||
git commit -m "chore: lockfile after deploy build" || echo "no lockfile changes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Update DECISIONS.md, CLAUDE.md, write handoff
|
||
|
||
**Files:**
|
||
- Modify: `DECISIONS.md`
|
||
- Modify: `CLAUDE.md`
|
||
- Create: `.claude/handoffs/YYYY-MM-DD-HHMMSS-ai-phase-1-shipped.md`
|
||
|
||
- [ ] **Step 13.1: Append Phase 1 outcome to DECISIONS.md**
|
||
|
||
In the "AI / computer player" section, append:
|
||
|
||
```markdown
|
||
- 2026-MM-DD: **Phase 1 (Casual bot) shipped to https://chess.sethpc.xyz.** Outcome metrics from self-play: <X>% Casual-vs-random win rate (target ≥80%), <Y> avg ply per game, <Z>ms avg per-move latency, 0 crashes over 100 games. Implementation surface: ~<N> LoC in `packages/server/src/bot/`, ~<M> LoC in test, no new runtime dependencies. Phase 2 (Recon) has its own plan and starts when Phase 1 has soaked for at least a few days of real play.
|
||
```
|
||
|
||
(Fill in real numbers.)
|
||
|
||
- [ ] **Step 13.2: Update CLAUDE.md**
|
||
|
||
In "Current State", change:
|
||
> **AI player (designed, not built):** ...
|
||
|
||
to:
|
||
> **AI player Phase 1 (Casual) deployed.** Live at https://chess.sethpc.xyz "Play vs computer" → Casual bot. Phase 2 (gemma4 recon) has its own plan; not yet started.
|
||
|
||
In "Key files", add:
|
||
> - `packages/server/src/bot/` — bot module: `Brain` interface, `BotDriver`, `CasualBrain`, candidates.
|
||
|
||
- [ ] **Step 13.3: Write handoff**
|
||
|
||
Use `/session-handoff` skill or write `.claude/handoffs/YYYY-MM-DD-HHMMSS-ai-phase-1-shipped.md` with:
|
||
- Session metadata (branch, commits)
|
||
- Phase 1 outcome stats
|
||
- Files added / modified
|
||
- Next steps: write Phase 2 plan
|
||
- Anything surprising encountered during implementation (a Future Self should know)
|
||
|
||
- [ ] **Step 13.4: Commit and merge**
|
||
|
||
```bash
|
||
git add DECISIONS.md CLAUDE.md .claude/handoffs/
|
||
git commit -m "docs: AI Phase 1 shipped — context, decisions, handoff"
|
||
git push -u origin feat/ai-player-phase-1-casual
|
||
|
||
# Merge to main once smoke passes:
|
||
git checkout main
|
||
git merge --ff-only feat/ai-player-phase-1-casual || git merge --no-ff feat/ai-player-phase-1-casual
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Acceptance gate
|
||
|
||
Phase 1 is **done** when:
|
||
|
||
- [ ] All unit + integration tests pass (`pnpm -r test`).
|
||
- [ ] `pnpm selfplay --games 100 --mode vanilla` and `--mode blind` complete with `Err=0`.
|
||
- [ ] `pnpm selfplay --white casual --black random --games 100 --mode vanilla` shows ≥80% Casual win rate (and the same with colors swapped).
|
||
- [ ] Median game length 20–200 plies in self-play.
|
||
- [ ] Live smoke checklist for Casual passes on https://chess.sethpc.xyz.
|
||
- [ ] No errors in journald for the test sessions.
|
||
- [ ] DECISIONS.md, CLAUDE.md, and handoff document updated.
|
||
- [ ] Branch merged to `main` and pushed.
|
||
|
||
If any of these fail, fix in branch before merging. Don't ship a broken Phase 1 — Phase 2 will inherit the same infrastructure.
|