From 5393b96b3fe1e0af6760efcf7feb6b0f9f3b9abf Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 08:29:54 -0600 Subject: [PATCH] feat(presets): berolina-pawns (diagonal push + orthogonal capture, scope-flippable) --- .../chess/src/presets/berolina-pawns.test.ts | 549 ++++++++++++++++++ packages/chess/src/presets/berolina-pawns.ts | 235 ++++++++ packages/chess/src/presets/index.ts | 1 + packages/chess/src/presets/presets.test.ts | 1 + 4 files changed, 786 insertions(+) create mode 100644 packages/chess/src/presets/berolina-pawns.test.ts create mode 100644 packages/chess/src/presets/berolina-pawns.ts diff --git a/packages/chess/src/presets/berolina-pawns.test.ts b/packages/chess/src/presets/berolina-pawns.test.ts new file mode 100644 index 0000000..c9ed421 --- /dev/null +++ b/packages/chess/src/presets/berolina-pawns.test.ts @@ -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(); + 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(); + }); +}); diff --git a/packages/chess/src/presets/berolina-pawns.ts b/packages/chess/src/presets/berolina-pawns.ts new file mode 100644 index 0000000..4994744 --- /dev/null +++ b/packages/chess/src/presets/berolina-pawns.ts @@ -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 = { + white: 7, + black: 0, +}; + +/** Home rank (where pawns start) for each color. */ +const HOME_RANK: Record = { + 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; + }, +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index ba2618c..5a63c81 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -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"; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 2c3b9aa..902d0c3 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -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", () => {