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 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { legalCandidates } from './candidates.js';
|
|||||||
import { handleCommit } from '../commit.js';
|
import { handleCommit } from '../commit.js';
|
||||||
import { buildView } from '../view.js';
|
import { buildView } from '../view.js';
|
||||||
import { announce } from '../translator.js';
|
import { announce } from '../translator.js';
|
||||||
|
import { finalizeIfEnded } from '../game-end.js';
|
||||||
|
|
||||||
const RETRY_CAP = 5;
|
const RETRY_CAP = 5;
|
||||||
|
|
||||||
@@ -76,27 +77,31 @@ export class BotDriver {
|
|||||||
|
|
||||||
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
|
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
|
||||||
const input = this.buildBrainInput(attemptHistory);
|
const input = this.buildBrainInput(attemptHistory);
|
||||||
let action: BrainAction;
|
let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry };
|
||||||
try {
|
try {
|
||||||
action = await this.brain.decide(input);
|
const action = await this.brain.decide(input);
|
||||||
|
outcome = this.dispatch(action);
|
||||||
} catch {
|
} catch {
|
||||||
// Brain exception → bot resigns. CasualBrain only throws on zero
|
// Brain exception OR programming error in dispatch. Safe failure: resign.
|
||||||
// candidates (impossible if shouldDecide passed).
|
|
||||||
this.botResign();
|
this.botResign();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (outcome.kind === 'done') {
|
||||||
const outcome = this.dispatch(action);
|
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||||
if (outcome.kind === 'done') return;
|
return;
|
||||||
|
}
|
||||||
attemptHistory.push(outcome.entry);
|
attemptHistory.push(outcome.entry);
|
||||||
}
|
}
|
||||||
|
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
||||||
this.botResign();
|
this.botResign();
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
|
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
|
||||||
const view = buildView(this.game, this.color);
|
const view = buildView(this.game, this.color);
|
||||||
const sliceStart = this.lastSeenAnnouncementCount;
|
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
|
const newAnnouncements = this.game.announcements
|
||||||
.slice(sliceStart)
|
.slice(sliceStart)
|
||||||
.filter((a) => a.audience === 'both' || a.audience === this.color);
|
.filter((a) => a.audience === 'both' || a.audience === this.color);
|
||||||
@@ -121,7 +126,10 @@ export class BotDriver {
|
|||||||
const result = handleCommit(this.game, this.color, {
|
const result = handleCommit(this.game, this.color, {
|
||||||
from: action.from, to: action.to, promotion: action.promotion,
|
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') {
|
if (result.kind === 'announce') {
|
||||||
const text = result.announcements[0]!.text;
|
const text = result.announcements[0]!.text;
|
||||||
if (text === 'wont_help' || text === 'illegal_move'
|
if (text === 'wont_help' || text === 'illegal_move'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { handleCommit } from './commit.js';
|
|||||||
import { announce } from './translator.js';
|
import { announce } from './translator.js';
|
||||||
import { buildView } from './view.js';
|
import { buildView } from './view.js';
|
||||||
import { consumeCommitToken } from './ratelimit.js';
|
import { consumeCommitToken } from './ratelimit.js';
|
||||||
|
import { endGame, finalizeIfEnded } from './game-end.js';
|
||||||
|
|
||||||
interface SocketCtx {
|
interface SocketCtx {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
@@ -231,23 +232,6 @@ function maybeAbandon(game: Game, color: Color): void {
|
|||||||
broadcastNewAnnouncements(game, [a]);
|
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(
|
function broadcastNewAnnouncements(
|
||||||
game: Game,
|
game: Game,
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ describe('BotDriver', () => {
|
|||||||
expect(game.status).toBe('finished');
|
expect(game.status).toBe('finished');
|
||||||
expect(game.endReason).toBe('resign');
|
expect(game.endReason).toBe('resign');
|
||||||
expect(game.winner).toBe('w');
|
expect(game.winner).toBe('w');
|
||||||
|
expect(brain.decide).toHaveBeenCalledTimes(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('respond-draw: when drawOffer is from opponent, driver fires decide and dispatches', async () => {
|
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.decide).not.toHaveBeenCalled();
|
||||||
expect(brain.dispose).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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user