feat(bot): CasualBrain with capture/development/center heuristics

This commit is contained in:
claude (blind_chess)
2026-04-28 13:48:34 -04:00
parent f48e0a9cdf
commit aa7bc30ee1
2 changed files with 282 additions and 0 deletions
+143
View File
@@ -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<PieceType, number> = {
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<void> {
this.color = args.color;
this.mode = args.mode;
}
async decide(input: BrainInput): Promise<BrainAction> {
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;
};
}