import { Game as EngineGame } from 'js-chess-engine'; import type { BoardView, Color, PieceType, PromotionType, Square } from '@blind-chess/shared'; import type { Brain, BrainAction, BrainInitArgs, BrainInput, CandidateMove, } from './brain.js'; interface CasualOpts { seed?: number; /** * Engine difficulty for vanilla mode (1-5; 1 is weakest). * `js-chess-engine` level 1 plays at roughly beginner strength — * crushes random moves but loses to a careful human. Higher levels * raise both strength and per-move latency. */ level?: 1 | 2 | 3 | 4 | 5; } 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 level: 1 | 2 | 3 | 4 | 5; private rng: () => number; constructor(opts: CasualOpts = {}) { this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff)); this.level = opts.level ?? 2; } 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'); } // Vanilla mode: delegate to a real chess engine. The driver supplies // a FEN only in vanilla mode, so this branch is naturally gated. if (this.mode === 'vanilla' && input.fen) { const engineMove = this.engineMove(input.fen, filtered); if (engineMove) { return { type: 'commit', from: engineMove.from, to: engineMove.to, promotion: engineMove.promotion, }; } // Fall through to heuristic if the engine produced something we // can't validate against the candidate list. } // Blind mode (or vanilla fallback): score-based heuristic. const choice = this.heuristicPick(filtered, input.view, input.ply); return { type: 'commit', from: choice.from, to: choice.to, promotion: choice.promotion, }; } /** * Run js-chess-engine on the given FEN and return a candidate matching * its choice, or null if no match was found. */ private engineMove(fen: string, candidates: CandidateMove[]): CandidateMove | null { let result: { move: Record }; try { const g = new EngineGame(fen); // randomness=30 picks among moves within 30 centipawns of best; this // breaks threefold-repetition draws when the bot is clearly winning // but doesn't see the conversion path. result = g.ai({ level: this.level, play: false, randomness: 30 }) as { move: Record }; } catch { return null; } const entry = Object.entries(result.move ?? {})[0]; if (!entry) return null; const [fromUC, toUC] = entry; const from = (fromUC as string).toLowerCase() as Square; const to = (toUC as string).toLowerCase() as Square; // Find a candidate matching this from-to. If the move is a promotion, // js-chess-engine emits the destination square (e.g., {E7: 'E8'}) but // doesn't separately surface the promotion piece — default to queen. const matches = candidates.filter((c) => c.from === from && c.to === to); if (matches.length === 0) return null; const queen = matches.find((c) => c.promotion === 'q'); if (queen) return queen; return matches[0]!; } /** * Score-based fallback used for blind mode and any vanilla case where * the engine's pick wasn't in the candidate list. Plays badly on purpose. */ private heuristicPick( candidates: CandidateMove[], view: BoardView, ply: number, ): CandidateMove { const scored = candidates.map((c) => { let score = this.scoreMove(c, view, ply); 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); return scored[0]!.move; } 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; const destPiece = view.pieces[move.to]; if (!destPiece) score += 50; const piece = view.pieces[move.from]; if (!piece) return score; const ownStartingRank = this.color === 'w' ? '1' : '8'; const ownPawnStartingRank = this.color === 'w' ? '2' : '7'; if (ply < 16 && (piece.type === 'n' || piece.type === 'b') && move.from[1] === ownStartingRank) { score += 30; } 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; } 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; if (move.from[1] === ownStartingRank && (piece.type === 'q' || piece.type === 'r')) { score -= 40; } return score; } private acceptDraw(view: BoardView): boolean { 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 ?? ''}`; } 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; }; }