diff --git a/packages/chess/src/presets/berolina-pawns-2.test.ts b/packages/chess/src/presets/berolina-pawns-2.test.ts new file mode 100644 index 0000000..988bb57 --- /dev/null +++ b/packages/chess/src/presets/berolina-pawns-2.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/presets/berolina-pawns-2.ts b/packages/chess/src/presets/berolina-pawns-2.ts new file mode 100644 index 0000000..595f102 --- /dev/null +++ b/packages/chess/src/presets/berolina-pawns-2.ts @@ -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 = { + 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 (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; + }, +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index 1fba158..d71b9df 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -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"; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index e417080..d39238c 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -52,6 +52,7 @@ describe("Preset registry — all registered", () => { "suicide-chess", "extinction-chess", "berolina-pawns", + "berolina-pawns-2", "bouncing-pieces", ];