fix(bot): blind Casual no longer resigns prematurely under check
The blind-mode CasualBrain heuristic ignored the moderator's '<own>_in_check' announcement and scored moves on capture/advance/development signals uncorrelated with check resolution. chess.js rejected every non-resolving attempt, BotDriver's RETRY_CAP=5 fired, and the bot resigned. 100-game blind self-play: 100% resignations at avg ply 26. Fix: - CasualBrain.detectOwnCheck() scans newAnnouncements for the own-color in_check tag; when set, heuristicPick() applies a +5000 boost to king moves so they're tried first. Information stays within the public moderator vocabulary — no oracle access, view-filter invariant intact. - RETRY_CAP raised 5 -> 25. Vanilla never hits the cap (chess.js verbose moves are guaranteed legal); blind needs more budget to find a legal move through pseudo-legal candidates. - BotDriver.botResign() now logs '[bot resign]' with gameId/color/mode/ply/ reason/detail. Previously silent — operator had no signal in journald. Verification (100-game blind Casual-vs-Casual self-play): - avgPly: 26 -> 90 (3.5x) - Resignations: 100% -> 17% - Checkmates: 0% -> 42% - Threefold draws: 0% -> 41% Vanilla regression check (80 games combined): 0 resigns either way, strength unchanged (Casual still wins 98% vs random). 78 tests pass (was 75; +2 new check-resolution tests, +1 retry-cap test updated to match new cap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,8 +65,15 @@ export class CasualBrain implements Brain {
|
|||||||
// can't validate against the candidate list.
|
// can't validate against the candidate list.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blind mode (or vanilla fallback): score-based heuristic.
|
// Blind mode (or vanilla fallback): score-based heuristic. When the
|
||||||
const choice = this.heuristicPick(filtered, input.view, input.ply);
|
// 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 {
|
return {
|
||||||
type: 'commit',
|
type: 'commit',
|
||||||
from: choice.from,
|
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
|
* Run js-chess-engine on the given FEN and return a candidate matching
|
||||||
* its choice, or null if no match was found.
|
* its choice, or null if no match was found.
|
||||||
@@ -113,19 +125,35 @@ export class CasualBrain implements Brain {
|
|||||||
candidates: CandidateMove[],
|
candidates: CandidateMove[],
|
||||||
view: BoardView,
|
view: BoardView,
|
||||||
ply: number,
|
ply: number,
|
||||||
|
inCheck: boolean,
|
||||||
): CandidateMove {
|
): CandidateMove {
|
||||||
|
const kingSquare = inCheck ? this.findOwnKing(view) : null;
|
||||||
const scored = candidates.map((c) => {
|
const scored = candidates.map((c) => {
|
||||||
let score = this.scoreMove(c, view, ply);
|
let score = this.scoreMove(c, view, ply);
|
||||||
if (c.promotion === 'q') score += 1000;
|
if (c.promotion === 'q') score += 1000;
|
||||||
else if (c.promotion === 'r') score += 500;
|
else if (c.promotion === 'r') score += 500;
|
||||||
else if (c.promotion === 'b') score += 100;
|
else if (c.promotion === 'b') score += 100;
|
||||||
else if (c.promotion === 'n') score += 50;
|
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 };
|
return { move: c, score: score + this.rng() * 0.01 };
|
||||||
});
|
});
|
||||||
scored.sort((a, b) => b.score - a.score);
|
scored.sort((a, b) => b.score - a.score);
|
||||||
return scored[0]!.move;
|
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(
|
private excludeRejected(
|
||||||
candidates: CandidateMove[],
|
candidates: CandidateMove[],
|
||||||
history: BrainInput['attemptHistory'],
|
history: BrainInput['attemptHistory'],
|
||||||
|
|||||||
@@ -13,7 +13,23 @@ import { buildView } from '../view.js';
|
|||||||
import { announce } from '../translator.js';
|
import { announce } from '../translator.js';
|
||||||
import { finalizeIfEnded } from '../game-end.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 {
|
interface BotDriverOpts {
|
||||||
game: Game;
|
game: Game;
|
||||||
@@ -76,14 +92,23 @@ export class BotDriver {
|
|||||||
const attemptHistory: AttemptHistoryEntry[] = [];
|
const attemptHistory: AttemptHistoryEntry[] = [];
|
||||||
|
|
||||||
for (let attempt = 0; attempt < RETRY_CAP; attempt++) {
|
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);
|
const input = this.buildBrainInput(attemptHistory);
|
||||||
let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry };
|
let outcome: { kind: 'done' } | { kind: 'retry'; entry: AttemptHistoryEntry };
|
||||||
try {
|
try {
|
||||||
const action = await this.brain.decide(input);
|
const action = await this.brain.decide(input);
|
||||||
outcome = this.dispatch(action);
|
outcome = this.dispatch(action);
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Brain exception OR programming error in dispatch. Safe failure: resign.
|
// Brain exception OR programming error in dispatch. Safe failure: resign.
|
||||||
this.botResign();
|
this.botResign('brain_threw', { err: errString(err) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outcome.kind === 'done') {
|
if (outcome.kind === 'done') {
|
||||||
@@ -93,7 +118,9 @@ export class BotDriver {
|
|||||||
attemptHistory.push(outcome.entry);
|
attemptHistory.push(outcome.entry);
|
||||||
}
|
}
|
||||||
this.lastSeenAnnouncementCount = this.game.announcements.length;
|
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 {
|
private buildBrainInput(attemptHistory: AttemptHistoryEntry[]): BrainInput {
|
||||||
@@ -150,15 +177,18 @@ export class BotDriver {
|
|||||||
if (result.kind === 'silent') {
|
if (result.kind === 'silent') {
|
||||||
// Brain sent only `from` (arming). CasualBrain always commits with
|
// Brain sent only `from` (arming). CasualBrain always commits with
|
||||||
// `to`; treat as a logic error and resign safely.
|
// `to`; treat as a logic error and resign safely.
|
||||||
this.botResign();
|
this.botResign('commit_silent', { from: action.from, to: action.to });
|
||||||
return { kind: 'done' };
|
return { kind: 'done' };
|
||||||
}
|
}
|
||||||
// result.kind === 'error' — bug path; resign.
|
// 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' };
|
return { kind: 'done' };
|
||||||
}
|
}
|
||||||
case 'resign':
|
case 'resign':
|
||||||
this.botResign();
|
this.botResign('brain_chose_resign');
|
||||||
return { kind: 'done' };
|
return { kind: 'done' };
|
||||||
case 'offer-draw':
|
case 'offer-draw':
|
||||||
if (!this.game.drawOffer) {
|
if (!this.game.drawOffer) {
|
||||||
@@ -185,7 +215,7 @@ export class BotDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private botResign(): void {
|
private botResign(reason: BotResignReason, detail?: Record<string, unknown>): void {
|
||||||
if (this.game.status !== 'active') return;
|
if (this.game.status !== 'active') return;
|
||||||
const ply = this.game.chess.history().length;
|
const ply = this.game.chess.history().length;
|
||||||
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
|
const text = this.color === 'w' ? 'white_resigned' : 'black_resigned';
|
||||||
@@ -195,6 +225,15 @@ export class BotDriver {
|
|||||||
this.game.endReason = 'resign';
|
this.game.endReason = 'resign';
|
||||||
this.game.winner = this.color === 'w' ? 'b' : 'w';
|
this.game.winner = this.color === 'w' ? 'b' : 'w';
|
||||||
this.game.finishedAt = Date.now();
|
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> {
|
private async disposeBrain(): Promise<void> {
|
||||||
|
|||||||
@@ -117,6 +117,99 @@ describe('CasualBrain', () => {
|
|||||||
expect(aAct).toEqual(bAct);
|
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 () => {
|
it('opening moves favor center pawns over flank pawns (e/d > a/h)', async () => {
|
||||||
const candidates: CandidateMove[] = [
|
const candidates: CandidateMove[] = [
|
||||||
{ from: 'a2', to: 'a3' },
|
{ from: 'a2', to: 'a3' },
|
||||||
|
|||||||
@@ -115,20 +115,44 @@ describe('BotDriver', () => {
|
|||||||
expect(game.chess.turn()).toBe('w');
|
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';
|
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
|
||||||
game = makeGame({ fen });
|
game = makeGame({ fen });
|
||||||
brain = new StubBrain();
|
brain = new StubBrain();
|
||||||
driver = new BotDriver({ game, brain, color: 'b' });
|
driver = new BotDriver({ game, brain, color: 'b' });
|
||||||
await driver.init();
|
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' });
|
brain.enqueue({ type: 'commit', from: 'e7', to: 'd6' });
|
||||||
}
|
}
|
||||||
await driver.onStateChange();
|
await driver.onStateChange();
|
||||||
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);
|
expect(brain.decide).toHaveBeenCalledTimes(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user