feat(bot): legalCandidates for vanilla and blind modes
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
geometricMoves,
|
||||||
|
type Color,
|
||||||
|
type Piece,
|
||||||
|
type PieceType,
|
||||||
|
type PromotionType,
|
||||||
|
type Square,
|
||||||
|
} from '@blind-chess/shared';
|
||||||
|
import type { Game } from '../state.js';
|
||||||
|
import { ownSquares } from '../view.js';
|
||||||
|
import type { CandidateMove } from './brain.js';
|
||||||
|
|
||||||
|
const PROMOTION_TYPES: PromotionType[] = ['q', 'r', 'b', 'n'];
|
||||||
|
|
||||||
|
export function legalCandidates(game: Game, color: Color): CandidateMove[] {
|
||||||
|
if (game.mode === 'vanilla') return vanillaCandidates(game, color);
|
||||||
|
return blindCandidates(game, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vanillaCandidates(game: Game, color: Color): CandidateMove[] {
|
||||||
|
// chess.js only returns moves for the side to move via `.moves()`. To get a
|
||||||
|
// hypothetical move list for the other color we'd need to rotate — but the
|
||||||
|
// bot driver only invokes legalCandidates when it's the bot's turn, so this
|
||||||
|
// is fine in practice. Tests for "wrong color" use blind mode.
|
||||||
|
if (game.chess.turn() !== color) return [];
|
||||||
|
|
||||||
|
const moves = game.chess.moves({ verbose: true }) as Array<{
|
||||||
|
from: Square; to: Square; promotion?: PromotionType;
|
||||||
|
}>;
|
||||||
|
const out: CandidateMove[] = [];
|
||||||
|
for (const m of moves) {
|
||||||
|
out.push({ from: m.from, to: m.to, promotion: m.promotion });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blindCandidates(game: Game, color: Color): CandidateMove[] {
|
||||||
|
const own = ownSquares(game, color);
|
||||||
|
const board = game.chess.board();
|
||||||
|
const out: CandidateMove[] = [];
|
||||||
|
|
||||||
|
for (const row of board) {
|
||||||
|
if (!row) continue;
|
||||||
|
for (const cell of row) {
|
||||||
|
if (!cell) continue;
|
||||||
|
if (cell.color !== color) continue;
|
||||||
|
const piece: Piece = { color: cell.color, type: cell.type as PieceType };
|
||||||
|
const from = cell.square as Square;
|
||||||
|
const tos = geometricMoves(piece, from, own);
|
||||||
|
for (const to of tos) {
|
||||||
|
if (isPromotionSquare(piece, to)) {
|
||||||
|
for (const promo of PROMOTION_TYPES) {
|
||||||
|
out.push({ from, to, promotion: promo });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push({ from, to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromotionSquare(piece: Piece, to: Square): boolean {
|
||||||
|
if (piece.type !== 'p') return false;
|
||||||
|
const rank = to[1];
|
||||||
|
return (piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1');
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Chess } from 'chess.js';
|
||||||
|
import { legalCandidates } from '../../../src/bot/candidates.js';
|
||||||
|
import type { Game } from '../../../src/state.js';
|
||||||
|
import { RATE_LIMIT } from '../../../src/state.js';
|
||||||
|
|
||||||
|
function makeGame(mode: 'blind' | 'vanilla', fen?: string): Game {
|
||||||
|
return {
|
||||||
|
id: 'cand0001',
|
||||||
|
mode,
|
||||||
|
highlightingEnabled: false,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
chess: fen ? new Chess(fen) : new Chess(),
|
||||||
|
moveHistory: [],
|
||||||
|
announcements: [],
|
||||||
|
players: {
|
||||||
|
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
|
||||||
|
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||||
|
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
|
||||||
|
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
||||||
|
},
|
||||||
|
armed: null,
|
||||||
|
drawOffer: null,
|
||||||
|
disconnectAt: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('legalCandidates / vanilla', () => {
|
||||||
|
it('starting position: 20 candidates for white', () => {
|
||||||
|
const game = makeGame('vanilla');
|
||||||
|
const candidates = legalCandidates(game, 'w');
|
||||||
|
expect(candidates.length).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns from/to on each candidate', () => {
|
||||||
|
const game = makeGame('vanilla');
|
||||||
|
const candidates = legalCandidates(game, 'w');
|
||||||
|
expect(candidates.every((c) => c.from && c.to)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vanilla excludes pinned-piece moves (chess.js filters self-check)', () => {
|
||||||
|
// White king e1, white bishop e2, black rook e8. Bishop is pinned.
|
||||||
|
const game = makeGame('vanilla', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
|
||||||
|
const candidates = legalCandidates(game, 'w');
|
||||||
|
// Bishop on e2 has zero legal moves (any move drops the king to check).
|
||||||
|
expect(candidates.find((c) => c.from === 'e2')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vanilla expands all 4 promotion options', () => {
|
||||||
|
// White pawn on a7, ready to promote.
|
||||||
|
const game = makeGame('vanilla', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
|
||||||
|
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7');
|
||||||
|
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legalCandidates / blind', () => {
|
||||||
|
it('starting position: 34 geometric candidates for white', () => {
|
||||||
|
// 8 pawns: edge pawns (a2, h2) return 3 moves each (forward 1, forward 2, 1 diagonal),
|
||||||
|
// interior pawns (b2-g2) return 4 moves each (forward 1, forward 2, 2 diagonals).
|
||||||
|
// Total pawn: 2*3 + 6*4 = 30.
|
||||||
|
// 2 knights: 2 moves each = 4.
|
||||||
|
// Total: 34.
|
||||||
|
const game = makeGame('blind');
|
||||||
|
const candidates = legalCandidates(game, 'w');
|
||||||
|
expect(candidates.length).toBe(34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blind INCLUDES pinned-piece moves (geometric does not know about pins)', () => {
|
||||||
|
// Same pinned-bishop position. Geometric move-gen sees no own piece blocking;
|
||||||
|
// bishop can geometrically reach d3, c4, b5, a6, f3, etc.
|
||||||
|
const game = makeGame('blind', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
|
||||||
|
const candidates = legalCandidates(game, 'w');
|
||||||
|
expect(candidates.some((c) => c.from === 'e2')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blind expands all 4 promotion options for own pawn', () => {
|
||||||
|
const game = makeGame('blind', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
|
||||||
|
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7' && c.to === 'a8');
|
||||||
|
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blind ignores whose turn it is (returns moves for either color)', () => {
|
||||||
|
// Vanilla path filters by chess.js .moves() which respects toMove. Blind
|
||||||
|
// path iterates own pieces directly, so black candidates exist on move 0.
|
||||||
|
const game = makeGame('blind');
|
||||||
|
const candidates = legalCandidates(game, 'b');
|
||||||
|
// Same geometric move count as white: 34.
|
||||||
|
expect(candidates.length).toBe(34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zero own pieces = zero candidates (degenerate)', () => {
|
||||||
|
// FEN with only black king + pieces — but FEN must be valid, kings required.
|
||||||
|
const game = makeGame('blind', '4k3/8/8/8/8/8/8/4K3 w - - 0 1');
|
||||||
|
const black = legalCandidates(game, 'b');
|
||||||
|
// Black king on e8 has 5 geometric king moves (d8, f8, d7, e7, f7).
|
||||||
|
expect(black.length).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user