diff --git a/packages/server/src/bot/casual-brain.ts b/packages/server/src/bot/casual-brain.ts index d70dad4..5642183 100644 --- a/packages/server/src/bot/casual-brain.ts +++ b/packages/server/src/bot/casual-brain.ts @@ -65,8 +65,15 @@ export class CasualBrain implements Brain { // can't validate against the candidate list. } - // Blind mode (or vanilla fallback): score-based heuristic. - const choice = this.heuristicPick(filtered, input.view, input.ply); + // Blind mode (or vanilla fallback): score-based heuristic. When the + // moderator says we're in check, bias toward king moves — the only + // class of moves that resolves nearly every check (king moves out of + // attack), and the only class the bot can identify without seeing the + // attacker. Without this bias the heuristic scores capture/advance + // signals uncorrelated with check resolution, the FSM rejects every + // non-resolving attempt, and the driver's retry cap fires. + const inCheck = this.detectOwnCheck(input.newAnnouncements); + const choice = this.heuristicPick(filtered, input.view, input.ply, inCheck); return { type: 'commit', from: choice.from, @@ -75,6 +82,11 @@ export class CasualBrain implements Brain { }; } + private detectOwnCheck(announcements: BrainInput['newAnnouncements']): boolean { + const tag = this.color === 'w' ? 'white_in_check' : 'black_in_check'; + return announcements.some((a) => a.text === tag); + } + /** * Run js-chess-engine on the given FEN and return a candidate matching * its choice, or null if no match was found. @@ -113,19 +125,35 @@ export class CasualBrain implements Brain { candidates: CandidateMove[], view: BoardView, ply: number, + inCheck: boolean, ): CandidateMove { + const kingSquare = inCheck ? this.findOwnKing(view) : null; const scored = candidates.map((c) => { let score = this.scoreMove(c, view, ply); if (c.promotion === 'q') score += 1000; else if (c.promotion === 'r') score += 500; else if (c.promotion === 'b') score += 100; else if (c.promotion === 'n') score += 50; + // King moves dominate when in check. The boost is large enough to + // beat any combination of other heuristic factors so the driver + // exhausts king escapes first; if all king moves are rejected the + // attemptHistory exclusion strips them and the bot falls through + // to non-king options (block / capture-attacker guesses). + if (kingSquare && c.from === kingSquare) score += 5000; return { move: c, score: score + this.rng() * 0.01 }; }); scored.sort((a, b) => b.score - a.score); return scored[0]!.move; } + private findOwnKing(view: BoardView): Square | null { + for (const sq of Object.keys(view.pieces) as Square[]) { + const p = view.pieces[sq]; + if (p && p.color === this.color && p.type === 'k') return sq; + } + return null; + } + private excludeRejected( candidates: CandidateMove[], history: BrainInput['attemptHistory'], diff --git a/packages/server/src/bot/driver.ts b/packages/server/src/bot/driver.ts index 3dd8d1e..178650e 100644 --- a/packages/server/src/bot/driver.ts +++ b/packages/server/src/bot/driver.ts @@ -13,7 +13,23 @@ import { buildView } from '../view.js'; import { announce } from '../translator.js'; import { finalizeIfEnded } from '../game-end.js'; -const RETRY_CAP = 5; +// 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; @@ -76,14 +92,23 @@ export class BotDriver { 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 { + } catch (err) { // Brain exception OR programming error in dispatch. Safe failure: resign. - this.botResign(); + this.botResign('brain_threw', { err: errString(err) }); return; } if (outcome.kind === 'done') { @@ -93,7 +118,9 @@ export class BotDriver { attemptHistory.push(outcome.entry); } this.lastSeenAnnouncementCount = this.game.announcements.length; - this.botResign(); + this.botResign('retry_cap_exhausted', { + attempts: attemptHistory.map((a) => `${a.move.from}-${a.move.to}:${a.rejection}`), + }); } private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput { @@ -150,15 +177,18 @@ export class BotDriver { if (result.kind === 'silent') { // Brain sent only `from` (arming). CasualBrain always commits with // `to`; treat as a logic error and resign safely. - this.botResign(); + this.botResign('commit_silent', { from: action.from, to: action.to }); return { kind: 'done' }; } // result.kind === 'error' — bug path; resign. - this.botResign(); + 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(); + this.botResign('brain_chose_resign'); return { kind: 'done' }; case 'offer-draw': if (!this.game.drawOffer) { @@ -185,7 +215,7 @@ export class BotDriver { } } - private botResign(): void { + 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'; @@ -195,6 +225,15 @@ export class BotDriver { 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 { diff --git a/packages/server/test/unit/bot/casual-brain.test.ts b/packages/server/test/unit/bot/casual-brain.test.ts index 3e04aa4..ab196b8 100644 --- a/packages/server/test/unit/bot/casual-brain.test.ts +++ b/packages/server/test/unit/bot/casual-brain.test.ts @@ -117,6 +117,99 @@ describe('CasualBrain', () => { expect(aAct).toEqual(bAct); }); + it('blind mode + own_color_in_check announcement -> prefers king moves over other candidates', async () => { + // The bot only sees its own pieces in blind mode and cannot deduce the + // attacker. Per the AI spec ("Casual never resigns voluntarily"), the + // brain must use the public moderator announcement to bias toward + // check-resolving moves — most commonly, moving the king. Without this + // bias, the heuristic scores capture/advance signals that are uncorrelated + // with check resolution, the FSM rejects every non-resolving move, and + // the driver's retry cap fires => premature resignation. + const view: BoardView = { + pieces: { + e1: { color: 'w', type: 'k' }, + a2: { color: 'w', type: 'p' }, + h2: { color: 'w', type: 'p' }, + b1: { color: 'w', type: 'n' }, + }, + toMove: 'w', + inCheck: true, + }; + const candidates: CandidateMove[] = [ + // king moves (8 possible escape squares; only some are off the board / + // off own-occupied — geometricMoves would have excluded those, but for + // the test we just enumerate a few plausible ones). + { from: 'e1', to: 'd1' }, + { from: 'e1', to: 'f1' }, + { from: 'e1', to: 'd2' }, + { from: 'e1', to: 'e2' }, + { from: 'e1', to: 'f2' }, + // non-king alternatives that the heuristic would otherwise prefer + { from: 'a2', to: 'a4' }, + { from: 'h2', to: 'h4' }, + { from: 'b1', to: 'c3' }, + { from: 'b1', to: 'a3' }, + ]; + let kingHits = 0; + for (let s = 0; s < 20; s++) { + const brain = new CasualBrain({ seed: s }); + await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' }); + const action = await brain.decide({ + view, + newAnnouncements: [ + { text: 'white_in_check', audience: 'both', ply: 10, at: Date.now() }, + ], + legalCandidates: candidates, + attemptHistory: [], + drawOfferFromOpponent: false, + ply: 10, + }); + if (action.type === 'commit' && action.from === 'e1') kingHits++; + } + // Every seed should pick a king move when the boost is large enough to + // dominate the heuristic + tiebreak. + expect(kingHits).toBe(20); + }); + + it('blind mode + own_color_in_check + king moves all rejected -> falls through to non-king', async () => { + // Defensive: if every king move has been tried (knight check forcing + // king moves into other attacks, double check, etc.), the bot should + // still pick *something* from remaining candidates rather than throw. + const view: BoardView = { + pieces: { + e1: { color: 'w', type: 'k' }, + b1: { color: 'w', type: 'n' }, + }, + toMove: 'w', + inCheck: true, + }; + const candidates: CandidateMove[] = [ + { from: 'e1', to: 'd1' }, + { from: 'e1', to: 'e2' }, + { from: 'b1', to: 'c3' }, + ]; + const brain = new CasualBrain({ seed: 1 }); + await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' }); + const action = await brain.decide({ + view, + newAnnouncements: [ + { text: 'white_in_check', audience: 'both', ply: 10, at: Date.now() }, + ], + legalCandidates: candidates, + attemptHistory: [ + { move: { from: 'e1', to: 'd1' }, rejection: 'illegal_move' }, + { move: { from: 'e1', to: 'e2' }, rejection: 'illegal_move' }, + ], + drawOfferFromOpponent: false, + ply: 10, + }); + expect(action.type).toBe('commit'); + if (action.type === 'commit') { + expect(action.from).toBe('b1'); + expect(action.to).toBe('c3'); + } + }); + it('opening moves favor center pawns over flank pawns (e/d > a/h)', async () => { const candidates: CandidateMove[] = [ { from: 'a2', to: 'a3' }, diff --git a/packages/server/test/unit/bot/driver.test.ts b/packages/server/test/unit/bot/driver.test.ts index 2742728..041c7b8 100644 --- a/packages/server/test/unit/bot/driver.test.ts +++ b/packages/server/test/unit/bot/driver.test.ts @@ -115,20 +115,44 @@ describe('BotDriver', () => { expect(game.chess.turn()).toBe('w'); }); - it('retry cap (5): after 5 wont_help, driver resigns the bot', async () => { + 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(); - for (let i = 0; i < 6; i++) { + // 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(5); + expect(brain.decide).toHaveBeenCalledTimes(25); }); it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {