feat(chess): add all 15 preset custom rules P3.4-P3.8
This commit is contained in:
parent
cf1a8a3aab
commit
8436df7986
12 changed files with 559 additions and 0 deletions
10
packages/chess/src/presets/capture-to-win.ts
Normal file
10
packages/chess/src/presets/capture-to-win.ts
Normal 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)
|
||||
});
|
||||
10
packages/chess/src/presets/explosive-rook.ts
Normal file
10
packages/chess/src/presets/explosive-rook.ts
Normal 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)
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
10
packages/chess/src/presets/king-heals.ts
Normal file
10
packages/chess/src/presets/king-heals.ts
Normal 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)
|
||||
});
|
||||
21
packages/chess/src/presets/knight-immunity.ts
Normal file
21
packages/chess/src/presets/knight-immunity.ts
Normal 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";
|
||||
});
|
||||
},
|
||||
});
|
||||
263
packages/chess/src/presets/knights-bishop-rook.test.ts
Normal file
263
packages/chess/src/presets/knights-bishop-rook.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
10
packages/chess/src/presets/last-piece-standing.ts
Normal file
10
packages/chess/src/presets/last-piece-standing.ts
Normal 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)
|
||||
});
|
||||
10
packages/chess/src/presets/piece-hp.ts
Normal file
10
packages/chess/src/presets/piece-hp.ts
Normal 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)
|
||||
});
|
||||
13
packages/chess/src/presets/poisoned-squares.ts
Normal file
13
packages/chess/src/presets/poisoned-squares.ts
Normal 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)
|
||||
});
|
||||
166
packages/chess/src/presets/presets.test.ts
Normal file
166
packages/chess/src/presets/presets.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
10
packages/chess/src/presets/queen-splits.ts
Normal file
10
packages/chess/src/presets/queen-splits.ts
Normal 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)
|
||||
});
|
||||
27
packages/chess/src/presets/wrap-board.ts
Normal file
27
packages/chess/src/presets/wrap-board.ts
Normal 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) }));
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue