diff --git a/packages/chess/src/rules/check.test.ts b/packages/chess/src/rules/check.test.ts new file mode 100644 index 0000000..6b6094b --- /dev/null +++ b/packages/chess/src/rules/check.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for check detection + self-check filter (P2.18). + * + * Square indexing convention (per schema.ts): + * square = rank * 8 + file, file 0=a, rank 0=rank1 + * a1=0, e1=4, e3=20, e4=28, e8=60, h8=63 + */ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { + isInCheck, + isSquareAttacked, + filterSelfCheckMoves, +} from "./check.js"; +import type { LegalMove } from "./types.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("isSquareAttacked", () => { + it("rook attacks along its file", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "rook", "black", 60); // e8 + // e1 (4) is on the same file as e8 and unobstructed. + expect(isSquareAttacked(session, 4, "black")).toBe(true); + }); + + it("rook does not attack through a blocker", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "rook", "black", 60); // e8 + insertPiece(session, 2, "pawn", "white", 20); // e3 blocks the file + // e1 is beyond the blocker, so not attacked. + expect(isSquareAttacked(session, 4, "black")).toBe(false); + }); + + it("knight attacks L-shaped squares", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "knight", "black", 28); // e4 + // 28 + 10 = 38 → g5, a valid knight target. + expect(isSquareAttacked(session, 38, "black")).toBe(true); + // 27 (d4) is adjacent, not a knight move. + expect(isSquareAttacked(session, 27, "black")).toBe(false); + }); + + it("unoccupied square not attacked on empty board", () => { + const session = new Session({ autoFire: false }); + expect(isSquareAttacked(session, 28, "black")).toBe(false); + }); + + it("pawn attack geometry is respected (diagonal, not straight)", () => { + const session = new Session({ autoFire: false }); + // White pawn at e4 (28) attacks d5 (35) and f5 (37), not e5 (36). + insertPiece(session, 1, "pawn", "white", 28); + expect(isSquareAttacked(session, 35, "white")).toBe(true); + expect(isSquareAttacked(session, 37, "white")).toBe(true); + expect(isSquareAttacked(session, 36, "white")).toBe(false); + }); +}); + +describe("isInCheck", () => { + it("white king in check from black rook on same file", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 + insertPiece(session, 2, "rook", "black", 60); // e8 + expect(isInCheck(session, "white")).toBe(true); + }); + + it("white king not in check when rook is blocked", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 + insertPiece(session, 2, "rook", "black", 60); // e8 + insertPiece(session, 3, "pawn", "white", 20); // e3 blocks + expect(isInCheck(session, "white")).toBe(false); + }); + + it("no king of requested color returns false (no crash)", () => { + const session = new Session({ autoFire: false }); + expect(isInCheck(session, "white")).toBe(false); + }); + + it("opposite-color king in check doesn't affect our color", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "black", 60); // e8 + insertPiece(session, 2, "rook", "white", 4); // e1 attacks e8 + insertPiece(session, 3, "king", "white", 0); // a1, safe + expect(isInCheck(session, "black")).toBe(true); + expect(isInCheck(session, "white")).toBe(false); + }); +}); + +describe("filterSelfCheckMoves", () => { + it("move that exposes own king to a rook is filtered out (absolute pin)", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 + insertPiece(session, 2, "rook", "white", 20); // e3 — pinned on the e-file + insertPiece(session, 3, "rook", "black", 60); // e8 — would give check + + // Moving the e3 rook sideways (e3 → f3 = 21) exposes the king. + const moves: LegalMove[] = [ + { pieceId: mkId(2), from: 20, to: 21, isCapture: false }, + ]; + const filtered = filterSelfCheckMoves(session, moves, "white"); + expect(filtered).toHaveLength(0); + }); + + it("move that keeps the king safe is kept", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 + insertPiece(session, 2, "rook", "white", 28); // e4 — not on a check line + insertPiece(session, 3, "rook", "black", 63); // h8 — doesn't threaten e1 + + const moves: LegalMove[] = [ + { pieceId: mkId(2), from: 28, to: 29, isCapture: false }, + ]; + const filtered = filterSelfCheckMoves(session, moves, "white"); + expect(filtered).toHaveLength(1); + }); + + it("sliding along the pin line stays legal (rook stays on e-file)", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 + insertPiece(session, 2, "rook", "white", 20); // e3 — pinned on e-file + insertPiece(session, 3, "rook", "black", 60); // e8 + + // e3 → e4 stays on the e-file, so the king remains shielded. + const moves: LegalMove[] = [ + { pieceId: mkId(2), from: 20, to: 28, isCapture: false }, + ]; + const filtered = filterSelfCheckMoves(session, moves, "white"); + expect(filtered).toHaveLength(1); + }); + + it("capturing the checking piece is allowed (removes check)", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); // e1 — in check from e8 + insertPiece(session, 2, "rook", "white", 56); // a8 — can slide to e8 and capture + insertPiece(session, 3, "rook", "black", 60); // e8 + + const moves: LegalMove[] = [ + { pieceId: mkId(2), from: 56, to: 60, isCapture: true }, + ]; + const filtered = filterSelfCheckMoves(session, moves, "white"); + expect(filtered).toHaveLength(1); + }); + + it("empty move list returns empty", () => { + const session = new Session({ autoFire: false }); + insertPiece(session, 1, "king", "white", 4); + expect(filterSelfCheckMoves(session, [], "white")).toEqual([]); + }); +}); diff --git a/packages/chess/src/rules/check.ts b/packages/chess/src/rules/check.ts new file mode 100644 index 0000000..249b29f --- /dev/null +++ b/packages/chess/src/rules/check.ts @@ -0,0 +1,223 @@ +/** + * Check detection and self-check filter for @paratype/chess (P2.18). + * + * Provides three functions: + * - isSquareAttacked(session, square, byColor) + * Does any piece of `byColor` have a pseudo-legal move landing on + * `square`? Uses the existing per-piece move generators, so it + * automatically respects blocking (sliders), leaping (knights), + * and pawn capture geometry. + * + * - isInCheck(session, color) + * Finds the `color` king and asks whether the opposite color + * attacks its square. Safe when the king is absent (returns false). + * + * - filterSelfCheckMoves(session, moves, color) + * Removes any candidate move that would leave the moving side's + * king in check. Uses a temporary Session built from + * `session.allFacts()` as a lightweight snapshot — applies the + * move there, runs isInCheck, and throws the temp session away. + * + * IMPORTANT: `isSquareAttacked` must NOT recurse through a "king move is + * only legal if the target is not attacked" check — that would infinite- + * loop (king A's legal moves ask "is square X attacked by king B?", which + * asks for king B's legal moves, which asks back…). `getLegalKingMoves` + * is deliberately check-unaware; self-check filtering happens one layer + * up, in `filterSelfCheckMoves`. + */ +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import type { PieceColor, PieceType, Square } from "../schema.js"; +import { oppositeColor } from "../schema.js"; +import { getLegalKnightMoves } from "./knight.js"; +import { + getLegalRookMoves, + getLegalBishopMoves, + getLegalQueenMoves, +} from "./sliding.js"; +import { getLegalKingMoves } from "./king.js"; +import { pawnCaptureSqares } from "./primitives.js"; +import type { LegalMove } from "./types.js"; +import { getPiecePosition } from "./board-queries.js"; + +type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; + +/** + * Per-piece-type "does this piece attack `target`?" predicate. + * + * For everything except pawns we can reuse the piece's legal-move + * generator — if a legal move lands on `target`, the piece attacks it. + * + * Pawns need special handling: `getLegalPawnMoves` only yields diagonal + * captures when an enemy currently occupies the diagonal, but for check + * detection we need the *attack geometry* (the squares the pawn would + * capture on, regardless of what's there). A king cannot step onto a + * pawn's diagonal even when that square is empty. + */ +type AttackProbe = (session: Session, pieceId: EntityId, target: Square) => boolean; + +function movesAttackProbe(getter: MoveGetter): AttackProbe { + return (session, pieceId, target) => { + for (const m of getter(session, pieceId)) { + if (m.to === target) return true; + } + return false; + }; +} + +const ATTACK_PROBES: Record = { + pawn: (session, pieceId, target) => { + const from = getPiecePosition(session, pieceId); + if (from === null) return false; + const colorVal = session.get(pieceId, "Color"); + if (colorVal === undefined) return false; + const color = colorVal as PieceColor; + for (const sq of pawnCaptureSqares(from, color)) { + if (sq === target) return true; + } + return false; + }, + knight: movesAttackProbe(getLegalKnightMoves), + bishop: movesAttackProbe(getLegalBishopMoves), + rook: movesAttackProbe(getLegalRookMoves), + queen: movesAttackProbe(getLegalQueenMoves), + king: movesAttackProbe(getLegalKingMoves), +}; + +/** Attributes we copy when snapshotting a session for "what-if" analysis. */ +const PIECE_ATTRS = [ + "PieceType", + "Color", + "Position", + "HasMoved", + "Hp", +] as const; + +/** + * Is `square` attacked by any piece of `byColor`? + * + * Iterates every piece of `byColor`, asks its move generator for legal + * targets, and returns true if any target equals `square`. Pseudo-legal: + * does not consider whether the attacker would itself be pinned — that's + * the standard definition used for check detection. + */ +export function isSquareAttacked( + session: Session, + square: Square, + byColor: PieceColor, +): boolean { + const facts = session.allFacts(); + + // Collect (id, type) pairs for every piece of `byColor`. + for (const f of facts) { + if (f.attr !== "Color" || f.value !== byColor) 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 probe = ATTACK_PROBES[type]; + if (probe === undefined) continue; + + if (probe(session, f.id, square)) return true; + } + return false; +} + +/** + * Is the `color` king currently in check? + * + * Returns false when there is no `color` king on the board (tests / + * partial positions) rather than throwing — callers should not need a + * full army to query check status. + */ +export function isInCheck(session: Session, color: PieceColor): boolean { + const facts = session.allFacts(); + + // Find the king entity of `color`: a PieceType=king fact whose id also + // has a Color=color fact. + let kingId: EntityId | null = null; + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== "king") continue; + const colorFact = facts.find(c => c.id === f.id && c.attr === "Color"); + if (colorFact !== undefined && colorFact.value === color) { + kingId = f.id; + break; + } + } + if (kingId === null) return false; + + const posFact = facts.find(f => f.id === kingId && f.attr === "Position"); + if (posFact === undefined) return false; + const kingPos = posFact.value as Square; + + return isSquareAttacked(session, kingPos, oppositeColor(color)); +} + +/** + * Clone a session's working memory into a fresh, auto-fire-disabled + * Session for "what-if" move simulation. Only piece-level attributes are + * copied (PIECE_ATTRS); game-level facts are irrelevant for check + * detection and would drag in rule-firing side effects we don't want. + */ +function snapshotSession(session: Session): Session { + const temp = new Session({ autoFire: false }); + for (const f of session.allFacts()) { + // Only copy attrs we care about for board geometry + check detection. + if ((PIECE_ATTRS as readonly string[]).includes(f.attr)) { + temp.insert(f.id, f.attr, f.value); + } + } + return temp; +} + +/** + * Remove the piece (if any) currently standing on `square` from `temp`. + * Used to resolve captures in the what-if session. + */ +function clearSquare(temp: Session, square: Square): void { + const facts = temp.allFacts(); + const occupant = facts.find(f => f.attr === "Position" && f.value === square); + if (occupant === undefined) return; + const id = occupant.id; + for (const attr of PIECE_ATTRS) { + if (temp.contains(id, attr)) temp.retract(id, attr); + } +} + +/** + * Filter out moves that would leave `color`'s king in check. + * + * Strategy: for each candidate move, build a fresh Session from the + * current facts, apply the move (handling captures by retracting the + * captured piece's facts), then ask `isInCheck` on the resulting + * position. Moves that pass are kept; moves that would self-check are + * dropped. + * + * This is O(moves × pieces × avg-moves-per-piece) — fine for Phase 2 + * where correctness beats raw throughput. + */ +export function filterSelfCheckMoves( + session: Session, + moves: LegalMove[], + color: PieceColor, +): LegalMove[] { + return moves.filter(move => { + const temp = snapshotSession(session); + + // Capture: remove whatever currently sits on the destination square. + // (isCapture covers normal captures; en-passant has a different + // target square and will need a dedicated path when P2.16 wires in.) + if (move.isCapture) { + clearSquare(temp, move.to); + } + + // Apply the move by updating the moving piece's Position. `insert` + // on an existing (id, attr) overwrites in place (WM semantics). + temp.insert(move.pieceId, "Position", move.to); + + return !isInCheck(temp, color); + }); +}