- packages/chess/src/engine.ts — ChessEngine integrates all rule modules (pawn, knight, sliding, king, castling, en-passant, promotion, check, checkmate, stalemate, draws) into a playable game without the Rete production network - packages/chess/src/pgn.ts — minimal SAN/PGN parser with full disambiguation support (file/rank hints, full from-square) - packages/chess/tests/fide-games/classic-games.test.ts — 5 game tests: Fool's Mate, Scholar's Mate, Ruy López, Sicilian Defence, Italian Game All 5 tests green; typecheck clean.
155 lines
6.1 KiB
TypeScript
155 lines
6.1 KiB
TypeScript
/**
|
|
* FIDE game replay integration tests (P2.23).
|
|
*
|
|
* Each test replays a real chess game (or opening sequence) through the
|
|
* rules engine, asserting that every move is accepted without error and
|
|
* that the correct terminal state is detected.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { ChessEngine, type GameResult } from "../../src/engine.js";
|
|
import { parseSanMove } from "../../src/pgn.js";
|
|
import type { LegalMove } from "../../src/rules/types.js";
|
|
import type { CastlingMove } from "../../src/rules/castling.js";
|
|
|
|
// ── Move resolution ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a SAN string against the engine's current legal moves.
|
|
* Uses piece-type, target square, and available disambiguation fields
|
|
* (full from-square, file hint, rank hint) to identify the unique legal move.
|
|
*/
|
|
function resolveMove(engine: ChessEngine, san: string): LegalMove {
|
|
const parsed = parseSanMove(san);
|
|
const legalMoves = engine.getAllLegalMoves();
|
|
|
|
// ── Castling ──
|
|
if (parsed.isCastlingKingside || parsed.isCastlingQueenside) {
|
|
const m = legalMoves.find(
|
|
mv =>
|
|
(mv as CastlingMove).isCastling === true &&
|
|
((parsed.isCastlingKingside && (mv as CastlingMove).side === "kingside") ||
|
|
(parsed.isCastlingQueenside && (mv as CastlingMove).side === "queenside")),
|
|
);
|
|
if (!m) {
|
|
throw new Error(
|
|
`Castling not available for "${san}" (turn=${engine.getCurrentTurn()})`,
|
|
);
|
|
}
|
|
return m;
|
|
}
|
|
|
|
// ── Piece/pawn moves ──
|
|
const facts = engine.session.allFacts();
|
|
|
|
const candidates = legalMoves.filter(mv => {
|
|
// Filter by target square
|
|
if (mv.to !== parsed.to) return false;
|
|
|
|
// Filter by piece type
|
|
const typeFact = facts.find(
|
|
f => f.id === mv.pieceId && f.attr === "PieceType",
|
|
);
|
|
if (typeFact?.value !== parsed.pieceType) return false;
|
|
|
|
// Disambiguation: full from-square
|
|
if (parsed.from !== undefined && mv.from !== parsed.from) return false;
|
|
|
|
// Disambiguation: file hint
|
|
if (parsed.fromFile !== undefined && (mv.from % 8) !== parsed.fromFile) {
|
|
return false;
|
|
}
|
|
|
|
// Disambiguation: rank hint
|
|
if (
|
|
parsed.fromRank !== undefined &&
|
|
Math.floor(mv.from / 8) !== parsed.fromRank
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (candidates.length === 0) {
|
|
const turn = engine.getCurrentTurn();
|
|
const allMovesSummary = legalMoves
|
|
.slice(0, 8)
|
|
.map(mv => `${mv.from}→${mv.to}`)
|
|
.join(", ");
|
|
throw new Error(
|
|
`No legal move found for "${san}" ` +
|
|
`(to=${parsed.to}, type=${parsed.pieceType}, turn=${turn}). ` +
|
|
`First 8 legal moves: ${allMovesSummary}`,
|
|
);
|
|
}
|
|
|
|
// If promotion, prefer the matching promoteTo variant
|
|
if (parsed.promoteTo) {
|
|
const promo = candidates.find(mv => mv.promoteTo === parsed.promoteTo);
|
|
if (promo) return promo;
|
|
}
|
|
|
|
return candidates[0]!;
|
|
}
|
|
|
|
/**
|
|
* Play a sequence of SAN moves on a fresh engine.
|
|
* Returns the engine and the result string after the last move.
|
|
*/
|
|
function playGame(moves: string[]): { engine: ChessEngine; result: GameResult } {
|
|
const engine = new ChessEngine();
|
|
let result: GameResult = "ongoing";
|
|
for (const san of moves) {
|
|
const move = resolveMove(engine, san);
|
|
result = engine.applyMove(move, move.promoteTo ?? "queen");
|
|
}
|
|
return { engine, result };
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
describe("Classic chess games — FIDE replay (P2.23)", () => {
|
|
// ── 1. Fool's Mate ────────────────────────────────────────────────────────
|
|
// Shortest possible checkmate: white self-destructs in 2 moves.
|
|
it("Fool's Mate (2 white moves, white checkmated)", () => {
|
|
// 1. f3 e5 2. g4 Qh4#
|
|
const { result } = playGame(["f3", "e5", "g4", "Qh4"]);
|
|
expect(result).toBe("checkmate");
|
|
});
|
|
|
|
// ── 2. Scholar's Mate ─────────────────────────────────────────────────────
|
|
// Classic 4-move checkmate against a careless black.
|
|
it("Scholar's Mate (4 moves, black checkmated)", () => {
|
|
// 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? 4. Qxf7#
|
|
const { result } = playGame(["e4", "e5", "Bc4", "Nc6", "Qh5", "Nf6", "Qxf7"]);
|
|
expect(result).toBe("checkmate");
|
|
});
|
|
|
|
// ── 3. Ruy López opening (6 moves) ───────────────────────────────────────
|
|
it("Ruy López opening plays 6 moves without error", () => {
|
|
// 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6
|
|
const { engine, result } = playGame(["e4", "e5", "Nf3", "Nc6", "Bb5", "a6"]);
|
|
expect(result).toBe("ongoing");
|
|
// After 6 half-moves (3 full), white to move
|
|
expect(engine.getCurrentTurn()).toBe("white");
|
|
});
|
|
|
|
// ── 4. Sicilian Defense — capture on d4 ───────────────────────────────────
|
|
it("Sicilian Defense — pawn capture cxd4 on move 3", () => {
|
|
// 1. e4 c5 2. Nf3 d6 3. d4 cxd4
|
|
const { engine, result } = playGame(["e4", "c5", "Nf3", "d6", "d4", "cxd4"]);
|
|
expect(result).toBe("ongoing");
|
|
// 6 half-moves played; white to move
|
|
expect(engine.getCurrentTurn()).toBe("white");
|
|
});
|
|
|
|
// ── 5. Italian Game — 10 moves with bishop capture ───────────────────────
|
|
it("Italian Game / Evans Gambit opening — 10 moves, bishop capture", () => {
|
|
// 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. b4 Bxb4 5. c3 Ba5
|
|
const moves = ["e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "b4", "Bxb4", "c3", "Ba5"];
|
|
const { engine, result } = playGame(moves);
|
|
expect(result).toBe("ongoing");
|
|
// 10 half-moves (5 full), white to move
|
|
expect(engine.getCurrentTurn()).toBe("white");
|
|
});
|
|
});
|