feat(chess): add castling rules (P2.15)
This commit is contained in:
parent
d9eaeb2f06
commit
f1da22641f
2 changed files with 422 additions and 0 deletions
242
packages/chess/src/rules/castling.test.ts
Normal file
242
packages/chess/src/rules/castling.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
packages/chess/src/rules/castling.ts
Normal file
180
packages/chess/src/rules/castling.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue