4407110147
Extract endGame/finalizeIfEnded to game-end.ts so driver.ts can call finalizeIfEnded after an applied move (fix: bot checkmate was not setting game.status='finished'). Wrap entire dispatch() call in try/catch for exception safety. Move lastSeenAnnouncementCount advance to after successful dispatch so retry attempts see FSM rejection announcements. Add checkmate-finalize test; lock retry-cap at 5 calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
170 lines
5.9 KiB
TypeScript
170 lines
5.9 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');
|
|
expect(brain.decide).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it('bot move that delivers checkmate finalizes game.status', async () => {
|
|
// FEN: '1k6/8/1K6/8/8/8/8/7Q w - - 0 1'
|
|
// White king b6, white queen h1, black king b8.
|
|
// Qh8# is mate: queen moves h1→h8, covers h8; white king b6 covers a7,b7,c7,a5,b5,c5.
|
|
// Black king b8 escape squares (a7,b7,c7,a8,c8) are all covered. Verified with chess.js.
|
|
const fen = '1k6/8/1K6/8/8/8/8/7Q w - - 0 1';
|
|
game = makeGame({ fen });
|
|
game.aiOpponent = { color: 'w', brain: 'casual' };
|
|
brain = new StubBrain();
|
|
driver = new BotDriver({ game, brain, color: 'w' });
|
|
await driver.init();
|
|
|
|
brain.enqueue({ type: 'commit', from: 'h1', to: 'h8' });
|
|
await driver.onStateChange();
|
|
|
|
expect(game.status).toBe('finished');
|
|
expect(game.endReason).toBe('checkmate');
|
|
expect(game.winner).toBe('w');
|
|
});
|
|
});
|