feat(chess): add stalemate detection (P2.20)

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

View file

@ -0,0 +1,83 @@
/**
* Tests for stalemate detection (P2.20).
*
* Square indexing (per schema.ts):
* square = rank * 8 + file, file 0=a..7=h, rank 0=rank1..7=rank8
* a1=0, h1=7, a8=56, h8=63
*/
import { describe, it, expect } from "vitest";
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import { isStalemate } from "./stalemate.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("isStalemate", () => {
it("classic stalemate: king at h8 with enemy queen on g6, not in check, no legal moves", () => {
const session = new Session({ autoFire: false });
// White king at h8(63); escape squares: g8(62), g7(54), h7(55).
// Black queen at g6(46) covers g-file (g7, g8) and diagonal to h7.
// Queen does NOT attack h8 itself (h8 vs g6 is file-diff 1, rank-diff 2).
insertPiece(session, 1, "king", "white", 63); // h8
insertPiece(session, 2, "queen", "black", 46); // g6
// Black king needs to exist (and must be far from action so it doesn't
// accidentally attack white king or be adjacent to the queen).
insertPiece(session, 3, "king", "black", 0); // a1
expect(isStalemate(session, "white")).toBe(true);
});
it("NOT stalemate when side is in check", () => {
const session = new Session({ autoFire: false });
insertPiece(session, 1, "king", "white", 4); // e1
insertPiece(session, 2, "rook", "black", 60); // e8 — gives check along e-file
insertPiece(session, 3, "king", "black", 56); // a8 (far corner)
expect(isStalemate(session, "white")).toBe(false);
});
it("NOT stalemate when king has legal escape squares", () => {
const session = new Session({ autoFire: false });
insertPiece(session, 1, "king", "white", 4); // e1, open board
insertPiece(session, 2, "king", "black", 60); // e8
expect(isStalemate(session, "white")).toBe(false);
});
it("checkmate position is NOT stalemate (in check disqualifies)", () => {
const session = new Session({ autoFire: false });
// Back-rank-style mate: white king h1 boxed in by own pawn g2,h2;
// black rook on a1 delivers check along rank 1 (file 0..7). Note
// the rook's line to the king is clear — nothing between a1 and h1.
// King cannot step to h2/g2 (own pieces) or g1 (attacked along rank).
insertPiece(session, 1, "king", "white", 7); // h1
insertPiece(session, 2, "pawn", "white", 14); // g2
insertPiece(session, 3, "pawn", "white", 15); // h2
insertPiece(session, 4, "rook", "black", 0); // a1 — check!
insertPiece(session, 5, "king", "black", 56); // a8
// In check → isStalemate must return false regardless of move count.
expect(isStalemate(session, "white")).toBe(false);
});
it("stalemate check is color-specific", () => {
const session = new Session({ autoFire: false });
// Stalemate white (same position as first test); black side has
// plenty of moves (queen can move anywhere) — not stalemated.
insertPiece(session, 1, "king", "white", 63); // h8
insertPiece(session, 2, "queen", "black", 46); // g6
insertPiece(session, 3, "king", "black", 0); // a1
expect(isStalemate(session, "white")).toBe(true);
expect(isStalemate(session, "black")).toBe(false);
});
});

View file

@ -0,0 +1,124 @@
/**
* Stalemate detection for @paratype/chess (P2.20).
*
* A side is stalemated when:
* 1. It is NOT currently in check, AND
* 2. It has no legal moves available (every candidate move would
* either be illegal for piece-movement reasons or would leave
* its own king in check).
*
* This is the inverse of checkmate on condition (1): checkmate requires
* the side to BE in check, stalemate requires the side NOT to be in
* check. Both share the "no legal moves" leg.
*
* Implementation mirrors the what-if approach used by `filterSelfCheckMoves`:
* for each friendly piece, generate its pseudo-legal moves, apply each
* to a temporary Session snapshot, and check whether the resulting
* position leaves our king in check. If ANY move survives, we are not
* stalemated (and we can short-circuit).
*
* P2.19 (checkmate) is implemented in parallel and may expose a shared
* `hasAnyEscapeMove` helper; to keep this module independent we
* implement our own `hasAnyLegalMove` inline.
*/
import { Session as ReteSession } from "@paratype/rete";
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor, PieceType } from "../schema.js";
import { isInCheck } 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[];
const MOVE_GETTERS: Record<PieceType, MoveGetter> = {
pawn: getLegalPawnMoves,
knight: getLegalKnightMoves,
bishop: getLegalBishopMoves,
rook: getLegalRookMoves,
queen: getLegalQueenMoves,
king: getLegalKingMoves,
};
/** Attributes we copy when snapshotting a session for "what-if" analysis. */
const PIECE_ATTRS = [
"PieceType",
"Color",
"Position",
"HasMoved",
"Hp",
] as const;
/**
* Does `color` have at least one move that doesn't leave its king in
* check? Short-circuits on the first legal move found.
*/
function hasAnyLegalMove(session: Session, color: PieceColor): boolean {
const facts = session.allFacts();
// Collect (pieceId, type) for every piece of `color`. Game-level
// facts live on id 0 — skip those.
const pieces: { id: EntityId; type: PieceType }[] = [];
for (const f of facts) {
if (f.attr !== "Color" || f.value !== color) continue;
if (f.id <= 0) continue;
const typeFact = facts.find(t => t.id === f.id && t.attr === "PieceType");
if (typeFact === undefined) continue;
pieces.push({ id: f.id, type: typeFact.value as PieceType });
}
for (const piece of pieces) {
const getter = MOVE_GETTERS[piece.type];
if (getter === undefined) continue;
for (const move of getter(session, piece.id)) {
// Build a what-if session with only the piece-geometry attrs.
const temp = new ReteSession({ autoFire: false });
for (const f of facts) {
if (!(PIECE_ATTRS as readonly string[]).includes(f.attr)) continue;
// For captures, drop whatever sits on the destination square.
if (move.isCapture && f.attr === "Position" && f.value === move.to) {
continue;
}
temp.insert(f.id, f.attr, f.value);
}
// For captures we also need to clear the captured piece's other
// attrs (PieceType, Color, ...). Easiest: retract by id.
if (move.isCapture) {
const captured = facts.find(
g => g.attr === "Position" && g.value === move.to && g.id !== move.pieceId,
);
if (captured !== undefined) {
for (const attr of PIECE_ATTRS) {
if (temp.contains(captured.id, attr)) {
temp.retract(captured.id, attr);
}
}
}
}
// Apply the move (overwrite Position in place).
temp.insert(move.pieceId, "Position", move.to);
if (!isInCheck(temp, color)) return true;
}
}
return false;
}
/**
* Is `color` stalemated in the given session?
*
* Stalemate iff `color` is NOT in check AND has no legal moves.
* Returns false when in check (that's checkmate, handled by P2.19).
*/
export function isStalemate(session: Session, color: PieceColor): boolean {
if (isInCheck(session, color)) return false;
return !hasAnyLegalMove(session, color);
}