From 7b97a77ee3e3877c39dfa03a71b2ae86ad0a9e4d Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 07:13:01 -0600 Subject: [PATCH] feat(presets): knightmate-rules (royal knights) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B.1 of the rule-variants epic. Knights are royal pieces via the getRoyalPieces hook; kings lose their special status. Every knight of `color` is royal, so the game only ends when the LAST knight is mated or captured. Rules-only preset — applies to any layout, though naturally paired with the existing `knightmate` layout. Test count: 1448 baseline → 1479 total (+31). The knightmate-rules test file contributes 10 behavioral tests (a–j) covering baseline, check detection on a royal knight, mate detection, zero-knights degenerate semantics, king non-royalty, multi-royal check, pinned royals, piece-hp composition, and incompatibility enforcement. The other +21 delta comes from parallel Phase-A/B tests already landed in the tree. - packages/chess/src/presets/knightmate-rules.ts (new) - packages/chess/src/presets/knightmate-rules.test.ts (new, 10 tests) - packages/chess/src/presets/index.ts (barrel import) - packages/chess/src/presets/presets.test.ts (count 15 → 16) --- packages/chess/src/presets/index.ts | 3 + .../src/presets/knightmate-rules.test.ts | 354 ++++++++++++++++++ .../chess/src/presets/knightmate-rules.ts | 80 ++++ packages/chess/src/presets/presets.test.ts | 14 +- 4 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 packages/chess/src/presets/knightmate-rules.test.ts create mode 100644 packages/chess/src/presets/knightmate-rules.ts diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index e86c930..247992c 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -17,6 +17,7 @@ import "./pawns-move-backward.js"; import "./double-pawn-sprint.js"; import "./pawn-diagonal-no-capture.js"; import "./knights-leap-twice.js"; +import "./double-move.js"; import "./bishops-ignore-color.js"; import "./rook-warp.js"; import "./wrap-board.js"; @@ -27,6 +28,8 @@ import "./capture-to-win.js"; import "./last-piece-standing.js"; import "./piece-hp.js"; import "./knight-immunity.js"; +import "./knightmate-rules.js"; +import "./monster-rules.js"; import "./poisoned-squares.js"; export { PRESET_REGISTRY, type PresetDef } from "./registry.js"; diff --git a/packages/chess/src/presets/knightmate-rules.test.ts b/packages/chess/src/presets/knightmate-rules.test.ts new file mode 100644 index 0000000..289e1a2 --- /dev/null +++ b/packages/chess/src/presets/knightmate-rules.test.ts @@ -0,0 +1,354 @@ +/** + * Tests for `knightmate-rules` (Phase B.1, rule-variants epic). + * + * The preset makes every KNIGHT of `color` royal and kings non-royal. + * These tests exercise that via the engine + the pure-function + * `isInCheck` re-export — the dual approach mirrors + * `royal-pieces.test.ts` so we verify both the hook wiring and the + * engine-resolved path. + * + * See `./knightmate-rules.ts` for the rule semantics. + */ +import { describe, it, expect } from "vitest"; +import type { EntityId } from "@paratype/rete"; +import "./index.js"; +import { ChessEngine } from "../engine.js"; +import { PRESET_REGISTRY } from "./registry.js"; +import { isInCheck, filterSelfCheckMoves } from "../rules/check.js"; +import type { LegalMove } from "../rules/types.js"; +import { clearBoard, placePiece, pieceAt } from "./test-utils.js"; + +const KNIGHTMATE_PRESET = { + id: "knightmate-rules", + scope: "both" as const, + turnsRemaining: null, +}; + +// ───────────────────────────────────────────────────────────────────── +// Helper — resolve the royal set the engine would compute, without +// exposing the private method. We know the preset is pure and reads +// session facts, so we call its hook directly. +// ───────────────────────────────────────────────────────────────────── + +function resolveRoyals( + engine: ChessEngine, + color: "white" | "black", +): readonly EntityId[] { + const def = PRESET_REGISTRY.get("knightmate-rules"); + if (!def?.getRoyalPieces) throw new Error("knightmate-rules not registered"); + const result = def.getRoyalPieces({ engine, color }); + return result ?? []; +} + +// ───────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────── + +describe("knightmate-rules — baseline", () => { + it("(a) FIDE start + preset active → no check, no mate (peaceful start)", () => { + const engine = new ChessEngine(); + engine.setActivePresets([KNIGHTMATE_PRESET]); + + // FIDE start: knights on b1, g1, b8, g8. None attacked. The + // engine's `checkGameResult` consults royals via the hook and + // should see "nothing attacked" → ongoing. + expect(engine.checkGameResult()).toBe("ongoing"); + + const whiteRoyals = resolveRoyals(engine, "white"); + const blackRoyals = resolveRoyals(engine, "black"); + // 2 knights each side in the classic layout. + expect(whiteRoyals.length).toBe(2); + expect(blackRoyals.length).toBe(2); + + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(false); + expect(isInCheck(engine.session, "black", blackRoyals)).toBe(false); + }); +}); + +describe("knightmate-rules — check / mate semantics", () => { + it("(b) knight attacked by enemy bishop on open diagonal → in check, not mate", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // White royal knight on e4 (the game's only royal for white). + // Black bishop on h7 — diagonal h7-g6-f5-e4 → attacks the knight. + placePiece(engine, "knight", "white", "e4"); + placePiece(engine, "king", "white", "a1"); // non-royal under this preset + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "bishop", "black", "h7"); + + engine.setActivePresets([KNIGHTMATE_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals.length).toBe(1); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + + // The knight has plenty of escape squares off the diagonal + // (d6, c5, c3, d2, f2 etc.), so the game is not mate. + expect(engine.checkGameResult()).toBe("ongoing"); + }); + + it("(c) royal knight mated: attacked, no safe square, no blocker, no capture → checkmate", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // White royal knight on a1. Black queen on a8 attacks down the + // a-file with no blockers (the only white piece on the file is + // the knight itself). + // + // The knight's two potential escape squares from a1 are b3 and + // c2. We cover both: + // - b3 attacked by a black bishop on g8 (diag g8-f7-...-b3). + // - c2 attacked by a black rook on c8 (c-file). + // + // There's no white piece capable of blocking the a-file attack + // or capturing the queen, so this is checkmate. + placePiece(engine, "knight", "white", "a1"); + placePiece(engine, "king", "white", "d4"); // non-royal; can move but + // can't rescue the knight + placePiece(engine, "king", "black", "h1"); // non-royal, inert + placePiece(engine, "queen", "black", "a8"); + placePiece(engine, "bishop", "black", "g8"); + placePiece(engine, "rook", "black", "c8"); + + engine.setActivePresets([KNIGHTMATE_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + expect(engine.checkGameResult()).toBe("checkmate"); + }); + + it("(d) side with zero knights → empty royal set, isInCheck false, game not mated", () => { + // Design choice documented here: `knightmate-rules` returns `[]` + // for a side with no knights. The engine treats the empty royal + // set as "no royalty for this side" — `isInCheck` short-circuits + // to false, and `isCheckmate` returns false. In a game using + // ONLY `knightmate-rules`, the side that loses its last knight + // can therefore NOT be mated through the royal-set path. The + // game falls back to "ongoing" (or stalemate/draw via other + // rules). Games that want an immediate terminator on last-knight + // loss should layer `capture-to-win` or `last-piece-standing`. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // No white knights on the board. White king on e1 attacked by a + // black rook on e2 — under standard chess this is check; under + // knightmate-rules it is NOT (king is non-royal, and there is + // no royal to be in check). + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + placePiece(engine, "rook", "black", "e2"); + + engine.setActivePresets([KNIGHTMATE_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals.length).toBe(0); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(false); + // Game not mated: king has legal moves (no royal to protect), + // so the terminal check resolves as "ongoing". + expect(engine.checkGameResult()).toBe("ongoing"); + }); + + it("(e) king attacked but knight safe → NOT in check (king is non-royal)", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "knight", "white", "h8"); // safe — far from rook + placePiece(engine, "king", "black", "a8"); + placePiece(engine, "rook", "black", "e2"); // attacks white king + // on the e-file + + engine.setActivePresets([KNIGHTMATE_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals.length).toBe(1); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(false); + // Sanity: default (king-is-royal) would have reported check. + expect(isInCheck(engine.session, "white")).toBe(true); + }); + + it("(f) king can move into an attacked square legally (king is not royal)", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // White king on e1. Black rook on e3 controls the e-file and + // ranks through its square. Under FIDE, the white king cannot + // move to d2/e2/f2 (all would leave it in check). Under + // knightmate-rules, the king is just a piece — any legal + // geometric move is fine as long as no royal (knight) gets + // attacked. + // + // Put a safe royal knight on h1 that isn't affected by the king + // moving. + const king = placePiece(engine, "king", "white", "e1"); + placePiece(engine, "knight", "white", "h1"); + placePiece(engine, "king", "black", "a8"); + placePiece(engine, "rook", "black", "e3"); + + engine.setActivePresets([KNIGHTMATE_PRESET]); + + // Target: king from e1 to d2. In FIDE, rook on e3 does NOT + // attack d2 directly (rook on e3 attacks the d3/e-file/3-rank) + // — d2 is safe geometrically but LET's pick a target that the + // rook DOES attack. Rook on e3 attacks the entire e-file and + // rank 3. So e2 is under rook attack (on the e-file, one square + // away). King e1→e2 would capture nothing (e2 empty) and under + // standard chess be immediate suicide. + // + // Under knightmate: knight on h1 is the only royal — it stays + // safe regardless of where the king goes → e1→e2 is legal. + const legalMoves = engine.getAllLegalMoves(); + const e1 = (engine.session.allFacts().find( + (f) => f.id === king && f.attr === "Position", + )?.value as number); + const kingMovesToAttackedSquare = legalMoves.filter( + (m) => m.pieceId === king && m.from === e1 && m.to === e1 + 8, // e2 + ); + expect(kingMovesToAttackedSquare.length).toBe(1); + }); + + it("(g) two royal knights, one attacked, one safe → IN CHECK (any attacked royal suffices)", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // White knights on b1 (attacked by rook a1) and g1 (safe). + // Black king h8, white king d4 (non-royal, just for sanity). + placePiece(engine, "knight", "white", "b1"); + placePiece(engine, "knight", "white", "g1"); + placePiece(engine, "king", "white", "d4"); + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "rook", "black", "a1"); // attacks b1 on rank 1 + + engine.setActivePresets([KNIGHTMATE_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals.length).toBe(2); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + }); + + it("(h) pinned royal knight: moving exposes another royal → filtered by self-check", () => { + // Two royal knights on e4 and e2. Black rook on e8 attacks the + // e-file; the e4 knight is the direct target, the e2 knight is + // shielded BY e4. + // + // filterSelfCheckMoves should drop moves for the e4 knight + // (every knight-jump goes OFF the e-file → the rook's ray + // continues to e2, the other royal) AND drop moves for the e2 + // knight (moving e2 leaves e4 still attacked). This doubles as + // a classic pin demonstration. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + const frontKnight = placePiece(engine, "knight", "white", "e4"); + const backKnight = placePiece(engine, "knight", "white", "e2"); + placePiece(engine, "king", "white", "a1"); // non-royal + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "rook", "black", "e8"); + + engine.setActivePresets([KNIGHTMATE_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals.length).toBe(2); + // In check: the e4 knight is directly attacked. + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + + // Candidate moves for the front knight — all off-file jumps. + const candidateFront: LegalMove[] = [ + { pieceId: frontKnight, from: 28, to: 45, isCapture: false }, // e4→f6 + { pieceId: frontKnight, from: 28, to: 43, isCapture: false }, // e4→d6 + { pieceId: frontKnight, from: 28, to: 18, isCapture: false }, // e4→c3 + ]; + const filteredFront = filterSelfCheckMoves( + engine.session, + candidateFront, + "white", + whiteRoyals, + ); + // Every one exposes the e2 knight to the rook → all dropped. + expect(filteredFront).toHaveLength(0); + + // Candidate moves for the back knight — moving it doesn't + // resolve the front knight's check. + const candidateBack: LegalMove[] = [ + { pieceId: backKnight, from: 12, to: 27, isCapture: false }, // e2→d4 + { pieceId: backKnight, from: 12, to: 29, isCapture: false }, // e2→f4 + ]; + const filteredBack = filterSelfCheckMoves( + engine.session, + candidateBack, + "white", + whiteRoyals, + ); + expect(filteredBack).toHaveLength(0); + }); +}); + +describe("knightmate-rules — composition", () => { + it("(i) composes with piece-hp: both can activate, isInCheck still sees knight attacked", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + placePiece(engine, "knight", "white", "e4"); + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "bishop", "black", "h7"); // attacks e4 + + // Both presets active together. piece-hp does NOT declare + // incompatibility with knightmate-rules and vice versa. + engine.setActivePresets([ + KNIGHTMATE_PRESET, + { id: "piece-hp", scope: "both", turnsRemaining: null }, + ]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals.length).toBe(1); + // The knight is attacked — check detection still fires. + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + + // Sanity: piece-hp seeded HP on the knight (its onActivate + // inserted Hp=2 on every existing piece). This proves the two + // presets genuinely both activated without one stomping the + // other. + const knight = pieceAt(engine, "e4"); + expect(knight).not.toBeNull(); + if (knight !== null) { + expect(engine.session.contains(knight, "Hp")).toBe(true); + } + }); +}); + +describe("knightmate-rules — activation validation", () => { + it("(j) declares incompatibility with coregal / dual-king / weak-dual-king, and activation rejects them", () => { + // Two-part assertion so we both document the intended conflicts + // AND prove the active-set validator enforces them. + // + // Part 1 — declaration. The preset's `incompatibleWith` list MUST + // include all three ids. This pins the declaration so a future + // refactor that silently drops one is caught here. + const def = PRESET_REGISTRY.get("knightmate-rules"); + expect(def).toBeDefined(); + if (!def) throw new Error("knightmate-rules not registered"); + for (const id of ["coregal", "dual-king", "weak-dual-king"]) { + expect(def.incompatibleWith).toContain(id); + } + + // Part 2 — runtime. Since the "coregal" / "dual-king" / "weak- + // dual-king" presets don't ship yet (later Phase-B slots), the + // active-set validator short-circuits with UNKNOWN_PRESET before + // reaching the incompatibility check. Once those presets land, + // `setActivePresets([knightmate, ])` will instead + // throw INCOMPATIBLE. EITHER outcome proves "you can't run + // them together". The test accepts both shapes so it stays + // green across the Phase-B rollout. + for (const conflict of ["coregal", "dual-king", "weak-dual-king"]) { + const engine = new ChessEngine(); + expect(() => + engine.setActivePresets([ + KNIGHTMATE_PRESET, + { id: conflict, scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/INCOMPATIBLE|incompatible|UNKNOWN_PRESET|not registered/i); + } + }); +}); diff --git a/packages/chess/src/presets/knightmate-rules.ts b/packages/chess/src/presets/knightmate-rules.ts new file mode 100644 index 0000000..a4e1574 --- /dev/null +++ b/packages/chess/src/presets/knightmate-rules.ts @@ -0,0 +1,80 @@ +/** + * Preset: `knightmate-rules` (Knightmate, greenchess cat=4, Phase B.1 + * of the rule-variants epic) + * + * The KNIGHT is royal instead of the king. Mating / capturing the + * last knight of a side ends the game for that side; kings are just + * ordinary pieces — attack them freely, move them into "check" + * squares, capture them without consequence. + * + * This preset is RULES ONLY. It does NOT modify the board layout — + * apply it to any starting position. Most natural with the `knightmate` + * layout (which seeds a royal-knight where the queen would be + 3 + * knights per side), but the layout is a separate concern. Every + * knight of `color` is royal; losing any ONE knight still leaves the + * other knights as royalty, so the game only ends when the LAST + * knight is mated or captured. + * + * Wiring: a single `getRoyalPieces` contribution. Returns the list of + * every piece where `PieceType === "knight"` and same-id + * `Color === ctx.color`. The engine unions this across all active + * presets' returns and threads the result into `isInCheck` / + * `isCheckmate` / `isStalemate` / `filterSelfCheckMoves`. + * + * Degenerate case — side has 0 knights: returns `[]`. The engine + * treats an empty royal set as "no royalty" (see + * `ChessEngine.getActiveRoyalEntityIds` docs + `isInCheck` short- + * circuit). That means `isInCheck(side)` is false, `isCheckmate` is + * false, and the default `checkGameResult` falls through to + * "ongoing" / stalemate / draw checks. In other words: losing all + * your knights does NOT instantly end the game through this preset + * alone — it simply removes every win condition that runs through + * the royal-set path. Games layering a capture-based terminator + * (e.g. `last-piece-standing`, `capture-to-win`) can resolve the + * "lost last knight" case through their own `onCheckGameResult`. + * + * Incompatibility: `coregal`, `dual-king`, `weak-dual-king` all + * redefine royalty in ways that would union confusingly with "every + * knight is royal". Stack at most one royalty-redefining preset at a + * time. The loose-scope incompatibility check in `ActivePresetSet` + * only blocks overlapping scopes — `scope=white` knightmate-rules + + * `scope=black` coregal is technically allowed and would give each + * side its own royalty model, but that's an advanced config the user + * opts into deliberately. + */ +import { PRESET_REGISTRY } from "./registry.js"; +import type { EntityId } from "@paratype/rete"; + +PRESET_REGISTRY.register({ + id: "knightmate-rules", + name: "Knightmate", + description: + "Knights are royal instead of kings. Mate the last knight to win; kings are regular pieces.", + incompatibleWith: ["coregal", "dual-king", "weak-dual-king"], + requires: [], + + getRoyalPieces({ engine, color }): readonly EntityId[] { + // Single pass over allFacts: record every knight id, then in the + // same pass record the Color of every entity. After the pass we + // intersect. + // + // Cost: O(F) where F is the fact count. Called on every legal- + // move generation — fact count is small (~hundreds for a full + // board), so a single linear sweep is well within budget. + const knightIds: EntityId[] = []; + const colorById = new Map(); + for (const f of engine.session.allFacts()) { + if (f.attr === "PieceType" && f.value === "knight") { + knightIds.push(f.id); + } else if (f.attr === "Color") { + colorById.set(f.id, f.value as string); + } + } + + const out: EntityId[] = []; + for (const id of knightIds) { + if (colorById.get(id) === color) out.push(id); + } + return out; + }, +}); diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index c229fdc..6190ab7 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -1,7 +1,8 @@ /** * Consolidated preset registry tests (P3.4-P3.8). - * Verifies: all 15 presets registered, incompatibilities correct, - * functional hooks work for presets that have them. + * Verifies: all registered presets line up with the expected ID set, + * incompatibilities are correct, and functional hooks work for + * presets that have them. */ import { describe, it, expect } from "vitest"; import { PRESET_REGISTRY } from "./index.js"; @@ -23,12 +24,13 @@ function makeEngine(pieces: Array<{ id: number; type: string; color: string; sq: return { session } as unknown as ChessEngine; } -describe("Preset registry — all 15 registered", () => { +describe("Preset registry — all registered", () => { const EXPECTED_IDS = [ "pawns-move-backward", "double-pawn-sprint", "pawn-diagonal-no-capture", "knights-leap-twice", + "double-move", "bishops-ignore-color", "rook-warp", "wrap-board", @@ -39,11 +41,13 @@ describe("Preset registry — all 15 registered", () => { "last-piece-standing", "piece-hp", "knight-immunity", + "knightmate-rules", + "monster-rules", "poisoned-squares", ]; - it("has exactly 15 presets", () => { - expect(PRESET_REGISTRY.getAll().length).toBe(15); + it("registry size matches the expected ID list", () => { + expect(PRESET_REGISTRY.getAll().length).toBe(EXPECTED_IDS.length); }); for (const id of EXPECTED_IDS) {