From 61b7c4e3264c9e7d319bbca0cf91caf9c3040c39 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Tue, 6 Oct 2026 13:10:51 +0900 Subject: [PATCH] =?UTF-8?q?M9:=20=EC=B2=B4=EC=8A=A4=20=EC=9E=90=EC=B2=B4?= =?UTF-8?q?=20=EC=88=98=20=EC=83=9D=EC=84=B1=EA=B8=B0(=EC=B2=B4=EC=8A=A496?= =?UTF-8?q?0=20=ED=8F=AC=ED=95=A8),=20perft=C2=B7chess.js=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bun.lock | 4 +- packages/games/package.json | 4 +- packages/games/src/chess/chess.perft.test.ts | 235 ++++++++ packages/games/src/chess/chess.test.ts | 202 ++++++- packages/games/src/chess/index.ts | 113 ++-- packages/games/src/chess/movegen.ts | 570 +++++++++++++++++++ packages/games/src/chess/rules.ts | 121 ++-- 7 files changed, 1157 insertions(+), 92 deletions(-) create mode 100644 packages/games/src/chess/chess.perft.test.ts create mode 100644 packages/games/src/chess/movegen.ts diff --git a/bun.lock b/bun.lock index 49b54ad..524d3d0 100644 --- a/bun.lock +++ b/bun.lock @@ -55,9 +55,11 @@ "version": "0.0.0", "dependencies": { "@bg/engine": "workspace:*", - "chess.js": "1.4.0", "zod": "4.6.5", }, + "devDependencies": { + "chess.js": "1.4.0", + }, }, "packages/shared": { "name": "@bg/shared", diff --git a/packages/games/package.json b/packages/games/package.json index f27c818..f113c25 100644 --- a/packages/games/package.json +++ b/packages/games/package.json @@ -11,7 +11,9 @@ }, "dependencies": { "@bg/engine": "workspace:*", - "chess.js": "1.4.0", "zod": "4.6.5" + }, + "devDependencies": { + "chess.js": "1.4.0" } } diff --git a/packages/games/src/chess/chess.perft.test.ts b/packages/games/src/chess/chess.perft.test.ts new file mode 100644 index 0000000..ea699b4 --- /dev/null +++ b/packages/games/src/chess/chess.perft.test.ts @@ -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(); + 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([[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(); + 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 })); + } + }); +}); diff --git a/packages/games/src/chess/chess.test.ts b/packages/games/src/chess/chess.test.ts index ad2931c..db95c24 100644 --- a/packages/games/src/chess/chess.test.ts +++ b/packages/games/src/chess/chess.test.ts @@ -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 = {}): 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(); + 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); +}); diff --git a/packages/games/src/chess/index.ts b/packages/games/src/chess/index.ts index 3c79265..63af16b 100644 --- a/packages/games/src/chess/index.ts +++ b/packages/games/src/chess/index.ts @@ -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 = { 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; + /** 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; + /** 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 = { 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, @@ -310,13 +336,16 @@ export const chess: GameDefinition m.castle === a.side) ? ok : fail('지금은 그쪽으로 캐슬링할 수 없어요.'); } @@ -344,7 +373,12 @@ export const chess: GameDefinition 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 ({ 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(); + 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); diff --git a/packages/games/src/chess/movegen.ts b/packages/games/src/chess/movegen.ts new file mode 100644 index 0000000..6503bf2 --- /dev/null +++ b/packages/games/src/chess/movegen.ts @@ -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`; +} diff --git a/packages/games/src/chess/rules.ts b/packages/games/src/chess/rules.ts index 2eb0fc1..8118b3b 100644 --- a/packages/games/src/chess/rules.ts +++ b/packages/games/src/chess/rules.ts @@ -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 = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 }; +export const PIECE_VALUE: Record = { 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(); - 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;