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
@@ -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<BrainAction> => {
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<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 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();
});
});