From 4407110147b294d98acf91adf6db5fc7bf6f5299 Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Tue, 28 Apr 2026 14:04:22 -0400 Subject: [PATCH] fix(bot): finalize game on bot checkmate; harden driver dispatch Extract endGame/finalizeIfEnded to game-end.ts so driver.ts can call finalizeIfEnded after an applied move (fix: bot checkmate was not setting game.status='finished'). Wrap entire dispatch() call in try/catch for exception safety. Move lastSeenAnnouncementCount advance to after successful dispatch so retry attempts see FSM rejection announcements. Add checkmate-finalize test; lock retry-cap at 5 calls. Co-Authored-By: Claude Sonnet 4.6 --- packages/server/src/bot/driver.ts | 26 +++++++++++++------- packages/server/src/game-end.ts | 20 +++++++++++++++ packages/server/src/ws.ts | 18 +------------- packages/server/test/unit/bot/driver.test.ts | 21 ++++++++++++++++ 4 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 packages/server/src/game-end.ts diff --git a/packages/server/src/bot/driver.ts b/packages/server/src/bot/driver.ts index e447542..b598fe2 100644 --- a/packages/server/src/bot/driver.ts +++ b/packages/server/src/bot/driver.ts @@ -11,6 +11,7 @@ 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'; const RETRY_CAP = 5; @@ -76,27 +77,31 @@ export class BotDriver { for (let attempt = 0; attempt < RETRY_CAP; attempt++) { const input = this.buildBrainInput(attemptHistory); - let action: BrainAction; + let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry }; try { - action = await this.brain.decide(input); + const action = await this.brain.decide(input); + outcome = this.dispatch(action); } catch { - // Brain exception → bot resigns. CasualBrain only throws on zero - // candidates (impossible if shouldDecide passed). + // Brain exception OR programming error in dispatch. Safe failure: resign. this.botResign(); return; } - - const outcome = this.dispatch(action); - if (outcome.kind === 'done') return; + if (outcome.kind === 'done') { + this.lastSeenAnnouncementCount = this.game.announcements.length; + return; + } attemptHistory.push(outcome.entry); } + this.lastSeenAnnouncementCount = this.game.announcements.length; this.botResign(); } private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput { const view = buildView(this.game, this.color); const sliceStart = this.lastSeenAnnouncementCount; - this.lastSeenAnnouncementCount = this.game.announcements.length; + // 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); @@ -121,7 +126,10 @@ export class BotDriver { 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 === 'applied') { + finalizeIfEnded(this.game, result.announcements); + return { kind: 'done' }; + } if (result.kind === 'announce') { const text = result.announcements[0]!.text; if (text === 'wont_help' || text === 'illegal_move' diff --git a/packages/server/src/game-end.ts b/packages/server/src/game-end.ts new file mode 100644 index 0000000..6405a8e --- /dev/null +++ b/packages/server/src/game-end.ts @@ -0,0 +1,20 @@ +import type { Color } from '@blind-chess/shared'; +import type { Game } from './state.js'; + +export function endGame(game: Game, reason: Game['endReason'], winner: Color | null): void { + game.status = 'finished'; + game.endReason = reason; + game.winner = winner; + game.finishedAt = Date.now(); +} + +export function finalizeIfEnded(game: Game, announcements: ReadonlyArray<{ text: string }>): void { + // Detect terminal moderator announcements. + const lastTexts = new Set(announcements.map((a) => a.text)); + if (lastTexts.has('white_checkmate')) endGame(game, 'checkmate', 'w'); + else if (lastTexts.has('black_checkmate')) endGame(game, 'checkmate', 'b'); + else if (lastTexts.has('stalemate')) endGame(game, 'stalemate', null); + else if (lastTexts.has('draw_insufficient')) endGame(game, 'insufficient', null); + else if (lastTexts.has('draw_threefold')) endGame(game, 'threefold', null); + else if (lastTexts.has('draw_fifty')) endGame(game, 'fifty_move', null); +} diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index 258f678..af391d3 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -17,6 +17,7 @@ import { handleCommit } from './commit.js'; import { announce } from './translator.js'; import { buildView } from './view.js'; import { consumeCommitToken } from './ratelimit.js'; +import { endGame, finalizeIfEnded } from './game-end.js'; interface SocketCtx { socket: WebSocket; @@ -231,23 +232,6 @@ function maybeAbandon(game: Game, color: Color): void { broadcastNewAnnouncements(game, [a]); } -function endGame(game: Game, reason: Game['endReason'], winner: Color | null): void { - game.status = 'finished'; - game.endReason = reason; - game.winner = winner; - game.finishedAt = Date.now(); -} - -function finalizeIfEnded(game: Game, announcements: ReadonlyArray<{ text: string }>): void { - // Detect terminal moderator announcements. - const lastTexts = new Set(announcements.map((a) => a.text)); - if (lastTexts.has('white_checkmate')) endGame(game, 'checkmate', 'w'); - else if (lastTexts.has('black_checkmate')) endGame(game, 'checkmate', 'b'); - else if (lastTexts.has('stalemate')) endGame(game, 'stalemate', null); - else if (lastTexts.has('draw_insufficient')) endGame(game, 'insufficient', null); - else if (lastTexts.has('draw_threefold')) endGame(game, 'threefold', null); - else if (lastTexts.has('draw_fifty')) endGame(game, 'fifty_move', null); -} function broadcastNewAnnouncements( game: Game, diff --git a/packages/server/test/unit/bot/driver.test.ts b/packages/server/test/unit/bot/driver.test.ts index df27bcf..63c31f8 100644 --- a/packages/server/test/unit/bot/driver.test.ts +++ b/packages/server/test/unit/bot/driver.test.ts @@ -127,6 +127,7 @@ describe('BotDriver', () => { expect(game.status).toBe('finished'); expect(game.endReason).toBe('resign'); expect(game.winner).toBe('w'); + expect(brain.decide).toHaveBeenCalledTimes(5); }); it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => { @@ -145,4 +146,24 @@ describe('BotDriver', () => { expect(brain.decide).not.toHaveBeenCalled(); expect(brain.dispose).toHaveBeenCalled(); }); + + 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'); + }); });