From 3798b9c00de5c40821b352e66e0e8c8d01aa9537 Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Tue, 28 Apr 2026 13:56:28 -0400 Subject: [PATCH] feat(bot): BotDriver with mutex, retry cap, and dispatch Wires Brain to Game: init/onStateChange/dispose lifecycle, in-flight mutex, 5-attempt retry loop with attemptHistory, resign-on-cap. Also adds Game.aiOpponent? field to state.ts for Task 5. Co-Authored-By: Claude Sonnet 4.6 --- packages/server/src/bot/driver.ts | 195 +++++++++++++++++++ packages/server/src/bot/index.ts | 3 + packages/server/src/state.ts | 1 + packages/server/test/unit/bot/driver.test.ts | 148 ++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 packages/server/src/bot/driver.ts create mode 100644 packages/server/test/unit/bot/driver.test.ts diff --git a/packages/server/src/bot/driver.ts b/packages/server/src/bot/driver.ts new file mode 100644 index 0000000..e447542 --- /dev/null +++ b/packages/server/src/bot/driver.ts @@ -0,0 +1,195 @@ +import type { Color } from '@blind-chess/shared'; +import type { Game } from '../state.js'; +import type { + AttemptHistoryEntry, + Brain, + BrainAction, + BrainInput, + CandidateMove, +} from './brain.js'; +import { legalCandidates } from './candidates.js'; +import { handleCommit } from '../commit.js'; +import { buildView } from '../view.js'; +import { announce } from '../translator.js'; + +const RETRY_CAP = 5; + +interface BotDriverOpts { + game: Game; + brain: Brain; + color: Color; +} + +export class BotDriver { + private game: Game; + private brain: Brain; + private color: Color; + + private decideInFlight = false; + private disposed = false; + private lastSeenAnnouncementCount = 0; + + constructor(opts: BotDriverOpts) { + this.game = opts.game; + this.brain = opts.brain; + this.color = opts.color; + } + + async init(): Promise { + await this.brain.init({ + color: this.color, + mode: this.game.mode, + gameId: this.game.id, + }); + this.lastSeenAnnouncementCount = this.game.announcements.length; + } + + async onStateChange(): Promise { + if (this.disposed) return; + + if (this.game.status === 'finished') { + await this.disposeBrain(); + return; + } + + if (this.decideInFlight) return; + if (!this.shouldDecide()) return; + + this.decideInFlight = true; + try { + await this.runDecisionCycle(); + } finally { + this.decideInFlight = false; + } + } + + private shouldDecide(): boolean { + if (this.game.status !== 'active') return false; + // Respond to a draw offer from opponent even when it's not our turn. + if (this.game.drawOffer && this.game.drawOffer.from !== this.color) return true; + if (this.game.chess.turn() === this.color) return true; + return false; + } + + private async runDecisionCycle(): Promise { + 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 { + // Brain exception → bot resigns. CasualBrain only throws on zero + // candidates (impossible if shouldDecide passed). + this.botResign(); + return; + } + + const outcome = this.dispatch(action); + if (outcome.kind === 'done') return; + attemptHistory.push(outcome.entry); + } + this.botResign(); + } + + 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, + }; + } + + 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') { + // Brain sent only `from` (arming). CasualBrain always commits with + // `to`; treat as a logic error and resign safely. + this.botResign(); + return { kind: 'done' }; + } + // result.kind === 'error' — bug path; resign. + this.botResign(); + return { kind: 'done' }; + } + case 'resign': + this.botResign(); + return { kind: 'done' }; + case 'offer-draw': + if (!this.game.drawOffer) { + this.game.drawOffer = { from: this.color, at: Date.now() }; + } + return { kind: 'done' }; + case 'respond-draw': + if (!this.game.drawOffer || this.game.drawOffer.from === this.color) { + return { kind: 'done' }; + } + if (action.accept) { + const ply = this.game.chess.history().length; + const a = announce('draw_agreed', 'both', ply); + this.game.announcements.push(a); + this.game.drawOffer = null; + this.game.status = 'finished'; + this.game.endReason = 'draw_agreed'; + this.game.winner = null; + this.game.finishedAt = Date.now(); + } else { + this.game.drawOffer = null; + } + return { kind: 'done' }; + } + } + + private botResign(): void { + if (this.game.status !== 'active') return; + const ply = this.game.chess.history().length; + const text = this.color === 'w' ? 'white_resigned' : 'black_resigned'; + const a = announce(text, 'both', ply); + this.game.announcements.push(a); + this.game.status = 'finished'; + this.game.endReason = 'resign'; + this.game.winner = this.color === 'w' ? 'b' : 'w'; + this.game.finishedAt = Date.now(); + } + + private async disposeBrain(): Promise { + if (this.disposed) return; + this.disposed = true; + try { + await this.brain.dispose?.(); + } catch {/* ignore */} + } +} diff --git a/packages/server/src/bot/index.ts b/packages/server/src/bot/index.ts index d86506f..6b084ca 100644 --- a/packages/server/src/bot/index.ts +++ b/packages/server/src/bot/index.ts @@ -2,3 +2,6 @@ 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'; diff --git a/packages/server/src/state.ts b/packages/server/src/state.ts index e39f787..93c4b48 100644 --- a/packages/server/src/state.ts +++ b/packages/server/src/state.ts @@ -51,6 +51,7 @@ export interface Game { armed: { color: Color; from: Square } | null; drawOffer: { from: Color; at: number } | null; disconnectAt: { w?: number; b?: number }; + aiOpponent?: { color: Color; brain: 'casual' | 'recon' }; } export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 }; diff --git a/packages/server/test/unit/bot/driver.test.ts b/packages/server/test/unit/bot/driver.test.ts new file mode 100644 index 0000000..df27bcf --- /dev/null +++ b/packages/server/test/unit/bot/driver.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Chess } from 'chess.js'; +import { BotDriver } from '../../../src/bot/driver.js'; +import type { Brain, BrainAction, BrainInput } from '../../../src/bot/brain.js'; +import type { Game } from '../../../src/state.js'; +import { RATE_LIMIT } from '../../../src/state.js'; + +function makeGame(opts: { mode?: 'blind' | 'vanilla'; fen?: string; status?: Game['status'] } = {}): Game { + return { + id: 'gabcd123', + mode: opts.mode ?? 'blind', + highlightingEnabled: false, + status: opts.status ?? 'active', + createdAt: Date.now(), + chess: opts.fen ? new Chess(opts.fen) : new Chess(), + moveHistory: [], + announcements: [], + players: { + w: { token: 'w'.repeat(24), socket: null, joinedAt: 0, + rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } }, + b: { token: 'b'.repeat(24), socket: null, joinedAt: 0, + rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } }, + }, + armed: null, + drawOffer: null, + disconnectAt: {}, + aiOpponent: { color: 'b', brain: 'casual' }, + }; +} + +class StubBrain implements Brain { + public decideCalls = 0; + private script: BrainAction[] = []; + init = vi.fn(async () => {}); + dispose = vi.fn(async () => {}); + decide = vi.fn(async (input: BrainInput): Promise => { + this.decideCalls++; + if (this.script.length === 0) { + // Default: trivial commit on the first legal candidate. + if (input.legalCandidates.length === 0) throw new Error('no candidates'); + const c = input.legalCandidates[0]!; + return { type: 'commit', from: c.from, to: c.to, promotion: c.promotion }; + } + return this.script.shift()!; + }); + + enqueue(...actions: BrainAction[]) { this.script.push(...actions); } +} + +describe('BotDriver', () => { + let game: Game; + let brain: StubBrain; + let driver: BotDriver; + + beforeEach(async () => { + game = makeGame(); + brain = new StubBrain(); + driver = new BotDriver({ game, brain, color: 'b' }); + await driver.init(); + }); + + it('init() invokes brain.init with correct args', async () => { + expect(brain.init).toHaveBeenCalledWith({ + color: 'b', + mode: 'blind', + gameId: 'gabcd123', + }); + }); + + it('onStateChange does nothing when not bot turn', async () => { + // White to move (start). Bot is black. + await driver.onStateChange(); + expect(brain.decide).not.toHaveBeenCalled(); + }); + + it('onStateChange fires decide when it is bot turn', async () => { + game.chess.move('e4'); + await driver.onStateChange(); + expect(brain.decide).toHaveBeenCalledTimes(1); + expect(game.chess.turn()).toBe('w'); // turn advanced + }); + + it('mutex: second onStateChange while in-flight is a no-op', async () => { + game.chess.move('e4'); + let release: () => void; + const gate = new Promise((r) => { release = r; }); + brain.decide.mockImplementationOnce(async (input) => { + await gate; + const c = input.legalCandidates[0]!; + return { type: 'commit', from: c.from, to: c.to }; + }); + const p1 = driver.onStateChange(); + const p2 = driver.onStateChange(); + release!(); + await Promise.all([p1, p2]); + expect(brain.decide).toHaveBeenCalledTimes(1); + }); + + it('retry on wont_help: pinned bishop scenario', async () => { + // Black king h8, black bishop e7 pinned by white rook on e1. + const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1'; + game = makeGame({ fen }); + brain = new StubBrain(); + driver = new BotDriver({ game, brain, color: 'b' }); + await driver.init(); + // First action: pinned-bishop move (FSM rejects with wont_help) + // Second action: legal king move (black king at e8, not h8 which is white) + brain.enqueue( + { type: 'commit', from: 'e7', to: 'd6' }, + { type: 'commit', from: 'e8', to: 'f8' }, + ); + await driver.onStateChange(); + expect(brain.decide).toHaveBeenCalledTimes(2); + expect(game.chess.turn()).toBe('w'); + }); + + it('retry cap (5): after 5 wont_help, driver resigns the bot', async () => { + const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1'; + game = makeGame({ fen }); + brain = new StubBrain(); + driver = new BotDriver({ game, brain, color: 'b' }); + await driver.init(); + for (let i = 0; i < 6; i++) { + brain.enqueue({ type: 'commit', from: 'e7', to: 'd6' }); + } + await driver.onStateChange(); + expect(game.status).toBe('finished'); + expect(game.endReason).toBe('resign'); + expect(game.winner).toBe('w'); + }); + + 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(); + }); +});