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