feat(chess): add checkmate detection (P2.19)
This commit is contained in:
parent
3f8a38bb41
commit
f94ab386a8
2 changed files with 244 additions and 0 deletions
141
packages/chess/src/rules/checkmate.test.ts
Normal file
141
packages/chess/src/rules/checkmate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
packages/chess/src/rules/checkmate.ts
Normal file
103
packages/chess/src/rules/checkmate.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue