From 4334a0382ad9b3a913cc213ee9508dbc9b9c88dd Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 07:53:54 -0600 Subject: [PATCH] feat(presets): dual-king + dual-classic layout --- packages/chess/src/layouts/dual-classic.ts | 76 +++++ packages/chess/src/layouts/index.ts | 2 + packages/chess/src/layouts/premades.test.ts | 2 + packages/chess/src/presets/dual-king.test.ts | 320 +++++++++++++++++++ packages/chess/src/presets/dual-king.ts | 118 +++++++ packages/chess/src/presets/index.ts | 1 + packages/chess/src/presets/presets.test.ts | 1 + 7 files changed, 520 insertions(+) create mode 100644 packages/chess/src/layouts/dual-classic.ts create mode 100644 packages/chess/src/presets/dual-king.test.ts create mode 100644 packages/chess/src/presets/dual-king.ts diff --git a/packages/chess/src/layouts/dual-classic.ts b/packages/chess/src/layouts/dual-classic.ts new file mode 100644 index 0000000..56da3a0 --- /dev/null +++ b/packages/chess/src/layouts/dual-classic.ts @@ -0,0 +1,76 @@ +/** + * Layout: Dual Classic (piece placement only). + * + * Two kings per side on the central d/e files, otherwise FIDE-standard. + * Designed to pair with the `dual-king` preset so EVERY king of a side + * is royal and checkmating either one ends the game. + * + * - White back rank (file 0..7): + * R N B K K B N R (kings on d1 and e1; queen omitted) + * - Black back rank mirrors on rank 8. + * + * Rationale for omitting the queen (rather than moving her off the d/e + * files): the canonical dual-king brief (rule-variants Phase C.2) + * trades the queen for a second king, one-for-one. Shoving the queen + * onto a minor-piece square would displace a bishop or knight and + * create an arbitrary asymmetry in the back rank; leaving her out + * keeps the piece set to "FIDE minus queen plus king" — a clean swap + * that makes the variant's identity obvious. Games that want a + * queened dual-king should ship a separate layout. + * + * Without the `dual-king` preset active, this layout plays as "two + * ordinary kings per side" — the first king's capture doesn't end the + * game under FIDE's default king-is-royal rule (since there's still + * another king), but mate detection against EITHER king is not + * defined by default either. Net effect: without the preset, the + * layout is functional but the terminal conditions are murky. Pair + * with the preset for the intended behaviour. + */ +import type { StartingLayout, PiecePlacement } from "./types.js"; +import { LAYOUT_REGISTRY } from "./registry.js"; +import { squareOf } from "../coord.js"; +import type { PieceColor } from "../schema.js"; + +/** + * Back rank with kings on the d- and e-files, queen omitted. + * File order: R N B K K B N R. + */ +function dualClassicBackRank(color: PieceColor): PiecePlacement[] { + const rank = color === "white" ? 0 : 7; + return [ + { type: "rook", color, square: squareOf(0, rank) }, + { type: "knight", color, square: squareOf(1, rank) }, + { type: "bishop", color, square: squareOf(2, rank) }, + { type: "king", color, square: squareOf(3, rank) }, + { type: "king", color, square: squareOf(4, rank) }, + { type: "bishop", color, square: squareOf(5, rank) }, + { type: "knight", color, square: squareOf(6, rank) }, + { type: "rook", color, square: squareOf(7, rank) }, + ]; +} + +function pawnRank(color: PieceColor): PiecePlacement[] { + const rank = color === "white" ? 1 : 6; + const pieces: PiecePlacement[] = []; + for (let file = 0; file < 8; file++) { + pieces.push({ type: "pawn", color, square: squareOf(file, rank) }); + } + return pieces; +} + +export const DUAL_CLASSIC_LAYOUT: StartingLayout = { + id: "dual-classic", + name: "Dual Classic", + description: + "Two kings per side on the d/e files (queen omitted). Pairs with 'dual-king' so every king is royal and mating either ends the game.", + pieces: [ + ...dualClassicBackRank("white"), + ...pawnRank("white"), + ...dualClassicBackRank("black"), + ...pawnRank("black"), + ], + suggestedPresets: ["dual-king"], + source: "premade", +}; + +LAYOUT_REGISTRY.register(DUAL_CLASSIC_LAYOUT); diff --git a/packages/chess/src/layouts/index.ts b/packages/chess/src/layouts/index.ts index 9353790..ac4d031 100644 --- a/packages/chess/src/layouts/index.ts +++ b/packages/chess/src/layouts/index.ts @@ -20,6 +20,7 @@ import "./monster.js"; import "./pawns-only.js"; import "./horde.js"; import "./knightmate.js"; +import "./dual-classic.js"; import "./chess960.js"; export { LAYOUT_REGISTRY } from "./registry.js"; @@ -33,6 +34,7 @@ export { MONSTER_LAYOUT } from "./monster.js"; export { PAWNS_ONLY_LAYOUT } from "./pawns-only.js"; export { HORDE_LAYOUT } from "./horde.js"; export { KNIGHTMATE_LAYOUT } from "./knightmate.js"; +export { DUAL_CLASSIC_LAYOUT } from "./dual-classic.js"; export { CHESS960_SHIM, buildChess960Layout } from "./chess960.js"; export { toFen, fromFen } from "./fen.js"; export type { diff --git a/packages/chess/src/layouts/premades.test.ts b/packages/chess/src/layouts/premades.test.ts index f4bced3..c5344dc 100644 --- a/packages/chess/src/layouts/premades.test.ts +++ b/packages/chess/src/layouts/premades.test.ts @@ -20,6 +20,7 @@ describe("LAYOUT_REGISTRY — premade roster", () => { expect(ids).toContain("pawns-only"); expect(ids).toContain("horde"); expect(ids).toContain("knightmate"); + expect(ids).toContain("dual-classic"); expect(ids).toContain("chess960"); }); @@ -66,6 +67,7 @@ describe("premade piece counts", () => { ["pawns-only", 18], // 2 kings + 16 pawns ["horde", 52], // 31 white pawns (ranks 1-4 minus e1) + 1 king + 4 advanced pawns + 16 black pieces ["knightmate", 32], // standard 32 with swapped back rank + ["dual-classic", 32], // 8 back-rank + 8 pawns per side (queen → king swap, one-for-one) ["chess960", 32], // shim is FIDE by default ]; diff --git a/packages/chess/src/presets/dual-king.test.ts b/packages/chess/src/presets/dual-king.test.ts new file mode 100644 index 0000000..c8ebea2 --- /dev/null +++ b/packages/chess/src/presets/dual-king.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for the `dual-king` preset (Phase C.2, rule-variants epic). + * + * The preset makes EVERY king of `color` royal. These tests cover + * activation, the dual-classic layout pairing, check/mate semantics + * for multi-royal boards, the degenerate no-king case, and + * incompatibility declarations. + * + * See `./dual-king.ts` for the rule semantics; `isInCheck` is + * re-exported from `../rules/check.js` so we verify both the hook + * wiring and the engine-resolved terminal paths. + */ +import { describe, it, expect } from "vitest"; +import type { EntityId } from "@paratype/rete"; +import "./index.js"; +import "../layouts/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 { DUAL_CLASSIC_LAYOUT } from "../layouts/dual-classic.js"; +import { clearBoard, placePiece } from "./test-utils.js"; + +const DUAL_KING_PRESET = { + id: "dual-king", + scope: "both" as const, + turnsRemaining: null, +}; + +// ───────────────────────────────────────────────────────────────────── +// Helper — resolve the royal set the engine would compute by calling +// the preset's hook directly (mirrors knightmate-rules.test.ts). +// ───────────────────────────────────────────────────────────────────── + +function resolveRoyals( + engine: ChessEngine, + color: "white" | "black", +): readonly EntityId[] { + const def = PRESET_REGISTRY.get("dual-king"); + if (!def?.getRoyalPieces) throw new Error("dual-king not registered"); + return def.getRoyalPieces({ engine, color }) ?? []; +} + +function countPieces( + engine: ChessEngine, + predicate: (type: string, color: string) => boolean, +): number { + const colorById = new Map(); + for (const f of engine.session.allFacts()) { + if (f.attr === "Color") colorById.set(f.id, f.value as string); + } + let n = 0; + for (const f of engine.session.allFacts()) { + if (f.attr !== "PieceType") continue; + const color = colorById.get(f.id); + if (!color) continue; + if (predicate(f.value as string, color)) n++; + } + return n; +} + +// ───────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────── + +describe("dual-king — activation", () => { + it("(a) activates cleanly on its own, hook resolves to default-2 kings on FIDE start", () => { + // FIDE start has exactly one king per side. With dual-king + // active, the royal set is {that single king} — the preset + // degrades gracefully rather than throwing. + const engine = new ChessEngine(); + engine.setActivePresets([DUAL_KING_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + const blackRoyals = resolveRoyals(engine, "black"); + expect(whiteRoyals).toHaveLength(1); + expect(blackRoyals).toHaveLength(1); + + expect(engine.checkGameResult()).toBe("ongoing"); + }); +}); + +describe("dual-king — dual-classic layout pairing", () => { + it("(b) loading dual-classic produces 2 kings per side and no queens", () => { + const engine = new ChessEngine({ layout: DUAL_CLASSIC_LAYOUT }); + engine.setActivePresets([DUAL_KING_PRESET]); + + const whiteKings = countPieces( + engine, + (t, c) => t === "king" && c === "white", + ); + const blackKings = countPieces( + engine, + (t, c) => t === "king" && c === "black", + ); + expect(whiteKings).toBe(2); + expect(blackKings).toBe(2); + + const queens = countPieces(engine, (t) => t === "queen"); + expect(queens).toBe(0); + + const whiteRoyals = resolveRoyals(engine, "white"); + const blackRoyals = resolveRoyals(engine, "black"); + expect(whiteRoyals).toHaveLength(2); + expect(blackRoyals).toHaveLength(2); + }); +}); + +describe("dual-king — check / mate / capture semantics", () => { + it("(c) attacking one king while the other is safe → isInCheck true", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // Two white kings; black rook attacks one of them. + placePiece(engine, "king", "white", "d1"); // attacked + placePiece(engine, "king", "white", "a1"); // safe + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "rook", "black", "d8"); // attacks d-file + + engine.setActivePresets([DUAL_KING_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals).toHaveLength(2); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + }); + + it("(d) mating one king (the other cannot save or block) → checkmate", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // White kings on a1 and h1. Black pieces cage the a1 king: + // - Black rook on a8 attacks down the a-file. + // - Black rook on b8 controls the b-file, sealing a1's only + // orthogonal escape (b1) and denying b2. + // - Black king on h4 (lowered to keep h1 non-royal-safe + // unaffected by king-proximity rules at h1). + // + // The a1 king has no escape (b1 covered by Rb8, a2 covered by + // Ra8, b2 covered by Rb8), no blocker on the a-file, no white + // piece able to capture either rook. The h1 king is too far to + // help. Under dual-king semantics, any royal attacked with no + // legal resolution → checkmate. + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "white", "h1"); + placePiece(engine, "king", "black", "h4"); + placePiece(engine, "rook", "black", "a8"); + placePiece(engine, "rook", "black", "b8"); + + engine.setActivePresets([DUAL_KING_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals).toHaveLength(2); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + expect(engine.checkGameResult()).toBe("checkmate"); + }); + + it("(e) capturing one king leaves the other royal; game continues if the survivor is safe", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // Seed two white kings + enough material on both sides that + // "insufficient material" doesn't terminate the game once one + // king is removed. Then retract one white king — simulating a + // capture — and verify the remaining king is still contributed + // as royal. + const captured = placePiece(engine, "king", "white", "d1"); + const survivor = placePiece(engine, "king", "white", "e1"); + placePiece(engine, "rook", "white", "a1"); + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "rook", "black", "h7"); + + engine.setActivePresets([DUAL_KING_PRESET]); + + // Pre-capture: two royals. + expect(resolveRoyals(engine, "white")).toHaveLength(2); + + // Retract the d1 king's facts (capture = off-board). + for (const attr of engine.effectivePieceAttrs) { + if (engine.session.contains(captured, attr)) { + engine.session.retract(captured, attr); + } + } + + const afterWhiteRoyals = resolveRoyals(engine, "white"); + expect(afterWhiteRoyals).toHaveLength(1); + expect(afterWhiteRoyals[0]).toBe(survivor); + + // Survivor is on e1 with no attackers → not in check, game on. + expect(isInCheck(engine.session, "white", afterWhiteRoyals)).toBe(false); + expect(engine.checkGameResult()).toBe("ongoing"); + }); + + it("(f) both kings simultaneously attacked → isInCheck true (any-royal-attacked = check)", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + // Two white kings, both attacked by different black pieces. + placePiece(engine, "king", "white", "a1"); + placePiece(engine, "king", "white", "h1"); + placePiece(engine, "king", "black", "d5"); + placePiece(engine, "rook", "black", "a8"); // attacks a1 on a-file + placePiece(engine, "rook", "black", "h8"); // attacks h1 on h-file + + engine.setActivePresets([DUAL_KING_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals).toHaveLength(2); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + }); + + it("(g) pin across royals: moving one king exposes the other → filtered by self-check", () => { + // Two white kings on e1 and e3. Black rook on e8 attacks the + // e-file. The e3 king shields the e1 king. Moving e3 anywhere + // off the e-file exposes e1 → must be filtered. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + + const frontKing = placePiece(engine, "king", "white", "e3"); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "a8"); + placePiece(engine, "rook", "black", "e8"); + + engine.setActivePresets([DUAL_KING_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals).toHaveLength(2); + + // Already in check: e3 is the attacked royal (rook on e-file, + // no blocker between e8 and e3). + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true); + + // Candidate e3 king moves that leave the e-file open. If the + // front king steps off, rook sees all the way to e1 → exposes + // the other royal → dropped by the self-check filter. + const candidate: LegalMove[] = [ + { pieceId: frontKing, from: 20, to: 19, isCapture: false }, // e3→d3 + { pieceId: frontKing, from: 20, to: 21, isCapture: false }, // e3→f3 + { pieceId: frontKing, from: 20, to: 11, isCapture: false }, // e3→d2 + { pieceId: frontKing, from: 20, to: 13, isCapture: false }, // e3→f2 + ]; + const filtered = filterSelfCheckMoves( + engine.session, + candidate, + "white", + whiteRoyals, + ); + expect(filtered).toHaveLength(0); + }); + + it("(h) no kings at all → empty royal set, isInCheck short-circuits to false", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: true }); + + // Retract both starting kings — degenerate "no royalty" state. + const toKill: EntityId[] = []; + for (const f of engine.session.allFacts()) { + if (f.attr === "PieceType" && f.value === "king") toKill.push(f.id); + } + for (const id of toKill) { + for (const attr of engine.effectivePieceAttrs) { + if (engine.session.contains(id, attr)) { + engine.session.retract(id, attr); + } + } + } + + // Place a black rook on an otherwise-empty board so that IF a + // white king somehow still counted, it would be "attacked". + placePiece(engine, "rook", "black", "e4"); + + engine.setActivePresets([DUAL_KING_PRESET]); + + const whiteRoyals = resolveRoyals(engine, "white"); + expect(whiteRoyals).toHaveLength(0); + expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(false); + }); +}); + +describe("dual-king — activation validation", () => { + it("(i) incompatibility declarations line up with the active-set validator", () => { + // Part 1 — declaration. The preset must list every conflicting + // royalty-redefining id so future refactors that drop one are + // caught at build time. + const def = PRESET_REGISTRY.get("dual-king"); + expect(def).toBeDefined(); + if (!def) throw new Error("dual-king not registered"); + for (const id of [ + "weak-dual-king", + "coregal", + "knightmate-rules", + "suicide-chess", + "capture-all", + ]) { + expect(def.incompatibleWith).toContain(id); + } + + // Part 2 — runtime. Pair dual-king with knightmate-rules (which + // ships in the same phase window) → INCOMPATIBLE at activation. + // For the presets that don't exist yet (weak-dual-king, + // coregal, suicide-chess, capture-all), the validator + // short-circuits to UNKNOWN_PRESET before reaching the + // incompatibility check. Either throw shape proves the pair is + // blocked. + for (const conflict of [ + "weak-dual-king", + "coregal", + "knightmate-rules", + "suicide-chess", + "capture-all", + ]) { + const engine = new ChessEngine(); + expect(() => + engine.setActivePresets([ + DUAL_KING_PRESET, + { id: conflict, scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/INCOMPATIBLE|incompatible|UNKNOWN_PRESET|not registered/i); + } + }); +}); diff --git a/packages/chess/src/presets/dual-king.ts b/packages/chess/src/presets/dual-king.ts new file mode 100644 index 0000000..10f7cc0 --- /dev/null +++ b/packages/chess/src/presets/dual-king.ts @@ -0,0 +1,118 @@ +/** + * Preset: `dual-king` (Phase C.2 of the rule-variants epic) + * + * "Every king is royal." Any piece with `PieceType === "king"` and the + * matching `Color` on the board is contributed to that side's royal + * set. The engine's royal-set union threads this into `isInCheck` / + * `isCheckmate` / `isStalemate` / `filterSelfCheckMoves` — so: + * + * - A side is IN CHECK when ANY of its kings is attacked. + * - A side is CHECKMATED when ANY of its kings is under attack and + * no legal move resolves the attack (via move, block, or capture) + * — matching the "strong dual-king" variant where losing either + * king is the loss condition. + * - A legal move must leave EVERY friendly king not-attacked; a + * move that unpins one king but exposes another is filtered. + * + * Pairing: typically used with the `dual-classic` layout, which seeds + * two kings per side on d1/e1 and d8/e8 with the queen omitted. + * Orthogonal to layout — dropping the preset onto FIDE classic is + * a valid (degenerate) config: with only one king per side, the + * behaviour collapses to regular chess. + * + * Composition with `piece-hp`: each king maintains its own HP. When + * piece-hp is also active, a royal king is not "captured" until its + * HP reaches zero, and it is still considered live-royal (and + * therefore can be in check) while its HP is positive. The preset's + * royal contribution is a pure read of current facts — whatever + * kings remain on the board at query time define royalty, so the + * composition needs no special wiring. + * + * Degenerate case — no friendly kings at all (both captured, or a + * position that just happens to have none): returns `[]`. Per the + * engine's empty-royal-set contract, `isInCheck` short-circuits to + * false and the terminal check resolves via the normal non-royal + * fallbacks (stalemate/draw/ongoing). Games that want "last king + * captured = instant loss" should layer `capture-to-win` or similar. + * + * ## Known non-feature — pawn underpromotion to king + * + * The FIDE promotion set is {queen, rook, bishop, knight} and king + * is not a selectable promotion target. A third (or further) king + * therefore cannot arise via ordinary promotion. The preset's + * `getRoyalPieces` is nevertheless a blind scan of all live king + * entities of the matching color — if any custom mechanism (scripted + * spawn, debug utility, a future "king-promotion" preset, …) creates + * additional kings mid-game, they are picked up automatically. + * No additional code path is needed from this preset's side. + * + * ## Incompatibility + * + * Declared against every other royalty-redefining preset: + * + * - `weak-dual-king` — the alternative where mating ALL kings is + * required. Stacking with dual-king would be contradictory + * (strong vs weak). Ship one or the other. + * - `coregal` — makes a non-king piece co-royal. Union would + * produce "kings + coregal target is royal" which isn't what + * either preset promises on its own. + * - `knightmate-rules` — replaces kings with knights as royal. + * Strictly contradictory. + * - `suicide-chess`, `capture-all` — explicitly remove royalty. + * Stacking makes no sense. + * + * Scope-split incompatibility edge case: `active-set` rejects the + * declared pairs only when their scopes overlap, so an advanced + * operator can still configure "dual-king on white, coregal on + * black" deliberately. That's considered opt-in exotica, not a bug. + */ +import { PRESET_REGISTRY } from "./registry.js"; +import type { EntityId } from "@paratype/rete"; + +PRESET_REGISTRY.register({ + id: "dual-king", + name: "Dual King", + description: + "Two kings per side. Checkmating either king ends the game.", + incompatibleWith: [ + "weak-dual-king", + "coregal", + "knightmate-rules", + "suicide-chess", + "capture-all", + ], + requires: [], + + getRoyalPieces({ engine, color }): readonly EntityId[] { + // Single linear sweep over allFacts, same pattern as + // knightmate-rules. We need the intersection of: + // - PieceType === "king" + // - Color === `color` + // - Live Position fact (retracted pieces have no Position, + // so filtering on Position presence keeps captured kings + // out of the royal set). + // + // Cost: O(F) per call. Acceptable — fact counts are small and + // the royal set is cached per move-generation by the engine. + const kingIds: EntityId[] = []; + const colorById = new Map(); + const hasPosition = new Set(); + for (const f of engine.session.allFacts()) { + if (f.attr === "PieceType" && f.value === "king") { + kingIds.push(f.id); + } 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 kingIds) { + 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 f37114e..aaf95dd 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 "./dual-king.js"; import "./monster-rules.js"; import "./poisoned-squares.js"; import "./first-promotion-wins.js"; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 9468944..899d606 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", + "dual-king", "monster-rules", "poisoned-squares", "first-promotion-wins",