diff --git a/packages/chess/src/rules/stalemate.test.ts b/packages/chess/src/rules/stalemate.test.ts new file mode 100644 index 0000000..67f21cd --- /dev/null +++ b/packages/chess/src/rules/stalemate.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/rules/stalemate.ts b/packages/chess/src/rules/stalemate.ts new file mode 100644 index 0000000..6bc242a --- /dev/null +++ b/packages/chess/src/rules/stalemate.ts @@ -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 = { + 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); +}