From f48e0a9cdf3011e69d9f0045a87aad7643d64b61 Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Tue, 28 Apr 2026 13:42:37 -0400 Subject: [PATCH] feat(bot): legalCandidates for vanilla and blind modes --- packages/server/src/bot/candidates.ts | 68 ++++++++++++ .../server/test/unit/bot/candidates.test.ts | 100 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 packages/server/src/bot/candidates.ts create mode 100644 packages/server/test/unit/bot/candidates.test.ts diff --git a/packages/server/src/bot/candidates.ts b/packages/server/src/bot/candidates.ts new file mode 100644 index 0000000..9e7fe69 --- /dev/null +++ b/packages/server/src/bot/candidates.ts @@ -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'); +} diff --git a/packages/server/test/unit/bot/candidates.test.ts b/packages/server/test/unit/bot/candidates.test.ts new file mode 100644 index 0000000..1a85e4f --- /dev/null +++ b/packages/server/test/unit/bot/candidates.test.ts @@ -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); + }); +});