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