feat(presets): coregal (king + queen both royal)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 08:02:17 -06:00
commit 0d152861af
No known key found for this signature in database
4 changed files with 627 additions and 0 deletions

View file

@ -0,0 +1,508 @@
/**
* Tests for `coregal` (Phase C.1, rule-variants epic).
*
* The preset makes BOTH king and queen royal per side. These tests
* exercise the preset via the engine plus direct calls to the
* pure-function `isInCheck` / `filterSelfCheckMoves` the dual path
* mirrors `royal-pieces.test.ts` and `knightmate-rules.test.ts` so we
* verify the hook wiring AND the engine-resolved royal set.
*
* See `./coregal.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 { algebraicToSquare } from "../coord.js";
import { clearBoard, placePiece, pieceAt } from "./test-utils.js";
const COREGAL_PRESET = {
id: "coregal",
scope: "both" as const,
turnsRemaining: null,
};
// Resolve the royal set the engine would compute via coregal's hook.
// We can't introspect the engine's private resolver, so we call the
// preset's hook directly with the same context shape.
function resolveRoyals(
engine: ChessEngine,
color: "white" | "black",
): readonly EntityId[] {
const def = PRESET_REGISTRY.get("coregal");
if (!def?.getRoyalPieces) throw new Error("coregal not registered");
return def.getRoyalPieces({ engine, color }) ?? [];
}
// ─────────────────────────────────────────────────────────────────────
// (a) Activation works on a fresh engine
// ─────────────────────────────────────────────────────────────────────
describe("coregal — activation", () => {
it("(a) activates cleanly on a fresh engine; preset is registered", () => {
const engine = new ChessEngine();
engine.setActivePresets([COREGAL_PRESET]);
expect(engine.activePresets.has("coregal")).toBe(true);
const def = PRESET_REGISTRY.get("coregal");
expect(def).toBeDefined();
expect(def?.id).toBe("coregal");
// The preset MUST expose getRoyalPieces — that's the whole point.
expect(typeof def?.getRoyalPieces).toBe("function");
});
});
// ─────────────────────────────────────────────────────────────────────
// (b) Baseline FIDE opening: no attackers, no check on either side
// ─────────────────────────────────────────────────────────────────────
describe("coregal — baseline", () => {
it("(b) FIDE start + coregal active → royal set is [king, queen] per side, nobody in check", () => {
const engine = new ChessEngine();
engine.setActivePresets([COREGAL_PRESET]);
const whiteRoyals = resolveRoyals(engine, "white");
const blackRoyals = resolveRoyals(engine, "black");
// Exactly two royals per side (king + queen from the FIDE 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);
// And the engine reports ongoing — no mates at the start.
expect(engine.checkGameResult()).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// (c) Queen attacked → in check
// ─────────────────────────────────────────────────────────────────────
describe("coregal — check on queen", () => {
it("(c) white queen attacked by black rook on open file → isInCheck true via coregal royals", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// White king on a1 (far from action), white queen on e4.
// Black rook on e8 → attacks queen down the e-file (no blockers).
placePiece(engine, "king", "white", "a1");
placePiece(engine, "queen", "white", "e4");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "rook", "black", "e8");
engine.setActivePresets([COREGAL_PRESET]);
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals.length).toBe(2);
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
// Sanity: the default king-only rule would NOT report check — the
// white king on a1 is safe. This isolates the queen-royalty delta.
expect(isInCheck(engine.session, "white")).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────
// (d) King attacked, queen safe elsewhere → still in check
// ─────────────────────────────────────────────────────────────────────
describe("coregal — check on king", () => {
it("(d) king attacked with queen safely off the attack line → still in check", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// White king on e1 attacked by black rook on e8. White queen
// tucked on h8-ish (a8, well away from the e-file rook).
placePiece(engine, "king", "white", "e1");
placePiece(engine, "queen", "white", "a8");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "rook", "black", "e8");
engine.setActivePresets([COREGAL_PRESET]);
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals.length).toBe(2);
// King attacked — coregal keeps king royal so this is check.
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────
// (e) Mate on queen
// ─────────────────────────────────────────────────────────────────────
describe("coregal — checkmate on queen", () => {
it("(e) queen attacked, no legal move saves her → checkmate", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// Construct a trapped-queen mate:
// - White queen on g1, king on e5 (far).
// - Black rook a1: attacks the whole rank 1 (and a-file). Threat.
// - Black queen a2: defends rook a1 AND attacks rank 2 (so f2
// / g2 / h2 / e2 / d2 / c2 / b2 are all attacked, covering
// every rank-2 escape).
// - Black knight f3: attacks f1, h2, d2, etc., specifically
// covering f1 and h2. (f1 is on rank 1 already, doubled cover
// is fine; knight f3's key contribution is h2 — which is NOT
// covered by queen a2, wait rank-2 IS covered by a2. Actually
// the knight's real contribution is covering g1's diagonal
// neighbour via h3… let me just cover carefully.)
// - Black bishop h3: attacks g2 / f1 / e6 etc. (covers g2 even
// though queen a2 already does — harmless overlap).
// - Black king h8: far, just there for board legality.
//
// Queen g1 move candidates and why each is dead:
// - h1: rank 1, attacked by rook a1. ✓ blocked
// - f1 / e1 / d1 / c1 / b1: rank 1. ✓ blocked
// - a1: captures rook; a1 defended by queen a2 → still in check.
// - h2: rank 2, attacked by black queen a2. ✓
// - g2: rank 2, attacked by black queen a2 (+ bishop h3). ✓
// - f2 / e3 / d4 / c5 / b6 / a7 (nw diag): f2 on rank 2 attacked
// by queen a2. ✓
// - Along g-file upward: queen would be on g2..g8. g2 already
// covered, g3..g8 free geometrically — BUT the queen STILL
// leaves g1 undefended and the rook on rank 1 keeps attacking
// g1? Wait, the queen MOVES off g1. After she moves to g3, is
// SHE in check on g3? Rook a1 attacks rank 1 (not g3). Queen
// a2 attacks rank 2 and a-file (not g3). Bishop h3 diagonal
// from h3: (7,2)→g4,f5,e6,...; going (7,2)→(6,3)=g4, not g3.
// Knight f3 attacks from (5,2): (6,4)g5, (4,4)e5, (7,3)h4,
// (3,3)d4, (7,1)h2, (3,1)d2, (6,0)g1, (4,0)e1 — no g3.
// So g3 IS a safe escape! Mate fails.
//
// Need to also cover g3+. Add black rook g8 → attacks g-file
// (g8..g2, meeting bishop ray or similar). g-file from g8 down:
// g7, g6, g5, g4, g3, g2, (and g1 but white queen is there —
// actually it's the attacker so let's see). Black rook g8 would
// attack all of g-file to g1 if empty; with white queen on g1,
// ray stops at g1. So g1 is ALSO attacked by rook g8. Queen
// moves g1→g3: after move, rook g8 ray goes g7..g3 hitting the
// queen on g3 → queen still attacked. ✓
//
// Now all g-file escapes are covered by the black rook g8.
//
// Diagonal escapes from g1 going up-right: h2 (covered by queen
// a2). Going up-left: f2,e3,d4,c5,b6,a7. f2 on rank 2 covered by
// queen a2. e3: attacked by? knight f3 no (listed above). Queen
// a2 along diagonals: a2→b3,c4,d5,e6...; a2→b1. So a2 queen's
// nw diagonal hits e6,f7,g8. Her ne diagonal (a2→b3→c4→d5→e6)
// doesn't reach e3. Bishop h3 diagonals: h3→g4,f5,e6,d7,c8;
// h3→g2,f1 (already covered); no e3. So e3 is a safe escape!
//
// Add covering: black bishop d5 or similar covering e3. Bishop
// b6 diagonals: b6→a5,a7; b6→c5,d4,e3,f2,g1,h0 (off). So bishop
// on b6 attacks e3 (and f2 and d4, c5). And it attacks g1
// directly through c5,d4,e3,f2,g1. But wait — does bishop b6
// currently see g1? Path b6→c5(empty)→d4(empty)→e3(empty)→f2
// (empty)→g1(white queen). Yes, bishop b6 also attacks queen.
// Queen is already attacked by rook a1 AND rook g8, adding a
// third attacker is redundant but harmless. And bishop b6 covers
// e3, d4, c5, f2.
//
// Final board:
// white: king e5, queen g1
// black: king h8, rook a1, queen a2, knight f3, bishop h3,
// rook g8, bishop b6
//
// This is checkmate: white is in check (queen attacked), and
// NO legal move removes check — every queen move lands on an
// attacked square OR captures a defended attacker; king moves
// don't help (king is on e5, queen stays attacked).
placePiece(engine, "king", "white", "e5");
placePiece(engine, "queen", "white", "g1");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "rook", "black", "a1");
placePiece(engine, "queen", "black", "a2");
placePiece(engine, "knight", "black", "f3");
placePiece(engine, "bishop", "black", "h3");
placePiece(engine, "rook", "black", "g8");
placePiece(engine, "bishop", "black", "b6");
engine.setActivePresets([COREGAL_PRESET]);
// White to move (fresh engine defaults to white). Queen is royal
// under coregal → queen attacked means white in check; no move
// resolves → checkmate.
const whiteRoyals = resolveRoyals(engine, "white");
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
expect(engine.checkGameResult()).toBe("checkmate");
});
});
// ─────────────────────────────────────────────────────────────────────
// (f) Mate on king (queen alive but can't save)
// ─────────────────────────────────────────────────────────────────────
describe("coregal — checkmate on king with queen alive", () => {
it("(f) back-rank mate on king; queen trapped behind own pawn, can't help → checkmate", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// Classic back-rank mate on the white king at g1:
// - White pawns f2/g2/h2 block king escapes to rank 2.
// - Black rook a1 sweeps rank 1 → attacks g1 through empty
// b1..f1 (h1 also covered).
// - White queen on h8, white pawn g7 blocks the a1-h8 diagonal
// from her side. The queen's h-file and rank-8 slides don't
// reach rank 1 in one move (h-file ends at h2 blocked by own
// pawn? no, h-file from h8: h7, h6, ..., h1 — h2 is a WHITE
// pawn, so h-file slide goes h7..h3 and STOPS at h3 (can't
// pass through own pawn h2). The queen never reaches rank 1.
// Rank 8 moves obviously don't help the king on rank 1.
// Diagonals: h8 has only the a1-h8 diagonal, and g7 blocks it
// one square in. So the queen has no way to block rank 1 or
// capture the rook on a1.
//
// King has no legal move (f1/h1 attacked by rook; f2/g2/h2 are
// own pawns). No blocker. No capture. → checkmate.
placePiece(engine, "king", "white", "g1");
placePiece(engine, "pawn", "white", "f2");
placePiece(engine, "pawn", "white", "g2");
placePiece(engine, "pawn", "white", "h2");
placePiece(engine, "queen", "white", "h8");
placePiece(engine, "pawn", "white", "g7"); // seals the h8-a1 diag
placePiece(engine, "king", "black", "a8");
placePiece(engine, "rook", "black", "a1");
engine.setActivePresets([COREGAL_PRESET]);
const whiteRoyals = resolveRoyals(engine, "white");
// King attacked → in check under coregal (king still royal).
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
expect(engine.checkGameResult()).toBe("checkmate");
});
});
// ─────────────────────────────────────────────────────────────────────
// (g) Queen captured but king safe → ongoing
// ─────────────────────────────────────────────────────────────────────
describe("coregal — queen lost, king still royal", () => {
it("(g) no queen on board, king safe, some other pieces around → ongoing, not in check", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// Simulate "white queen was captured earlier": no white queen on
// the board. King e1 safe. Black has a rook on h8 that doesn't
// threaten e1. Enough material to avoid insufficient-material
// draw (a single black rook + two kings is borderline, but the
// engine classifies insufficient only for K+K / K+N+K / K+B+K /
// K+NN+K, so K+K+R is "sufficient" and the game is ongoing).
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
placePiece(engine, "rook", "black", "h8");
engine.setActivePresets([COREGAL_PRESET]);
const whiteRoyals = resolveRoyals(engine, "white");
// Only one royal — the king. Coregal degraded gracefully.
expect(whiteRoyals.length).toBe(1);
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(false);
// Game continues.
expect(engine.checkGameResult()).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// (h) Pinned queen: moving her exposes king → illegal
// ─────────────────────────────────────────────────────────────────────
describe("coregal — pinned queen", () => {
it("(h) queen pinned against king; moving her off the attack line is filtered", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// White king e1, white queen e4, black rook e8. Rook attacks the
// e-file; queen blocks. Moving queen off the e-file exposes the
// king to the rook → self-check → filtered by the self-check
// filter regardless of coregal (the king alone is royal enough
// for the classic pin to drop the move). This test asserts the
// filter still fires WITH coregal active — the queen is also
// royal now, but the pin logic against the king-royal is what
// we're verifying here (and the next test exercises the mirror).
const queen = placePiece(engine, "queen", "white", "e4");
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "rook", "black", "e8");
engine.setActivePresets([COREGAL_PRESET]);
const whiteRoyals = resolveRoyals(engine, "white");
// Candidate: queen e4 → d5 (diagonal off-file move). Without the
// pin filter this would be a legal queen move. With the filter
// and king+queen both royal, applying it exposes the king → drop.
const candidate: LegalMove = {
pieceId: queen,
from: algebraicToSquare("e4"),
to: algebraicToSquare("d5"),
isCapture: false,
};
const filtered = filterSelfCheckMoves(
engine.session,
[candidate],
"white",
whiteRoyals,
);
expect(filtered).toHaveLength(0);
// Sanity: the queen CAN legally capture the pinning rook. After
// the capture, the attacker is gone — king no longer attacked,
// queen on e8 attacked by nobody (black king on h8 doesn't reach
// e8). The filter MUST keep this move.
const captureAttacker: LegalMove = {
pieceId: queen,
from: algebraicToSquare("e4"),
to: algebraicToSquare("e8"),
isCapture: true,
};
const keep = filterSelfCheckMoves(
engine.session,
[captureAttacker],
"white",
whiteRoyals,
);
expect(keep).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────────────
// (i) Pinned king: moving exposes queen → illegal under coregal
// ─────────────────────────────────────────────────────────────────────
describe("coregal — pinned king (queen-royal-exposure)", () => {
it("(i) king move that exposes queen to attacker is filtered ONLY under coregal", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// Setup: black rook e8, white king e6, white queen e4. Rook ray
// down the e-file hits king first → king is in check. If the
// king steps OFF the e-file (e.g. d6), the rook ray continues
// through e5 to the queen on e4 → queen exposed.
//
// Under DEFAULT royalty (king only), moving king to d6 resolves
// the king's own attack (king is safe on d6) — the queen being
// exposed is irrelevant because she's not royal. The filter
// KEEPS d6 in the default path.
//
// Under COREGAL, queen is royal → exposing her = self-check →
// filter DROPS d6.
//
// We assert BOTH paths to make the delta concrete.
const king = placePiece(engine, "king", "white", "e6");
placePiece(engine, "queen", "white", "e4");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "rook", "black", "e8");
engine.setActivePresets([COREGAL_PRESET]);
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals.length).toBe(2);
// Candidate: king e6 → d6.
const candidate: LegalMove = {
pieceId: king,
from: algebraicToSquare("e6"),
to: algebraicToSquare("d6"),
isCapture: false,
};
// Under coregal royal set: filter drops it (queen exposed).
const underCoregal = filterSelfCheckMoves(
engine.session,
[candidate],
"white",
whiteRoyals,
);
expect(underCoregal).toHaveLength(0);
// Under default (king-only) royals: filter KEEPS it. This proves
// the queen-royal contribution is what makes the pin apply to
// the king's movement.
const underDefault = filterSelfCheckMoves(
engine.session,
[candidate],
"white",
// Pass just the king id — simulates the default royalty model.
[king],
);
expect(underDefault).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────────────
// (j) No queen → coregal degrades to king-only royalty
// ─────────────────────────────────────────────────────────────────────
describe("coregal — graceful degradation when no queen", () => {
it("(j) no queen on board → royal set is just the king; king attack still reads as check", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
placePiece(engine, "rook", "black", "e2"); // attacks white king
engine.setActivePresets([COREGAL_PRESET]);
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals.length).toBe(1);
// The single royal is the king itself — its entity id must match.
const kingId = pieceAt(engine, "e1");
expect(kingId).not.toBeNull();
expect(whiteRoyals[0]).toBe(kingId);
// King attacked → check under coregal's degraded (king-only) set.
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────
// (k) Incompatibility with knightmate-rules
// ─────────────────────────────────────────────────────────────────────
describe("coregal — activation validation", () => {
it("(k) declares incompatibility with knightmate-rules and setActivePresets rejects the pair", () => {
// Declaration check — this pins the intent so a silent drop in a
// future refactor is caught immediately.
const def = PRESET_REGISTRY.get("coregal");
expect(def).toBeDefined();
if (!def) throw new Error("coregal not registered");
for (const id of [
"knightmate-rules",
"dual-king",
"weak-dual-king",
"suicide-chess",
"capture-all",
]) {
expect(def.incompatibleWith).toContain(id);
}
// Runtime check — activating coregal alongside knightmate-rules
// (both registered, both declared incompatible with each other)
// MUST throw. The other incompatibles (dual-king /
// weak-dual-king / suicide-chess / capture-all) may or may not
// be registered yet depending on which sibling C.x preset has
// landed; knightmate-rules is the guaranteed-present pair (Phase
// B.1, already committed), so we assert against it explicitly.
const engine = new ChessEngine();
expect(() =>
engine.setActivePresets([
COREGAL_PRESET,
{ id: "knightmate-rules", scope: "both", turnsRemaining: null },
]),
).toThrow(/INCOMPATIBLE|incompatible/i);
});
it("(k) coregal-alone activation succeeds (sanity — the incompatibility is pair-specific)", () => {
const engine = new ChessEngine();
expect(() => engine.setActivePresets([COREGAL_PRESET])).not.toThrow();
expect(engine.activePresets.has("coregal")).toBe(true);
});
});

View file

@ -0,0 +1,117 @@
/**
* Preset: `coregal` (Phase C.1 of the rule-variants epic).
*
* BOTH the king AND the queen are royal. Attacking or mating EITHER
* ends the game losing your queen is now as game-ending as losing
* your king, but losing ONE of the two is survivable as long as the
* other is safe.
*
* Semantics:
* - Two royal pieces per side (when both are on the board): king +
* queen. `isInCheck(color)` fires when ANY of the two is attacked.
* - If the queen is captured, the king remains royal the game
* continues against a king-only royalty model (identical to FIDE
* from that point forward).
* - If the king is captured (only possible via a preset that bypasses
* the self-check filter, e.g. piece-hp), the queen remains royal
* and the game continues against her alone.
* - Pins work against BOTH royals: moving a piece that would leave
* either the king OR the queen under attack is filtered by the
* self-check filter. This is automatic the engine already unions
* the royal-set per color and threads it through
* `filterSelfCheckMoves`, which iterates every listed royal.
* - Degenerate case no queen on the board (traded or never seeded):
* `getRoyalPieces` simply omits her, and the preset degrades to
* king-only royalty. The preset stays valid; it just contributes
* one id per side instead of two.
*
* Layout coupling: NONE. Works on any starting position that contains
* both kings and queens (the canonical FIDE layout does). If a layout
* preset strips queens from the starting position the coregal rule is
* effectively king-only from move 1 a configuration the user opted
* into by pairing those presets.
*
* Composition with piece-hp: HP applies to BOTH royals. The piece-hp
* preset seeds `Hp` on every piece at activation (queens included),
* and `onDamage` / default-kill behaviour is identical for kings and
* queens. So a coregal + piece-hp game lets either royal soak HP hits
* before dying. piece-hp continues to suppress default-checkmate via
* `onCheckGameResult → "ongoing"` as before.
*
* Wiring: a single `getRoyalPieces` contribution. Iterate
* `engine.session.allFacts()` once, collecting every piece entity with
* (`PieceType === "king" OR "queen"`) AND matching `Color`. Require a
* `Position` fact to filter OUT captured pieces (retracted pieces no
* longer have Position, but may retain PieceType briefly in the damage
* pipeline the Position gate is the canonical "is this piece still
* on the board?" check in this codebase, mirroring
* `knightmate-rules`/`royal-pieces.test.ts` conventions).
*
* Incompatibility: `knightmate-rules` (knights royal) and
* `dual-king`/`weak-dual-king` (two kings per side) also redefine
* royalty; stacking them with coregal would union three conflicting
* royalty models into a confusing soup. `suicide-chess` and
* `capture-all` contribute empty royal sets (game ends by
* annihilation / compulsory capture), which is the OPPOSITE intent of
* coregal union them and one preset's "every royal attack is check"
* fights with the other's "no royalty". Declared incompatible to keep
* configurations coherent. The loose scope-overlap check in
* `ActivePresetSet` still lets scope=white coregal coexist with
* scope=black knightmate-rules if a user explicitly wants asymmetric
* royalty this is the deliberate escape hatch, not a bug.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({
id: "coregal",
name: "Coregal",
description:
"King and queen are both royal. Checkmating either piece ends the game.",
incompatibleWith: [
"knightmate-rules",
"dual-king",
"weak-dual-king",
"suicide-chess",
"capture-all",
],
requires: [],
getRoyalPieces({ engine, color }): readonly EntityId[] {
// Single pass over allFacts: per entity, remember its PieceType
// (if king/queen), Color, and whether it has a Position. After
// the pass, intersect — emit ids that are (king|queen) AND
// ours AND still on the board.
//
// Position gate: a piece retracted mid-capture loses Position
// first (see rules/capture.ts). Omitting Position-less entities
// keeps us from reporting a "dead queen" as royal, which would
// confuse `isInCheck` (it skips missing-Position royals anyway
// but including them wastes iterations).
//
// Cost: O(F) over fact count. Called on every legal-move
// generation — fact count is ~hundreds, well within budget.
const typeById = new Map<EntityId, string>();
const colorById = new Map<EntityId, string>();
const hasPosition = new Set<EntityId>();
for (const f of engine.session.allFacts()) {
if ((f.id as number) <= 0) continue;
if (f.attr === "PieceType") {
const t = f.value as string;
if (t === "king" || t === "queen") typeById.set(f.id, t);
} else if (f.attr === "Color") {
colorById.set(f.id, f.value as string);
} else if (f.attr === "Position") {
hasPosition.add(f.id);
}
}
const out: EntityId[] = [];
for (const [id] of typeById) {
if (colorById.get(id) !== color) continue;
if (!hasPosition.has(id)) continue;
out.push(id);
}
return out;
},
});

View file

@ -29,6 +29,7 @@ import "./last-piece-standing.js";
import "./piece-hp.js";
import "./knight-immunity.js";
import "./knightmate-rules.js";
import "./coregal.js";
import "./dual-king.js";
import "./monster-rules.js";
import "./poisoned-squares.js";

View file

@ -42,6 +42,7 @@ describe("Preset registry — all registered", () => {
"piece-hp",
"knight-immunity",
"knightmate-rules",
"coregal",
"dual-king",
"monster-rules",
"poisoned-squares",