feat(bot): BotDriver with mutex, retry cap, and dispatch

Wires Brain to Game: init/onStateChange/dispose lifecycle, in-flight
mutex, 5-attempt retry loop with attemptHistory, resign-on-cap. Also
adds Game.aiOpponent? field to state.ts for Task 5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude (blind_chess)
2026-04-28 13:56:28 -04:00
parent ebd1463b0a
commit 3798b9c00d
4 changed files with 347 additions and 0 deletions
+195
View File
@@ -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<void> {
await this.brain.init({
color: this.color,
mode: this.game.mode,
gameId: this.game.id,
});
this.lastSeenAnnouncementCount = this.game.announcements.length;
}
async onStateChange(): Promise<void> {
if (this.disposed) return;
if (this.game.status === 'finished') {
await this.disposeBrain();
return;
}
if (this.decideInFlight) return;
if (!this.shouldDecide()) return;
this.decideInFlight = true;
try {
await this.runDecisionCycle();
} finally {
this.decideInFlight = false;
}
}
private shouldDecide(): boolean {
if (this.game.status !== 'active') return false;
// Respond to a draw offer from opponent even when it's not our turn.
if (this.game.drawOffer && this.game.drawOffer.from !== this.color) return true;
if (this.game.chess.turn() === this.color) return true;
return false;
}
private async runDecisionCycle(): Promise<void> {
const attemptHistory: AttemptHistoryEntry[] = [];
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
const input = this.buildBrainInput(attemptHistory);
let 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<void> {
if (this.disposed) return;
this.disposed = true;
try {
await this.brain.dispose?.();
} catch {/* ignore */}
}
}
+3
View File
@@ -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';
+1
View File
@@ -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 };