M9: 체스 자체 수 생성기(체스960 포함), perft·chess.js 비교 테스트

- movegen.ts: 0x88 생성기, 캐슬링 권리를 룩 칸으로 저장(standard·960 공용), Shredder-FEN/X-FEN
- chess.js는 테스트 전용 devDependency로 이동(perft·무작위 대국 비교)
- variant 옵션(일반/체스960), chess960Index, stateVersion 2 + v1 마이그레이션
- 960 캐슬링 규칙·입력(킹→룩 칸, 킹→g/c칸)·무작위 대국 테스트

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 13:10:51 +09:00
parent c27cb0a11a
commit 61b7c4e326
7 changed files with 1157 additions and 92 deletions

View File

@@ -1,20 +1,27 @@
/**
* chess.js adapter (docs/games/chess.md §8.0). Move generation/validation comes from chess.js (BSD-2-Clause,
* Copyright (c) 2025 Jeff Hlywa); position keys, draw rules, insufficient material and the FIDE 6.9
* time-out exception are decided here with our own rules.
* Chess rules adapter (docs/games/chess.md §8.0). Move generation/validation comes from our own generator
* (`movegen.ts`, standard + Chess960, perft-verified); position keys, draw rules, insufficient material and the
* FIDE 6.9 time-out exception are decided here with our own rules.
*/
import { Chess, DEFAULT_POSITION, type Move, type PieceSymbol, type Square } from 'chess.js';
import { Position, chess960Fen, moveInfo, type Move, type PieceType } from './movegen';
export { Position, chess960BackRank, chess960Fen } from './movegen';
export type Color = 'white' | 'black';
export type Promo = 'q' | 'r' | 'b' | 'n';
export const START_FEN = DEFAULT_POSITION;
export const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
/** Chess960 number of the standard set-up (docs/games/chess.md §4). */
export const STANDARD_960_INDEX = 518;
export const toSide = (c: Color): 'w' | 'b' => (c === 'white' ? 'w' : 'b');
export const fromSide = (s: 'w' | 'b'): Color => (s === 'w' ? 'white' : 'black');
export const other = (c: Color): Color => (c === 'white' ? 'black' : 'white');
export const PIECE_VALUE: Record<PieceSymbol, number> = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 };
export const PIECE_VALUE: Record<PieceType, number> = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 };
/** One entry per (from, to); promotions are a single entry with `promotion: true` (client shows a picker). */
/**
* One entry per (from, to); promotions are a single entry with `promotion: true` (client shows a picker).
* Castling: standard lists the king's two-step (e1→g1). Chess960 lists the king onto its castling rook and, when that
* is not also a plain king move, onto its destination square (both entries carry `castle`).
*/
export interface LegalMove {
from: string;
to: string;
@@ -22,63 +29,79 @@ export interface LegalMove {
castle?: 'king' | 'queen';
}
export const isSquare = (s: string): s is Square => /^[a-h][1-8]$/.test(s);
export const isSquare = (s: string): boolean => /^[a-h][1-8]$/.test(s);
export function load(fen: string): Chess {
return new Chess(fen);
/** Load a FEN. Chess960 is recognised by its Shredder-FEN castling letters unless `chess960` is given. */
export function load(fen: string, chess960?: boolean): Position {
return Position.fromFen(fen, chess960);
}
/** Start FEN for a variant: standard, or the Chess960 position with that Scharnagl number. */
export const startFenFor = (chess960Index: number | null): string => (chess960Index === null ? START_FEN : chess960Fen(chess960Index));
/**
* Repetition key: placement, side to move, castling rights, en-passant square. chess.js's fen() already prints
* the e.p. square only when a legal e.p. capture exists, which is exactly the FIDE 9.2 normalisation.
* Repetition key: placement, side to move, castling rights, en-passant square. `fen()` prints the e.p. square only
* when a legal e.p. capture exists, which is exactly the FIDE 9.2 normalisation.
*/
export function positionKey(fen: string): string {
return fen.split(' ').slice(0, 4).join(' ');
}
export const keyOf = (c: Chess): string => positionKey(c.fen());
export const keyOf = (c: Position): string => positionKey(c.fen());
/** Stored FEN keeps the e.p. square whenever a double step just happened (for accurate rejection messages). */
export const fullFen = (c: Chess): string => c.fen({ forceEnpassantSquare: true });
export const fullFen = (c: Position): string => c.fen({ forceEnpassantSquare: true });
export const halfmoveClock = (fen: string): number => Number(fen.split(' ')[4] ?? 0);
export const turnOf = (fen: string): Color => (fen.split(' ')[1] === 'b' ? 'black' : 'white');
function toLegal(m: Move): LegalMove {
const out: LegalMove = { from: m.from, to: m.to };
if (m.isPromotion()) out.promotion = true;
if (m.isKingsideCastle()) out.castle = 'king';
if (m.isQueensideCastle()) out.castle = 'queen';
return out;
}
/** Legal moves for the side to move (optionally from one square), promotions collapsed. */
export function legalMoves(c: Chess, square?: Square): LegalMove[] {
const raw = square ? c.moves({ square, verbose: true }) : c.moves({ verbose: true });
export function legalMoves(c: Position, square?: string): LegalMove[] {
const out: LegalMove[] = [];
const seen = new Set<string>();
for (const m of raw) {
const push = (m: LegalMove) => {
const k = m.from + m.to;
if (seen.has(k)) continue;
if (seen.has(k)) return;
seen.add(k);
out.push(toLegal(m));
out.push(m);
};
const all = c.moves();
const castles: Move[] = [];
for (const m of all) {
const from = moveInfo.from(m);
if (square && from !== square) continue;
const castle = moveInfo.isCastle(m);
if (castle) castles.push(m);
else push(m.promo ? { from, to: moveInfo.to(m), promotion: true } : { from, to: moveInfo.to(m) });
}
for (const m of castles) {
const castle = moveInfo.isCastle(m)!;
const from = moveInfo.from(m);
const to = moveInfo.to(m);
if (!c.chess960) {
push({ from, to, castle });
continue;
}
push({ from, to: moveInfo.rook(m), castle });
if (to !== from && !seen.has(from + to)) push({ from, to, castle });
}
return out;
}
/** Count leaf nodes through the adapter (legalMoves → expand promotions → chess.move). */
/** Count leaf nodes through the adapter (legalMoves → expand promotions → findMove → make). */
export function adapterPerft(fen: string, depth: number): number {
const c = load(fen);
const walk = (d: number): number => {
const moves = legalMoves(c);
let n = 0;
for (const m of moves) {
for (const m of legalMoves(c)) {
// Chess960 lists some castles twice (rook square and king destination); count each move once.
if (m.castle && c.chess960 && c.get(m.to)?.type !== 'r') continue;
for (const promotion of m.promotion ? (['q', 'r', 'b', 'n'] as const) : [undefined]) {
if (d === 1) {
n++;
continue;
}
c.move({ from: m.from, to: m.to, promotion });
c.make(c.findMove(m.from, m.to, promotion)!);
n += walk(d - 1);
c.undo();
c.unmake();
}
}
return n;
@@ -90,7 +113,7 @@ export function adapterPerft(fen: string, depth: number): number {
* Can `from → to` be reached by the piece's movement pattern, ignoring checks? Used only to pick the rejection
* message ("킹이 공격받게 되어…" vs "그렇게 움직일 수 없어요"). Castling is handled separately.
*/
export function pseudoReachable(c: Chess, from: Square, to: Square): boolean {
export function pseudoReachable(c: Position, from: string, to: string): boolean {
const p = c.get(from);
if (!p || from === to) return false;
const target = c.get(to);
@@ -103,7 +126,7 @@ export function pseudoReachable(c: Chess, from: Square, to: Square): boolean {
const dy = ty - fy;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
const sq = (x: number, y: number) => `${String.fromCharCode(97 + x)}${y + 1}` as Square;
const sq = (x: number, y: number) => `${String.fromCharCode(97 + x)}${y + 1}`;
const clear = () => {
const sx = Math.sign(dx);
const sy = Math.sign(dy);
@@ -135,28 +158,20 @@ export function pseudoReachable(c: Chess, from: Square, to: Square): boolean {
return false;
}
interface PieceAt {
square: Square;
color: 'w' | 'b';
type: PieceSymbol;
}
export function pieces(c: Chess): PieceAt[] {
const out: PieceAt[] = [];
for (const row of c.board()) for (const p of row) if (p) out.push(p);
return out;
}
export const pieces = (c: Position) => c.pieces();
/** 'light' | 'dark' square colour (a1 is dark). */
const squareColor = (sq: string): 'light' | 'dark' => ((sq.charCodeAt(0) - 97 + Number(sq[1]) - 1) % 2 === 0 ? 'dark' : 'light');
/**
* Automatic dead-position draws (§6 무승부 2): K vs K, KB vs K, KN vs K, and kings plus bishops only where every
* bishop stands on the same square colour. KNN vs K is *not* included.
*/
export function insufficientMaterial(c: Chess): boolean {
export function insufficientMaterial(c: Position): boolean {
const minors = pieces(c).filter((p) => p.type !== 'k');
if (minors.length === 0) return true;
if (minors.length === 1 && (minors[0]!.type === 'b' || minors[0]!.type === 'n')) return true;
if (minors.every((p) => p.type === 'b')) {
const colors = new Set(minors.map((p) => c.squareColor(p.square)));
const colors = new Set(minors.map((p) => squareColor(p.square)));
return colors.size === 1;
}
return false;
@@ -166,20 +181,20 @@ export function insufficientMaterial(c: Chess): boolean {
* FIDE 6.9 `cannotMate(side)` (§6 무승부 6): true only for the conservative, certain cases. Everything else is
* treated as "mate is possible" (so the flagged player loses).
*/
export function cannotMate(c: Chess, side: 'w' | 'b'): boolean {
export function cannotMate(c: Position, side: 'w' | 'b'): boolean {
const all = pieces(c).filter((p) => p.type !== 'k');
const mine = all.filter((p) => p.color === side);
const theirs = all.filter((p) => p.color !== side);
if (mine.length === 0) return true;
if (mine.length === 1 && theirs.length === 0 && (mine[0]!.type === 'n' || mine[0]!.type === 'b')) return true;
if (all.every((p) => p.type === 'b')) {
return new Set(all.map((p) => c.squareColor(p.square))).size === 1;
return new Set(all.map((p) => squareColor(p.square))).size === 1;
}
return false;
}
/** The side to move's non-king pieces that are attacked and not defended (hints=movesAndThreats). */
export function threatenedSquares(c: Chess): string[] {
export function threatenedSquares(c: Position): string[] {
const us = c.turn();
const them = us === 'w' ? 'b' : 'w';
return pieces(c)
@@ -187,12 +202,12 @@ export function threatenedSquares(c: Chess): string[] {
.map((p) => p.square);
}
export function kingSquare(c: Chess, side: 'w' | 'b'): Square | null {
return c.findPiece({ type: 'k', color: side })[0] ?? null;
export function kingSquare(c: Position, side: 'w' | 'b'): string {
return c.kingSquare(side);
}
/** White material minus black material (Q9 R5 B3 N3 P1). */
export function materialDiff(c: Chess): number {
export function materialDiff(c: Position): number {
let d = 0;
for (const p of pieces(c)) d += (p.color === 'w' ? 1 : -1) * PIECE_VALUE[p.type];
return d;