feat(chess): add castling rules (P2.15)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 14:57:39 -06:00
commit f1da22641f
No known key found for this signature in database
2 changed files with 422 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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<Square>;
/** Squares the king passes through (including destination) — must not be attacked. */
readonly kingTransit: ReadonlyArray<Square>;
}
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<readonly ["kingside" | "queenside", CastlingConfig]> = [
["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);
}