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:
235
packages/games/src/chess/chess.perft.test.ts
Normal file
235
packages/games/src/chess/chess.perft.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Move generator verification (docs/games/chess.md §8.0): perft node counts against published numbers, Chess960
|
||||
* start positions, and a differential random-game test against chess.js (dev dependency, standard chess only).
|
||||
* The default run takes a few seconds; `PERFT_DEEP=1 bun test chess.perft` adds the deep counts (~1–2 minutes).
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { Chess, type Move as JsMove } from 'chess.js';
|
||||
import { SeededRng } from '@bg/engine';
|
||||
import { Position, chess960BackRank, chess960Fen } from './movegen';
|
||||
import { START_FEN, adapterPerft, insufficientMaterial, positionKey } from './rules';
|
||||
|
||||
const DEEP = !!process.env.PERFT_DEEP;
|
||||
const DEEP_TIMEOUT = 600_000;
|
||||
|
||||
interface Case {
|
||||
name: string;
|
||||
fen: string;
|
||||
/** Node counts for depth 1, 2, 3, … */
|
||||
counts: number[];
|
||||
/** Depths run by default; the rest only with PERFT_DEEP. */
|
||||
fast: number;
|
||||
}
|
||||
|
||||
// https://www.chessprogramming.org/Perft_Results (positions 1–6) and python-chess examples/perft/tricky.perft.
|
||||
const STANDARD: Case[] = [
|
||||
{ name: 'start', fen: START_FEN, counts: [20, 400, 8902, 197281, 4865609, 119060324], fast: 4 },
|
||||
{ name: 'Kiwipete', fen: 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1', counts: [48, 2039, 97862, 4085603], fast: 3 },
|
||||
{ name: 'position 3', fen: '8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1', counts: [14, 191, 2812, 43238, 674624, 11030083], fast: 5 },
|
||||
{ name: 'position 4', fen: 'r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1', counts: [6, 264, 9467, 422333, 15833292], fast: 3 },
|
||||
{ name: 'position 4 mirrored', fen: 'r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ - 0 1', counts: [6, 264, 9467, 422333], fast: 3 },
|
||||
{ name: 'position 5', fen: 'rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8', counts: [44, 1486, 62379, 2103487], fast: 3 },
|
||||
{ name: 'position 6', fen: 'r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10', counts: [46, 2079, 89890, 3894594], fast: 3 },
|
||||
{ name: 'align-diag-1', fen: '3R4/8/q4k2/2B5/1NK5/3b4/8/8 w - - 0 1', counts: [4, 125, 2854], fast: 3 },
|
||||
{ name: 'align-horizontal', fen: '5R2/2P5/8/4k3/8/3rK2r/8/8 w - - 0 1', counts: [2, 56, 1030], fast: 3 },
|
||||
{ name: 'align-ep (e.p. capture exposes the king)', fen: '8/8/8/1k6/3Pp3/8/8/4KQ2 b - d3 0 1', counts: [6, 121, 711], fast: 3 },
|
||||
{ name: 'align-ep-pinned', fen: '1b1k4/8/8/1rPpK3/8/8/8/8 w - d6 0 1', counts: [5, 100, 555], fast: 3 },
|
||||
{ name: 'ep-unrelated-check', fen: 'rnbqk1nr/bb3p1p/1q2r3/2pPp3/3P4/7P/1PP1NpPP/R1BQKBNR w KQkq c6 0 1', counts: [2, 92, 2528], fast: 3 },
|
||||
{ name: 'gotta-love-perft-2 (many queens)', fen: '8/6kR/8/8/8/bq6/1rqqqqqq/K1nqnbrq b - - 0 1', counts: [7, 52, 4593, 50268], fast: 4 },
|
||||
];
|
||||
|
||||
// Chess960: https://www.chessprogramming.org/Chess960_Perft_Results (fischer.epd ids) and the X-FEN set from
|
||||
// talkchess t=55274 (KQkq letters meaning the outermost rook), both via python-chess examples/perft.
|
||||
const CHESS960: Case[] = [
|
||||
{ name: '#0', fen: 'bqnb1rkr/pp3ppp/3ppn2/2p5/5P2/P2P4/NPP1P1PP/BQ1BNRKR w HFhf - 0 1', counts: [21, 528, 12189, 326672, 8146062], fast: 3 },
|
||||
{ name: '#1', fen: '2nnrbkr/p1qppppp/8/1ppb4/6PP/3PP3/PPP2P2/BQNNRBKR w HEhe - 0 1', counts: [21, 807, 18002, 667366], fast: 3 },
|
||||
{ name: '#2', fen: 'b1q1rrkb/pppppppp/3nn3/8/P7/1PPP4/4PPPP/BQNNRKRB w GE - 0 1', counts: [20, 479, 10471, 273318, 6417013], fast: 3 },
|
||||
{ name: '#3', fen: 'qbbnnrkr/2pp2pp/p7/1p2pp2/8/P3PP2/1PPP1KPP/QBBNNR1R w hf - 0 1', counts: [22, 593, 13440, 382958], fast: 3 },
|
||||
{ name: '#117', fen: 'nqbr1bkr/p1p1ppp1/1p1n4/3pN2p/1P6/8/P1PPPPPP/NQBR1BKR w HDhd - 0 1', counts: [29, 898, 26532, 809605], fast: 3 },
|
||||
{ name: '#245', fen: '1rbkqbr1/ppp1pppp/1n5n/3p4/3P4/1PP3P1/P3PP1P/NRBKQBNR w HBb - 0 1', counts: [27, 752, 20686, 606783], fast: 3 },
|
||||
{ name: '#389', fen: 'qrb2bkr/1pp1pppp/2np1n2/pN6/3P4/4B3/PPP1PPPP/QR2NBKR w HBhb - 0 1', counts: [27, 730, 20534, 585091], fast: 3 },
|
||||
{ name: '#518', fen: 'r1bqk1rb/pppnpppp/5n2/3p4/2P3PP/2N5/PP1PPP2/R1BQKNRB w GAga - 0 1', counts: [32, 821, 27121, 733155], fast: 3 },
|
||||
{ name: '#640', fen: 'b1rbkrqn/ppp2ppp/1n2p3/3p4/6P1/2PP4/PP2PP1P/BRNBKRQN w FBf - 0 1', counts: [21, 463, 10610, 253204, 6307276], fast: 3 },
|
||||
{ name: '#777', fen: 'qrknbbrn/ppp1ppp1/8/7p/2Bp4/4PPP1/PPPP3P/QRKNB1RN w GBgb - 0 1', counts: [27, 593, 16168, 376808], fast: 3 },
|
||||
{ name: '#902', fen: 'rkb1rn1b/ppppqppp/4p3/8/1P2n1P1/5Q2/P1PP1P1P/RKB1RNNB w EAea - 0 1', counts: [37, 1158, 40114, 1234768], fast: 3 },
|
||||
{ name: '#959', fen: 'bbq1nr1r/pppppk1p/2n2p2/6p1/P4P2/4P1P1/1PPP3P/BBQNNRKR w HF - 0 1', counts: [23, 589, 14744, 387556], fast: 3 },
|
||||
{ name: 'xfen-00', fen: 'r1k1r2q/p1ppp1pp/8/8/8/8/P1PPP1PP/R1K1R2Q w KQkq - 0 1', counts: [23, 522, 12333, 285754], fast: 3 },
|
||||
{ name: 'xfen-01', fen: 'r1k2r1q/p1ppp1pp/8/8/8/8/P1PPP1PP/R1K2R1Q w KQkq - 0 1', counts: [28, 738, 20218, 541480], fast: 3 },
|
||||
{ name: 'xfen-02', fen: '8/8/8/4B2b/6nN/8/5P2/2R1K2k w Q - 0 1', counts: [34, 318, 9002, 118388], fast: 4 },
|
||||
{ name: 'xfen-03 (king d1, rook e1)', fen: '2r5/8/8/8/8/8/6PP/k2KR3 w K - 0 1', counts: [17, 242, 3931, 57700], fast: 4 },
|
||||
{ name: 'xfen-04 (rook shields the king destination)', fen: '4r3/3k4/8/8/8/8/6PP/qR1K1R2 w KQ - 0 1', counts: [19, 628, 12858, 405636], fast: 3 },
|
||||
];
|
||||
|
||||
function perftCases(cases: Case[], chess960: boolean) {
|
||||
for (const c of cases) {
|
||||
test(`${c.name} depth 1-${c.fast}`, () => {
|
||||
const p = Position.fromFen(c.fen, chess960);
|
||||
expect(c.counts.slice(0, c.fast).map((_, i) => p.perft(i + 1))).toEqual(c.counts.slice(0, c.fast));
|
||||
// make/unmake leaves the position untouched.
|
||||
expect(p.fen()).toBe(Position.fromFen(c.fen, chess960).fen());
|
||||
});
|
||||
if (c.counts.length > c.fast) {
|
||||
test.skipIf(!DEEP)(
|
||||
`${c.name} depth ${c.fast + 1}-${c.counts.length} (PERFT_DEEP)`,
|
||||
() => {
|
||||
const p = Position.fromFen(c.fen, chess960);
|
||||
for (let d = c.fast + 1; d <= c.counts.length; d++) expect([d, p.perft(d)]).toEqual([d, c.counts[d - 1]!]);
|
||||
},
|
||||
DEEP_TIMEOUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('perft: standard', () => perftCases(STANDARD, false));
|
||||
describe('perft: Chess960', () => perftCases(CHESS960, true));
|
||||
|
||||
describe('perft through the adapter (legalMoves → findMove → make)', () => {
|
||||
test('standard and Chess960 counts match the generator', () => {
|
||||
expect(adapterPerft(STANDARD[1]!.fen, 3)).toBe(97862);
|
||||
expect(adapterPerft(STANDARD[3]!.fen, 3)).toBe(9467);
|
||||
// Shredder-FEN, so the adapter sees Chess960 and takes the king-onto-rook castling input.
|
||||
for (const c of CHESS960.slice(0, 12)) expect([c.name, adapterPerft(c.fen, 3)]).toEqual([c.name, c.counts[2]!]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chess960 start positions (docs/games/chess.md §4)', () => {
|
||||
test('known numbers', () => {
|
||||
expect(chess960BackRank(0)).toBe('BBQNNRKR');
|
||||
expect(chess960BackRank(1)).toBe('BQNBNRKR');
|
||||
expect(chess960BackRank(4)).toBe('QBBNNRKR');
|
||||
expect(chess960BackRank(518)).toBe('RNBQKBNR');
|
||||
expect(chess960BackRank(959)).toBe('RKRNNQBB');
|
||||
expect(chess960Fen(518)).toBe('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w HAha - 0 1');
|
||||
expect(() => chess960BackRank(960)).toThrow();
|
||||
expect(() => chess960BackRank(-1)).toThrow();
|
||||
});
|
||||
test('all 960 are distinct, bishops on opposite colours, king between the rooks', () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 960; i++) {
|
||||
const r = chess960BackRank(i);
|
||||
seen.add(r);
|
||||
expect([...r].sort().join('')).toBe('BBKNNQRR');
|
||||
const [b1, b2] = [r.indexOf('B'), r.lastIndexOf('B')];
|
||||
expect((b1 + b2) % 2).toBe(1);
|
||||
expect(r.indexOf('R') < r.indexOf('K') && r.indexOf('K') < r.lastIndexOf('R')).toBe(true);
|
||||
}
|
||||
expect(seen.size).toBe(960);
|
||||
});
|
||||
test('every start position: 2 castling rights per side, FEN round trip, perft(2) as symmetric as expected', () => {
|
||||
for (let i = 0; i < 960; i++) {
|
||||
const fen = chess960Fen(i);
|
||||
const p = Position.fromFen(fen);
|
||||
expect(p.chess960).toBe(true);
|
||||
expect(p.castle.every((r) => r >= 0)).toBe(true);
|
||||
expect(p.fen()).toBe(fen);
|
||||
// Mirrored sides and no first move can touch the other side's options (even an immediate K/R-swap castle):
|
||||
// perft(2) = perft(1)².
|
||||
const n1 = p.perft(1);
|
||||
expect(p.perft(2)).toBe(n1 * n1);
|
||||
}
|
||||
// Standard set-up through the 960 path gives the standard numbers.
|
||||
expect([1, 2, 3, 4].map((d) => Position.fromFen(chess960Fen(518)).perft(d))).toEqual([20, 400, 8902, 197281]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- differential test against chess.js
|
||||
|
||||
const uciOf = (m: JsMove) => m.from + m.to + (m.promotion ?? '');
|
||||
|
||||
describe('differential vs chess.js 1.4 (standard chess, random games)', () => {
|
||||
test('legal moves, SAN, FEN, check/mate/stalemate/draw detection agree move by move', () => {
|
||||
const rng = SeededRng.fromSeed([9, 6, 0, 1]);
|
||||
let plies = 0;
|
||||
let mates = 0;
|
||||
let stalemates = 0;
|
||||
let castles = 0;
|
||||
let enPassants = 0;
|
||||
let promotions = 0;
|
||||
let insufficient = 0;
|
||||
let threefolds = 0;
|
||||
for (let game = 0; game < (DEEP ? 200 : 24); game++) {
|
||||
const ref = new Chess();
|
||||
const p = Position.fromFen(START_FEN);
|
||||
const seen = new Map<string, number>([[positionKey(p.fen()), 1]]);
|
||||
for (let ply = 0; ply < 300; ply++) {
|
||||
// Same position text: fen() normalises e.p. like chess.js, forceEnpassantSquare keeps it.
|
||||
expect(p.fen()).toBe(ref.fen());
|
||||
expect(p.fen({ forceEnpassantSquare: true })).toBe(ref.fen({ forceEnpassantSquare: true }));
|
||||
const refMoves = ref.moves({ verbose: true });
|
||||
const legal = p.moves();
|
||||
const mine = new Map(legal.map((m) => [p.uci(m), m]));
|
||||
expect([...mine.keys()].sort()).toEqual(refMoves.map(uciOf).sort());
|
||||
expect(p.inCheck()).toBe(ref.inCheck());
|
||||
expect(p.isCheckmate()).toBe(ref.isCheckmate());
|
||||
expect(p.isStalemate()).toBe(ref.isStalemate());
|
||||
expect(insufficientMaterial(p)).toBe(ref.isInsufficientMaterial());
|
||||
expect(p.half >= 100).toBe(ref.isDrawByFiftyMoves());
|
||||
// chess.js keys repetitions by its own hash (placement, side, castling, e.p. when capturable).
|
||||
const key = positionKey(p.fen());
|
||||
expect((seen.get(key) ?? 0) >= 3).toBe(ref.isThreefoldRepetition());
|
||||
if (ref.isThreefoldRepetition()) threefolds++;
|
||||
if (ref.isInsufficientMaterial()) insufficient++;
|
||||
if (refMoves.length === 0) {
|
||||
if (ref.isCheckmate()) mates++;
|
||||
else stalemates++;
|
||||
break;
|
||||
}
|
||||
if (ref.isInsufficientMaterial() || ref.isDrawByFiftyMoves()) break;
|
||||
// Prefer captures, castling and promotions now and then so games reach endings and special moves.
|
||||
const special = refMoves.filter((m) => m.isCapture() || m.isPromotion() || m.isKingsideCastle() || m.isQueensideCastle() || m.isEnPassant());
|
||||
const pool = special.length && rng.int(3) === 0 ? special : refMoves;
|
||||
const pick = pool[rng.int(pool.length)]!;
|
||||
const mv = mine.get(uciOf(pick))!;
|
||||
expect(p.san(mv, legal)).toBe(pick.san);
|
||||
// The adapter input path finds the same move from (from, to, promotion).
|
||||
expect(p.findMove(pick.from, pick.to, pick.promotion as 'q' | undefined, legal)).toBe(mv);
|
||||
const played = p.play(mv, legal);
|
||||
expect(played.san).toBe(pick.san);
|
||||
expect(played.uci).toBe(uciOf(pick));
|
||||
if (played.castle) castles++;
|
||||
if (played.enPassant) enPassants++;
|
||||
if (played.promotion) promotions++;
|
||||
ref.move(pick);
|
||||
seen.set(positionKey(p.fen()), (seen.get(positionKey(p.fen())) ?? 0) + 1);
|
||||
plies++;
|
||||
}
|
||||
}
|
||||
// The sample really exercised the special cases.
|
||||
expect(plies).toBeGreaterThan(2_000);
|
||||
for (const n of [mates, castles, enPassants, promotions, insufficient]) expect(n).toBeGreaterThan(0);
|
||||
expect(stalemates + threefolds).toBeGreaterThan(0);
|
||||
}, DEEP_TIMEOUT);
|
||||
|
||||
test('threefold repetition keys agree, including a double step without a capturable e.p.', () => {
|
||||
// 1. e4 (no black pawn can take e.p.: the key must not carry e3), then knights out and back twice.
|
||||
const line = ['e2e4', 'g8f6', 'g1f3', 'f6g8', 'f3g1', 'g8f6', 'g1f3', 'f6g8', 'f3g1', 'g8f6', 'g1f3', 'f6g8', 'f3g1'];
|
||||
const ref = new Chess();
|
||||
const p = Position.fromFen(START_FEN);
|
||||
const seen = new Map<string, number>();
|
||||
const flags: [boolean, boolean][] = [];
|
||||
for (const u of line) {
|
||||
ref.move({ from: u.slice(0, 2), to: u.slice(2, 4) });
|
||||
p.play(p.findMove(u.slice(0, 2), u.slice(2, 4))!);
|
||||
const key = positionKey(p.fen());
|
||||
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||
flags.push([seen.get(key)! >= 3, ref.isThreefoldRepetition()]);
|
||||
}
|
||||
expect(flags.map(([a]) => a)).toEqual(flags.map(([, b]) => b));
|
||||
expect(flags.some(([a]) => a)).toBe(true);
|
||||
});
|
||||
|
||||
test('loading FENs saved by chess.js (state v1) prints them back unchanged', () => {
|
||||
for (const fen of [
|
||||
START_FEN,
|
||||
'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 2',
|
||||
'rnbqkbnr/pppp1ppp/8/8/3pP3/8/PPP2PPP/RNBQKBNR b KQkq e3 0 3',
|
||||
'r3k2r/8/8/8/8/8/8/R3K2R w Kq - 4 20',
|
||||
'8/8/8/8/8/8/8/K6k w - - 99 120',
|
||||
]) {
|
||||
const ref = new Chess(fen);
|
||||
expect(Position.fromFen(fen).fen()).toBe(ref.fen());
|
||||
expect(Position.fromFen(fen).fen({ forceEnpassantSquare: true })).toBe(ref.fen({ forceEnpassantSquare: true }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
|
||||
import { chess, type ChessAction, type ChessOptions, type ChessState } from './index';
|
||||
import { START_FEN, adapterPerft, cannotMate, fullFen, insufficientMaterial, keyOf, load, positionKey, threatenedSquares } from './rules';
|
||||
import { START_FEN, adapterPerft, cannotMate, chess960Fen, fullFen, insufficientMaterial, keyOf, load, positionKey, threatenedSquares } from './rules';
|
||||
|
||||
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
|
||||
|
||||
@@ -39,7 +39,7 @@ const canCastle = (s: ChessState, side: 'king' | 'queen') => chess.validate(s, t
|
||||
|
||||
describe('perft (§8.0)', () => {
|
||||
const KIWI = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1';
|
||||
test('initial position depth 1-4 (chess.js generator on adapter-loaded position)', () => {
|
||||
test('initial position depth 1-4 (adapter-loaded position; more in chess.perft.test.ts)', () => {
|
||||
expect([1, 2, 3, 4].map((d) => load(START_FEN).perft(d))).toEqual([20, 400, 8902, 197281]);
|
||||
});
|
||||
test('Kiwipete depth 1-3', () => {
|
||||
@@ -376,3 +376,201 @@ describe('views and playouts', () => {
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Chess960
|
||||
|
||||
/** Chess960 game from a FEN (Shredder-FEN or X-FEN). White = 'A', black = 'B', clocks running. */
|
||||
function make960(fen: string, opts: Partial<ChessOptions> = {}): ChessState {
|
||||
const s = make({ variant: 'chess960', ...opts });
|
||||
const c = load(fen, true);
|
||||
s.startFen = fullFen(c);
|
||||
s.fen = s.startFen;
|
||||
s.repetition = { [keyOf(c)]: 1 };
|
||||
return s;
|
||||
}
|
||||
const backRank = (s: ChessState) => s.fen.split(' ')[0]!.split('/')[7];
|
||||
const castling = (s: ChessState) => s.fen.split(' ')[2];
|
||||
const castleTargets = (s: ChessState) =>
|
||||
chess
|
||||
.view(s, turnPlayer(s))
|
||||
.legalMoves!.filter((m) => m.castle)
|
||||
.map((m) => `${m.castle}:${m.from}${m.to}`)
|
||||
.sort();
|
||||
|
||||
describe('Chess960 set-up (§4)', () => {
|
||||
test('number drawn from the seeded rng, start FEN in Shredder-FEN, shown in the view', () => {
|
||||
const setup = (seed: number, variant: ChessOptions['variant'] = 'chess960') =>
|
||||
chess.setup({ players: ['H', 'G'], options: chess.optionsSchema.parse({ variant }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||
const s = setup(1);
|
||||
expect(s.chess960Index).toBeGreaterThanOrEqual(0);
|
||||
expect(s.chess960Index).toBeLessThan(960);
|
||||
expect(s.startFen).toBe(chess960Fen(s.chess960Index!));
|
||||
expect(s.fen).toBe(s.startFen);
|
||||
expect(s.repetition).toEqual({ [positionKey(s.startFen)]: 1 });
|
||||
expect(setup(1)).toEqual(s);
|
||||
expect(chess.view(s, null).chess960Index).toBe(s.chess960Index);
|
||||
expect(new Set(Array.from({ length: 200 }, (_, i) => setup(i).chess960Index)).size).toBeGreaterThan(150);
|
||||
const std = setup(1, 'standard');
|
||||
expect(std.chess960Index).toBeNull();
|
||||
expect(std.startFen).toBe(START_FEN);
|
||||
expect(chess.view(std, null).chess960Index).toBeNull();
|
||||
});
|
||||
test('the variant option draws the number after the colours, so standard games keep their rng sequence', () => {
|
||||
const o = (variant: ChessOptions['variant']) => chess.optionsSchema.parse({ variant });
|
||||
const a = chess.setup({ players: ['H', 'G'], options: o('standard'), rng: SeededRng.fromSeed(seedN(7)), now: 0 });
|
||||
const b = chess.setup({ players: ['H', 'G'], options: o('chess960'), rng: SeededRng.fromSeed(seedN(7)), now: 0 });
|
||||
expect(b.players).toEqual(a.players);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chess960 castling (§5.2)', () => {
|
||||
test('king already on g1: king-side castling moves only the rook; input by king onto rook or castle action', () => {
|
||||
const s = make960('4k3/8/8/8/8/8/8/R5KR w HA - 0 1');
|
||||
expect(castleTargets(s)).toEqual(['king:g1h1', 'queen:g1a1', 'queen:g1c1']);
|
||||
for (const a of [act(s, 'A', { type: 'castle', side: 'king' }), play(s, 'g1h1')]) {
|
||||
expect(backRank(a)).toBe('R4RK1');
|
||||
expect(castling(a)).toBe('-');
|
||||
expect(a.moves[0]).toMatchObject({ san: 'O-O', uci: 'g1h1' });
|
||||
expect(chess.view(a, null).lastMove).toEqual({ from: 'g1', to: 'g1' });
|
||||
}
|
||||
// Queen side: the king walks g1 → c1, the rook a1 → d1; c1 works as the king's target as well.
|
||||
for (const a of [play(s, 'g1c1'), play(s, 'g1a1'), act(s, 'A', { type: 'castle', side: 'queen' })]) {
|
||||
expect(backRank(a)).toBe('2KR3R');
|
||||
expect(a.moves[0]).toMatchObject({ san: 'O-O-O', uci: 'g1a1' });
|
||||
expect(chess.view(a, null).lastMove).toEqual({ from: 'g1', to: 'c1' });
|
||||
}
|
||||
});
|
||||
test('king on b1: b1→g1 castles, but b1→c1 is a plain king move (castle queen-side via the rook or the button)', () => {
|
||||
const s = make960('4k3/8/8/8/8/8/8/RK5R w HA - 0 1');
|
||||
expect(castleTargets(s)).toEqual(['king:b1g1', 'king:b1h1', 'queen:b1a1']);
|
||||
const kingSide = play(s, 'b1g1');
|
||||
expect(backRank(kingSide)).toBe('R4RK1');
|
||||
expect(kingSide.moves[0]!.san).toBe('O-O');
|
||||
const step = play(s, 'b1c1');
|
||||
expect(backRank(step)).toBe('R1K4R');
|
||||
expect(step.moves[0]!.san).toBe('Kc1');
|
||||
expect(castling(step)).toBe('-');
|
||||
const queenSide = play(s, 'b1a1');
|
||||
expect(backRank(queenSide)).toBe('2KR3R');
|
||||
expect(queenSide.moves[0]!.san).toBe('O-O-O');
|
||||
});
|
||||
test('black castles the same way (8th rank)', () => {
|
||||
const s = make960('rk5r/8/8/8/8/8/8/4K3 b ha - 0 1');
|
||||
expect(backRank(s)).toBe('4K3');
|
||||
const a = play(s, 'b8g8');
|
||||
expect(a.fen.split(' ')[0]!.split('/')[0]).toBe('r4rk1');
|
||||
expect(a.moves[0]).toMatchObject({ san: 'O-O', uci: 'b8h8', color: 'black' });
|
||||
});
|
||||
test('every square the king and the rook cross must be empty except for themselves', () => {
|
||||
// Knight on b1: the king's path f1→c1 is clear, but the rook's path a1→d1 is not.
|
||||
const rookPath = make960('4k3/8/8/8/8/8/8/RN3K1R w HA - 0 1');
|
||||
expect(canCastle(rookPath, 'queen')).toBe(false);
|
||||
expect(canCastle(rookPath, 'king')).toBe(true);
|
||||
expect(tryMove(rookPath, 'f1a1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
|
||||
expect(tryMove(rookPath, 'f1c1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
|
||||
// Knight on g1 blocks the king's destination.
|
||||
const kingDest = make960('4k3/8/8/8/8/8/8/RK4NR w HA - 0 1');
|
||||
expect(canCastle(kingDest, 'king')).toBe(false);
|
||||
expect(tryMove(kingDest, 'b1h1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
|
||||
// The other rook in the way (king b1, rooks a1 and c1): queen-side king target c1 is occupied by it.
|
||||
const otherRook = make960('4k3/8/8/8/8/8/8/RKR5 w CA - 0 1');
|
||||
expect(canCastle(otherRook, 'queen')).toBe(false);
|
||||
expect(canCastle(otherRook, 'king')).toBe(true);
|
||||
expect(backRank(play(otherRook, 'b1c1'))).toBe('R4RK1');
|
||||
});
|
||||
test("the king's path may not be attacked; the rook's path may", () => {
|
||||
// Black rook on e8 covers e1, which the king crosses on b1→g1.
|
||||
const kingPath = make960('4r1k1/8/8/8/8/8/8/RK5R w HA - 0 1');
|
||||
expect(canCastle(kingPath, 'king')).toBe(false);
|
||||
expect(canCastle(kingPath, 'queen')).toBe(true);
|
||||
// b1 is attacked, but only the rook crosses it (king g1 → c1).
|
||||
expect(canCastle(make960('1r4k1/8/8/8/8/8/8/R5K1 w A - 0 1'), 'queen')).toBe(true);
|
||||
// In check: no castling at all.
|
||||
const check = make960('6k1/8/8/8/8/8/8/R5Kr w A - 0 1');
|
||||
expect(canCastle(check, 'queen')).toBe(false);
|
||||
});
|
||||
test('the castling rook may not be shielding the king destination (queen a1, rook b1, king d1)', () => {
|
||||
const s = make960('3k4/8/8/8/8/8/8/qR1K4 w B - 0 1');
|
||||
expect(canCastle(s, 'queen')).toBe(false);
|
||||
expect(tryMove(s, 'd1b1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
|
||||
});
|
||||
test('rights: a moved rook loses its side, a moved king both; captured rooks too', () => {
|
||||
const s = make960('r3k2r/8/8/8/8/8/8/RK5R w HAha - 0 1');
|
||||
expect(castling(s)).toBe('HAha');
|
||||
expect(castling(play(s, 'h1h2'))).toBe('Aha');
|
||||
expect(castling(play(s, 'b1b2'))).toBe('ha');
|
||||
expect(castling(play(s, 'h1h8'))).toBe('Aa');
|
||||
});
|
||||
test('standard chess never treats king-onto-rook as castling', () => {
|
||||
const s = make({}, 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1');
|
||||
expect(tryMove(s, 'e1h1')).toEqual({ ok: false, reason: '그 기물은 그렇게 움직일 수 없어요.' });
|
||||
expect(castleTargets(s)).toEqual(['king:e1g1', 'queen:e1c1']);
|
||||
});
|
||||
test('rights still read as Chess960 after both sides lost them; undo restores them', () => {
|
||||
let s = make960('r3k2r/8/8/8/8/8/8/RK5R w HAha - 0 1', { undo: '3' });
|
||||
s = play(s, 'b1b2', 'e8e7');
|
||||
expect(castling(s)).toBe('-');
|
||||
s = act(s, 'A', { type: 'requestUndo' });
|
||||
s = act(s, 'B', { type: 'respondUndo', accept: true });
|
||||
expect(s.fen).toBe(s.startFen);
|
||||
expect(castling(s)).toBe('HAha');
|
||||
expect(canCastle(s, 'king')).toBe(true);
|
||||
});
|
||||
test('PGN carries Variant, SetUp and the Shredder-FEN start', () => {
|
||||
let s = make960(chess960Fen(0));
|
||||
s = act(s, 'A', { type: 'resign' });
|
||||
expect(s.result!.pgn).toContain('[Variant "Chess960"]');
|
||||
expect(s.result!.pgn).toContain('[SetUp "1"]');
|
||||
expect(s.result!.pgn).toContain(`[FEN "${chess960Fen(0)}"]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saved games (stateVersion 1 → 2)', () => {
|
||||
test('a v1 state (no variant, no chess960Index) migrates to standard and keeps playing', () => {
|
||||
const v2 = play(make(), 'e2e4', 'e7e5');
|
||||
const { chess960Index: _drop, ...rest } = v2;
|
||||
const { variant: _v, ...oldOptions } = v2.options;
|
||||
const v1 = JSON.parse(JSON.stringify({ ...rest, options: oldOptions }));
|
||||
const m = chess.migrate!(v1, 1);
|
||||
expect(m.options.variant).toBe('standard');
|
||||
expect(m.chess960Index).toBeNull();
|
||||
expect(m).toEqual(v2);
|
||||
expect(play(m, 'g1f3').fen).toBe(play(v2, 'g1f3').fen);
|
||||
expect(chess.migrate!(v2, 2)).toBe(v2);
|
||||
// Finished games are never migrated; their view and PGN still read as standard chess.
|
||||
const finished = act({ ...v1, chess960Index: undefined } as unknown as ChessState, 'A', { type: 'resign' });
|
||||
expect(chess.view(finished, null).chess960Index).toBeNull();
|
||||
expect(finished.result!.pgn).toContain('[Variant "Standard"]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chess960 playouts', () => {
|
||||
test('random 960 games finish, castle sometimes, keep FENs consistent and replay deterministically', () => {
|
||||
const options = chess.optionsSchema.parse({ variant: 'chess960', undo: '3' });
|
||||
let castles = 0;
|
||||
const indices = new Set<number>();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = randomPlayout(chess, {
|
||||
players: ['A', 'B'],
|
||||
options,
|
||||
seed: seedN(100 + i),
|
||||
maxApplyMs: 50,
|
||||
invariant: (st) => {
|
||||
const s = st as ChessState;
|
||||
expect(load(s.fen, true).fen({ forceEnpassantSquare: true })).toBe(s.fen);
|
||||
expect(s.fen.split(' ')[2]).toMatch(/^(-|[A-H]{0,2}[a-h]{0,2})$/);
|
||||
},
|
||||
});
|
||||
expect(a.finished).toBe(true);
|
||||
const final = a.finalState as ChessState;
|
||||
indices.add(final.chess960Index!);
|
||||
castles += final.moves.filter((m) => m.san.startsWith('O-O')).length;
|
||||
if (i === 0) {
|
||||
const b = randomPlayout(chess, { players: ['A', 'B'], options, seed: seedN(100), maxApplyMs: 50 });
|
||||
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
|
||||
}
|
||||
}
|
||||
expect(indices.size).toBeGreaterThan(4);
|
||||
expect(castles).toBeGreaterThan(0);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* 체스 (docs/games/chess.md). Standard chess only: chess.js 1.4.0 has no Chess960 castling support, so the
|
||||
* `variant` option is left out until a 960-capable generator is chosen (§8.0).
|
||||
* 체스 (docs/games/chess.md): standard and Chess960 on one perft-verified move generator (`movegen.ts`, §8.0).
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { Chess, Square } from 'chess.js';
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
|
||||
import {
|
||||
START_FEN,
|
||||
boardArray,
|
||||
buildPgn,
|
||||
cannotMate,
|
||||
@@ -22,17 +19,22 @@ import {
|
||||
keyOf,
|
||||
positionKey,
|
||||
pseudoReachable,
|
||||
startFenFor,
|
||||
threatenedSquares,
|
||||
toSide,
|
||||
type Color,
|
||||
type LegalMove,
|
||||
type Promo,
|
||||
type Position,
|
||||
} from './rules';
|
||||
|
||||
export type { Color, LegalMove, Promo } from './rules';
|
||||
const COLOR_KO: Record<Color, string> = { white: '백', black: '흑' };
|
||||
|
||||
export const chessOptionsSchema = z.object({
|
||||
variant: z
|
||||
.enum(['standard', 'chess960'])
|
||||
.default('standard')
|
||||
.meta({ title: '변형', labels: { standard: '일반', chess960: '체스960(첫 줄 무작위)' } }),
|
||||
timeControl: z
|
||||
.object({
|
||||
kind: z
|
||||
@@ -129,7 +131,10 @@ export interface ChessMoveRecord {
|
||||
export interface ChessState {
|
||||
options: ChessOptions;
|
||||
players: Record<Color, PlayerId>;
|
||||
/** Shredder-FEN for Chess960 (castling rights as rook files, e.g. `HAha`). */
|
||||
startFen: string;
|
||||
/** Chess960 Scharnagl number 0..959 (518 = standard set-up); null for standard chess. */
|
||||
chess960Index: number | null;
|
||||
/** Current position (single source of truth). */
|
||||
fen: string;
|
||||
moves: ChessMoveRecord[];
|
||||
@@ -149,6 +154,8 @@ export interface ChessView {
|
||||
/** 64 FEN letters (uppercase = white), a8 first, then b8 … h1. */
|
||||
board: (string | null)[];
|
||||
players: Record<Color, PlayerId>;
|
||||
/** Chess960 number, null for standard chess. */
|
||||
chess960Index: number | null;
|
||||
myColor: Color | null;
|
||||
turn: Color;
|
||||
phase: ChessState['phase'];
|
||||
@@ -183,6 +190,10 @@ const undoLimit = (o: ChessOptions): number => (o.undo === 'off' ? 0 : o.undo ==
|
||||
const colorOf = (s: ChessState, p: PlayerId): Color | null =>
|
||||
s.players.white === p ? 'white' : s.players.black === p ? 'black' : null;
|
||||
const turnOf = (s: ChessState): Color => (s.fen.split(' ')[1] === 'b' ? 'black' : 'white');
|
||||
/** `!= null`: finished games saved before v2 are never migrated and have no `chess960Index`. */
|
||||
const is960 = (s: ChessState): boolean => s.chess960Index != null;
|
||||
/** Load a position of this game (Chess960 rules even after both sides lost their castling rights). */
|
||||
const position = (s: ChessState, fen = s.fen): Position => load(fen, is960(s));
|
||||
|
||||
const REASON_KO: Record<ChessReason, string> = {
|
||||
checkmate: '체크메이트',
|
||||
@@ -213,7 +224,7 @@ function finish(s: ChessState, winner: Color | null, reason: ChessReason, events
|
||||
const pgn = buildPgn(
|
||||
s.startFen,
|
||||
s.moves.map((m) => m.san),
|
||||
{ White: s.players.white, Black: s.players.black, Termination: REASON_KO[reason] },
|
||||
{ ...(is960(s) ? { Variant: 'Chess960' } : {}), White: s.players.white, Black: s.players.black, Termination: REASON_KO[reason] },
|
||||
tag,
|
||||
);
|
||||
s.result = { winner, reason, moveCount, pgn, summary };
|
||||
@@ -223,11 +234,11 @@ function finish(s: ChessState, winner: Color | null, reason: ChessReason, events
|
||||
/** Rebuild repetition counts from the start position and the stored positions after each move. */
|
||||
function rebuildRepetition(s: ChessState): void {
|
||||
s.repetition = {};
|
||||
for (const k of [keyOf(load(s.startFen)), ...s.moves.map((m) => m.key)]) s.repetition[k] = (s.repetition[k] ?? 0) + 1;
|
||||
for (const k of [keyOf(position(s, s.startFen)), ...s.moves.map((m) => m.key)]) s.repetition[k] = (s.repetition[k] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** §8.2 end order: checkmate > stalemate > insufficient > (75/5-fold) > (auto: 50/3-fold) → claimable. */
|
||||
function judge(s: ChessState, c: Chess, events: GameEvent[]): void {
|
||||
function judge(s: ChessState, c: Position, events: GameEvent[]): void {
|
||||
const mover = other(turnOf(s));
|
||||
if (c.isCheckmate()) {
|
||||
events.push({ type: 'checkmate', color: mover });
|
||||
@@ -266,15 +277,30 @@ function flag(s: ChessState, loser: Color, events: GameEvent[]): void {
|
||||
if (s.options.timeControl.kind === 'fischer') s.clock.remainingMs[loser] = 0;
|
||||
events.push({ type: 'timeLoss', color: loser });
|
||||
const winner = other(loser);
|
||||
if (cannotMate(load(s.fen), toSide(winner))) finish(s, null, 'timeoutVsInsufficient', events);
|
||||
if (cannotMate(position(s), toSide(winner))) finish(s, null, 'timeoutVsInsufficient', events);
|
||||
else finish(s, winner, 'timeLoss', events);
|
||||
}
|
||||
|
||||
function resolveMove(s: ChessState, a: ChessAction): { from: Square; to: Square; promotion?: Promo } | null {
|
||||
if (a.type === 'move') return { from: a.from as Square, to: a.to as Square, promotion: a.promotion };
|
||||
if (a.type !== 'castle') return null;
|
||||
const m = legalMoves(load(s.fen)).find((x) => x.castle === a.side);
|
||||
return m ? { from: m.from as Square, to: m.to as Square } : null;
|
||||
/**
|
||||
* Copy for `apply`. Move records, options, players, pending requests and results are never mutated once stored, so
|
||||
* they are shared; everything `apply` mutates in place is copied. (structuredClone of a long game cost ~0.5ms a move.)
|
||||
*/
|
||||
function cloneState(p: ChessState): ChessState {
|
||||
return {
|
||||
...p,
|
||||
moves: p.moves.slice(),
|
||||
repetition: { ...p.repetition },
|
||||
clock: { remainingMs: { ...p.clock.remainingMs }, turnStartedAt: p.clock.turnStartedAt, started: { ...p.clock.started } },
|
||||
undo: { usedBy: { ...p.undo.usedBy }, pending: p.undo.pending },
|
||||
draw: { pendingBy: p.draw.pendingBy, lastOfferPly: { ...p.draw.lastOfferPly } },
|
||||
};
|
||||
}
|
||||
|
||||
/** From/to squares of a stored move. Chess960 castling UCI is king → rook; the king actually lands on c or g. */
|
||||
function lastMoveOf(m: ChessMoveRecord): { from: string; to: string } {
|
||||
const from = m.uci.slice(0, 2);
|
||||
if (!m.san.startsWith('O-O')) return { from, to: m.uci.slice(2, 4) };
|
||||
return { from, to: (m.san.startsWith('O-O-O') ? 'c' : 'g') + from[1] };
|
||||
}
|
||||
|
||||
function pendingResponder(s: ChessState): Color | null {
|
||||
@@ -288,7 +314,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
nameKo: '체스',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
stateVersion: 1,
|
||||
stateVersion: 2,
|
||||
defaultOptions: chessOptionsSchema.parse({}),
|
||||
optionsSchema: chessOptionsSchema,
|
||||
actionSchema: chessActionSchema as z.ZodType<ChessAction>,
|
||||
@@ -310,13 +336,16 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
hostWhite = rng.int(2) === 0;
|
||||
}
|
||||
const base = options.timeControl.baseMin * 60_000;
|
||||
const chess960Index = options.variant === 'chess960' ? rng.int(960) : null;
|
||||
const startFen = startFenFor(chess960Index);
|
||||
return {
|
||||
options,
|
||||
players: hostWhite ? { white: host, black: guest } : { white: guest, black: host },
|
||||
startFen: START_FEN,
|
||||
fen: START_FEN,
|
||||
startFen,
|
||||
chess960Index,
|
||||
fen: startFen,
|
||||
moves: [],
|
||||
repetition: { [positionKey(START_FEN)]: 1 },
|
||||
repetition: { [positionKey(startFen)]: 1 },
|
||||
phase: 'playing',
|
||||
clock: { remainingMs: { white: base, black: base }, turnStartedAt: now, started: { white: false, black: false } },
|
||||
undo: { usedBy: { white: 0, black: 0 }, pending: null },
|
||||
@@ -335,7 +364,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
case 'move':
|
||||
case 'castle': {
|
||||
if (turn !== me) return fail('지금은 상대 차례예요.');
|
||||
const c = load(s.fen);
|
||||
const c = position(s);
|
||||
if (a.type === 'castle') {
|
||||
return legalMoves(c).some((m) => m.castle === a.side) ? ok : fail('지금은 그쪽으로 캐슬링할 수 없어요.');
|
||||
}
|
||||
@@ -344,7 +373,12 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
if (!piece || piece.color !== toSide(me)) return fail('내 기물을 골라 주세요.');
|
||||
const m = legalMoves(c, a.from).find((x) => x.to === a.to);
|
||||
if (!m) {
|
||||
if (piece.type === 'k' && Math.abs(a.from.charCodeAt(0) - a.to.charCodeAt(0)) === 2 && a.from[1] === a.to[1]) {
|
||||
const target = c.get(a.to);
|
||||
const fileGap = Math.abs(a.from.charCodeAt(0) - a.to.charCodeAt(0));
|
||||
const castleTry = c.chess960
|
||||
? (target?.type === 'r' && target.color === piece.color) || (fileGap >= 2 && 'cg'.includes(a.to[0]!))
|
||||
: fileGap === 2;
|
||||
if (piece.type === 'k' && a.from[1] === a.to[1] && castleTry) {
|
||||
return fail('지금은 캐슬링할 수 없어요.');
|
||||
}
|
||||
if (pseudoReachable(c, a.from, a.to)) {
|
||||
@@ -394,7 +428,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { now }) {
|
||||
const s = structuredClone(prev);
|
||||
const s = cloneState(prev);
|
||||
const me = colorOf(s, actor)!;
|
||||
const events: GameEvent[] = [];
|
||||
switch (a.type) {
|
||||
@@ -417,14 +451,14 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000;
|
||||
}
|
||||
// 2) Move.
|
||||
const mv = resolveMove(s, a)!;
|
||||
const c = load(s.fen);
|
||||
const isPromo = c.get(mv.from)?.type === 'p' && (mv.to[1] === '8' || mv.to[1] === '1');
|
||||
const done = c.move({ from: mv.from, to: mv.to, promotion: isPromo ? (mv.promotion ?? 'q') : undefined });
|
||||
const c = position(s);
|
||||
const legal = c.moves();
|
||||
const mv = a.type === 'castle' ? c.castleMove(a.side, legal)! : c.findMove(a.from, a.to, a.promotion, legal)!;
|
||||
const done = c.play(mv, legal);
|
||||
s.fen = fullFen(c);
|
||||
const key = keyOf(c);
|
||||
s.moves.push({
|
||||
uci: done.lan,
|
||||
uci: done.uci,
|
||||
san: done.san,
|
||||
color: me,
|
||||
at: now,
|
||||
@@ -441,11 +475,9 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
}
|
||||
const check = c.inCheck();
|
||||
events.push({ type: 'moved', color: me, san: done.san, from: done.from, to: done.to, captured: done.captured ?? null, check });
|
||||
if (done.isPromotion()) events.push({ type: 'promoted', color: me, piece: done.promotion });
|
||||
if (done.isKingsideCastle() || done.isQueensideCastle()) {
|
||||
events.push({ type: 'castled', color: me, side: done.isKingsideCastle() ? 'king' : 'queen' });
|
||||
}
|
||||
if (done.isEnPassant()) events.push({ type: 'enPassant', color: me });
|
||||
if (done.promotion) events.push({ type: 'promoted', color: me, piece: done.promotion });
|
||||
if (done.castle) events.push({ type: 'castled', color: me, side: done.castle });
|
||||
if (done.enPassant) events.push({ type: 'enPassant', color: me });
|
||||
if (check) events.push({ type: 'check', color: other(me) });
|
||||
// 3) End of game / claimable.
|
||||
judge(s, c, events);
|
||||
@@ -482,7 +514,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
rebuildRepetition(s);
|
||||
s.undo.usedBy[by]++;
|
||||
s.clock.turnStartedAt = now;
|
||||
refreshClaimable(s, s.moves.length ? s.moves[s.moves.length - 1]!.key : keyOf(load(s.startFen)));
|
||||
refreshClaimable(s, s.moves.length ? s.moves[s.moves.length - 1]!.key : keyOf(position(s, s.startFen)));
|
||||
events.push({ type: 'undoAccepted', color: by, plies });
|
||||
break;
|
||||
}
|
||||
@@ -518,7 +550,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
view(s, viewer) {
|
||||
const myColor = viewer ? colorOf(s, viewer) : null;
|
||||
const turn = turnOf(s);
|
||||
const c = load(s.fen);
|
||||
const c = position(s);
|
||||
const mover = s.phase === 'playing' && myColor !== null && myColor === turn;
|
||||
const limit = undoLimit(s.options);
|
||||
const last = s.moves[s.moves.length - 1];
|
||||
@@ -528,11 +560,12 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
fen: s.fen,
|
||||
board: boardArray(s.fen),
|
||||
players: s.players,
|
||||
chess960Index: s.chess960Index ?? null,
|
||||
myColor,
|
||||
turn,
|
||||
phase: s.phase,
|
||||
moves: s.moves.map((m) => ({ san: m.san, uci: m.uci, color: m.color })),
|
||||
lastMove: last ? { from: last.uci.slice(0, 2), to: last.uci.slice(2, 4) } : null,
|
||||
lastMove: last ? lastMoveOf(last) : null,
|
||||
checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null,
|
||||
timeControl: s.options.timeControl,
|
||||
firstMoveSec: s.options.firstMoveSec,
|
||||
@@ -615,6 +648,13 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
return { ranking, summary, reason: map[reason] };
|
||||
},
|
||||
|
||||
/** v1 (standard only) → v2: Chess960 option and `chess960Index`. */
|
||||
migrate(old, fromVersion) {
|
||||
const s = old as ChessState;
|
||||
if (fromVersion < 2) return { ...s, options: chessOptionsSchema.parse(s.options), chess960Index: null };
|
||||
return s;
|
||||
},
|
||||
|
||||
legalActions(s, p) {
|
||||
const me = colorOf(s, p);
|
||||
if (!me || s.phase !== 'playing') return [];
|
||||
@@ -629,10 +669,13 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
}
|
||||
if (turnOf(s) !== me) return [];
|
||||
const out: ChessAction[] = [];
|
||||
for (const m of legalMoves(load(s.fen))) {
|
||||
const castles = new Set<'king' | 'queen'>();
|
||||
for (const m of legalMoves(position(s))) {
|
||||
if (m.promotion) for (const promotion of ['q', 'r', 'b', 'n'] as const) out.push({ type: 'move', from: m.from, to: m.to, promotion });
|
||||
else out.push({ type: 'move', from: m.from, to: m.to });
|
||||
if (m.castle) castles.add(m.castle);
|
||||
}
|
||||
for (const side of castles) out.push({ type: 'castle', side });
|
||||
if (s.claimable) out.push({ type: 'claimDraw' });
|
||||
for (const extra of [{ type: 'requestUndo' }, { type: 'offerDraw' }] as const) {
|
||||
if (chess.validate(s, p, extra).ok) out.push(extra);
|
||||
|
||||
570
packages/games/src/chess/movegen.ts
Normal file
570
packages/games/src/chess/movegen.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* Self-contained legal move generator for standard chess and Chess960 (docs/games/chess.md §8.0).
|
||||
*
|
||||
* - 0x88 board (index = rank * 16 + file, a1 = 0), make/unmake with an undo stack.
|
||||
* - Castling is stored as the castling rook's square per colour and side, so standard and Chess960 share one code
|
||||
* path. FEN input accepts KQkq (X-FEN: outermost rook) and Shredder-FEN file letters (`HAha`); output uses KQkq
|
||||
* for standard and Shredder-FEN for Chess960.
|
||||
* - En passant follows chess.js 1.4 exactly (the stored square is set only when an enemy pawn stands next to the
|
||||
* double-stepped pawn, and `fen()` prints it only when a legal e.p. capture exists), so FENs and repetition keys of
|
||||
* games saved before this generator replaced chess.js stay byte-identical.
|
||||
* - SAN (incl. disambiguation and +/#) follows chess.js; Chess960 castling is written O-O / O-O-O.
|
||||
* Verified with perft (chess.perft.test.ts) and differentially against chess.js (dev dependency only).
|
||||
*/
|
||||
|
||||
export type Side = 'w' | 'b';
|
||||
export type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k';
|
||||
|
||||
const PAWN = 1;
|
||||
const KNIGHT = 2;
|
||||
const BISHOP = 3;
|
||||
const ROOK = 4;
|
||||
const QUEEN = 5;
|
||||
const KING = 6;
|
||||
const BLACK = 8;
|
||||
const TYPE_CHARS = ' pnbrqk';
|
||||
|
||||
const F_EP = 1;
|
||||
const F_DOUBLE = 2;
|
||||
const F_CASTLE_K = 4;
|
||||
const F_CASTLE_Q = 8;
|
||||
const F_CASTLE = F_CASTLE_K | F_CASTLE_Q;
|
||||
|
||||
const KNIGHT_STEPS = [33, 31, 18, 14, -33, -31, -18, -14];
|
||||
const KING_STEPS = [1, -1, 16, -16, 15, 17, -15, -17];
|
||||
const DIAG = [15, 17, -15, -17];
|
||||
const ORTHO = [1, -1, 16, -16];
|
||||
|
||||
/** A move in board coordinates. Castling: `to` is the king's destination and `rook` the castling rook's square. */
|
||||
export interface Move {
|
||||
from: number;
|
||||
to: number;
|
||||
/** Moving piece type (1..6). */
|
||||
piece: number;
|
||||
/** Captured piece type, 0 if none (castling never captures). */
|
||||
captured: number;
|
||||
/** Promotion piece type, 0 if none. */
|
||||
promo: number;
|
||||
flags: number;
|
||||
rook: number;
|
||||
}
|
||||
|
||||
interface Undo {
|
||||
move: Move;
|
||||
castle: [number, number, number, number];
|
||||
ep: number;
|
||||
half: number;
|
||||
}
|
||||
|
||||
export interface PieceAt {
|
||||
square: string;
|
||||
color: Side;
|
||||
type: PieceType;
|
||||
}
|
||||
|
||||
/** What `play` reports about a move (enough for events, records and SAN). */
|
||||
export interface Played {
|
||||
from: string;
|
||||
to: string;
|
||||
/** UCI; Chess960 castling is king square + rook square (UCI_Chess960 convention). */
|
||||
uci: string;
|
||||
san: string;
|
||||
piece: PieceType;
|
||||
captured?: PieceType;
|
||||
promotion?: PieceType;
|
||||
castle?: 'king' | 'queen';
|
||||
enPassant: boolean;
|
||||
}
|
||||
|
||||
const FILES = 'abcdefgh';
|
||||
export const sqName = (s: number): string => FILES[s & 7]! + ((s >> 4) + 1);
|
||||
export function sqIndex(name: string): number {
|
||||
const f = name.charCodeAt(0) - 97;
|
||||
const r = name.charCodeAt(1) - 49;
|
||||
return f >= 0 && f < 8 && r >= 0 && r < 8 && name.length === 2 ? r * 16 + f : -1;
|
||||
}
|
||||
const onBoard = (s: number) => (s & 0x88) === 0;
|
||||
|
||||
export class Position {
|
||||
readonly b = new Int8Array(128);
|
||||
/** 0 = white, 1 = black to move. */
|
||||
side = 0;
|
||||
/** Castling rook squares: [white king side, white queen side, black king side, black queen side], -1 = no right. */
|
||||
castle: [number, number, number, number] = [-1, -1, -1, -1];
|
||||
ep = -1;
|
||||
half = 0;
|
||||
full = 1;
|
||||
readonly king = [-1, -1];
|
||||
/** Chess960: Shredder-FEN output and king-onto-rook castling input. */
|
||||
chess960 = false;
|
||||
private readonly stack: Undo[] = [];
|
||||
|
||||
/** Parse a FEN. Chess960 is detected from Shredder-FEN castling letters unless given explicitly. */
|
||||
static fromFen(fen: string, chess960?: boolean): Position {
|
||||
const p = new Position();
|
||||
const [placement = '', side = 'w', castling = '-', ep = '-', half = '0', full = '1'] = fen.trim().split(/\s+/);
|
||||
const rows = placement.split('/');
|
||||
if (rows.length !== 8) throw new Error(`bad FEN: ${fen}`);
|
||||
rows.forEach((row, i) => {
|
||||
let f = 0;
|
||||
for (const ch of row) {
|
||||
if (ch >= '1' && ch <= '8') f += Number(ch);
|
||||
else {
|
||||
const t = TYPE_CHARS.indexOf(ch.toLowerCase());
|
||||
if (t <= 0 || f > 7) throw new Error(`bad FEN: ${fen}`);
|
||||
const s = (7 - i) * 16 + f;
|
||||
p.b[s] = t | (ch === ch.toLowerCase() ? BLACK : 0);
|
||||
if (t === KING) p.king[ch === 'k' ? 1 : 0] = s;
|
||||
f++;
|
||||
}
|
||||
}
|
||||
if (f !== 8) throw new Error(`bad FEN: ${fen}`);
|
||||
});
|
||||
if (p.king[0]! < 0 || p.king[1]! < 0) throw new Error(`bad FEN (king missing): ${fen}`);
|
||||
p.side = side === 'b' ? 1 : 0;
|
||||
p.chess960 = chess960 ?? /[A-Ha-h]/.test(castling);
|
||||
for (const ch of castling === '-' ? '' : castling) p.addCastlingRight(ch);
|
||||
p.ep = ep === '-' ? -1 : sqIndex(ep);
|
||||
p.half = Number(half) || 0;
|
||||
p.full = Number(full) || 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
private addCastlingRight(ch: string): void {
|
||||
const c = ch === ch.toUpperCase() ? 0 : 1;
|
||||
const base = c * 0x70;
|
||||
const k = this.king[c]!;
|
||||
if (k >> 4 !== c * 7) return;
|
||||
const rook = ROOK | (c ? BLACK : 0);
|
||||
const lower = ch.toLowerCase();
|
||||
let rs = -1;
|
||||
if (lower === 'k') {
|
||||
for (let f = 7; f > (k & 7); f--) if (this.b[base + f] === rook) { rs = base + f; break; }
|
||||
} else if (lower === 'q') {
|
||||
for (let f = 0; f < (k & 7); f++) if (this.b[base + f] === rook) { rs = base + f; break; }
|
||||
} else {
|
||||
const f = lower.charCodeAt(0) - 97;
|
||||
if (f >= 0 && f < 8 && f !== (k & 7) && this.b[base + f] === rook) rs = base + f;
|
||||
}
|
||||
if (rs < 0) return;
|
||||
// Standard chess: only the e-file king with a/h rooks may castle (as chess.js).
|
||||
if (!this.chess960 && ((k & 7) !== 4 || ((rs & 7) !== 0 && (rs & 7) !== 7))) return;
|
||||
this.castle[c * 2 + ((rs & 7) > (k & 7) ? 0 : 1)] = rs;
|
||||
}
|
||||
|
||||
fen(opts: { forceEnpassantSquare?: boolean } = {}): string {
|
||||
let out = '';
|
||||
for (let r = 7; r >= 0; r--) {
|
||||
let empty = 0;
|
||||
for (let f = 0; f < 8; f++) {
|
||||
const p = this.b[r * 16 + f]!;
|
||||
if (!p) {
|
||||
empty++;
|
||||
continue;
|
||||
}
|
||||
if (empty) out += empty;
|
||||
empty = 0;
|
||||
const ch = TYPE_CHARS[p & 7]!;
|
||||
out += p & BLACK ? ch : ch.toUpperCase();
|
||||
}
|
||||
if (empty) out += empty;
|
||||
if (r) out += '/';
|
||||
}
|
||||
let castling = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const rs = this.castle[i]!;
|
||||
if (rs < 0) continue;
|
||||
const ch = this.chess960 ? FILES[rs & 7]! : i % 2 === 0 ? 'k' : 'q';
|
||||
castling += i < 2 ? ch.toUpperCase() : ch;
|
||||
}
|
||||
const ep = this.ep >= 0 && (opts.forceEnpassantSquare || this.epCapturable()) ? sqName(this.ep) : '-';
|
||||
return `${out} ${this.side ? 'b' : 'w'} ${castling || '-'} ${ep} ${this.half} ${this.full}`;
|
||||
}
|
||||
|
||||
/** Is there a legal en-passant capture right now? */
|
||||
private epCapturable(): boolean {
|
||||
const us = this.side;
|
||||
const pawn = PAWN | (us ? BLACK : 0);
|
||||
const behind = this.ep + (us ? 16 : -16);
|
||||
for (const s of [behind - 1, behind + 1]) {
|
||||
if (!onBoard(s) || this.b[s] !== pawn) continue;
|
||||
const m: Move = { from: s, to: this.ep, piece: PAWN, captured: PAWN, promo: 0, flags: F_EP, rook: -1 };
|
||||
if (this.isLegal(m)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
turn(): Side {
|
||||
return this.side ? 'b' : 'w';
|
||||
}
|
||||
|
||||
get(square: string): { type: PieceType; color: Side } | null {
|
||||
const s = sqIndex(square);
|
||||
const p = s < 0 ? 0 : this.b[s]!;
|
||||
return p ? { type: TYPE_CHARS[p & 7] as PieceType, color: p & BLACK ? 'b' : 'w' } : null;
|
||||
}
|
||||
|
||||
pieces(): PieceAt[] {
|
||||
const out: PieceAt[] = [];
|
||||
for (let r = 7; r >= 0; r--) {
|
||||
for (let f = 0; f < 8; f++) {
|
||||
const p = this.b[r * 16 + f]!;
|
||||
if (p) out.push({ square: sqName(r * 16 + f), type: TYPE_CHARS[p & 7] as PieceType, color: p & BLACK ? 'b' : 'w' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
kingSquare(side: Side): string {
|
||||
return sqName(this.king[side === 'w' ? 0 : 1]!);
|
||||
}
|
||||
|
||||
/** Is square `s` attacked by colour `by` (0 white, 1 black)? */
|
||||
attacked(s: number, by: number): boolean {
|
||||
const b = this.b;
|
||||
const col = by ? BLACK : 0;
|
||||
const pawnFrom = by ? [s + 15, s + 17] : [s - 15, s - 17];
|
||||
for (const t of pawnFrom) if (onBoard(t) && b[t] === (PAWN | col)) return true;
|
||||
for (const d of KNIGHT_STEPS) {
|
||||
const t = s + d;
|
||||
if (onBoard(t) && b[t] === (KNIGHT | col)) return true;
|
||||
}
|
||||
for (const d of KING_STEPS) {
|
||||
const t = s + d;
|
||||
if (onBoard(t) && b[t] === (KING | col)) return true;
|
||||
}
|
||||
for (const d of DIAG) {
|
||||
for (let t = s + d; onBoard(t); t += d) {
|
||||
const p = b[t]!;
|
||||
if (!p) continue;
|
||||
if (p === (BISHOP | col) || p === (QUEEN | col)) return true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const d of ORTHO) {
|
||||
for (let t = s + d; onBoard(t); t += d) {
|
||||
const p = b[t]!;
|
||||
if (!p) continue;
|
||||
if (p === (ROOK | col) || p === (QUEEN | col)) return true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isAttacked(square: string, by: Side): boolean {
|
||||
return this.attacked(sqIndex(square), by === 'w' ? 0 : 1);
|
||||
}
|
||||
|
||||
inCheck(): boolean {
|
||||
return this.attacked(this.king[this.side]!, this.side ^ 1);
|
||||
}
|
||||
|
||||
/** Pseudo-legal moves (own king may be left in check), castling fully checked except the final square. */
|
||||
private pseudo(): Move[] {
|
||||
const out: Move[] = [];
|
||||
const b = this.b;
|
||||
const us = this.side;
|
||||
const col = us ? BLACK : 0;
|
||||
const add = (from: number, to: number, piece: number, captured: number, flags = 0) =>
|
||||
out.push({ from, to, piece, captured, promo: 0, flags, rook: -1 });
|
||||
const enemy = (p: number) => p !== 0 && (p & BLACK) !== col;
|
||||
for (let s = 0; s < 120; s++) {
|
||||
if (s & 0x88) {
|
||||
s += 7;
|
||||
continue;
|
||||
}
|
||||
const p = b[s]!;
|
||||
if (!p || (p & BLACK) !== col) continue;
|
||||
const t0 = p & 7;
|
||||
if (t0 === PAWN) {
|
||||
const dir = us ? -16 : 16;
|
||||
const promoRank = us ? 0 : 7;
|
||||
const pawnMove = (to: number, captured: number, flags = 0) => {
|
||||
if (to >> 4 === promoRank) {
|
||||
for (const promo of [QUEEN, ROOK, BISHOP, KNIGHT]) out.push({ from: s, to, piece: PAWN, captured, promo, flags, rook: -1 });
|
||||
} else add(s, to, PAWN, captured, flags);
|
||||
};
|
||||
const one = s + dir;
|
||||
if (onBoard(one) && !b[one]) {
|
||||
pawnMove(one, 0);
|
||||
const two = one + dir;
|
||||
if (s >> 4 === (us ? 6 : 1) && !b[two]) add(s, two, PAWN, 0, F_DOUBLE);
|
||||
}
|
||||
for (const t of [one - 1, one + 1]) {
|
||||
if (!onBoard(t)) continue;
|
||||
if (enemy(b[t]!)) pawnMove(t, b[t]! & 7);
|
||||
else if (t === this.ep) add(s, t, PAWN, PAWN, F_EP);
|
||||
}
|
||||
} else if (t0 === KNIGHT || t0 === KING) {
|
||||
for (const d of t0 === KNIGHT ? KNIGHT_STEPS : KING_STEPS) {
|
||||
const t = s + d;
|
||||
if (!onBoard(t)) continue;
|
||||
const q = b[t]!;
|
||||
if (!q) add(s, t, t0, 0);
|
||||
else if (enemy(q)) add(s, t, t0, q & 7);
|
||||
}
|
||||
} else {
|
||||
const dirs = t0 === BISHOP ? DIAG : t0 === ROOK ? ORTHO : KING_STEPS;
|
||||
for (const d of dirs) {
|
||||
for (let t = s + d; onBoard(t); t += d) {
|
||||
const q = b[t]!;
|
||||
if (!q) add(s, t, t0, 0);
|
||||
else {
|
||||
if (enemy(q)) add(s, t, t0, q & 7);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.castlingMoves(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* §5.2: king and rook unmoved (rights), every square between king and its destination and between rook and its
|
||||
* destination empty except for that king and rook, the king not in check and not passing through an attacked
|
||||
* square (all checked on the current board, start and destination included). The general legality test after the
|
||||
* move additionally catches a Chess960 castling rook that was shielding the king's destination from a slider.
|
||||
*/
|
||||
private castlingMoves(out: Move[]): void {
|
||||
const us = this.side;
|
||||
const them = us ^ 1;
|
||||
const k = this.king[us]!;
|
||||
const base = us * 0x70;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const r = this.castle[us * 2 + i]!;
|
||||
if (r < 0) continue;
|
||||
const kTo = base + (i === 0 ? 6 : 2);
|
||||
const rTo = base + (i === 0 ? 5 : 3);
|
||||
let ok = true;
|
||||
for (const [a, z] of [
|
||||
[k, kTo],
|
||||
[r, rTo],
|
||||
] as const) {
|
||||
for (let s = Math.min(a, z); s <= Math.max(a, z) && ok; s++) if (s !== k && s !== r && this.b[s]) ok = false;
|
||||
}
|
||||
for (let s = Math.min(k, kTo); s <= Math.max(k, kTo) && ok; s++) if (this.attacked(s, them)) ok = false;
|
||||
if (ok) out.push({ from: k, to: kTo, piece: KING, captured: 0, promo: 0, flags: i === 0 ? F_CASTLE_K : F_CASTLE_Q, rook: r });
|
||||
}
|
||||
}
|
||||
|
||||
make(m: Move): void {
|
||||
const b = this.b;
|
||||
const us = this.side;
|
||||
const them = us ^ 1;
|
||||
const col = us ? BLACK : 0;
|
||||
this.stack.push({ move: m, castle: [...this.castle], ep: this.ep, half: this.half });
|
||||
if (m.flags & F_CASTLE) {
|
||||
const base = us * 0x70;
|
||||
b[m.from] = 0;
|
||||
b[m.rook] = 0;
|
||||
b[m.to] = KING | col;
|
||||
b[base + (m.flags & F_CASTLE_K ? 5 : 3)] = ROOK | col;
|
||||
} else {
|
||||
if (m.flags & F_EP) b[m.to + (us ? 16 : -16)] = 0;
|
||||
b[m.to] = m.promo ? m.promo | col : b[m.from]!;
|
||||
b[m.from] = 0;
|
||||
}
|
||||
if (m.piece === KING) {
|
||||
this.king[us] = m.to;
|
||||
this.castle[us * 2] = -1;
|
||||
this.castle[us * 2 + 1] = -1;
|
||||
}
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (this.castle[us * 2 + i] === m.from) this.castle[us * 2 + i] = -1;
|
||||
if (m.captured && this.castle[them * 2 + i] === m.to) this.castle[them * 2 + i] = -1;
|
||||
}
|
||||
this.ep = -1;
|
||||
if (m.flags & F_DOUBLE) {
|
||||
const enemyPawn = PAWN | (them ? BLACK : 0);
|
||||
if ((onBoard(m.to - 1) && b[m.to - 1] === enemyPawn) || (onBoard(m.to + 1) && b[m.to + 1] === enemyPawn)) this.ep = (m.from + m.to) >> 1;
|
||||
}
|
||||
this.half = m.piece === PAWN || m.captured ? 0 : this.half + 1;
|
||||
if (us) this.full++;
|
||||
this.side = them;
|
||||
}
|
||||
|
||||
unmake(): void {
|
||||
const u = this.stack.pop()!;
|
||||
const m = u.move;
|
||||
const b = this.b;
|
||||
const us = this.side ^ 1;
|
||||
const col = us ? BLACK : 0;
|
||||
this.side = us;
|
||||
this.castle = u.castle;
|
||||
this.ep = u.ep;
|
||||
this.half = u.half;
|
||||
if (us) this.full--;
|
||||
if (m.flags & F_CASTLE) {
|
||||
b[m.to] = 0;
|
||||
b[us * 0x70 + (m.flags & F_CASTLE_K ? 5 : 3)] = 0;
|
||||
b[m.from] = KING | col;
|
||||
b[m.rook] = ROOK | col;
|
||||
} else {
|
||||
b[m.from] = m.promo ? PAWN | col : b[m.to]!;
|
||||
const capCol = us ? 0 : BLACK;
|
||||
if (m.flags & F_EP) {
|
||||
b[m.to] = 0;
|
||||
b[m.to + (us ? 16 : -16)] = PAWN | capCol;
|
||||
} else b[m.to] = m.captured ? m.captured | capCol : 0;
|
||||
}
|
||||
if (m.piece === KING) this.king[us] = m.from;
|
||||
}
|
||||
|
||||
private isLegal(m: Move): boolean {
|
||||
const us = this.side;
|
||||
this.make(m);
|
||||
const ok = !this.attacked(this.king[us]!, us ^ 1);
|
||||
this.unmake();
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** All legal moves; promotions appear once per piece (q, r, b, n). */
|
||||
moves(): Move[] {
|
||||
return this.pseudo().filter((m) => this.isLegal(m));
|
||||
}
|
||||
|
||||
isCheckmate(): boolean {
|
||||
return this.inCheck() && this.moves().length === 0;
|
||||
}
|
||||
|
||||
isStalemate(): boolean {
|
||||
return !this.inCheck() && this.moves().length === 0;
|
||||
}
|
||||
|
||||
perft(depth: number): number {
|
||||
if (depth === 0) return 1;
|
||||
const moves = this.moves();
|
||||
if (depth === 1) return moves.length;
|
||||
let n = 0;
|
||||
for (const m of moves) {
|
||||
this.make(m);
|
||||
n += this.perft(depth - 1);
|
||||
this.unmake();
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** UCI for a move; Chess960 castling is written king square → rook square. */
|
||||
uci(m: Move): string {
|
||||
if (m.flags & F_CASTLE && this.chess960) return sqName(m.from) + sqName(m.rook);
|
||||
return sqName(m.from) + sqName(m.to) + (m.promo ? TYPE_CHARS[m.promo] : '');
|
||||
}
|
||||
|
||||
/** SAN as chess.js writes it (`legal` = this position's legal moves, for disambiguation). */
|
||||
san(m: Move, legal: Move[] = this.moves()): string {
|
||||
let out: string;
|
||||
if (m.flags & F_CASTLE) out = m.flags & F_CASTLE_K ? 'O-O' : 'O-O-O';
|
||||
else {
|
||||
out = '';
|
||||
if (m.piece !== PAWN) {
|
||||
let ambiguities = 0;
|
||||
let sameRank = 0;
|
||||
let sameFile = 0;
|
||||
for (const o of legal) {
|
||||
if (o.piece !== m.piece || o.from === m.from || o.to !== m.to || o.flags & F_CASTLE) continue;
|
||||
ambiguities++;
|
||||
if (o.from >> 4 === m.from >> 4) sameRank++;
|
||||
if ((o.from & 7) === (m.from & 7)) sameFile++;
|
||||
}
|
||||
let dis = '';
|
||||
if (ambiguities > 0) {
|
||||
const name = sqName(m.from);
|
||||
dis = sameRank > 0 && sameFile > 0 ? name : sameFile > 0 ? name[1]! : name[0]!;
|
||||
}
|
||||
out += TYPE_CHARS[m.piece]!.toUpperCase() + dis;
|
||||
}
|
||||
if (m.captured) {
|
||||
if (m.piece === PAWN) out += FILES[m.from & 7];
|
||||
out += 'x';
|
||||
}
|
||||
out += sqName(m.to);
|
||||
if (m.promo) out += '=' + TYPE_CHARS[m.promo]!.toUpperCase();
|
||||
}
|
||||
this.make(m);
|
||||
if (this.inCheck()) out += this.moves().length === 0 ? '#' : '+';
|
||||
this.unmake();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Castling move for one side, if legal. */
|
||||
castleMove(side: 'king' | 'queen', legal: Move[] = this.moves()): Move | undefined {
|
||||
return legal.find((m) => m.flags & (side === 'king' ? F_CASTLE_K : F_CASTLE_Q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the legal move for a from/to input. Castling: standard accepts the king's two-step (e1g1); Chess960
|
||||
* accepts the king onto its own castling rook, or onto its destination square when that is not also a plain
|
||||
* king move (e.g. king b1 → g1). Promotions default to a queen when `promotion` is not given.
|
||||
*/
|
||||
findMove(from: string, to: string, promotion?: PieceType, legal: Move[] = this.moves()): Move | undefined {
|
||||
const f = sqIndex(from);
|
||||
const t = sqIndex(to);
|
||||
const promo = promotion ? TYPE_CHARS.indexOf(promotion) : QUEEN;
|
||||
const plain = legal.find((m) => m.from === f && m.to === t && !(m.flags & F_CASTLE) && (!m.promo || m.promo === promo));
|
||||
if (plain) return plain;
|
||||
return legal.find((m) => m.flags & F_CASTLE && m.from === f && (this.chess960 ? m.rook === t || (m.to === t && m.to !== f) : m.to === t));
|
||||
}
|
||||
|
||||
/** Play a legal move and describe it. */
|
||||
play(m: Move, legal: Move[] = this.moves()): Played {
|
||||
const san = this.san(m, legal);
|
||||
const out: Played = {
|
||||
from: sqName(m.from),
|
||||
to: sqName(m.to),
|
||||
uci: this.uci(m),
|
||||
san,
|
||||
piece: TYPE_CHARS[m.piece] as PieceType,
|
||||
enPassant: !!(m.flags & F_EP),
|
||||
};
|
||||
if (m.captured) out.captured = TYPE_CHARS[m.captured] as PieceType;
|
||||
if (m.promo) out.promotion = TYPE_CHARS[m.promo] as PieceType;
|
||||
if (m.flags & F_CASTLE) out.castle = m.flags & F_CASTLE_K ? 'king' : 'queen';
|
||||
this.make(m);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Helpers on raw moves for the adapter. */
|
||||
export const moveInfo = {
|
||||
isCastle: (m: Move): 'king' | 'queen' | null => (m.flags & F_CASTLE_K ? 'king' : m.flags & F_CASTLE_Q ? 'queen' : null),
|
||||
isEnPassant: (m: Move) => !!(m.flags & F_EP),
|
||||
from: (m: Move) => sqName(m.from),
|
||||
to: (m: Move) => sqName(m.to),
|
||||
rook: (m: Move) => sqName(m.rook),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Chess960 start positions
|
||||
|
||||
/** Back rank (white, a→h) for Scharnagl number 0..959 (docs/games/chess.md §4). 518 = RNBQKBNR. */
|
||||
export function chess960BackRank(index: number): string {
|
||||
if (!Number.isInteger(index) || index < 0 || index > 959) throw new Error(`bad Chess960 index ${index}`);
|
||||
const rank: (string | null)[] = Array(8).fill(null);
|
||||
let n = index;
|
||||
rank[2 * (n % 4) + 1] = 'B';
|
||||
n = Math.floor(n / 4);
|
||||
rank[2 * (n % 4)] = 'B';
|
||||
n = Math.floor(n / 4);
|
||||
const free = () => rank.flatMap((p, i) => (p ? [] : [i]));
|
||||
rank[free()[n % 6]!] = 'Q';
|
||||
n = Math.floor(n / 6);
|
||||
const KNIGHTS: [number, number][] = [[0, 1], [0, 2], [0, 3], [0, 4], [1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]];
|
||||
const [k1, k2] = KNIGHTS[n]!;
|
||||
const f5 = free();
|
||||
rank[f5[k1]!] = 'N';
|
||||
rank[f5[k2]!] = 'N';
|
||||
const [r1, k, r2] = free() as [number, number, number];
|
||||
rank[r1] = 'R';
|
||||
rank[k] = 'K';
|
||||
rank[r2] = 'R';
|
||||
return rank.join('');
|
||||
}
|
||||
|
||||
/** Shredder-FEN start position for a Chess960 number, e.g. 518 → `rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w HAha - 0 1`. */
|
||||
export function chess960Fen(index: number): string {
|
||||
const back = chess960BackRank(index);
|
||||
const rooks = [back.lastIndexOf('R'), back.indexOf('R')].map((f) => FILES[f]!);
|
||||
return `${back.toLowerCase()}/pppppppp/8/8/8/8/PPPPPPPP/${back} w ${rooks.join('').toUpperCase()}${rooks.join('')} - 0 1`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user