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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user