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'; import { finalizeIfEnded } from '../game-end.js'; // Per-decision-cycle retry budget. In vanilla mode chess.js verbose moves are // guaranteed legal so the cap is never exercised. In blind mode the brain // supplies pseudo-legal candidates and chess.js may reject many (pinned pieces, // unresolved check); we need budget to find a legal move before giving up. const RETRY_CAP = 25; type BotResignReason = | 'retry_cap_exhausted' | 'brain_threw' | 'brain_chose_resign' | 'commit_silent' | 'commit_error'; function errString(err: unknown): string { if (err instanceof Error) return err.message; return String(err); } 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 { await this.brain.init({ color: this.color, mode: this.game.mode, gameId: this.game.id, }); this.lastSeenAnnouncementCount = this.game.announcements.length; } async onStateChange(): Promise { 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 { const attemptHistory: AttemptHistoryEntry[] = []; for (let attempt = 0; attempt < RETRY_CAP; attempt++) { // The bot makes atomic (from,to) commits — there is no touched-piece UX. // A prior attempt that survived past `tryMove` (e.g. illegal_move, // promotion_required) leaves `game.armed` set; a retry that picks a // different `from` would otherwise be rejected as // `must_move_touched_piece` and resign the bot. Clear here so each // attempt starts from a clean FSM state. if (this.game.armed?.color === this.color) { this.game.armed = null; } const input = this.buildBrainInput(attemptHistory); let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry }; try { const action = await this.brain.decide(input); outcome = this.dispatch(action); } catch (err) { // Brain exception OR programming error in dispatch. Safe failure: resign. this.botResign('brain_threw', { err: errString(err) }); return; } if (outcome.kind === 'done') { this.lastSeenAnnouncementCount = this.game.announcements.length; return; } attemptHistory.push(outcome.entry); } this.lastSeenAnnouncementCount = this.game.announcements.length; this.botResign('retry_cap_exhausted', { attempts: attemptHistory.map((a) => `${a.move.from}-${a.move.to}:${a.rejection}`), }); } private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput { const view = buildView(this.game, this.color); const sliceStart = this.lastSeenAnnouncementCount; // NOTE: do NOT advance lastSeenAnnouncementCount here. The caller advances // it once the decision cycle terminates successfully — otherwise retried // attempts would not see the FSM's rejection announcements in their input. 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, // Vanilla mode: full reveal, FEN exposes nothing the brain can't already // see. Blind mode: omit FEN so the engine path can't smuggle opponent // positions past the view filter. fen: this.game.mode === 'vanilla' ? this.game.chess.fen() : undefined, }; } 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') { finalizeIfEnded(this.game, result.announcements); return { kind: 'done' }; } if (result.kind === 'announce') { const rejection = result.announcements[0]!; const text = rejection.text; if (text === 'wont_help' || text === 'illegal_move' || text === 'no_such_piece' || text === 'no_legal_moves') { // Attempted-move announcements are audience 'both'. The bot's // intermediate retry rejections are internal search churn, not // deliberate probing — suppress them so they don't broadcast to // the human. The bot tracks its own rejections via attemptHistory, // so removing the announcement is safe. The whole decision cycle // runs before ws.ts broadcasts, so this pop always happens before // any broadcast. const anns = this.game.announcements; if (anns[anns.length - 1] === rejection) anns.pop(); 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('commit_silent', { from: action.from, to: action.to }); return { kind: 'done' }; } // result.kind === 'error' — bug path; resign. this.botResign('commit_error', { code: result.kind === 'error' ? result.code : undefined, announcement: result.kind === 'announce' ? result.announcements[0]?.text : undefined, }); return { kind: 'done' }; } case 'resign': this.botResign('brain_chose_resign'); 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(reason: BotResignReason, detail?: Record): 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(); // eslint-disable-next-line no-console console.error('[bot resign]', { gameId: this.game.id, color: this.color, mode: this.game.mode, ply, reason, ...detail, }); } private async disposeBrain(): Promise { if (this.disposed) return; this.disposed = true; try { await this.brain.dispose?.(); } catch {/* ignore */} } }