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
@@ -117,6 +117,99 @@ describe('CasualBrain', () => {
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 () => {
const candidates: CandidateMove[] = [
{ from: 'a2', to: 'a3' },