feat(presets): knightmate-rules (royal knights)

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)
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 07:13:01 -06:00
commit 7b97a77ee3
No known key found for this signature in database
4 changed files with 446 additions and 5 deletions

View file

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

View file

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

View file

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

View file

@ -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) {