feat(bot): CasualBrain with capture/development/center heuristics
This commit is contained in:
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user