feat(presets): berolina-pawns (diagonal push + orthogonal capture, scope-flippable)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 08:29:54 -06:00
commit 5393b96b3f
No known key found for this signature in database
4 changed files with 786 additions and 0 deletions

View file

@ -0,0 +1,549 @@
/**
* Tests for `berolina-pawns` (Phase E.1, rule-variants epic).
*
* The preset overrides pawn move generation via `overridePieceMoves`:
* diagonal pushes + home-rank diagonal double-pushes replace
* orthogonal pushes, and orthogonal forward is the capture vector.
* Promotions are synthesized inside the override. En-passant is
* explicitly deferred (not implemented in v1).
*
* Coverage map:
* (a) activation does not throw
* (b) non-pawn pieces keep their default moves (override returns
* undefined for any type !== "pawn")
* (c) white pawn on e2 empty board → 4 diagonal pushes (d3, f3,
* c4, g4) and NO orthogonal e3/e4
* (d) black pawn on e7 empty board → 4 diagonal pushes (d6, f6,
* c5, g5)
* (e) non-home-rank pawn → single diagonals only (2 moves)
* (f) one blocked diagonal, other open → still get the open side
* (g) blocker on diagonal-one kills diagonal-two too
* (h) orthogonal capture on enemy — isCapture=true
* (i) no orthogonal move when target is empty
* (j) no orthogonal move when target holds a friendly piece
* (k) promotions synthesize 4 variants per landing square
* (l) file-edge pawn only has the in-board diagonal
* (m) scope=white → black pawn uses default FIDE rules
* (n) scope=black → white pawn uses default FIDE rules
* (o) scope=both → both colors Berolina
* (p) filterMoves still runs on overridden output
* (q) incompatibility with `pawn-diagonal-no-capture` throws at
* activation time
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { GAME_ENTITY } from "../schema.js";
import { PRESET_REGISTRY } from "./registry.js";
import { algebraicToSquare } from "../coord.js";
import type { LegalMove } from "../rules/types.js";
import { clearBoard, placePiece } from "./test-utils.js";
const BEROLINA = {
id: "berolina-pawns",
scope: "both" as const,
turnsRemaining: null,
};
const BEROLINA_WHITE = {
id: "berolina-pawns",
scope: "white" as const,
turnsRemaining: null,
};
const BEROLINA_BLACK = {
id: "berolina-pawns",
scope: "black" as const,
turnsRemaining: null,
};
/** Collect all legal moves for a specific piece id. */
function movesFor(engine: ChessEngine, pieceId: number): LegalMove[] {
return engine.getAllLegalMoves().filter((m) => m.pieceId === pieceId);
}
// A test-local filter preset used by (p). Registered in
// `beforeEach` so it's fresh for each test and tear-down is trivial.
const FILTER_ID = "test-berolina-filter-rank3";
beforeEach(() => {
PRESET_REGISTRY.register({
id: FILTER_ID,
name: "Rank-3 filter (test)",
description:
"Drops pawn moves whose destination is on rank 3 (indices 16-23).",
incompatibleWith: [],
requires: [],
filterMoves(moves, engine, pieceId) {
const type = engine.session.get(pieceId, "PieceType") as string | undefined;
if (type !== "pawn") return moves;
return moves.filter((m) => m.to < 16 || m.to > 23);
},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
// ─────────────────────────────────────────────────────────────────────
// (a) Activation
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — activation", () => {
it("(a) activates on a fresh engine without throwing", () => {
const engine = new ChessEngine();
expect(() => engine.setActivePresets([BEROLINA])).not.toThrow();
});
});
// ─────────────────────────────────────────────────────────────────────
// (b) Non-pawn pieces unaffected
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — piece-type check", () => {
it("(b) knight moves are unchanged when berolina-pawns is active", () => {
const engine = new ChessEngine();
engine.setActivePresets([BEROLINA]);
// FIDE opening: each knight has 2 legal moves; 2 knights → 4
// total knight moves. If the override wrongly ran on knights
// this count would change.
const knightMoves = engine.getAllLegalMoves().filter((m) => {
const type = engine.session.get(m.pieceId, "PieceType") as string;
return type === "knight";
});
expect(knightMoves).toHaveLength(4);
});
});
// ─────────────────────────────────────────────────────────────────────
// (c) White pawn on e2 empty board
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — white pawn diagonal pushes from home rank", () => {
it("(c) e2 empty board → d3, f3, c4, g4 (no orthogonal)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
const pawn = placePiece(engine, "pawn", "white", "e2");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("d3"))).toBe(true);
expect(targets.has(algebraicToSquare("f3"))).toBe(true);
expect(targets.has(algebraicToSquare("c4"))).toBe(true);
expect(targets.has(algebraicToSquare("g4"))).toBe(true);
// No orthogonal moves
expect(targets.has(algebraicToSquare("e3"))).toBe(false);
expect(targets.has(algebraicToSquare("e4"))).toBe(false);
expect(moves).toHaveLength(4);
// Every move is a non-capture.
for (const m of moves) expect(m.isCapture).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────
// (d) Black pawn on e7 empty board
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — black pawn diagonal pushes from home rank", () => {
it("(d) e7 empty board → d6, f6, c5, g5", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
const pawn = placePiece(engine, "pawn", "black", "e7");
// Set turn to black so getAllLegalMoves generates black moves.
engine.session.insert(GAME_ENTITY, "Turn", "black");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("d6"))).toBe(true);
expect(targets.has(algebraicToSquare("f6"))).toBe(true);
expect(targets.has(algebraicToSquare("c5"))).toBe(true);
expect(targets.has(algebraicToSquare("g5"))).toBe(true);
// No orthogonal push
expect(targets.has(algebraicToSquare("e6"))).toBe(false);
expect(targets.has(algebraicToSquare("e5"))).toBe(false);
expect(moves).toHaveLength(4);
});
});
// ─────────────────────────────────────────────────────────────────────
// (e) Non-home-rank pawn: no double-push
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — non-home-rank pawn", () => {
it("(e) white pawn on e4 empty board → only d5, f5 (2 moves)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const pawn = placePiece(engine, "pawn", "white", "e4");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("d5"))).toBe(true);
expect(targets.has(algebraicToSquare("f5"))).toBe(true);
expect(moves).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// (f) Blocker on one diagonal, other open
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — blocker on diagonal", () => {
it("(f) b2 home-rank pawn, friendly on a3: right diagonal still works, left blocked", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
const pawn = placePiece(engine, "pawn", "white", "b2");
// Friendly pawn on a3 blocks the left diagonal.
placePiece(engine, "pawn", "white", "a3");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
// Left diagonal blocked.
expect(targets.has(algebraicToSquare("a3"))).toBe(false);
// Right diagonal open — c3 push and d4 double.
expect(targets.has(algebraicToSquare("c3"))).toBe(true);
expect(targets.has(algebraicToSquare("d4"))).toBe(true);
// No orthogonal b3 / b4 (not capture squares).
expect(targets.has(algebraicToSquare("b3"))).toBe(false);
expect(targets.has(algebraicToSquare("b4"))).toBe(false);
// b2 has file=1 → no left-two (off-board), so only 2 moves.
expect(moves).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// (g) Blocker mid-path kills the double
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — blocker mid double-push path", () => {
it("(g) c2 home pawn, friendly on d3, e4 empty: d3 blocks, e4 not reachable", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const pawn = placePiece(engine, "pawn", "white", "c2");
// Friendly pawn on d3 — blocks c2→d3 push AND the c2→e4 double.
placePiece(engine, "pawn", "white", "d3");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
// Right diagonal: d3 is blocked → NO push to d3 AND no e4
// double (path goes through d3).
expect(targets.has(algebraicToSquare("d3"))).toBe(false);
expect(targets.has(algebraicToSquare("e4"))).toBe(false);
// Left diagonal still works: b3 push + a4 double (c2 has file=2
// → file > 1, left-two valid).
expect(targets.has(algebraicToSquare("b3"))).toBe(true);
expect(targets.has(algebraicToSquare("a4"))).toBe(true);
expect(moves).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// (h) Orthogonal capture on enemy
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — orthogonal capture", () => {
it("(h) white pawn e4, enemy e5: e5 is a capture move (isCapture=true)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const pawn = placePiece(engine, "pawn", "white", "e4");
placePiece(engine, "pawn", "black", "e5");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const e5Move = moves.find((m) => m.to === algebraicToSquare("e5"));
expect(e5Move).toBeDefined();
expect(e5Move!.isCapture).toBe(true);
// Diagonal pushes still present: d5, f5 (both empty).
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("d5"))).toBe(true);
expect(targets.has(algebraicToSquare("f5"))).toBe(true);
expect(moves).toHaveLength(3);
});
});
// ─────────────────────────────────────────────────────────────────────
// (i) No orthogonal when empty
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — no orthogonal move when empty", () => {
it("(i) white pawn e4 empty board: no e5 move", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const pawn = placePiece(engine, "pawn", "white", "e4");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("e5"))).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────
// (j) No orthogonal capture on friendly
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — no friendly-orthogonal capture", () => {
it("(j) white pawn e4, friendly e5: no move to e5", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const pawn = placePiece(engine, "pawn", "white", "e4");
placePiece(engine, "knight", "white", "e5");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("e5"))).toBe(false);
// Diagonal pushes d5, f5 unaffected (both empty).
expect(targets.has(algebraicToSquare("d5"))).toBe(true);
expect(targets.has(algebraicToSquare("f5"))).toBe(true);
expect(moves).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// (k) Promotion synthesis
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — promotion on reaching last rank", () => {
it("(k) white pawn c7, enemy on c8: 4 promotion variants per landing square (b8, d8 pushes + c8 capture = 12 moves)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const pawn = placePiece(engine, "pawn", "white", "c7");
// Enemy on c8 → orthogonal capture (which also promotes).
placePiece(engine, "rook", "black", "c8");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
// Group by destination.
const byDest = new Map<number, LegalMove[]>();
for (const m of moves) {
const list = byDest.get(m.to) ?? [];
list.push(m);
byDest.set(m.to, list);
}
// b8 push → 4 promotion variants.
const b8 = byDest.get(algebraicToSquare("b8")) ?? [];
expect(b8).toHaveLength(4);
for (const m of b8) {
expect(m.isCapture).toBe(false);
expect(m.promoteTo).toBeDefined();
}
// d8 push → 4 variants.
const d8 = byDest.get(algebraicToSquare("d8")) ?? [];
expect(d8).toHaveLength(4);
for (const m of d8) {
expect(m.isCapture).toBe(false);
expect(m.promoteTo).toBeDefined();
}
// c8 capture → 4 variants.
const c8 = byDest.get(algebraicToSquare("c8")) ?? [];
expect(c8).toHaveLength(4);
for (const m of c8) {
expect(m.isCapture).toBe(true);
expect(m.promoteTo).toBeDefined();
}
// Promotion targets cover queen/rook/bishop/knight.
const promoteSet = new Set(b8.map((m) => m.promoteTo));
expect(promoteSet).toEqual(new Set(["queen", "rook", "bishop", "knight"]));
expect(moves).toHaveLength(12);
});
});
// ─────────────────────────────────────────────────────────────────────
// (l) File edge
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — file-edge pawn", () => {
it("(l) a2 home pawn on empty board: only right-diagonal (b3 push + c4 double)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
const pawn = placePiece(engine, "pawn", "white", "a2");
engine.setActivePresets([BEROLINA]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
// Left diagonal is off-board (file=0, can't decrement).
// Right diagonal: b3 push + c4 double (straight-line diagonal).
expect(targets.has(algebraicToSquare("b3"))).toBe(true);
expect(targets.has(algebraicToSquare("c4"))).toBe(true);
// NOT b4 — the double continues along the diagonal, not on-file.
expect(targets.has(algebraicToSquare("b4"))).toBe(false);
expect(moves).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// (m) scope=white → black pawn keeps default
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — scope=white leaves black pawns as default", () => {
it("(m) black pawn e7 empty board under scope=white uses FIDE default (e6, e5)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const blackPawn = placePiece(engine, "pawn", "black", "e7");
engine.session.insert(GAME_ENTITY, "Turn", "black");
engine.setActivePresets([BEROLINA_WHITE]);
const moves = movesFor(engine, blackPawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
// Default black pawn: orthogonal e6 push + e5 double-push.
expect(targets.has(algebraicToSquare("e6"))).toBe(true);
expect(targets.has(algebraicToSquare("e5"))).toBe(true);
// No Berolina diagonals.
expect(targets.has(algebraicToSquare("d6"))).toBe(false);
expect(targets.has(algebraicToSquare("f6"))).toBe(false);
expect(moves).toHaveLength(2);
});
it("(m') same game: white pawn IS Berolina under scope=white", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const whitePawn = placePiece(engine, "pawn", "white", "e2");
engine.setActivePresets([BEROLINA_WHITE]);
const moves = movesFor(engine, whitePawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("d3"))).toBe(true);
expect(targets.has(algebraicToSquare("f3"))).toBe(true);
expect(moves).toHaveLength(4);
});
});
// ─────────────────────────────────────────────────────────────────────
// (n) scope=black mirror
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — scope=black mirror", () => {
it("(n) white pawn under scope=black uses FIDE default (e3, e4)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const whitePawn = placePiece(engine, "pawn", "white", "e2");
engine.setActivePresets([BEROLINA_BLACK]);
const moves = movesFor(engine, whitePawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("e3"))).toBe(true);
expect(targets.has(algebraicToSquare("e4"))).toBe(true);
expect(targets.has(algebraicToSquare("d3"))).toBe(false);
expect(targets.has(algebraicToSquare("f3"))).toBe(false);
expect(moves).toHaveLength(2);
});
it("(n') black pawn under scope=black IS Berolina", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const blackPawn = placePiece(engine, "pawn", "black", "e7");
engine.session.insert(GAME_ENTITY, "Turn", "black");
engine.setActivePresets([BEROLINA_BLACK]);
const moves = movesFor(engine, blackPawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("d6"))).toBe(true);
expect(targets.has(algebraicToSquare("f6"))).toBe(true);
expect(targets.has(algebraicToSquare("c5"))).toBe(true);
expect(targets.has(algebraicToSquare("g5"))).toBe(true);
expect(moves).toHaveLength(4);
});
});
// ─────────────────────────────────────────────────────────────────────
// (o) scope=both (explicit)
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — scope=both", () => {
it("(o) both colors Berolina", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const whitePawn = placePiece(engine, "pawn", "white", "e2");
const blackPawn = placePiece(engine, "pawn", "black", "e7");
engine.setActivePresets([BEROLINA]);
const whiteMoves = movesFor(engine, whitePawn as unknown as number);
expect(whiteMoves).toHaveLength(4);
engine.session.insert(GAME_ENTITY, "Turn", "black");
const blackMoves = movesFor(engine, blackPawn as unknown as number);
expect(blackMoves).toHaveLength(4);
});
});
// ─────────────────────────────────────────────────────────────────────
// (p) filterMoves composition
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — composes with filterMoves", () => {
it("(p) rank-3 filter drops d3/f3 pushes; c4/g4 double-pushes survive", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const pawn = placePiece(engine, "pawn", "white", "e2");
engine.setActivePresets([
BEROLINA,
{ id: FILTER_ID, scope: "both", turnsRemaining: null },
]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
// Rank-3 indices (16..23) dropped.
expect(targets.has(algebraicToSquare("d3"))).toBe(false);
expect(targets.has(algebraicToSquare("f3"))).toBe(false);
// Rank-4 double-pushes survive.
expect(targets.has(algebraicToSquare("c4"))).toBe(true);
expect(targets.has(algebraicToSquare("g4"))).toBe(true);
expect(moves).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// (q) Incompatibility
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns — incompatibility", () => {
it("(q) berolina-pawns + pawn-diagonal-no-capture throws at activation", () => {
const engine = new ChessEngine();
expect(() =>
engine.setActivePresets([
BEROLINA,
{ id: "pawn-diagonal-no-capture", scope: "both", turnsRemaining: null },
]),
).toThrow();
});
});

View file

@ -0,0 +1,235 @@
/**
* Preset: Berolina Pawns (rule-variants epic, Phase E.1).
*
* Berolina swaps the standard pawn's motion and capture vectors:
* - PUSH (non-capture) happens on forward DIAGONALS. From the
* pawn's home rank the push may optionally run TWO diagonal
* squares in a straight diagonal line (mirror of the FIDE
* double-push). The entire diagonal path must be empty.
* - CAPTURE happens on the forward ORTHOGONAL (rank-ahead same
* file). One square only; the target square must hold an enemy.
*
* The override is implemented via `overridePieceMoves`. Returning a
* non-undefined array REPLACES the default pawn generator AND skips
* the engine's synthesized en-passant / castling / promotion paths
* for THAT piece (see PresetDef.overridePieceMoves contract). So we
* synthesize promotion moves ourselves below.
*
* Scope-flip semantics
* ────────────────────
* Read at hook time from `engine.activePresets.list()` (idiomatic
* pattern — see `monster-rules.ts`). The knob is interpreted as
* "which color's pawns play Berolina":
* - `scope: "both"` → override fires for every pawn.
* - `scope: "white"` → white pawns use Berolina; black pawns keep
* default FIDE pawn rules.
* - `scope: "black"` → mirror: black pawns use Berolina.
*
* When the scope does NOT match a particular pawn's color, the hook
* returns `undefined`, which lets the engine fall through to its
* default pawn generator (with en-passant / promotion synthesis
* reinstated for that pawn).
*
* Promotion
* ─────────
* A Berolina diagonal push that lands on the last rank IS a
* promotion (pawn just moved, reached promotion rank — same trigger
* as standard chess). Likewise an orthogonal capture that reaches
* the last rank promotes. For each such move we emit ONE LegalMove
* per entry in `PROMOTION_PIECES` (queen / rook / bishop / knight),
* tagged via the `promoteTo` field, matching the shape of the
* engine's default `getPromotionMoves` output.
*
* En-passant
* ──────────
* DEFERRED. Many Berolina rulesets don't include en-passant at all,
* and the traditional orthogonal en-passant rule doesn't map
* cleanly to a diagonal-push pawn. For v1 we emit NO en-passant
* moves and the override path prevents the engine from synthesizing
* the standard variant. Revisit in a later phase if requested.
*
* Blocker semantics
* ─────────────────
* - Diagonal push: target diagonal square must be EMPTY. For the
* home-rank double-push, both the intermediate diagonal-one AND
* the terminal diagonal-two squares must be empty.
* - Orthogonal capture: target orthogonal-forward square must
* contain an ENEMY piece. Empty → no capture; friendly → no
* capture.
*
* Layer composition
* ─────────────────
* `filterMoves` and `filterLegalMoves` ALWAYS run on this preset's
* output (engine contract — see `engine.ts` around line 1033). So
* other presets that drop moves from specific destinations (e.g.
* board-wrapping, zone-banned squares) continue to compose. The
* self-check filter also runs unless a preset opts out via
* `shouldFilterSelfCheck`.
*
* Incompatibilities
* ─────────────────
* - `pawn-diagonal-no-capture` — that preset adds diagonal
* non-capture pushes to FIDE pawns. Berolina already redefines
* pushes diagonally; stacking the two yields duplicate or
* contradictory moves.
* - `berolina-pawns-2` — sibling variant (Phase E.2) that
* redefines pawns differently. Only one pawn-override preset
* should be active under any given scope.
* - `pawns-move-backward` — that preset adds moves via
* `getExtraMoves`, which the engine DOES call on top of an
* overridden set. Adding "backward-1" onto a Berolina pawn
* doesn't match the spirit of either variant and the interaction
* is under-specified; forbid the combination pending a
* considered design.
* - `double-pawn-sprint` — forward-push-from-any-rank extension;
* meaningless overlay on a pawn that never pushes orthogonally.
*/
import type { EntityId } from "@paratype/rete";
import type { ChessEngine } from "../engine.js";
import type { LegalMove } from "../rules/types.js";
import type { PieceColor, PieceType, Square } from "../schema.js";
import { PROMOTION_PIECES } from "../schema.js";
import { fileOf, rankOf } from "../coord.js";
import {
getPiecePosition,
getPieceColor,
isPieceAt,
isEnemyAt,
} from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js";
const PRESET_ID = "berolina-pawns";
/** Promotion rank for each color. */
const PROMOTION_RANK: Record<PieceColor, number> = {
white: 7,
black: 0,
};
/** Home rank (where pawns start) for each color. */
const HOME_RANK: Record<PieceColor, number> = {
white: 1,
black: 6,
};
/**
* Append a move to `out`, expanding into promotion variants when the
* destination is on the mover's promotion rank.
*/
function emitMove(
out: LegalMove[],
pieceId: EntityId,
from: Square,
to: Square,
isCapture: boolean,
color: PieceColor,
): void {
if (rankOf(to) === PROMOTION_RANK[color]) {
for (const promoteTo of PROMOTION_PIECES) {
out.push({ pieceId, from, to, isCapture, promoteTo: promoteTo as PieceType });
}
} else {
out.push({ pieceId, from, to, isCapture });
}
}
PRESET_REGISTRY.register({
id: PRESET_ID,
name: "Berolina Pawns",
description:
"Pawns push forward diagonally and capture forward orthogonally. Scope chooses which side(s) use the rule.",
incompatibleWith: [
"pawn-diagonal-no-capture",
"berolina-pawns-2",
"pawns-move-backward",
"double-pawn-sprint",
],
requires: [],
overridePieceMoves(engine: ChessEngine, pieceId: EntityId): readonly LegalMove[] | undefined {
// Fast-reject non-pawns: the hook fires for every piece, but we
// only redefine pawn motion. Any other piece type → decline
// (undefined) so the default generator runs.
const type = engine.session.get(pieceId, "PieceType");
if (type !== "pawn") return undefined;
// Recover THIS preset's live scope. If the preset isn't in the
// active list (shouldn't happen — hook fires only for active
// presets) fall through to undefined.
let scope: "white" | "black" | "both" = "both";
let found = false;
for (const entry of engine.activePresets.list()) {
if (entry.id === PRESET_ID) {
scope = entry.scope;
found = true;
break;
}
}
if (!found) return undefined;
const color = getPieceColor(engine.session, pieceId);
const from = getPiecePosition(engine.session, pieceId);
if (color === null || from === null) return undefined;
// Scope check: Berolina only applies to pawns whose color matches
// the scope. Otherwise return undefined so the default pawn
// generator (+ en-passant / promotion synthesis) runs.
if (scope !== "both" && scope !== color) return undefined;
const out: LegalMove[] = [];
const file = fileOf(from);
const rank = rankOf(from);
// Direction: +1 for white (rank grows), -1 for black.
const dir = color === "white" ? 1 : -1;
// Forward one-rank square index delta.
const forwardDelta = dir * 8;
// Left/right diagonal deltas (file-0 safe).
const leftDiagDelta = dir * 8 - 1;
const rightDiagDelta = dir * 8 + 1;
// ── Diagonal pushes (non-capture) ─────────────────────────────
// Left diagonal (file-1, rank+dir). Requires file > 0.
if (file > 0) {
const leftOne = (from + leftDiagDelta) as Square;
if (!isPieceAt(engine.session, leftOne)) {
emitMove(out, pieceId, from, leftOne, false, color);
// Home-rank double-push: two squares along the same diagonal.
// Requires file > 1 (so rank+2*dir stays on-file).
if (rank === HOME_RANK[color] && file > 1) {
const leftTwo = (from + 2 * leftDiagDelta) as Square;
if (!isPieceAt(engine.session, leftTwo)) {
emitMove(out, pieceId, from, leftTwo, false, color);
}
}
}
}
// Right diagonal (file+1, rank+dir). Requires file < 7.
if (file < 7) {
const rightOne = (from + rightDiagDelta) as Square;
if (!isPieceAt(engine.session, rightOne)) {
emitMove(out, pieceId, from, rightOne, false, color);
if (rank === HOME_RANK[color] && file < 6) {
const rightTwo = (from + 2 * rightDiagDelta) as Square;
if (!isPieceAt(engine.session, rightTwo)) {
emitMove(out, pieceId, from, rightTwo, false, color);
}
}
}
}
// ── Orthogonal capture (forward one file-stable) ──────────────
// Forward rank must stay on-board. Since pawns promote on the
// last rank, `rank + dir` is 0..7 as long as `rank` isn't the
// promotion rank — and a pawn on the promotion rank should have
// already promoted. Guard anyway.
const forwardRank = rank + dir;
if (forwardRank >= 0 && forwardRank <= 7) {
const forwardSq = (from + forwardDelta) as Square;
if (isEnemyAt(engine.session, forwardSq, color)) {
emitMove(out, pieceId, from, forwardSq, true, color);
}
}
return out;
},
});

View file

@ -38,5 +38,6 @@ import "./first-promotion-wins.js";
import "./capture-all.js";
import "./suicide-chess.js";
import "./extinction-chess.js";
import "./berolina-pawns.js";
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";

View file

@ -51,6 +51,7 @@ describe("Preset registry — all registered", () => {
"capture-all",
"suicide-chess",
"extinction-chess",
"berolina-pawns",
];
it("registry size matches the expected ID list", () => {