dc7f8adcdf
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>
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { CasualBrain } from '../../../src/bot/casual-brain.js';
|
|
import type { BrainInput, CandidateMove } from '../../../src/bot/brain.js';
|
|
import type { BoardView } from '@blind-chess/shared';
|
|
|
|
function makeInput(overrides: Partial<BrainInput> = {}): BrainInput {
|
|
const view: BoardView = {
|
|
pieces: { e2: { color: 'w', type: 'p' } },
|
|
toMove: 'w',
|
|
inCheck: false,
|
|
};
|
|
return {
|
|
view,
|
|
newAnnouncements: [],
|
|
legalCandidates: [{ from: 'e2', to: 'e4' }],
|
|
attemptHistory: [],
|
|
drawOfferFromOpponent: false,
|
|
ply: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('CasualBrain', () => {
|
|
it('init() resolves', async () => {
|
|
const brain = new CasualBrain({ seed: 1 });
|
|
await brain.init({ color: 'w', mode: 'blind', gameId: 'casualab' });
|
|
});
|
|
|
|
it('single candidate -> picks it', async () => {
|
|
const brain = new CasualBrain({ seed: 1 });
|
|
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
const action = await brain.decide(makeInput());
|
|
expect(action.type).toBe('commit');
|
|
if (action.type === 'commit') {
|
|
expect(action.from).toBe('e2');
|
|
expect(action.to).toBe('e4');
|
|
}
|
|
});
|
|
|
|
it('zero candidates -> throws', async () => {
|
|
const brain = new CasualBrain({ seed: 1 });
|
|
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
await expect(brain.decide(makeInput({ legalCandidates: [] }))).rejects.toThrow();
|
|
});
|
|
|
|
it('attemptHistory excludes the rejected move', async () => {
|
|
const brain = new CasualBrain({ seed: 1 });
|
|
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
const input = makeInput({
|
|
legalCandidates: [
|
|
{ from: 'e2', to: 'e4' },
|
|
{ from: 'd2', to: 'd4' },
|
|
],
|
|
attemptHistory: [{ move: { from: 'e2', to: 'e4' }, rejection: 'wont_help' }],
|
|
});
|
|
const action = await brain.decide(input);
|
|
expect(action.type).toBe('commit');
|
|
if (action.type === 'commit') {
|
|
expect(action.from).toBe('d2');
|
|
expect(action.to).toBe('d4');
|
|
}
|
|
});
|
|
|
|
it('promotion: when multiple candidates differ only by promotion, picks queen', async () => {
|
|
const brain = new CasualBrain({ seed: 1 });
|
|
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
const candidates: CandidateMove[] = [
|
|
{ from: 'a7', to: 'a8', promotion: 'q' },
|
|
{ from: 'a7', to: 'a8', promotion: 'r' },
|
|
{ from: 'a7', to: 'a8', promotion: 'b' },
|
|
{ from: 'a7', to: 'a8', promotion: 'n' },
|
|
];
|
|
const action = await brain.decide(makeInput({ legalCandidates: candidates }));
|
|
if (action.type === 'commit') expect(action.promotion).toBe('q');
|
|
});
|
|
|
|
it('draw offer at material parity -> accept', async () => {
|
|
const brain = new CasualBrain({ seed: 1 });
|
|
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
// White has 1 king + 1 rook = 5 material; Casual heuristic accepts when own material < 15.
|
|
const view: BoardView = {
|
|
pieces: {
|
|
e1: { color: 'w', type: 'k' },
|
|
a1: { color: 'w', type: 'r' },
|
|
},
|
|
toMove: 'w', inCheck: false,
|
|
};
|
|
const action = await brain.decide({
|
|
view, newAnnouncements: [], legalCandidates: [{ from: 'e1', to: 'e2' }],
|
|
attemptHistory: [], drawOfferFromOpponent: true, ply: 30,
|
|
});
|
|
expect(action.type).toBe('respond-draw');
|
|
if (action.type === 'respond-draw') expect(action.accept).toBe(true);
|
|
});
|
|
|
|
it('never voluntarily offers resign', async () => {
|
|
const brain = new CasualBrain({ seed: 1 });
|
|
await brain.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
for (let i = 0; i < 50; i++) {
|
|
const action = await brain.decide(makeInput({ ply: i }));
|
|
expect(action.type).not.toBe('resign');
|
|
}
|
|
});
|
|
|
|
it('seeded determinism: same seed + same input -> same move', async () => {
|
|
const candidates: CandidateMove[] = [
|
|
{ from: 'e2', to: 'e4' },
|
|
{ from: 'd2', to: 'd4' },
|
|
{ from: 'g1', to: 'f3' },
|
|
];
|
|
const a = new CasualBrain({ seed: 42 });
|
|
await a.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
const b = new CasualBrain({ seed: 42 });
|
|
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
const aAct = await a.decide(makeInput({ legalCandidates: candidates }));
|
|
const bAct = await b.decide(makeInput({ legalCandidates: candidates }));
|
|
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' },
|
|
{ from: 'h2', to: 'h3' },
|
|
{ from: 'e2', to: 'e4' },
|
|
{ from: 'd2', to: 'd4' },
|
|
];
|
|
// Many seeds → assert e2 or d2 wins majority. The score gap (center pawn
|
|
// scores ~25 + 15*2 = 55 over flank pawn ~15*1 = 15) is well over the
|
|
// 0.01 random tiebreak, so center should win nearly always.
|
|
let centerHits = 0;
|
|
for (let s = 0; s < 20; s++) {
|
|
const b = new CasualBrain({ seed: s });
|
|
await b.init({ color: 'w', mode: 'blind', gameId: 'g1' });
|
|
const a = await b.decide(makeInput({ legalCandidates: candidates, ply: 0 }));
|
|
if (a.type === 'commit' && (a.from === 'e2' || a.from === 'd2')) centerHits++;
|
|
}
|
|
expect(centerHits).toBeGreaterThan(15);
|
|
});
|
|
});
|