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,195 @@
|
|||||||
|
import type { Color } from '@blind-chess/shared';
|
||||||
|
import type { Game } from '../state.js';
|
||||||
|
import type {
|
||||||
|
AttemptHistoryEntry,
|
||||||
|
Brain,
|
||||||
|
BrainAction,
|
||||||
|
BrainInput,
|
||||||
|
CandidateMove,
|
||||||
|
} from './brain.js';
|
||||||
|
import { legalCandidates } from './candidates.js';
|
||||||
|
import { handleCommit } from '../commit.js';
|
||||||
|
import { buildView } from '../view.js';
|
||||||
|
import { announce } from '../translator.js';
|
||||||
|
|
||||||
|
const RETRY_CAP = 5;
|
||||||
|
|
||||||
|
interface BotDriverOpts {
|
||||||
|
game: Game;
|
||||||
|
brain: Brain;
|
||||||
|
color: Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BotDriver {
|
||||||
|
private game: Game;
|
||||||
|
private brain: Brain;
|
||||||
|
private color: Color;
|
||||||
|
|
||||||
|
private decideInFlight = false;
|
||||||
|
private disposed = false;
|
||||||
|
private lastSeenAnnouncementCount = 0;
|
||||||
|
|
||||||
|
constructor(opts: BotDriverOpts) {
|
||||||
|
this.game = opts.game;
|
||||||
|
this.brain = opts.brain;
|
||||||
|
this.color = opts.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await this.brain.init({
|
||||||
|
color: this.color,
|
||||||
|
mode: this.game.mode,
|
||||||
|
gameId: this.game.id,
|
||||||
|
});
|
||||||
|
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onStateChange(): Promise<void> {
|
||||||
|
if (this.disposed) return;
|
||||||
|
|
||||||
|
if (this.game.status === 'finished') {
|
||||||
|
await this.disposeBrain();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.decideInFlight) return;
|
||||||
|
if (!this.shouldDecide()) return;
|
||||||
|
|
||||||
|
this.decideInFlight = true;
|
||||||
|
try {
|
||||||
|
await this.runDecisionCycle();
|
||||||
|
} finally {
|
||||||
|
this.decideInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldDecide(): boolean {
|
||||||
|
if (this.game.status !== 'active') return false;
|
||||||
|
// Respond to a draw offer from opponent even when it's not our turn.
|
||||||
|
if (this.game.drawOffer && this.game.drawOffer.from !== this.color) return true;
|
||||||
|
if (this.game.chess.turn() === this.color) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runDecisionCycle(): Promise<void> {
|
||||||
|
const attemptHistory: AttemptHistoryEntry[] = [];
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
|
||||||
|
const input = this.buildBrainInput(attemptHistory);
|
||||||
|
let action: BrainAction;
|
||||||
|
try {
|
||||||
|
action = await this.brain.decide(input);
|
||||||
|
} catch {
|
||||||
|
// Brain exception → bot resigns. CasualBrain only throws on zero
|
||||||
|
// candidates (impossible if shouldDecide passed).
|
||||||
|
this.botResign();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = this.dispatch(action);
|
||||||
|
if (outcome.kind === 'done') return;
|
||||||
|
attemptHistory.push(outcome.entry);
|
||||||
|
}
|
||||||
|
this.botResign();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
|
||||||
|
const view = buildView(this.game, this.color);
|
||||||
|
const sliceStart = this.lastSeenAnnouncementCount;
|
||||||
|
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||||
|
const newAnnouncements = this.game.announcements
|
||||||
|
.slice(sliceStart)
|
||||||
|
.filter((a) => a.audience === 'both' || a.audience === this.color);
|
||||||
|
|
||||||
|
const candidates: CandidateMove[] = legalCandidates(this.game, this.color);
|
||||||
|
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
newAnnouncements,
|
||||||
|
legalCandidates: candidates,
|
||||||
|
attemptHistory,
|
||||||
|
drawOfferFromOpponent: !!(this.game.drawOffer && this.game.drawOffer.from !== this.color),
|
||||||
|
ply: this.game.chess.history().length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatch(
|
||||||
|
action: BrainAction,
|
||||||
|
): { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry } {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'commit': {
|
||||||
|
const result = handleCommit(this.game, this.color, {
|
||||||
|
from: action.from, to: action.to, promotion: action.promotion,
|
||||||
|
});
|
||||||
|
if (result.kind === 'applied') return { kind: 'done' };
|
||||||
|
if (result.kind === 'announce') {
|
||||||
|
const text = result.announcements[0]!.text;
|
||||||
|
if (text === 'wont_help' || text === 'illegal_move'
|
||||||
|
|| text === 'no_such_piece' || text === 'no_legal_moves') {
|
||||||
|
return {
|
||||||
|
kind: 'retry',
|
||||||
|
entry: {
|
||||||
|
move: { from: action.from, to: action.to, promotion: action.promotion },
|
||||||
|
rejection: text,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.kind === 'silent') {
|
||||||
|
// Brain sent only `from` (arming). CasualBrain always commits with
|
||||||
|
// `to`; treat as a logic error and resign safely.
|
||||||
|
this.botResign();
|
||||||
|
return { kind: 'done' };
|
||||||
|
}
|
||||||
|
// result.kind === 'error' — bug path; resign.
|
||||||
|
this.botResign();
|
||||||
|
return { kind: 'done' };
|
||||||
|
}
|
||||||
|
case 'resign':
|
||||||
|
this.botResign();
|
||||||
|
return { kind: 'done' };
|
||||||
|
case 'offer-draw':
|
||||||
|
if (!this.game.drawOffer) {
|
||||||
|
this.game.drawOffer = { from: this.color, at: Date.now() };
|
||||||
|
}
|
||||||
|
return { kind: 'done' };
|
||||||
|
case 'respond-draw':
|
||||||
|
if (!this.game.drawOffer || this.game.drawOffer.from === this.color) {
|
||||||
|
return { kind: 'done' };
|
||||||
|
}
|
||||||
|
if (action.accept) {
|
||||||
|
const ply = this.game.chess.history().length;
|
||||||
|
const a = announce('draw_agreed', 'both', ply);
|
||||||
|
this.game.announcements.push(a);
|
||||||
|
this.game.drawOffer = null;
|
||||||
|
this.game.status = 'finished';
|
||||||
|
this.game.endReason = 'draw_agreed';
|
||||||
|
this.game.winner = null;
|
||||||
|
this.game.finishedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
this.game.drawOffer = null;
|
||||||
|
}
|
||||||
|
return { kind: 'done' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private botResign(): void {
|
||||||
|
if (this.game.status !== 'active') return;
|
||||||
|
const ply = this.game.chess.history().length;
|
||||||
|
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
|
||||||
|
const a = announce(text, 'both', ply);
|
||||||
|
this.game.announcements.push(a);
|
||||||
|
this.game.status = 'finished';
|
||||||
|
this.game.endReason = 'resign';
|
||||||
|
this.game.winner = this.color === 'w' ? 'b' : 'w';
|
||||||
|
this.game.finishedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disposeBrain(): Promise<void> {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
try {
|
||||||
|
await this.brain.dispose?.();
|
||||||
|
} catch {/* ignore */}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,6 @@ export type {
|
|||||||
Brain, BrainInput, BrainAction, BrainInitArgs,
|
Brain, BrainInput, BrainAction, BrainInitArgs,
|
||||||
CandidateMove, AttemptHistoryEntry,
|
CandidateMove, AttemptHistoryEntry,
|
||||||
} from './brain.js';
|
} from './brain.js';
|
||||||
|
export { CasualBrain } from './casual-brain.js';
|
||||||
|
export { BotDriver } from './driver.js';
|
||||||
|
export { legalCandidates } from './candidates.js';
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface Game {
|
|||||||
armed: { color: Color; from: Square } | null;
|
armed: { color: Color; from: Square } | null;
|
||||||
drawOffer: { from: Color; at: number } | null;
|
drawOffer: { from: Color; at: number } | null;
|
||||||
disconnectAt: { w?: number; b?: number };
|
disconnectAt: { w?: number; b?: number };
|
||||||
|
aiOpponent?: { color: Color; brain: 'casual' | 'recon' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 };
|
export const RATE_LIMIT = { capacity: 20, refillPerSec: 10 };
|
||||||
|
|||||||
@@ -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