feat(presets): berolina-pawns-2 (berolina with sideways captures)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 08:41:52 -06:00
commit ee08e20871
No known key found for this signature in database
4 changed files with 540 additions and 0 deletions

View file

@ -0,0 +1,341 @@
/**
* Tests for `berolina-pawns-2` (Phase E.2, rule-variants epic).
*
* The preset is identical to `berolina-pawns` but adds sideways
* captures (same rank, adjacent file). These tests focus on the
* DIFF — the sideways-capture behaviour — plus a couple of sanity
* tests that the baseline berolina rules still work through this
* preset (push + orthogonal capture).
*
* Coverage map:
* (a) Sideways-left capture: white pawn e4, enemy d4 → capture.
* (b) Sideways-right capture: white pawn e4, enemy f4 → capture.
* (c) No sideways capture on empty squares.
* (d) No sideways capture on friendly piece.
* (e) Edge file (a-file): only right-sideways available.
* (f) Mirror for black: black pawn e5, enemies d5/f5 → both
* sideways captures available.
* (g) Scope `white` flip: black pawns don't get sideways captures;
* white pawns do.
* (h) Incompatibility: berolina-pawns-2 + berolina-pawns throws.
* (i) Compose: diagonal pushes + orthogonal capture + sideways
* captures all available simultaneously.
* (j) Sanity: baseline berolina push still works under this preset.
* (k) Sanity: baseline orthogonal capture still works under this
* preset.
*/
import { describe, it, expect } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { GAME_ENTITY } from "../schema.js";
import { algebraicToSquare } from "../coord.js";
import type { LegalMove } from "../rules/types.js";
import { clearBoard, placePiece } from "./test-utils.js";
const BEROLINA2 = {
id: "berolina-pawns-2",
scope: "both" as const,
turnsRemaining: null,
};
const BEROLINA2_WHITE = {
id: "berolina-pawns-2",
scope: "white" as const,
turnsRemaining: null,
};
function movesFor(engine: ChessEngine, pieceId: number): LegalMove[] {
return engine.getAllLegalMoves().filter((m) => m.pieceId === pieceId);
}
// ─────────────────────────────────────────────────────────────────────
// (a) Sideways-left capture
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — sideways-left capture", () => {
it("(a) white pawn e4, enemy d4 → capture to d4 with 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", "d4");
engine.setActivePresets([BEROLINA2]);
const moves = movesFor(engine, pawn as unknown as number);
const d4 = moves.find((m) => m.to === algebraicToSquare("d4"));
expect(d4).toBeDefined();
expect(d4!.isCapture).toBe(true);
// Sideways move is NOT a promotion.
expect(d4!.promoteTo).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────
// (b) Sideways-right capture
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — sideways-right capture", () => {
it("(b) white pawn e4, enemy f4 → capture to f4 with 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", "f4");
engine.setActivePresets([BEROLINA2]);
const moves = movesFor(engine, pawn as unknown as number);
const f4 = moves.find((m) => m.to === algebraicToSquare("f4"));
expect(f4).toBeDefined();
expect(f4!.isCapture).toBe(true);
expect(f4!.promoteTo).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────
// (c) No sideways on empty squares
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — no sideways on empty", () => {
it("(c) white pawn e4, empty d4/f4 → no sideways 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([BEROLINA2]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("d4"))).toBe(false);
expect(targets.has(algebraicToSquare("f4"))).toBe(false);
// Just the two diagonal pushes.
expect(targets.has(algebraicToSquare("d5"))).toBe(true);
expect(targets.has(algebraicToSquare("f5"))).toBe(true);
expect(moves).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// (d) No sideways on friendly
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — no sideways on friendly", () => {
it("(d) white pawn e4, friendly d4 → no move to d4", () => {
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", "d4");
engine.setActivePresets([BEROLINA2]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("d4"))).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────
// (e) Edge file (a-file)
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — edge file pawn", () => {
it("(e) white pawn a4 with enemy b4 → right-sideways captures; no left (off-board)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
const pawn = placePiece(engine, "pawn", "white", "a4");
placePiece(engine, "pawn", "black", "b4");
engine.setActivePresets([BEROLINA2]);
const moves = movesFor(engine, pawn as unknown as number);
const b4 = moves.find((m) => m.to === algebraicToSquare("b4"));
expect(b4).toBeDefined();
expect(b4!.isCapture).toBe(true);
// Right diagonal push b5 also present (empty).
const targets = new Set(moves.map((m) => m.to));
expect(targets.has(algebraicToSquare("b5"))).toBe(true);
// No phantom wrap to h4.
expect(targets.has(algebraicToSquare("h4"))).toBe(false);
// Pawn on a-file: only right-diag push + sideways-right = 2
// moves (no left diagonal).
expect(moves).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// (f) Mirror for black
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — mirror for black", () => {
it("(f) black pawn e5 with enemies d5/f5 → both sideways captures", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const pawn = placePiece(engine, "pawn", "black", "e5");
placePiece(engine, "pawn", "white", "d5");
placePiece(engine, "pawn", "white", "f5");
engine.session.insert(GAME_ENTITY, "Turn", "black");
engine.setActivePresets([BEROLINA2]);
const moves = movesFor(engine, pawn as unknown as number);
const d5 = moves.find((m) => m.to === algebraicToSquare("d5"));
const f5 = moves.find((m) => m.to === algebraicToSquare("f5"));
expect(d5).toBeDefined();
expect(d5!.isCapture).toBe(true);
expect(f5).toBeDefined();
expect(f5!.isCapture).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────
// (g) Scope=white: black pawns don't get sideways; white pawns do
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — scope=white", () => {
it("(g) black pawn e5 with enemies d5/f5 under scope=white → no sideways (default FIDE)", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const blackPawn = placePiece(engine, "pawn", "black", "e5");
placePiece(engine, "pawn", "white", "d5");
placePiece(engine, "pawn", "white", "f5");
engine.session.insert(GAME_ENTITY, "Turn", "black");
engine.setActivePresets([BEROLINA2_WHITE]);
const moves = movesFor(engine, blackPawn as unknown as number);
const targets = new Set(moves.map((m) => m.to));
// FIDE black pawn from e5: captures on d4/f4 (diagonal) and push
// to e4 (if empty). NOT d5/f5 (those are same-rank).
expect(targets.has(algebraicToSquare("d5"))).toBe(false);
expect(targets.has(algebraicToSquare("f5"))).toBe(false);
});
it("(g') white pawn e4 with enemy d4 under scope=white → sideways capture available", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "black", "h8");
const whitePawn = placePiece(engine, "pawn", "white", "e4");
placePiece(engine, "pawn", "black", "d4");
engine.setActivePresets([BEROLINA2_WHITE]);
const moves = movesFor(engine, whitePawn as unknown as number);
const d4 = moves.find((m) => m.to === algebraicToSquare("d4"));
expect(d4).toBeDefined();
expect(d4!.isCapture).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────
// (h) Incompatibility with berolina-pawns
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — incompatibility", () => {
it("(h) berolina-pawns-2 + berolina-pawns throws at activation", () => {
const engine = new ChessEngine();
expect(() =>
engine.setActivePresets([
BEROLINA2,
{ id: "berolina-pawns", scope: "both", turnsRemaining: null },
]),
).toThrow();
});
});
// ─────────────────────────────────────────────────────────────────────
// (i) Compose: all three move kinds simultaneously
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — all move kinds compose", () => {
it("(i) white pawn e4: diagonal pushes d5/f5 + sideways cap d4 + orthogonal cap 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");
// Sideways target d4 (enemy).
placePiece(engine, "pawn", "black", "d4");
// Orthogonal capture target e5 (enemy).
placePiece(engine, "pawn", "black", "e5");
// Diagonal push squares d5, f5 must remain empty.
engine.setActivePresets([BEROLINA2]);
const moves = movesFor(engine, pawn as unknown as number);
const targets = new Map(moves.map((m) => [m.to, m] as const));
// Diagonal push (non-capture).
const d5 = targets.get(algebraicToSquare("d5"));
expect(d5).toBeDefined();
expect(d5!.isCapture).toBe(false);
const f5 = targets.get(algebraicToSquare("f5"));
expect(f5).toBeDefined();
expect(f5!.isCapture).toBe(false);
// Orthogonal capture.
const e5 = targets.get(algebraicToSquare("e5"));
expect(e5).toBeDefined();
expect(e5!.isCapture).toBe(true);
// Sideways capture.
const d4 = targets.get(algebraicToSquare("d4"));
expect(d4).toBeDefined();
expect(d4!.isCapture).toBe(true);
// No sideways-right (f4 empty) and no extra noise.
expect(targets.has(algebraicToSquare("f4"))).toBe(false);
expect(moves).toHaveLength(4);
});
});
// ─────────────────────────────────────────────────────────────────────
// (j) Sanity: baseline berolina push still works
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — baseline diagonal push sanity", () => {
it("(j) white pawn e2 empty board → 4 diagonal pushes (no sideways from empty adjacent)", () => {
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([BEROLINA2]);
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 sideways (d2/f2 empty).
expect(targets.has(algebraicToSquare("d2"))).toBe(false);
expect(targets.has(algebraicToSquare("f2"))).toBe(false);
expect(moves).toHaveLength(4);
});
});
// ─────────────────────────────────────────────────────────────────────
// (k) Sanity: baseline orthogonal capture still works
// ─────────────────────────────────────────────────────────────────────
describe("berolina-pawns-2 — baseline orthogonal capture sanity", () => {
it("(k) white pawn e4, enemy e5 → e5 is still a capture", () => {
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([BEROLINA2]);
const moves = movesFor(engine, pawn as unknown as number);
const e5 = moves.find((m) => m.to === algebraicToSquare("e5"));
expect(e5).toBeDefined();
expect(e5!.isCapture).toBe(true);
});
});

View file

@ -0,0 +1,197 @@
/**
* Preset: Berolina Pawns (Extended) — Phase E.2, rule-variants epic.
*
* Same as `berolina-pawns` but adds SIDEWAYS CAPTURES. Use with
* `scope: "white"` / `"black"` / `"both"` to pick which side(s) use
* the rule.
*
* Rules (diff from berolina-pawns):
* - Pushes: forward diagonal (same as berolina-pawns).
* - Captures: forward orthogonal (same as berolina-pawns) OR
* SIDEWAYS — same rank, adjacent file (file±1). Sideways capture
* requires an enemy piece on the adjacent square; empty or
* friendly → no move.
* - Sideways moves are NEVER promotions: the destination rank
* equals the source rank, so it can't be the promotion rank for
* a pawn that wasn't already there (pawns on the promotion rank
* should have already promoted).
* - No en-passant (same as berolina-pawns — deferred).
*
* Scope-flip semantics
* ────────────────────
* Read at hook time from `engine.activePresets.list()` (idiomatic
* pattern — see `monster-rules.ts` / `berolina-pawns.ts`). The knob
* is interpreted as "which color's pawns play Berolina-Extended":
* - `scope: "both"` → override fires for every pawn.
* - `scope: "white"` → white pawns use this rule; black pawns keep
* default FIDE pawn rules.
* - `scope: "black"` → mirror: black pawns use this rule.
*
* When the scope doesn't match a particular pawn's color, the hook
* returns `undefined`, letting the engine fall through to its
* default pawn generator (with en-passant / promotion synthesis
* reinstated for that pawn).
*
* Incompatibilities
* ─────────────────
* - `berolina-pawns` — sibling pawn-override; only one may claim
* pawn motion under a given scope.
* - `pawn-diagonal-no-capture` — adds diagonal non-capture pushes
* to FIDE pawns, which already overlap with this preset's
* pushes.
* - `pawns-move-backward` — adds moves via `getExtraMoves`, which
* runs on top of the overridden set; semantics under-specified.
* - `double-pawn-sprint` — forward-push extension; meaningless
* overlay on pawns that never push 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-2";
/** 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 (Extended)",
description:
"Berolina pawns with sideways captures. Pawns push diagonally, capture orthogonally or sideways.",
incompatibleWith: [
"berolina-pawns",
"pawn-diagonal-no-capture",
"pawns-move-backward",
"double-pawn-sprint",
],
requires: [],
overridePieceMoves(engine: ChessEngine, pieceId: EntityId): readonly LegalMove[] | undefined {
// Only redefine pawn motion.
const type = engine.session.get(pieceId, "PieceType");
if (type !== "pawn") return undefined;
// Recover THIS preset's live scope.
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.
if (scope !== "both" && scope !== color) return undefined;
const out: LegalMove[] = [];
const file = fileOf(from);
const rank = rankOf(from);
const dir = color === "white" ? 1 : -1;
const forwardDelta = dir * 8;
const leftDiagDelta = dir * 8 - 1;
const rightDiagDelta = dir * 8 + 1;
// ── Diagonal pushes (non-capture) ─────────────────────────────
if (file > 0) {
const leftOne = (from + leftDiagDelta) as Square;
if (!isPieceAt(engine.session, leftOne)) {
emitMove(out, pieceId, from, leftOne, false, color);
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);
}
}
}
}
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, same file) ───────────────────
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);
}
}
// ── Sideways captures (same rank, adjacent file) ──────────────
// These NEVER promote (same rank as source), so we emit direct
// LegalMove records without going through emitMove's promotion
// expansion. That said, a pawn is never ON its own promotion
// rank (would have promoted), so emitMove would have been
// correct too — we bypass it for clarity.
if (file > 0) {
const leftSq = (from - 1) as Square;
if (isEnemyAt(engine.session, leftSq, color)) {
out.push({ pieceId, from, to: leftSq, isCapture: true });
}
}
if (file < 7) {
const rightSq = (from + 1) as Square;
if (isEnemyAt(engine.session, rightSq, color)) {
out.push({ pieceId, from, to: rightSq, isCapture: true });
}
}
return out;
},
});

View file

@ -39,6 +39,7 @@ import "./capture-all.js";
import "./suicide-chess.js";
import "./extinction-chess.js";
import "./berolina-pawns.js";
import "./berolina-pawns-2.js";
import "./bouncing-pieces.js";
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";

View file

@ -52,6 +52,7 @@ describe("Preset registry — all registered", () => {
"suicide-chess",
"extinction-chess",
"berolina-pawns",
"berolina-pawns-2",
"bouncing-pieces",
];