diff --git a/packages/chess/src/presets/capture-to-win.ts b/packages/chess/src/presets/capture-to-win.ts new file mode 100644 index 0000000..a9677d9 --- /dev/null +++ b/packages/chess/src/presets/capture-to-win.ts @@ -0,0 +1,10 @@ +import { PRESET_REGISTRY } from "./registry.js"; + +PRESET_REGISTRY.register({ + id: "capture-to-win", + name: "First Blood", + description: "A player wins immediately upon capturing any enemy piece (first capture wins).", + incompatibleWith: ["last-piece-standing"], + requires: [], + // Win condition checked in ChessEngine (P3.11) +}); diff --git a/packages/chess/src/presets/explosive-rook.ts b/packages/chess/src/presets/explosive-rook.ts new file mode 100644 index 0000000..b16070e --- /dev/null +++ b/packages/chess/src/presets/explosive-rook.ts @@ -0,0 +1,10 @@ +import { PRESET_REGISTRY } from "./registry.js"; + +PRESET_REGISTRY.register({ + id: "explosive-rook", + name: "Detonating Rook", + description: "When a Rook captures, it also removes all pieces within 2 squares on the same rank and file.", + incompatibleWith: ["piece-hp"], + requires: [], + // Full integration happens in ChessEngine (P3.11) +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index 679325c..e0995af 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -11,5 +11,14 @@ import "./pawn-diagonal-no-capture.js"; import "./knights-leap-twice.js"; import "./bishops-ignore-color.js"; import "./rook-warp.js"; +import "./wrap-board.js"; +import "./queen-splits.js"; +import "./king-heals.js"; +import "./explosive-rook.js"; +import "./capture-to-win.js"; +import "./last-piece-standing.js"; +import "./piece-hp.js"; +import "./knight-immunity.js"; +import "./poisoned-squares.js"; export { PRESET_REGISTRY, type PresetDef } from "./registry.js"; diff --git a/packages/chess/src/presets/king-heals.ts b/packages/chess/src/presets/king-heals.ts new file mode 100644 index 0000000..c18f8f6 --- /dev/null +++ b/packages/chess/src/presets/king-heals.ts @@ -0,0 +1,10 @@ +import { PRESET_REGISTRY } from "./registry.js"; + +PRESET_REGISTRY.register({ + id: "king-heals", + name: "Regenerating King", + description: "If the King ends a turn NOT in check, it gains +1 HP (max 3 HP). Requires piece-hp.", + incompatibleWith: [], + requires: ["piece-hp"], + // Full integration happens in ChessEngine (P3.11) +}); diff --git a/packages/chess/src/presets/knight-immunity.ts b/packages/chess/src/presets/knight-immunity.ts new file mode 100644 index 0000000..b27deb7 --- /dev/null +++ b/packages/chess/src/presets/knight-immunity.ts @@ -0,0 +1,21 @@ +import { PRESET_REGISTRY } from "./registry.js"; + +PRESET_REGISTRY.register({ + id: "knight-immunity", + name: "Bishop-Proof Knights", + description: "Knights are immune to capture by Bishops. Bishops cannot make moves that would capture a Knight.", + incompatibleWith: [], + requires: [], + filterMoves: (moves, engine, pieceId) => { + const facts = engine.session.allFacts(); + const type = facts.find(f => f.id === pieceId && f.attr === "PieceType")?.value; + if (type !== "bishop") return moves; + return moves.filter(m => { + if (!m.isCapture) return true; + const targetFact = facts.find(f => f.attr === "Position" && f.value === m.to); + if (!targetFact) return true; + const targetType = facts.find(f => f.id === targetFact.id && f.attr === "PieceType")?.value; + return targetType !== "knight"; + }); + }, +}); diff --git a/packages/chess/src/presets/knights-bishop-rook.test.ts b/packages/chess/src/presets/knights-bishop-rook.test.ts new file mode 100644 index 0000000..086ae36 --- /dev/null +++ b/packages/chess/src/presets/knights-bishop-rook.test.ts @@ -0,0 +1,263 @@ +/** + * Tests for preset rules 4-6 (P3.5): knights-leap-twice, bishops-ignore-color, rook-warp. + */ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { PRESET_REGISTRY, type PresetDef } from "./index.js"; +import type { ChessEngine } from "../engine.js"; +import type { LegalMove } from "../rules/types.js"; + +const mkId = (n: number) => n as EntityId; + +function mkEngine(session: Session): ChessEngine { + // The preset hooks only touch `engine.session`, so a minimal stub is enough. + return { session } as unknown as ChessEngine; +} + +function getPreset(id: string): PresetDef { + const found = PRESET_REGISTRY.getAll().find(p => p.id === id); + if (!found) throw new Error(`preset '${id}' not registered`); + return found; +} + +function runExtras(preset: PresetDef, session: Session, pieceId: EntityId): LegalMove[] { + return preset.getExtraMoves!(mkEngine(session), pieceId); +} + +function insertPiece( + session: Session, + id: number, + type: string, + color: string, + square: number, +): EntityId { + const eid = mkId(id); + session.insert(eid, "PieceType", type); + session.insert(eid, "Color", color); + session.insert(eid, "Position", square); + return eid; +} + +// ─── Registry size ─────────────────────────────────────────────────────────── + +describe("Preset registry (P3.5)", () => { + it("has at least 6 presets registered after loading index.ts", () => { + expect(PRESET_REGISTRY.getAll().length).toBeGreaterThanOrEqual(6); + }); + + it("contains the three new presets", () => { + const ids = new Set(PRESET_REGISTRY.getAll().map(p => p.id)); + expect(ids.has("knights-leap-twice")).toBe(true); + expect(ids.has("bishops-ignore-color")).toBe(true); + expect(ids.has("rook-warp")).toBe(true); + }); +}); + +// ─── knights-leap-twice ────────────────────────────────────────────────────── + +describe("Preset: knights-leap-twice", () => { + const preset = getPreset("knights-leap-twice"); + + it("is registered with the expected metadata", () => { + expect(preset).toBeDefined(); + expect(preset.incompatibleWith).toEqual([]); + expect(preset.requires).toEqual([]); + expect(preset.getExtraMoves).toBeDefined(); + }); + + it("returns no extras for non-knight pieces", () => { + const session = new Session({ autoFire: false }); + const bishop = insertPiece(session, 1, "bishop", "white", 28); + const extras = runExtras(preset, session, bishop); + expect(extras).toEqual([]); + }); + + it("knight on empty board gets extra two-leap destinations", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 28); // e4 + const extras = runExtras(preset, session, knight); + // On empty board, two-leap reaches many squares beyond the 8 single-leap targets. + expect(extras.length).toBeGreaterThan(0); + // Must not include the starting square. + expect(extras.every(m => m.to !== 28)).toBe(true); + // Must not duplicate single-leap targets (those are base-rule territory). + const singleLeaps = new Set([11, 13, 18, 22, 34, 38, 43, 45]); + expect(extras.every(m => !singleLeaps.has(m.to))).toBe(true); + // All extras originate from this knight. + expect(extras.every(m => m.pieceId === knight && m.from === 28)).toBe(true); + }); + + it("skips routes whose intermediate square is occupied", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); // b1 + // Place an ally on a3 (=16) — one knight-leap from b1. + insertPiece(session, 2, "pawn", "white", 16); + const extras = runExtras(preset, session, knight); + // The presence of the ally on a3 eliminates a3-transit routes, but other + // intermediate squares (c3, d2) still yield many two-leap destinations. + expect(extras.length).toBeGreaterThan(0); + // Specifically, c4 (=26) is reachable via b1→c3→? — not directly relevant, + // but b1→a3→c4 is blocked; b1→c3→a4 or b1→c3→b5 remain legal when empty. + }); + + it("two-leap extra that lands on an enemy is marked as capture", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); // b1 + // Enemy on c3 (=18) is a single-leap target (not an extra), so place enemy + // on a square only reachable via two leaps: e.g. b1→c3→b5 (=33). + insertPiece(session, 2, "rook", "black", 33); // b5 + const extras = runExtras(preset, session, knight); + const hit = extras.find(m => m.to === 33); + expect(hit).toBeDefined(); + expect(hit!.isCapture).toBe(true); + }); + + it("two-leap cannot land on an ally", () => { + const session = new Session({ autoFire: false }); + const knight = insertPiece(session, 1, "knight", "white", 1); // b1 + insertPiece(session, 2, "rook", "white", 33); // ally on b5 (two-leap target) + const extras = runExtras(preset, session, knight); + expect(extras.find(m => m.to === 33)).toBeUndefined(); + }); +}); + +// ─── bishops-ignore-color ──────────────────────────────────────────────────── + +describe("Preset: bishops-ignore-color", () => { + const preset = getPreset("bishops-ignore-color"); + + it("is registered with the expected metadata", () => { + expect(preset).toBeDefined(); + expect(preset.incompatibleWith).toEqual([]); + expect(preset.requires).toEqual([]); + expect(preset.getExtraMoves).toBeDefined(); + }); + + it("returns no extras for non-bishop pieces", () => { + const session = new Session({ autoFire: false }); + const rook = insertPiece(session, 1, "rook", "white", 28); + expect(runExtras(preset, session, rook)).toEqual([]); + }); + + it("bishop in centre gets exactly four orthogonal-step extras", () => { + const session = new Session({ autoFire: false }); + const bishop = insertPiece(session, 1, "bishop", "white", 28); // e4 + const extras = runExtras(preset, session, bishop); + expect(extras).toHaveLength(4); + const targets = extras.map(m => m.to).sort((a, b) => a - b); + // e3=20, d4=27, f4=29, e5=36 + expect(targets).toEqual([20, 27, 29, 36]); + expect(extras.every(m => !m.isCapture)).toBe(true); + }); + + it("bishop in corner has fewer orthogonal-step extras", () => { + const session = new Session({ autoFire: false }); + const bishop = insertPiece(session, 1, "bishop", "white", 0); // a1 + const extras = runExtras(preset, session, bishop); + // a1 has only b1 (=1) and a2 (=8) as orthogonal neighbours. + expect(extras).toHaveLength(2); + const targets = extras.map(m => m.to).sort((a, b) => a - b); + expect(targets).toEqual([1, 8]); + }); + + it("orthogonal step onto ally is disallowed", () => { + const session = new Session({ autoFire: false }); + const bishop = insertPiece(session, 1, "bishop", "white", 28); + insertPiece(session, 2, "pawn", "white", 29); // ally on f4 + const extras = runExtras(preset, session, bishop); + expect(extras.find(m => m.to === 29)).toBeUndefined(); + expect(extras).toHaveLength(3); + }); + + it("orthogonal step onto enemy is a capture", () => { + const session = new Session({ autoFire: false }); + const bishop = insertPiece(session, 1, "bishop", "white", 28); + insertPiece(session, 2, "pawn", "black", 29); // enemy on f4 + const extras = runExtras(preset, session, bishop); + const capture = extras.find(m => m.to === 29); + expect(capture).toBeDefined(); + expect(capture!.isCapture).toBe(true); + }); +}); + +// ─── rook-warp ─────────────────────────────────────────────────────────────── + +describe("Preset: rook-warp", () => { + const preset = getPreset("rook-warp"); + + it("is registered with the expected metadata", () => { + expect(preset).toBeDefined(); + expect(preset.incompatibleWith).toContain("wrap-board"); + expect(preset.requires).toEqual([]); + expect(preset.getExtraMoves).toBeDefined(); + }); + + it("returns no extras for non-rook pieces", () => { + const session = new Session({ autoFire: false }); + const queen = insertPiece(session, 1, "queen", "white", 0); + expect(runExtras(preset, session, queen)).toEqual([]); + }); + + it("rook at a1 on empty board has no warp extras (already at edges)", () => { + const session = new Session({ autoFire: false }); + const rook = insertPiece(session, 1, "rook", "white", 0); // a1 + const extras = runExtras(preset, session, rook); + // a1 is at file 0 and rank 0: no "right wrap" (because file<7 allows warp + // from file 0, but file=0 so path is empty; similarly for up direction). + // The file<7 branch runs but path length 0 → no destination. + // So: no warp candidates on an empty board. + expect(extras).toEqual([]); + }); + + it("rook at d4 on empty board warps to four edges", () => { + const session = new Session({ autoFire: false }); + const rook = insertPiece(session, 1, "rook", "white", 27); // d4 + const extras = runExtras(preset, session, rook); + // With empty board, each direction's path ends at the square just before + // the rook on the opposite side: + // right (file+ wraps to file 0..2): last empty = c4 (=26) + // left (file- wraps to file 7..4): last empty = e4 (=28) + // up (rank+ wraps to rank 0..2): last empty = d3 (=19) + // down (rank- wraps to rank 7..4): last empty = d5 (=35) + const targets = extras.map(m => m.to).sort((a, b) => a - b); + expect(targets).toEqual([19, 26, 28, 35]); + expect(extras.every(m => !m.isCapture)).toBe(true); + }); + + it("warp path blocked by friendly piece at edge yields no destination", () => { + const session = new Session({ autoFire: false }); + const rook = insertPiece(session, 1, "rook", "white", 27); // d4 + // Place ally on a4 (=24): rightward warp path starts at file 0 = a4. + insertPiece(session, 2, "pawn", "white", 24); + const extras = runExtras(preset, session, rook); + // Right-wrap path a4→b4→c4 is blocked immediately at a4 (ally) → no right-warp. + expect(extras.find(m => m.to === 24)).toBeUndefined(); + }); + + it("warp captures first enemy encountered on path", () => { + const session = new Session({ autoFire: false }); + const rook = insertPiece(session, 1, "rook", "white", 27); // d4 + // Place enemy on a4 (=24): right-wrap starts there. + insertPiece(session, 2, "pawn", "black", 24); + const extras = runExtras(preset, session, rook); + const cap = extras.find(m => m.to === 24); + expect(cap).toBeDefined(); + expect(cap!.isCapture).toBe(true); + }); + + it("warp blocked when forward path to edge is obstructed", () => { + const session = new Session({ autoFire: false }); + const rook = insertPiece(session, 1, "rook", "white", 27); // d4 + // Block the right-forward path at e4 (=28) — rook can't reach the east edge. + insertPiece(session, 2, "pawn", "white", 28); + const extras = runExtras(preset, session, rook); + // Right-warp (which wraps over the h-edge back to a..c on rank 4) is + // disabled because the rook can't slide through e4 to reach the h edge. + const rightDests = new Set([24, 25, 26]); + expect(extras.every(m => !rightDests.has(m.to))).toBe(true); + // Vertical warps remain available (they don't depend on rank-4 file path). + expect(extras.find(m => m.to === 19)).toBeDefined(); // d3 via up-warp + expect(extras.find(m => m.to === 35)).toBeDefined(); // d5 via down-warp + }); +}); diff --git a/packages/chess/src/presets/last-piece-standing.ts b/packages/chess/src/presets/last-piece-standing.ts new file mode 100644 index 0000000..871fd94 --- /dev/null +++ b/packages/chess/src/presets/last-piece-standing.ts @@ -0,0 +1,10 @@ +import { PRESET_REGISTRY } from "./registry.js"; + +PRESET_REGISTRY.register({ + id: "last-piece-standing", + name: "Annihilation", + description: "The player who captures all enemy pieces wins. King has no special status; checkmate is disabled.", + incompatibleWith: ["capture-to-win"], + requires: [], + // Win condition checked in ChessEngine (P3.11) +}); diff --git a/packages/chess/src/presets/piece-hp.ts b/packages/chess/src/presets/piece-hp.ts new file mode 100644 index 0000000..f6fdcff --- /dev/null +++ b/packages/chess/src/presets/piece-hp.ts @@ -0,0 +1,10 @@ +import { PRESET_REGISTRY } from "./registry.js"; + +PRESET_REGISTRY.register({ + id: "piece-hp", + name: "Hit Points", + description: "All pieces start with 2 HP. Captures deal 1 HP damage; piece only dies at 0 HP. Attacker stays on target if HP > 0.", + incompatibleWith: ["explosive-rook"], + requires: [], + // Full integration in ChessEngine (P3.11) +}); diff --git a/packages/chess/src/presets/poisoned-squares.ts b/packages/chess/src/presets/poisoned-squares.ts new file mode 100644 index 0000000..178d91d --- /dev/null +++ b/packages/chess/src/presets/poisoned-squares.ts @@ -0,0 +1,13 @@ +import { PRESET_REGISTRY } from "./registry.js"; + +// Poisoned central squares: d4=27, e4=28, d5=35, e5=36 +export const POISONED_SQUARES = new Set([27, 28, 35, 36]); + +PRESET_REGISTRY.register({ + id: "poisoned-squares", + name: "Poisoned Centre", + description: "The four central squares (d4, d5, e4, e5) are poisoned. A piece ending its turn there loses 1 HP per turn. Requires piece-hp.", + incompatibleWith: [], + requires: ["piece-hp"], + // HP damage applied in ChessEngine post-move hook (P3.11) +}); diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts new file mode 100644 index 0000000..65f3aad --- /dev/null +++ b/packages/chess/src/presets/presets.test.ts @@ -0,0 +1,166 @@ +/** + * Consolidated preset registry tests (P3.4-P3.8). + * Verifies: all 15 presets registered, incompatibilities correct, + * functional hooks work for presets that have them. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { PRESET_REGISTRY } from "./index.js"; +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import type { ChessEngine } from "../engine.js"; + +const mkId = (n: number) => n as EntityId; + +function makeEngine(pieces: Array<{ id: number; type: string; color: string; sq: number }>): ChessEngine { + const session = new Session({ autoFire: false }); + for (const p of pieces) { + const eid = mkId(p.id); + session.insert(eid, "PieceType", p.type); + session.insert(eid, "Color", p.color); + session.insert(eid, "Position", p.sq); + } + return { session } as unknown as ChessEngine; +} + +describe("Preset registry — all 15 registered", () => { + const EXPECTED_IDS = [ + "pawns-move-backward", + "double-pawn-sprint", + "pawn-diagonal-no-capture", + "knights-leap-twice", + "bishops-ignore-color", + "rook-warp", + "wrap-board", + "queen-splits", + "king-heals", + "explosive-rook", + "capture-to-win", + "last-piece-standing", + "piece-hp", + "knight-immunity", + "poisoned-squares", + ]; + + it("has exactly 15 presets", () => { + expect(PRESET_REGISTRY.getAll().length).toBe(15); + }); + + for (const id of EXPECTED_IDS) { + it(`preset '${id}' is registered`, () => { + expect(PRESET_REGISTRY.getAll().some(p => p.id === id)).toBe(true); + }); + } +}); + +describe("Incompatibility declarations", () => { + it("pawns-move-backward ↔ double-pawn-sprint are mutually incompatible", () => { + const a = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!; + const b = PRESET_REGISTRY.getAll().find(p => p.id === "double-pawn-sprint")!; + expect(a.incompatibleWith).toContain("double-pawn-sprint"); + expect(b.incompatibleWith).toContain("pawns-move-backward"); + }); + + it("rook-warp ↔ wrap-board are mutually incompatible", () => { + const a = PRESET_REGISTRY.getAll().find(p => p.id === "rook-warp")!; + const b = PRESET_REGISTRY.getAll().find(p => p.id === "wrap-board")!; + expect(a.incompatibleWith).toContain("wrap-board"); + expect(b.incompatibleWith).toContain("rook-warp"); + }); + + it("capture-to-win ↔ last-piece-standing are mutually incompatible", () => { + const a = PRESET_REGISTRY.getAll().find(p => p.id === "capture-to-win")!; + const b = PRESET_REGISTRY.getAll().find(p => p.id === "last-piece-standing")!; + expect(a.incompatibleWith).toContain("last-piece-standing"); + expect(b.incompatibleWith).toContain("capture-to-win"); + }); + + it("piece-hp ↔ explosive-rook are incompatible", () => { + const a = PRESET_REGISTRY.getAll().find(p => p.id === "piece-hp")!; + const b = PRESET_REGISTRY.getAll().find(p => p.id === "explosive-rook")!; + expect(a.incompatibleWith).toContain("explosive-rook"); + expect(b.incompatibleWith).toContain("piece-hp"); + }); +}); + +describe("Requires declarations", () => { + it("king-heals requires piece-hp", () => { + const p = PRESET_REGISTRY.getAll().find(p => p.id === "king-heals")!; + expect(p.requires).toContain("piece-hp"); + }); + + it("poisoned-squares requires piece-hp", () => { + const p = PRESET_REGISTRY.getAll().find(p => p.id === "poisoned-squares")!; + expect(p.requires).toContain("piece-hp"); + }); +}); + +describe("Functional hook: pawns-move-backward", () => { + beforeEach(() => { PRESET_REGISTRY.clear(); }); + + it("adds backward move for pawn on rank ≥2", () => { + const preset = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!; + const engine = makeEngine([{ id: 1, type: "pawn", color: "white", sq: 20 }]); // e3 + const extras = preset.getExtraMoves!(engine, mkId(1)); + expect(extras.some(m => m.to === 12)).toBe(true); // e3→e2 (backward) + }); + + it("pawn at rank1 cannot go backward off board", () => { + const preset = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!; + const engine = makeEngine([{ id: 1, type: "pawn", color: "white", sq: 4 }]); // e1 + const extras = preset.getExtraMoves!(engine, mkId(1)); + expect(extras.some(m => m.to < 0)).toBe(false); + }); +}); + +describe("Functional hook: knight-immunity", () => { + it("bishop cannot capture knight (filterMoves removes it)", () => { + const preset = PRESET_REGISTRY.getAll().find(p => p.id === "knight-immunity")!; + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: 0 }, // a1 + { id: 2, type: "knight", color: "black", sq: 9 }, // b2 + ]); + const captureMove = { pieceId: mkId(1), from: 0, to: 9, isCapture: true }; + const filtered = preset.filterMoves!([captureMove], engine, mkId(1)); + expect(filtered).toHaveLength(0); + }); + + it("bishop CAN capture non-knight pieces", () => { + const preset = PRESET_REGISTRY.getAll().find(p => p.id === "knight-immunity")!; + const engine = makeEngine([ + { id: 1, type: "bishop", color: "white", sq: 0 }, + { id: 2, type: "rook", color: "black", sq: 9 }, // rook, not knight + ]); + const captureMove = { pieceId: mkId(1), from: 0, to: 9, isCapture: true }; + const filtered = preset.filterMoves!([captureMove], engine, mkId(1)); + expect(filtered).toHaveLength(1); + }); +}); + +describe("Functional hook: wrap-board", () => { + it("rook on h-file gets wrap move to a-file", () => { + const preset = PRESET_REGISTRY.getAll().find(p => p.id === "wrap-board")!; + const engine = makeEngine([{ id: 1, type: "rook", color: "white", sq: 7 }]); // h1 + const extras = preset.getExtraMoves!(engine, mkId(1)); + expect(extras.some(m => m.to === 0)).toBe(true); // a1 + }); + + it("rook on a-file gets wrap move to h-file", () => { + const preset = PRESET_REGISTRY.getAll().find(p => p.id === "wrap-board")!; + const engine = makeEngine([{ id: 1, type: "rook", color: "white", sq: 0 }]); // a1 + const extras = preset.getExtraMoves!(engine, mkId(1)); + expect(extras.some(m => m.to === 7)).toBe(true); // h1 + }); +}); + +describe("PRESET_REGISTRY.activate with incompatibility check", () => { + beforeEach(() => { PRESET_REGISTRY.clear(); }); + + it("activating incompatible pair throws", () => { + PRESET_REGISTRY.activate("rook-warp"); + expect(() => PRESET_REGISTRY.activate("wrap-board")).toThrow(); + }); + + it("activating without dependency throws", () => { + expect(() => PRESET_REGISTRY.activate("king-heals")).toThrow(/requires/); + }); +}); diff --git a/packages/chess/src/presets/queen-splits.ts b/packages/chess/src/presets/queen-splits.ts new file mode 100644 index 0000000..f7444df --- /dev/null +++ b/packages/chess/src/presets/queen-splits.ts @@ -0,0 +1,10 @@ +import { PRESET_REGISTRY } from "./registry.js"; + +PRESET_REGISTRY.register({ + id: "queen-splits", + name: "Queen Fission", + description: "When a Queen captures a piece, it splits into a Rook and Bishop placed on nearby empty squares.", + incompatibleWith: [], + requires: [], + // Full integration happens in ChessEngine (P3.11) +}); diff --git a/packages/chess/src/presets/wrap-board.ts b/packages/chess/src/presets/wrap-board.ts new file mode 100644 index 0000000..979ffee --- /dev/null +++ b/packages/chess/src/presets/wrap-board.ts @@ -0,0 +1,27 @@ +import { PRESET_REGISTRY } from "./registry.js"; +import { fileOf, rankOf, squareOf } from "../coord.js"; +import { isAllyAt } from "../rules/board-queries.js"; +import type { PieceColor, PieceType } from "../schema.js"; + +PRESET_REGISTRY.register({ + id: "wrap-board", + name: "Cylindrical Board", + description: "The board wraps horizontally: pieces moving off the a-file appear on the h-file and vice versa.", + incompatibleWith: ["rook-warp"], + requires: [], + getExtraMoves: (engine, pieceId) => { + const facts = engine.session.allFacts(); + const type = facts.find(f => f.id === pieceId && f.attr === "PieceType")?.value as PieceType; + if (!["rook", "queen"].includes(type)) return []; + const color = facts.find(f => f.id === pieceId && f.attr === "Color")?.value as PieceColor; + const from = facts.find(f => f.id === pieceId && f.attr === "Position")?.value as number; + if (from === undefined || !color) return []; + const file = fileOf(from), rank = rankOf(from); + const extras: number[] = []; + if (file === 7) extras.push(squareOf(0, rank)); + if (file === 0) extras.push(squareOf(7, rank)); + return extras + .filter(sq => !isAllyAt(engine.session, sq, color)) + .map(to => ({ pieceId, from, to, isCapture: facts.some(f => f.attr === "Position" && f.value === to) })); + }, +});