feat(presets): berolina-pawns (diagonal push + orthogonal capture, scope-flippable)
This commit is contained in:
parent
08b8e0fed2
commit
5393b96b3f
4 changed files with 786 additions and 0 deletions
549
packages/chess/src/presets/berolina-pawns.test.ts
Normal file
549
packages/chess/src/presets/berolina-pawns.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
235
packages/chess/src/presets/berolina-pawns.ts
Normal file
235
packages/chess/src/presets/berolina-pawns.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue