houserules/packages/chess/tests/fide-games/classic-games.test.ts
Joey Yakimowich-Payne 6baab9f3fd
test(chess): replay 5 classic FIDE games; Phase 2 acceptance gate (P2.23)
- 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.
2026-04-16 15:18:57 -06:00

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