From 6938a2aedbfa8d88f4b3004fd59d279bf33835e3 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 14:57:58 -0600 Subject: [PATCH] feat(chess): add en passant rule (P2.16) --- packages/chess/src/rules/enpassant.test.ts | 223 +++++++++++++++++++++ packages/chess/src/rules/enpassant.ts | 111 ++++++++++ 2 files changed, 334 insertions(+) create mode 100644 packages/chess/src/rules/enpassant.test.ts create mode 100644 packages/chess/src/rules/enpassant.ts diff --git a/packages/chess/src/rules/enpassant.test.ts b/packages/chess/src/rules/enpassant.test.ts new file mode 100644 index 0000000..bd81d89 --- /dev/null +++ b/packages/chess/src/rules/enpassant.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { + getEnPassantMoves, + setEnPassantTarget, + clearEnPassantTarget, + applyEnPassantCapture, +} from "./enpassant.js"; +import { GAME_ENTITY } from "../schema.js"; +import type { PieceColor, PieceType, Square } from "../schema.js"; + +function mkSession(): Session { + return new Session({ autoFire: false }); +} + +function insertPiece( + session: Session, + id: number, + type: PieceType, + color: PieceColor, + square: Square, +): EntityId { + const eid = id as EntityId; + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +// ─── setEnPassantTarget / clearEnPassantTarget ─────────────────────────────── + +describe("setEnPassantTarget", () => { + it("sets target to midpoint for white double advance e2→e4", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", null); + setEnPassantTarget(session, 12, 28); // e2→e4, midpoint = e3 (20) + expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBe(20); + }); + + it("sets target to midpoint for black double advance e7→e5", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", null); + setEnPassantTarget(session, 52, 36); // e7→e5, midpoint = e6 (44) + expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBe(44); + }); + + it("overwrites an existing target", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); + setEnPassantTarget(session, 12, 28); + expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBe(20); + }); +}); + +describe("clearEnPassantTarget", () => { + it("sets target to null", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); + clearEnPassantTarget(session); + expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBeNull(); + }); + + it("is idempotent when target already null", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", null); + clearEnPassantTarget(session); + expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBeNull(); + }); +}); + +// ─── getEnPassantMoves ─────────────────────────────────────────────────────── + +describe("getEnPassantMoves — capture within the window", () => { + it("white pawn on f5 captures en passant on e6 after black e7→e5", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); // e6 + const whitePawn = insertPiece(session, 1, "pawn", "white", 37); // f5 + insertPiece(session, 2, "pawn", "black", 36); // e5 (just doubled) + + const moves = getEnPassantMoves(session, whitePawn); + expect(moves).toHaveLength(1); + expect(moves[0]?.pieceId).toBe(whitePawn); + expect(moves[0]?.from).toBe(37); + expect(moves[0]?.to).toBe(44); + expect(moves[0]?.isCapture).toBe(true); + }); + + it("white pawn on d5 captures en passant on e6 after black e7→e5", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); // e6 + const whitePawn = insertPiece(session, 1, "pawn", "white", 35); // d5 + insertPiece(session, 2, "pawn", "black", 36); // e5 + + const moves = getEnPassantMoves(session, whitePawn); + expect(moves).toHaveLength(1); + expect(moves[0]?.to).toBe(44); + }); + + it("black pawn on e4 captures en passant on d3 after white d2→d4", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 19); // d3 + const blackPawn = insertPiece(session, 1, "pawn", "black", 28); // e4 + insertPiece(session, 2, "pawn", "white", 27); // d4 + + const moves = getEnPassantMoves(session, blackPawn); + expect(moves).toHaveLength(1); + expect(moves[0]?.to).toBe(19); + expect(moves[0]?.isCapture).toBe(true); + }); +}); + +describe("getEnPassantMoves — window closed / ineligible", () => { + it("returns [] when EnPassantTarget is null (window closed)", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", null); + const whitePawn = insertPiece(session, 1, "pawn", "white", 37); // f5 + insertPiece(session, 2, "pawn", "black", 36); // e5 still sitting there + + expect(getEnPassantMoves(session, whitePawn)).toHaveLength(0); + }); + + it("returns [] when EnPassantTarget is not in the capturing pawn's diagonals", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); // e6 + // White pawn at e3 (20) — not adjacent to e6 (44). + const whitePawn = insertPiece(session, 1, "pawn", "white", 20); + + expect(getEnPassantMoves(session, whitePawn)).toHaveLength(0); + }); + + it("returns [] when no pawn sits behind the target square", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); // e6 + const whitePawn = insertPiece(session, 1, "pawn", "white", 37); // f5 + // No black pawn on e5 — the EP target is stale / no captured piece exists. + + expect(getEnPassantMoves(session, whitePawn)).toHaveLength(0); + }); + + it("returns [] for a pawn with no Position / Color", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); + expect(getEnPassantMoves(session, 999 as EntityId)).toHaveLength(0); + }); + + it("returns [] when the EnPassantTarget fact is absent entirely", () => { + const session = mkSession(); + // No EnPassantTarget fact inserted at all. + const whitePawn = insertPiece(session, 1, "pawn", "white", 37); + insertPiece(session, 2, "pawn", "black", 36); + expect(getEnPassantMoves(session, whitePawn)).toHaveLength(0); + }); +}); + +// ─── applyEnPassantCapture ─────────────────────────────────────────────────── + +describe("applyEnPassantCapture", () => { + it("white EP: capturing pawn moves to target, black pawn is removed", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); // e6 + const whitePawn = insertPiece(session, 1, "pawn", "white", 37); // f5 + const blackPawn = insertPiece(session, 2, "pawn", "black", 36); // e5 + + applyEnPassantCapture( + session, + { pieceId: whitePawn, from: 37, to: 44, isCapture: true }, + "white", + ); + + expect(session.get(whitePawn, "Position")).toBe(44); + expect(session.get(whitePawn, "HasMoved")).toBe(true); + expect(session.contains(blackPawn, "Position")).toBe(false); + expect(session.contains(blackPawn, "PieceType")).toBe(false); + expect(session.contains(blackPawn, "Color")).toBe(false); + }); + + it("black EP: capturing pawn moves to target, white pawn is removed", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 19); // d3 + const blackPawn = insertPiece(session, 1, "pawn", "black", 28); // e4 + const whitePawn = insertPiece(session, 2, "pawn", "white", 27); // d4 + + applyEnPassantCapture( + session, + { pieceId: blackPawn, from: 28, to: 19, isCapture: true }, + "black", + ); + + expect(session.get(blackPawn, "Position")).toBe(19); + expect(session.get(blackPawn, "HasMoved")).toBe(true); + expect(session.contains(whitePawn, "Position")).toBe(false); + }); + + it("retracts HasMoved on captured pawn if it was set", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); + const whitePawn = insertPiece(session, 1, "pawn", "white", 37); + const blackPawn = insertPiece(session, 2, "pawn", "black", 36); + session.insert(blackPawn, "HasMoved", true); + + applyEnPassantCapture( + session, + { pieceId: whitePawn, from: 37, to: 44, isCapture: true }, + "white", + ); + + expect(session.contains(blackPawn, "HasMoved")).toBe(false); + }); + + it("is a no-op on the captured pawn if no pawn is behind the target", () => { + const session = mkSession(); + session.insert(GAME_ENTITY, "EnPassantTarget", 44); + const whitePawn = insertPiece(session, 1, "pawn", "white", 37); + // No black pawn at e5. applyEnPassantCapture should still move the + // white pawn without throwing. + applyEnPassantCapture( + session, + { pieceId: whitePawn, from: 37, to: 44, isCapture: true }, + "white", + ); + expect(session.get(whitePawn, "Position")).toBe(44); + }); +}); diff --git a/packages/chess/src/rules/enpassant.ts b/packages/chess/src/rules/enpassant.ts new file mode 100644 index 0000000..3534d0e --- /dev/null +++ b/packages/chess/src/rules/enpassant.ts @@ -0,0 +1,111 @@ +/** + * En passant rules (P2.16). + * + * FIDE en passant: + * - After a pawn makes a double advance, the `EnPassantTarget` game-level + * fact is set to the square the pawn passed over (the "target" square). + * - On the very next ply, an adjacent enemy pawn may capture to that target + * square, removing the double-advanced pawn. + * - The EnPassantTarget is cleared at the start of any turn that doesn't + * use it (one-ply window). + * + * This module does not decide when to clear the target — that's the turn + * manager's responsibility (P2.13). It only offers the setters/clearers and + * the move generator + capture applier. + */ +import type { Session, EntityId } from "@paratype/rete"; +import type { PieceColor, Square } from "../schema.js"; +import { GAME_ENTITY } from "../schema.js"; +import type { LegalMove } from "./types.js"; +import { + getPiecePosition, + getPieceColor, + getPieceAt, +} from "./board-queries.js"; +import { pawnCaptureSqares } from "./primitives.js"; + +/** + * Set the EnPassantTarget after a pawn double advance. + * Target = the square the pawn passed over (midpoint between from and to). + * + * @example white e2→e4: from=12, to=28, target=20 (e3) + * @example black e7→e5: from=52, to=36, target=44 (e6) + */ +export function setEnPassantTarget( + session: Session, + from: Square, + to: Square, +): void { + const target = ((from + to) / 2) as Square; // midpoint square + session.insert(GAME_ENTITY, "EnPassantTarget", target); +} + +/** Clear the en passant target (called when the window closes). */ +export function clearEnPassantTarget(session: Session): void { + session.insert(GAME_ENTITY, "EnPassantTarget", null); +} + +/** + * Get en passant capture moves for a pawn, if any. + * + * Returns a (possibly empty) array of LegalMoves. An EP capture is + * produced iff: + * - EnPassantTarget is set to a square + * - that square is one of the pawn's diagonal-capture squares + * - an enemy pawn actually sits "behind" the target (on the rank the + * capturing pawn currently occupies) + */ +export function getEnPassantMoves( + session: Session, + pieceId: EntityId, +): LegalMove[] { + const from = getPiecePosition(session, pieceId); + const color = getPieceColor(session, pieceId); + if (from === null || color === null) return []; + + const raw = session.get(GAME_ENTITY, "EnPassantTarget"); + if (raw === null || raw === undefined) return []; + const target = raw as Square; + + // Target must be one of this pawn's diagonal-capture squares. + const captureSqs = pawnCaptureSqares(from, color); + if (!captureSqs.includes(target)) return []; + + // The captured pawn sits on the rank "behind" the target square + // (i.e. on the same rank as the capturing pawn). + const capturedPawnSquare: Square = + color === "white" ? ((target - 8) as Square) : ((target + 8) as Square); + const capturedId = getPieceAt(session, capturedPawnSquare); + if (capturedId === null) return []; + + return [{ + pieceId, + from, + to: target, + isCapture: true, + }]; +} + +/** + * Apply an en passant capture: move the capturing pawn to the target and + * remove the captured pawn (which is NOT on the target square). + */ +export function applyEnPassantCapture( + session: Session, + move: LegalMove, + color: PieceColor, +): void { + // Captured pawn is on the rank behind the target. + const capturedPawnSquare: Square = + color === "white" ? ((move.to - 8) as Square) : ((move.to + 8) as Square); + const capturedId = getPieceAt(session, capturedPawnSquare); + if (capturedId !== null) { + for (const attr of ["PieceType", "Color", "Position", "HasMoved"] as const) { + if (session.contains(capturedId, attr)) session.retract(capturedId, attr); + } + } + + // Move the capturing pawn to the target. + session.insert(move.pieceId, "Position", move.to); + session.insert(move.pieceId, "HasMoved", true); +}