feat(chess): add en passant rule (P2.16)
This commit is contained in:
parent
f1da22641f
commit
6938a2aedb
2 changed files with 334 additions and 0 deletions
223
packages/chess/src/rules/enpassant.test.ts
Normal file
223
packages/chess/src/rules/enpassant.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
packages/chess/src/rules/enpassant.ts
Normal file
111
packages/chess/src/rules/enpassant.ts
Normal 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 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue