From f94ab386a82d6d5847da94ef35555a723f960fb6 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 15:06:27 -0600 Subject: [PATCH] feat(chess): add checkmate detection (P2.19) --- packages/chess/src/rules/checkmate.test.ts | 141 +++++++++++++++++++++ packages/chess/src/rules/checkmate.ts | 103 +++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 packages/chess/src/rules/checkmate.test.ts create mode 100644 packages/chess/src/rules/checkmate.ts diff --git a/packages/chess/src/rules/checkmate.test.ts b/packages/chess/src/rules/checkmate.test.ts new file mode 100644 index 0000000..f49b835 --- /dev/null +++ b/packages/chess/src/rules/checkmate.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for checkmate detection (P2.19). + * + * Square indexing (per schema.ts): + * square = rank * 8 + file, file 0=a, rank 0=rank1 + * a1=0, e1=4, h1=7, a2=8, h2=15, e4=28, e8=60, h8=63 + * + * We construct minimal positions rather than full armies: only the + * pieces relevant to the mating pattern are placed. `isInCheck` and + * `filterSelfCheckMoves` gracefully handle partial positions (missing + * enemy king, etc.). + */ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { isCheckmate } from "./checkmate.js"; + +const mkId = (n: number) => n as EntityId; + +function insertPiece( + s: Session, + id: number, + type: string, + color: string, + sq: number, +): EntityId { + const eid = mkId(id); + s.insert(eid, "PieceType", type); + s.insert(eid, "Color", color); + s.insert(eid, "Position", sq); + return eid; +} + +describe("isCheckmate — positive cases", () => { + it("back-rank mate: king walled in by own pawns, rook on back rank", () => { + // White K on g1 (6). Own pawns on f2(13), g2(14), h2(15) block every + // second-rank escape. Black rook on a1(0) rakes the entire 1st rank, + // attacking g1 (check) and also covering f1(5) and h1(7) — the only + // other escape squares. King has no piece to capture or block with. + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 6); // g1 + insertPiece(session, 2, "pawn", "white", 13); // f2 + insertPiece(session, 3, "pawn", "white", 14); // g2 + insertPiece(session, 4, "pawn", "white", 15); // h2 + insertPiece(session, 5, "rook", "black", 0); // a1 — delivers check on rank 1 + expect(isCheckmate(session, "white")).toBe(true); + }); + + it("Fool's Mate: white king mated by Qh4 along the h4–e1 diagonal", () => { + // Reproduces the position after 1.f3 e5 2.g4 Qh4#. Only the pieces + // needed to prove mate are placed: the king, the blockers that + // occupy d1/f1/d2/e2, and the black queen on h4. f2 is empty but + // attacked by the queen along h4-g3-f2-e1; no white piece can + // interpose on g3/f2 or capture the queen in one move. + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 + insertPiece(session, 2, "queen", "white", 3); // d1 (blocks d1 escape) + insertPiece(session, 3, "bishop", "white", 5); // f1 (blocks f1 escape) + insertPiece(session, 4, "pawn", "white", 11); // d2 (blocks d2 escape) + insertPiece(session, 5, "pawn", "white", 12); // e2 (blocks e2 escape) + insertPiece(session, 6, "knight", "white", 6); // g1 + insertPiece(session, 7, "pawn", "white", 21); // f3 (blocks Ng1-f3 defense) + insertPiece(session, 8, "queen", "black", 31); // h4 — checks along h4-e1 diag + expect(isCheckmate(session, "white")).toBe(true); + }); + + it("Scholar's Mate: black king mated by Qxf7 defended by bishop", () => { + // Final position of 1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6?? 4.Qxf7#. + // Black K on e8(60). White Q on f7(53), defended by B on c4(26) + // along the c4-d5-e6-f7 diagonal. The queen attacks e8 (adjacent + + // diagonal), plus e7(52) and d7(51) along rank 7 — so the king + // cannot step to those squares. Own Qd8 and Bf8 block d8/f8. The + // queen is defended, so Kxf7 loses the king. No black piece can + // capture f7 or interpose (queen is adjacent to king). + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "black", 60); // e8 + insertPiece(session, 2, "queen", "black", 59); // d8 (blocks d8 escape) + insertPiece(session, 3, "bishop", "black", 61); // f8 (blocks f8 escape) + insertPiece(session, 4, "knight", "black", 42); // c6 — can't reach f7 + insertPiece(session, 5, "knight", "black", 45); // f6 — can't reach f7 + insertPiece(session, 6, "queen", "white", 53); // f7 — delivers check + insertPiece(session, 7, "bishop", "white", 26); // c4 — defends f7 + expect(isCheckmate(session, "black")).toBe(true); + }); +}); + +describe("isCheckmate — negative cases", () => { + it("check with a flight square available is NOT mate", () => { + // Lone white king on e1, black rook on e8 gives check. Escape squares + // d1, f1, d2, e2, f2 are all empty and unattacked → king walks. + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 + insertPiece(session, 2, "rook", "black", 60); // e8 + expect(isCheckmate(session, "white")).toBe(false); + }); + + it("check that can be blocked is NOT mate", () => { + // Black rook on e8 checks white king on e1. White rook on a3 can + // interpose at e3(20) — the slide a3-e3 (same rank) is unobstructed. + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 + insertPiece(session, 2, "rook", "white", 16); // a3 — can block at e3 + insertPiece(session, 3, "rook", "black", 60); // e8 — gives check + // (No blockers near the king, so king could also flee — that's fine, + // we just need at least one legal move.) + expect(isCheckmate(session, "white")).toBe(false); + }); + + it("check that can be captured is NOT mate", () => { + // Rook-delivered check on back rank with king boxed in by pawns, BUT + // a defending rook on a1 can capture the checker. Same skeleton as + // the back-rank mate above but with a defender added. + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 6); // g1 + insertPiece(session, 2, "pawn", "white", 13); // f2 + insertPiece(session, 3, "pawn", "white", 14); // g2 + insertPiece(session, 4, "pawn", "white", 15); // h2 + insertPiece(session, 5, "rook", "black", 62); // g8 — checks down g-file + insertPiece(session, 6, "rook", "white", 54); // g7 — captures Rg8 + expect(isCheckmate(session, "white")).toBe(false); + }); + + it("not in check → not checkmate (lone king on empty board)", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); + expect(isCheckmate(session, "white")).toBe(false); + }); + + it("opposite color's checkmate doesn't report for our color", () => { + // Build the back-rank mate against white; asking about black must + // return false (black is the side delivering mate, not receiving it). + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 6); + insertPiece(session, 2, "pawn", "white", 13); + insertPiece(session, 3, "pawn", "white", 14); + insertPiece(session, 4, "pawn", "white", 15); + insertPiece(session, 5, "rook", "black", 0); + insertPiece(session, 6, "king", "black", 63); // h8, safe + expect(isCheckmate(session, "black")).toBe(false); + }); +}); diff --git a/packages/chess/src/rules/checkmate.ts b/packages/chess/src/rules/checkmate.ts new file mode 100644 index 0000000..079eaa3 --- /dev/null +++ b/packages/chess/src/rules/checkmate.ts @@ -0,0 +1,103 @@ +/** + * Checkmate detection (P2.19). + * + * Checkmate is the conjunction of two conditions: + * 1. The side to move IS currently in check. + * 2. No legal move for that side removes the check. + * + * Condition (2) is exactly "filter every pseudo-legal move through the + * self-check filter and see if any survive". We reuse the existing + * `filterSelfCheckMoves` (check.ts, P2.18) rather than re-rolling the + * snapshot-and-apply what-if dance here — that keeps the check-handling + * logic in one place and means any future fix to it (e.g. en-passant + * capture semantics) automatically propagates to checkmate detection. + * + * Scope — what this module does NOT do: + * - Stalemate detection → P2.20 + * - Castling moves as escapes. Castling out of check is illegal anyway, + * and castling is handled by its own rule module; the basic-move + * generators used here cover all piece moves that could possibly get + * a king out of check. + * - En passant as an escape. En-passant generation is P2.16; once it + * lands, it can be plumbed in by extending MOVE_GETTERS. + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { PieceColor, PieceType } from "../schema.js"; +import { isInCheck, filterSelfCheckMoves } from "./check.js"; +import { getLegalPawnMoves } from "./pawn.js"; +import { getLegalKnightMoves } from "./knight.js"; +import { + getLegalRookMoves, + getLegalBishopMoves, + getLegalQueenMoves, +} from "./sliding.js"; +import { getLegalKingMoves } from "./king.js"; +import type { LegalMove } from "./types.js"; + +type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; + +/** + * Dispatch table mapping PieceType → its pseudo-legal move generator. + * "Pseudo-legal" = respects piece geometry + blockers but ignores + * self-check; the self-check filter handles that afterwards. + */ +const MOVE_GETTERS: Record = { + pawn: getLegalPawnMoves, + knight: getLegalKnightMoves, + bishop: getLegalBishopMoves, + rook: getLegalRookMoves, + queen: getLegalQueenMoves, + king: getLegalKingMoves, +}; + +/** + * Collect every pseudo-legal move for every piece of `color`. + * + * Walks `allFacts()` once to find (id, type) pairs for the requested + * color, then dispatches each piece to its type-specific generator. The + * result is the union of all raw candidate moves before any self-check + * filtering. + */ +function getAllPseudoLegalMoves( + session: Session, + color: PieceColor, +): LegalMove[] { + const facts = session.allFacts(); + const moves: LegalMove[] = []; + + for (const f of facts) { + if (f.attr !== "Color" || f.value !== color) continue; + // Game-level facts live on GAME_ENTITY (id 0); skip anything non-piece. + if (f.id <= 0) continue; + + const typeFact = facts.find(t => t.id === f.id && t.attr === "PieceType"); + if (typeFact === undefined) continue; + const type = typeFact.value as PieceType; + + const getter = MOVE_GETTERS[type]; + if (getter === undefined) continue; + + for (const m of getter(session, f.id)) { + moves.push(m); + } + } + + return moves; +} + +/** + * Is `color` checkmated in the current position? + * + * Returns true iff `color` is in check AND has zero legal moves that + * leave the king out of check. Returns false when: + * - `color` is not in check (could be stalemate; not our concern here) + * - at least one move escapes check (block, capture, or king flight) + * - `color` has no king on the board (degenerate; `isInCheck` handles it) + */ +export function isCheckmate(session: Session, color: PieceColor): boolean { + if (!isInCheck(session, color)) return false; + + const pseudo = getAllPseudoLegalMoves(session, color); + const legal = filterSelfCheckMoves(session, pseudo, color); + return legal.length === 0; +}