Files
blind_chess/packages/server/test/unit/bot/driver.test.ts
T
claude (blind_chess) 558891ed37 feat(bot): suppress bot retry-search churn from the moderator log
Pop intermediate wont_help/illegal_move/no_such_piece/no_legal_moves
announcements produced during the bot's decision cycle before any
broadcast reaches the human opponent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:00:25 -04:00

213 lines
7.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: {},
lastBroadcastIdx: { w: 0, b: 0 },
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 on illegal_move: switching from after rejection still succeeds', async () => {
// Black bishop b8 has legal moves (c7, d6, e5xpawn, a7) but b8→f4 is
// illegal because the white pawn on e5 blocks the diagonal. Black king on a1.
// The FSM ARMS the b8 piece during its first attempt then rejects with
// illegal_move; on the retry the brain switches to a king move, which used
// to trip "must_move_touched_piece" and resign the bot.
const fen = '1b6/8/8/4P3/8/8/8/k6K b - - 0 1';
game = makeGame({ mode: 'blind', fen });
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
brain.enqueue(
{ type: 'commit', from: 'b8', to: 'f4' }, // illegal: blocked by white pawn
{ type: 'commit', from: 'a1', to: 'b1' }, // legal king move
);
await driver.onStateChange();
expect(brain.decide).toHaveBeenCalledTimes(2);
expect(game.status).toBe('active');
expect(game.chess.turn()).toBe('w');
expect(game.armed).toBeNull();
});
it('retry cap (25): after RETRY_CAP rejected attempts, 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();
// Enqueue more than RETRY_CAP repeated illegal moves; driver should
// exhaust the retry budget and resign.
for (let i = 0; i < 30; 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(25);
});
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('suppresses the bot intermediate retry rejection from the moderator log', async () => {
// Pinned-bishop position: first action is rejected (wont_help), second
// is a legal king move. The wont_help must NOT survive in announcements.
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();
brain.enqueue(
{ type: 'commit', from: 'e7', to: 'd6' }, // rejected: wont_help
{ type: 'commit', from: 'e8', to: 'f8' }, // legal king move
);
await driver.onStateChange();
const texts = game.announcements.map((a) => a.text);
expect(texts).not.toContain('wont_help');
expect(texts).toContain('black_moved');
});
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');
});
});