feat(chess): add en passant rule (P2.16)

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

View file

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

View file

@ -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 e2e4: from=12, to=28, target=20 (e3)
* @example black e7e5: 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);
}