/** * 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"); }); });