Files
blind_chess/packages/server/test/unit/bot/casual-brain.test.ts
T
2026-04-28 13:48:34 -04:00

140 lines
5.2 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('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);
});
});