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