3798b9c00d
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>
149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
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 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();
|
|
});
|
|
});
|