feat(bot): vanilla CasualBrain delegates to js-chess-engine
The hand-rolled scoring heuristic lost to a random-move baseline 7-7 in
self-play — far below the spec's >=80% acceptance bar. Swap in a real
chess engine (js-chess-engine, MIT, ~400KB, no native deps) for vanilla
mode at level 2 with randomness=30 to break threefold cycles.
- BrainInput.fen added; driver populates it ONLY in vanilla mode.
Blind mode omits the FEN so the engine path can't smuggle opponent
positions past the view filter.
- CasualBrain in vanilla: convert FEN -> EngineGame -> ai({level: 2});
validate the engine's move is in legalCandidates; fall back to
heuristic on miss.
- Blind mode unchanged (engine isn't useful when only own pieces are
visible — that's Phase 2 Recon's territory).
Self-play vs RandomBrain (100 games each direction, vanilla):
- Casual(W) vs Random(B): W=97%
- Random(W) vs Casual(B): B=96%
Casual-vs-Casual vanilla balanced, ~5-30ms/move. All 54 tests still pass.
Refresh .secrets.baseline (stale) to allow new pnpm-lock.yaml hashes.
This commit is contained in:
+337
-4373
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
"chess.js": "^1.4.0",
|
"chess.js": "^1.4.0",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
|
"js-chess-engine": "^2.4.6",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export interface BrainInput {
|
|||||||
attemptHistory: AttemptHistoryEntry[];
|
attemptHistory: AttemptHistoryEntry[];
|
||||||
drawOfferFromOpponent: boolean;
|
drawOfferFromOpponent: boolean;
|
||||||
ply: number;
|
ply: number;
|
||||||
|
/**
|
||||||
|
* Full position FEN. Set only in vanilla mode where `view` is already a
|
||||||
|
* full reveal — omitted in blind mode, since passing the FEN there would
|
||||||
|
* leak opponent positions and violate the view-filter invariant. Brains
|
||||||
|
* with an internal chess engine rely on this; brains that don't can
|
||||||
|
* ignore it.
|
||||||
|
*/
|
||||||
|
fen?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrainAction =
|
export type BrainAction =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BoardView, Color, PieceType, Square } from '@blind-chess/shared';
|
import { Game as EngineGame } from 'js-chess-engine';
|
||||||
|
import type { BoardView, Color, PieceType, PromotionType, Square } from '@blind-chess/shared';
|
||||||
import type {
|
import type {
|
||||||
Brain,
|
Brain,
|
||||||
BrainAction,
|
BrainAction,
|
||||||
@@ -9,6 +10,13 @@ import type {
|
|||||||
|
|
||||||
interface CasualOpts {
|
interface CasualOpts {
|
||||||
seed?: number;
|
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<PieceType, number> = {
|
const PIECE_VALUE: Record<PieceType, number> = {
|
||||||
@@ -18,10 +26,12 @@ const PIECE_VALUE: Record<PieceType, number> = {
|
|||||||
export class CasualBrain implements Brain {
|
export class CasualBrain implements Brain {
|
||||||
private color: Color = 'w';
|
private color: Color = 'w';
|
||||||
private mode: 'blind' | 'vanilla' = 'blind';
|
private mode: 'blind' | 'vanilla' = 'blind';
|
||||||
|
private level: 1 | 2 | 3 | 4 | 5;
|
||||||
private rng: () => number;
|
private rng: () => number;
|
||||||
|
|
||||||
constructor(opts: CasualOpts = {}) {
|
constructor(opts: CasualOpts = {}) {
|
||||||
this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff));
|
this.rng = mulberry32(opts.seed ?? Math.floor(Math.random() * 0xffffffff));
|
||||||
|
this.level = opts.level ?? 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(args: BrainInitArgs): Promise<void> {
|
async init(args: BrainInitArgs): Promise<void> {
|
||||||
@@ -39,22 +49,24 @@ export class CasualBrain implements Brain {
|
|||||||
throw new Error('CasualBrain: zero candidates after exclusion');
|
throw new Error('CasualBrain: zero candidates after exclusion');
|
||||||
}
|
}
|
||||||
|
|
||||||
const scored = filtered.map((c) => {
|
// Vanilla mode: delegate to a real chess engine. The driver supplies
|
||||||
let score = this.scoreMove(c, input.view, input.ply);
|
// a FEN only in vanilla mode, so this branch is naturally gated.
|
||||||
// Promotion bias: prefer queen >> rook >> bishop >> knight
|
if (this.mode === 'vanilla' && input.fen) {
|
||||||
// Add before random tiebreak to ensure queen wins when tied.
|
const engineMove = this.engineMove(input.fen, filtered);
|
||||||
if (c.promotion === 'q') score += 1000;
|
if (engineMove) {
|
||||||
else if (c.promotion === 'r') score += 500;
|
return {
|
||||||
else if (c.promotion === 'b') score += 100;
|
type: 'commit',
|
||||||
else if (c.promotion === 'n') score += 50;
|
from: engineMove.from,
|
||||||
return {
|
to: engineMove.to,
|
||||||
move: c,
|
promotion: engineMove.promotion,
|
||||||
score: score + this.rng() * 0.01,
|
};
|
||||||
};
|
}
|
||||||
});
|
// Fall through to heuristic if the engine produced something we
|
||||||
scored.sort((a, b) => b.score - a.score);
|
// can't validate against the candidate list.
|
||||||
const choice = scored[0]!.move;
|
}
|
||||||
|
|
||||||
|
// Blind mode (or vanilla fallback): score-based heuristic.
|
||||||
|
const choice = this.heuristicPick(filtered, input.view, input.ply);
|
||||||
return {
|
return {
|
||||||
type: 'commit',
|
type: 'commit',
|
||||||
from: choice.from,
|
from: choice.from,
|
||||||
@@ -63,6 +75,57 @@ export class CasualBrain implements Brain {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, string> };
|
||||||
|
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<string, string> };
|
||||||
|
} 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(
|
private excludeRejected(
|
||||||
candidates: CandidateMove[],
|
candidates: CandidateMove[],
|
||||||
history: BrainInput['attemptHistory'],
|
history: BrainInput['attemptHistory'],
|
||||||
@@ -74,43 +137,31 @@ export class CasualBrain implements Brain {
|
|||||||
|
|
||||||
private scoreMove(move: CandidateMove, view: BoardView, ply: number): number {
|
private scoreMove(move: CandidateMove, view: BoardView, ply: number): number {
|
||||||
let score = 0;
|
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];
|
const destPiece = view.pieces[move.to];
|
||||||
if (!destPiece) score += 50;
|
if (!destPiece) score += 50;
|
||||||
|
|
||||||
const piece = view.pieces[move.from];
|
const piece = view.pieces[move.from];
|
||||||
// Reachable when scoring tests pass minimal fixture views. In real play
|
|
||||||
// the candidate's `from` is always one of our pieces (since candidates
|
|
||||||
// came from `legalCandidates` over our own squares), but the early
|
|
||||||
// return keeps unit tests from needing full board fixtures.
|
|
||||||
if (!piece) return score;
|
if (!piece) return score;
|
||||||
|
|
||||||
const ownStartingRank = this.color === 'w' ? '1' : '8';
|
const ownStartingRank = this.color === 'w' ? '1' : '8';
|
||||||
const ownPawnStartingRank = this.color === 'w' ? '2' : '7';
|
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')
|
if (ply < 16 && (piece.type === 'n' || piece.type === 'b')
|
||||||
&& move.from[1] === ownStartingRank) {
|
&& move.from[1] === ownStartingRank) {
|
||||||
score += 30;
|
score += 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Center pawn bonus.
|
|
||||||
if (piece.type === 'p' && move.from[1] === ownPawnStartingRank) {
|
if (piece.type === 'p' && move.from[1] === ownPawnStartingRank) {
|
||||||
const file = move.from[0];
|
const file = move.from[0];
|
||||||
if (file === 'd' || file === 'e') score += 25;
|
if (file === 'd' || file === 'e') score += 25;
|
||||||
else if (file === 'c' || file === 'f') score += 10;
|
else if (file === 'c' || file === 'f') score += 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rank-advance bonus toward opponent.
|
|
||||||
const fromRank = parseInt(move.from[1]!, 10);
|
const fromRank = parseInt(move.from[1]!, 10);
|
||||||
const toRank = parseInt(move.to[1]!, 10);
|
const toRank = parseInt(move.to[1]!, 10);
|
||||||
const advance = this.color === 'w' ? toRank - fromRank : fromRank - toRank;
|
const advance = this.color === 'w' ? toRank - fromRank : fromRank - toRank;
|
||||||
if (advance > 0) score += 15 * advance;
|
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')) {
|
if (move.from[1] === ownStartingRank && (piece.type === 'q' || piece.type === 'r')) {
|
||||||
score -= 40;
|
score -= 40;
|
||||||
}
|
}
|
||||||
@@ -119,8 +170,6 @@ export class CasualBrain implements Brain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private acceptDraw(view: BoardView): boolean {
|
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;
|
let own = 0;
|
||||||
for (const sq of Object.keys(view.pieces) as Square[]) {
|
for (const sq of Object.keys(view.pieces) as Square[]) {
|
||||||
const p = view.pieces[sq];
|
const p = view.pieces[sq];
|
||||||
@@ -134,7 +183,6 @@ function moveKey(m: CandidateMove): string {
|
|||||||
return `${m.from}-${m.to}${m.promotion ?? ''}`;
|
return `${m.from}-${m.to}${m.promotion ?? ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mulberry32 PRNG: seedable, fast, good enough for tiebreaks.
|
|
||||||
function mulberry32(seed: number): () => number {
|
function mulberry32(seed: number): () => number {
|
||||||
let a = seed >>> 0;
|
let a = seed >>> 0;
|
||||||
return function () {
|
return function () {
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ export class BotDriver {
|
|||||||
attemptHistory,
|
attemptHistory,
|
||||||
drawOfferFromOpponent: !!(this.game.drawOffer && this.game.drawOffer.from !== this.color),
|
drawOfferFromOpponent: !!(this.game.drawOffer && this.game.drawOffer.from !== this.color),
|
||||||
ply: this.game.chess.history().length,
|
ply: this.game.chess.history().length,
|
||||||
|
// Vanilla mode: full reveal, FEN exposes nothing the brain can't already
|
||||||
|
// see. Blind mode: omit FEN so the engine path can't smuggle opponent
|
||||||
|
// positions past the view filter.
|
||||||
|
fen: this.game.mode === 'vanilla' ? this.game.chess.fen() : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+9
@@ -57,6 +57,9 @@ importers:
|
|||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.8.5
|
version: 5.8.5
|
||||||
|
js-chess-engine:
|
||||||
|
specifier: ^2.4.6
|
||||||
|
version: 2.4.6
|
||||||
pino:
|
pino:
|
||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.14.0
|
version: 9.14.0
|
||||||
@@ -936,6 +939,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
js-chess-engine@2.4.6:
|
||||||
|
resolution: {integrity: sha512-OKvWKICifXLjUilGzT5RstUv9iGpk04PjGpTyVT0lMlxX2HptoXZ2Q9hNicidnYjFcR7FHpnXFVwreDSF6a5Ng==}
|
||||||
|
engines: {node: '>=24'}
|
||||||
|
|
||||||
js-tokens@9.0.1:
|
js-tokens@9.0.1:
|
||||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||||
|
|
||||||
@@ -2078,6 +2085,8 @@ snapshots:
|
|||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
|
js-chess-engine@2.4.6: {}
|
||||||
|
|
||||||
js-tokens@9.0.1: {}
|
js-tokens@9.0.1: {}
|
||||||
|
|
||||||
json-schema-ref-resolver@3.0.0:
|
json-schema-ref-resolver@3.0.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user