diff --git a/packages/chess/src/presets/coregal.test.ts b/packages/chess/src/presets/coregal.test.ts new file mode 100644 index 0000000..865a9aa --- /dev/null +++ b/packages/chess/src/presets/coregal.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/presets/coregal.ts b/packages/chess/src/presets/coregal.ts new file mode 100644 index 0000000..88ce927 --- /dev/null +++ b/packages/chess/src/presets/coregal.ts @@ -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(); + const colorById = new Map(); + const hasPosition = new Set(); + 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; + }, +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index aaf95dd..0bd2cdd 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -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"; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 899d606..51fa49b 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -42,6 +42,7 @@ describe("Preset registry — all registered", () => { "piece-hp", "knight-immunity", "knightmate-rules", + "coregal", "dual-king", "monster-rules", "poisoned-squares",