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.
|
||||
}
|
||||
|
||||
// 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'],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user