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 { 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); }); });