From aa7bc30ee1692c980b08616fe4cb737ac6d2d8b4 Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Tue, 28 Apr 2026 13:48:34 -0400 Subject: [PATCH] feat(bot): CasualBrain with capture/development/center heuristics --- packages/server/src/bot/casual-brain.ts | 143 ++++++++++++++++++ .../server/test/unit/bot/casual-brain.test.ts | 139 +++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 packages/server/src/bot/casual-brain.ts create mode 100644 packages/server/test/unit/bot/casual-brain.test.ts diff --git a/packages/server/src/bot/casual-brain.ts b/packages/server/src/bot/casual-brain.ts new file mode 100644 index 0000000..adcdb85 --- /dev/null +++ b/packages/server/src/bot/casual-brain.ts @@ -0,0 +1,143 @@ +import type { BoardView, Color, PieceType, Square } from '@blind-chess/shared'; +import type { + Brain, + BrainAction, + BrainInitArgs, + BrainInput, + CandidateMove, +} from './brain.js'; + +interface CasualOpts { + seed?: number; +} + +const PIECE_VALUE: Record = { + p: 1, n: 3, b: 3, r: 5, q: 9, k: 0, +}; + +export class CasualBrain implements Brain { + private color: Color = 'w'; + private mode: 'blind' | 'vanilla' = 'blind'; + private rng: () => number; + + constructor(opts: CasualOpts = {}) { + this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff)); + } + + async init(args: BrainInitArgs): Promise { + this.color = args.color; + this.mode = args.mode; + } + + async decide(input: BrainInput): Promise { + if (input.drawOfferFromOpponent) { + return { type: 'respond-draw', accept: this.acceptDraw(input.view) }; + } + + const filtered = this.excludeRejected(input.legalCandidates, input.attemptHistory); + if (filtered.length === 0) { + throw new Error('CasualBrain: zero candidates after exclusion'); + } + + const scored = filtered.map((c) => { + let score = this.scoreMove(c, input.view, input.ply); + // Promotion bias: prefer queen >> rook >> bishop >> knight + // Add before random tiebreak to ensure queen wins when tied. + 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; + return { + move: c, + score: score + this.rng() * 0.01, + }; + }); + scored.sort((a, b) => b.score - a.score); + const choice = scored[0]!.move; + + return { + type: 'commit', + from: choice.from, + to: choice.to, + promotion: choice.promotion, + }; + } + + private excludeRejected( + candidates: CandidateMove[], + history: BrainInput['attemptHistory'], + ): CandidateMove[] { + if (history.length === 0) return candidates; + const rejected = new Set(history.map((h) => moveKey(h.move))); + return candidates.filter((c) => !rejected.has(moveKey(c))); + } + + private scoreMove(move: CandidateMove, view: BoardView, ply: number): number { + let score = 0; + + // Capture proxy: destination not own-occupied. (In view, we only see own + // pieces in blind mode; if dest has a piece it's ours -> not a capture. + // If empty in view, may be empty or opponent — guess.) + const destPiece = view.pieces[move.to]; + if (!destPiece) score += 50; + + const piece = view.pieces[move.from]; + if (!piece) return score; // shouldn't happen, but safe. + + const ownStartingRank = this.color === 'w' ? '1' : '8'; + const ownPawnStartingRank = this.color === 'w' ? '2' : '7'; + + // Development bonus for first 16 plies (8 moves per side). + if (ply < 16 && (piece.type === 'n' || piece.type === 'b') + && move.from[1] === ownStartingRank) { + score += 30; + } + + // Center pawn bonus. + if (piece.type === 'p' && move.from[1] === ownPawnStartingRank) { + const file = move.from[0]; + if (file === 'd' || file === 'e') score += 25; + else if (file === 'c' || file === 'f') score += 10; + } + + // Rank-advance bonus toward opponent. + const fromRank = parseInt(move.from[1]!, 10); + const toRank = parseInt(move.to[1]!, 10); + const advance = this.color === 'w' ? toRank - fromRank : fromRank - toRank; + if (advance > 0) score += 15 * advance; + + // Anti-shuffling: penalize moving major pieces from start before knights/bishops. + if (move.from[1] === ownStartingRank && (piece.type === 'q' || piece.type === 'r')) { + score -= 40; + } + + return score; + } + + private acceptDraw(view: BoardView): boolean { + // Crude material count from own view only. Accept if "low material" + // (assume opponent symmetric). Decline if "high material". + let own = 0; + for (const sq of Object.keys(view.pieces) as Square[]) { + const p = view.pieces[sq]; + if (p) own += PIECE_VALUE[p.type]; + } + return own < 15; + } +} + +function moveKey(m: CandidateMove): string { + return `${m.from}-${m.to}${m.promotion ?? ''}`; +} + +// Mulberry32 PRNG: seedable, fast, good enough for tiebreaks. +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return function () { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/packages/server/test/unit/bot/casual-brain.test.ts b/packages/server/test/unit/bot/casual-brain.test.ts new file mode 100644 index 0000000..3e04aa4 --- /dev/null +++ b/packages/server/test/unit/bot/casual-brain.test.ts @@ -0,0 +1,139 @@ +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('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); + }); +});