5282237027
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
257 lines
8.9 KiB
TypeScript
257 lines
8.9 KiB
TypeScript
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<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++) {
|
|
// 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<string, unknown>): 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<void> {
|
|
if (this.disposed) return;
|
|
this.disposed = true;
|
|
try {
|
|
await this.brain.dispose?.();
|
|
} catch {/* ignore */}
|
|
}
|
|
}
|