feat(chess): add check detection + self-check filter (P2.18)
This commit is contained in:
parent
fbdf10ec51
commit
6b760c9535
2 changed files with 388 additions and 0 deletions
165
packages/chess/src/rules/check.test.ts
Normal file
165
packages/chess/src/rules/check.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
223
packages/chess/src/rules/check.ts
Normal file
223
packages/chess/src/rules/check.ts
Normal file
|
|
@ -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<PieceType, AttackProbe> = {
|
||||
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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue