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:
claude (blind_chess)
2026-04-29 05:56:02 -04:00
parent 1213ec8fb1
commit dc7f8adcdf
4 changed files with 197 additions and 13 deletions
+30 -2
View File
@@ -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'],
+47 -8
View File
@@ -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<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';
@@ -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<void> {