feat(chess): add checkmate detection (P2.19)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 15:06:27 -06:00
commit f94ab386a8
No known key found for this signature in database
2 changed files with 244 additions and 0 deletions

View file

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

View file

@ -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<PieceType, MoveGetter> = {
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;
}