diff --git a/packages/chess/src/rules/castling.test.ts b/packages/chess/src/rules/castling.test.ts new file mode 100644 index 0000000..5548b3b --- /dev/null +++ b/packages/chess/src/rules/castling.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { getCastlingMoves, applyCastlingMove, type IsSquareAttacked } from "./castling.js"; + +const mkId = (n: number) => n as EntityId; + +function insertPiece( + session: Session, + id: number, + type: string, + color: string, + square: number, + hasMoved = false, +): EntityId { + const eid = mkId(id); + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + session.insert(eid, "HasMoved", hasMoved); + return eid; +} + +/** Minimal castling position: a king on its home square plus both home rooks. */ +function setupCastlingPosition(color: "white" | "black"): { + session: Session; + kingId: EntityId; + kingsideRook: EntityId; + queensideRook: EntityId; +} { + const session = new Session({ autoFire: false }); + const backRank = color === "white" ? 0 : 56; + const kingId = insertPiece(session, 1, "king", color, backRank + 4); + const kingsideRook = insertPiece(session, 2, "rook", color, backRank + 7); + const queensideRook = insertPiece(session, 3, "rook", color, backRank + 0); + return { session, kingId, kingsideRook, queensideRook }; +} + +describe("getCastlingMoves - white", () => { + it("both castling moves available when path is clear", () => { + const { session, kingId } = setupCastlingPosition("white"); + const moves = getCastlingMoves(session, kingId); + expect(moves).toHaveLength(2); + expect(moves.some(m => m.side === "kingside")).toBe(true); + expect(moves.some(m => m.side === "queenside")).toBe(true); + }); + + it("kingside target is g1 (6); queenside target is c1 (2)", () => { + const { session, kingId } = setupCastlingPosition("white"); + const moves = getCastlingMoves(session, kingId); + const ks = moves.find(m => m.side === "kingside"); + const qs = moves.find(m => m.side === "queenside"); + expect(ks?.to).toBe(6); + expect(ks?.rookFrom).toBe(7); + expect(ks?.rookTo).toBe(5); + expect(qs?.to).toBe(2); + expect(qs?.rookFrom).toBe(0); + expect(qs?.rookTo).toBe(3); + }); + + it("kingside castling only when queenside path blocked by piece on b1", () => { + const { session, kingId } = setupCastlingPosition("white"); + insertPiece(session, 10, "knight", "white", 1); // b1 blocks queenside + const moves = getCastlingMoves(session, kingId); + expect(moves).toHaveLength(1); + expect(moves[0]?.side).toBe("kingside"); + }); + + it("queenside castling only when kingside path blocked by piece on f1", () => { + const { session, kingId } = setupCastlingPosition("white"); + insertPiece(session, 10, "bishop", "white", 5); // f1 blocks kingside + const moves = getCastlingMoves(session, kingId); + expect(moves).toHaveLength(1); + expect(moves[0]?.side).toBe("queenside"); + }); + + it("enemy piece between king and rook also blocks castling", () => { + const { session, kingId } = setupCastlingPosition("white"); + insertPiece(session, 10, "knight", "black", 6); // g1 + const moves = getCastlingMoves(session, kingId); + expect(moves.some(m => m.side === "kingside")).toBe(false); + }); + + it("no castling after king has moved", () => { + const { session, kingId } = setupCastlingPosition("white"); + session.insert(kingId, "HasMoved", true); + const moves = getCastlingMoves(session, kingId); + expect(moves).toHaveLength(0); + }); + + it("no kingside castling when kingside rook has moved (but queenside still ok)", () => { + const { session, kingId, kingsideRook } = setupCastlingPosition("white"); + session.insert(kingsideRook, "HasMoved", true); + const moves = getCastlingMoves(session, kingId); + expect(moves.some(m => m.side === "kingside")).toBe(false); + expect(moves.some(m => m.side === "queenside")).toBe(true); + }); + + it("no castling when no rook present on rook-home square", () => { + const { session, kingId, kingsideRook } = setupCastlingPosition("white"); + session.retract(kingsideRook, "Position"); + const moves = getCastlingMoves(session, kingId); + expect(moves.some(m => m.side === "kingside")).toBe(false); + }); + + it("rejects castling when king is currently in check", () => { + const { session, kingId } = setupCastlingPosition("white"); + const isAttacked: IsSquareAttacked = (_s, sq) => sq === 4; // e1 attacked + const moves = getCastlingMoves(session, kingId, isAttacked); + expect(moves).toHaveLength(0); + }); + + it("rejects castling through check (king passes attacked square)", () => { + const { session, kingId } = setupCastlingPosition("white"); + const isAttacked: IsSquareAttacked = (_s, sq) => sq === 5; // f1 attacked + const moves = getCastlingMoves(session, kingId, isAttacked); + expect(moves.some(m => m.side === "kingside")).toBe(false); + // Queenside (transit d1=3, c1=2) unaffected. + expect(moves.some(m => m.side === "queenside")).toBe(true); + }); + + it("rejects castling into check (destination attacked)", () => { + const { session, kingId } = setupCastlingPosition("white"); + const isAttacked: IsSquareAttacked = (_s, sq) => sq === 6; // g1 attacked + const moves = getCastlingMoves(session, kingId, isAttacked); + expect(moves.some(m => m.side === "kingside")).toBe(false); + }); + + it("b1 being attacked does NOT prevent queenside castling (king doesn't cross b1)", () => { + const { session, kingId } = setupCastlingPosition("white"); + const isAttacked: IsSquareAttacked = (_s, sq) => sq === 1; // b1 attacked + const moves = getCastlingMoves(session, kingId, isAttacked); + expect(moves.some(m => m.side === "queenside")).toBe(true); + }); + + it("attacker color passed to callback is the opposing color", () => { + const { session, kingId } = setupCastlingPosition("white"); + const seenColors: string[] = []; + const spy: IsSquareAttacked = (_s, _sq, byColor) => { + seenColors.push(byColor); + return false; + }; + getCastlingMoves(session, kingId, spy); + expect(seenColors.every(c => c === "black")).toBe(true); + }); + + it("non-rook piece on rook home square blocks that side", () => { + const { session, kingId, kingsideRook } = setupCastlingPosition("white"); + // Replace the kingside rook with a queen (same square, same color). + session.insert(kingsideRook, "PieceType", "queen"); + const moves = getCastlingMoves(session, kingId); + expect(moves.some(m => m.side === "kingside")).toBe(false); + expect(moves.some(m => m.side === "queenside")).toBe(true); + }); + + it("returns empty list when pieceId has no Position/Color", () => { + const session = new Session({ autoFire: false }); + expect(getCastlingMoves(session, mkId(999))).toEqual([]); + }); + + it("king not on home square yields no castling moves", () => { + const session = new Session({ autoFire: false }); + // King on e2 (12) — HasMoved still false but geometry is wrong. + insertPiece(session, 1, "king", "white", 12); + insertPiece(session, 2, "rook", "white", 7); + insertPiece(session, 3, "rook", "white", 0); + expect(getCastlingMoves(session, mkId(1))).toHaveLength(0); + }); +}); + +describe("getCastlingMoves - black", () => { + it("both black castling moves available when path clear", () => { + const { session, kingId } = setupCastlingPosition("black"); + const moves = getCastlingMoves(session, kingId); + expect(moves).toHaveLength(2); + }); + + it("black kingside target is g8 (62); queenside target is c8 (58)", () => { + const { session, kingId } = setupCastlingPosition("black"); + const moves = getCastlingMoves(session, kingId); + const ks = moves.find(m => m.side === "kingside"); + const qs = moves.find(m => m.side === "queenside"); + expect(ks?.to).toBe(62); + expect(ks?.rookFrom).toBe(63); + expect(ks?.rookTo).toBe(61); + expect(qs?.to).toBe(58); + expect(qs?.rookFrom).toBe(56); + expect(qs?.rookTo).toBe(59); + }); + + it("black rejects castling through check on f8", () => { + const { session, kingId } = setupCastlingPosition("black"); + const isAttacked: IsSquareAttacked = (_s, sq) => sq === 61; // f8 attacked + const moves = getCastlingMoves(session, kingId, isAttacked); + expect(moves.some(m => m.side === "kingside")).toBe(false); + }); + + it("black attacker color passed to callback is white", () => { + const { session, kingId } = setupCastlingPosition("black"); + const seenColors: string[] = []; + const spy: IsSquareAttacked = (_s, _sq, byColor) => { + seenColors.push(byColor); + return false; + }; + getCastlingMoves(session, kingId, spy); + expect(seenColors.every(c => c === "white")).toBe(true); + }); +}); + +describe("applyCastlingMove", () => { + it("moves white king to g1 and kingside rook to f1", () => { + const { session, kingId, kingsideRook } = setupCastlingPosition("white"); + const moves = getCastlingMoves(session, kingId); + const kingside = moves.find(m => m.side === "kingside"); + expect(kingside).toBeDefined(); + applyCastlingMove(session, kingside!); + expect(session.get(kingId, "Position")).toBe(6); + expect(session.get(kingsideRook, "Position")).toBe(5); + expect(session.get(kingId, "HasMoved")).toBe(true); + expect(session.get(kingsideRook, "HasMoved")).toBe(true); + }); + + it("moves white king to c1 and queenside rook to d1", () => { + const { session, kingId, queensideRook } = setupCastlingPosition("white"); + const moves = getCastlingMoves(session, kingId); + const queenside = moves.find(m => m.side === "queenside"); + expect(queenside).toBeDefined(); + applyCastlingMove(session, queenside!); + expect(session.get(kingId, "Position")).toBe(2); + expect(session.get(queensideRook, "Position")).toBe(3); + }); + + it("moves black king to g8 and kingside rook to f8", () => { + const { session, kingId, kingsideRook } = setupCastlingPosition("black"); + const moves = getCastlingMoves(session, kingId); + const kingside = moves.find(m => m.side === "kingside"); + expect(kingside).toBeDefined(); + applyCastlingMove(session, kingside!); + expect(session.get(kingId, "Position")).toBe(62); + expect(session.get(kingsideRook, "Position")).toBe(61); + }); +}); diff --git a/packages/chess/src/rules/castling.ts b/packages/chess/src/rules/castling.ts new file mode 100644 index 0000000..c80d13f --- /dev/null +++ b/packages/chess/src/rules/castling.ts @@ -0,0 +1,180 @@ +/** + * Castling rules for @paratype/chess. + * + * FIDE castling conditions: + * 1. King has NOT moved (HasMoved === false) + * 2. The relevant Rook has NOT moved (HasMoved === false) + * 3. No pieces between king and rook + * 4. King is NOT currently in check + * 5. King does NOT pass through an attacked square + * 6. King does NOT land on an attacked square + * + * Conditions 4-6 require check-detection (P2.18). We accept an `isSquareAttacked` + * callback so that this module can be wired up once check-detection lands. By + * default the callback treats all squares as un-attacked, so Phase 2 pre-integration + * only enforces conditions 1-3. + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { PieceColor, Square } from "../schema.js"; +import { oppositeColor } from "../schema.js"; +import type { LegalMove } from "./types.js"; +import { + getPiecePosition, + getPieceColor, + isPieceAt, + getPieceAt, +} from "./board-queries.js"; + +/** Callback that reports whether `square` is attacked by `byColor`. */ +export type IsSquareAttacked = ( + session: Session, + square: Square, + byColor: PieceColor, +) => boolean; + +/** Default: no squares ever attacked (used until P2.18 integrates check detection). */ +const neverAttacked: IsSquareAttacked = () => false; + +interface CastlingConfig { + /** King's starting square (must match current king position). */ + readonly kingFrom: Square; + /** King's destination square. */ + readonly kingTo: Square; + /** Rook's starting square. */ + readonly rookFrom: Square; + /** Rook's destination square. */ + readonly rookTo: Square; + /** Squares between king and rook that must be empty. */ + readonly between: ReadonlyArray; + /** Squares the king passes through (including destination) — must not be attacked. */ + readonly kingTransit: ReadonlyArray; +} + +const CASTLING_CONFIGS: Record< + PieceColor, + { readonly kingside: CastlingConfig; readonly queenside: CastlingConfig } +> = { + white: { + kingside: { + kingFrom: 4, kingTo: 6, rookFrom: 7, rookTo: 5, + between: [5, 6], kingTransit: [5, 6], + }, + queenside: { + kingFrom: 4, kingTo: 2, rookFrom: 0, rookTo: 3, + between: [1, 2, 3], kingTransit: [3, 2], + }, + }, + black: { + kingside: { + kingFrom: 60, kingTo: 62, rookFrom: 63, rookTo: 61, + between: [61, 62], kingTransit: [61, 62], + }, + queenside: { + kingFrom: 60, kingTo: 58, rookFrom: 56, rookTo: 59, + between: [57, 58, 59], kingTransit: [59, 58], + }, + }, +}; + +/** A castling move — extends LegalMove with rook-move metadata. */ +export interface CastlingMove extends LegalMove { + readonly isCastling: true; + readonly rookFrom: Square; + readonly rookTo: Square; + readonly side: "kingside" | "queenside"; +} + +/** + * Return all currently-legal castling moves for `kingId` (0, 1 or 2). + * + * Enforces FIDE conditions 1-3 unconditionally. Conditions 4-6 are only enforced + * when a non-default `isSquareAttacked` callback is supplied. + */ +export function getCastlingMoves( + session: Session, + kingId: EntityId, + isSquareAttacked: IsSquareAttacked = neverAttacked, +): CastlingMove[] { + const from = getPiecePosition(session, kingId); + const color = getPieceColor(session, kingId); + if (from === null || color === null) return []; + + // Condition 1: King must not have moved. + if (session.get(kingId, "HasMoved") !== false) return []; + + const configs = CASTLING_CONFIGS[color]; + const enemyColor = oppositeColor(color); + const moves: CastlingMove[] = []; + + const entries: ReadonlyArray = [ + ["kingside", configs.kingside], + ["queenside", configs.queenside], + ]; + + for (const [side, cfg] of entries) { + // King must currently stand on the castling origin. + if (from !== cfg.kingFrom) continue; + + // Condition 2a: Rook must exist at rookFrom. + const rookId = getPieceAt(session, cfg.rookFrom); + if (rookId === null) continue; + + // Verify the piece at rookFrom actually is a friendly rook. + const rookType = session.get(rookId, "PieceType"); + const rookColor = session.get(rookId, "Color"); + if (rookType !== "rook" || rookColor !== color) continue; + + // Condition 2b: Rook must not have moved. + if (session.get(rookId, "HasMoved") !== false) continue; + + // Condition 3: All squares between king and rook must be empty. + let blocked = false; + for (const sq of cfg.between) { + if (isPieceAt(session, sq)) { blocked = true; break; } + } + if (blocked) continue; + + // Condition 4: King must not currently be in check. + if (isSquareAttacked(session, from, enemyColor)) continue; + + // Conditions 5 & 6: Every square the king passes through (and lands on) must be safe. + let passesAttackedSquare = false; + for (const sq of cfg.kingTransit) { + if (isSquareAttacked(session, sq, enemyColor)) { + passesAttackedSquare = true; + break; + } + } + if (passesAttackedSquare) continue; + + moves.push({ + pieceId: kingId, + from: cfg.kingFrom, + to: cfg.kingTo, + isCapture: false, + isCastling: true, + rookFrom: cfg.rookFrom, + rookTo: cfg.rookTo, + side, + }); + } + + return moves; +} + +/** + * Apply a castling move: move both king and rook, and mark both as having moved. + * Caller is responsible for passing a `CastlingMove` returned by `getCastlingMoves`. + */ +export function applyCastlingMove(session: Session, move: CastlingMove): void { + const rookId = getPieceAt(session, move.rookFrom); + if (rookId === null) return; + + // Move king. + session.insert(move.pieceId, "Position", move.to); + session.insert(move.pieceId, "HasMoved", true); + + // Move rook. + session.insert(rookId, "Position", move.rookTo); + session.insert(rookId, "HasMoved", true); +}