feat(chess): add all 15 preset custom rules P3.4-P3.8

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 15:32:18 -06:00
commit 8436df7986
No known key found for this signature in database
12 changed files with 559 additions and 0 deletions

View file

@ -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)
});

View file

@ -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)
});

View file

@ -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";

View file

@ -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)
});

View file

@ -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";
});
},
});

View file

@ -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
});
});

View file

@ -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)
});

View file

@ -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)
});

View file

@ -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)
});

View file

@ -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/);
});
});

View file

@ -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)
});

View file

@ -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) }));
},
});