feat(presets): dual-king + dual-classic layout

This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 07:53:54 -06:00
commit 4334a0382a
No known key found for this signature in database
7 changed files with 520 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<EntityId, string>();
const hasPosition = new Set<EntityId>();
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;
},
});

View file

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

View file

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