feat(presets): weak-dual-king (mating one king isn't terminal)

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

View file

@ -36,5 +36,6 @@ import "./monster-rules.js";
import "./poisoned-squares.js";
import "./first-promotion-wins.js";
import "./capture-all.js";
import "./suicide-chess.js";
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";

View file

@ -49,6 +49,7 @@ describe("Preset registry — all registered", () => {
"poisoned-squares",
"first-promotion-wins",
"capture-all",
"suicide-chess",
];
it("registry size matches the expected ID list", () => {

View file

@ -0,0 +1,322 @@
/**
* Tests for `weak-dual-king` (Phase C.3, rule-variants epic).
*
* The preset contributes EVERY king of `color` as royal (same as
* dual-king) but:
* - opts out of the self-check filter (`shouldFilterSelfCheck`
* returns false), so "sacrifice" moves exposing one king while
* the other is safe are legal,
* - provides a custom `onCheckGameResult` that only declares
* terminal when a side's kings are all captured OR all mated
* simultaneously (no legal move saves at least one royal).
*
* See `./weak-dual-king.ts` for the full docblock, including the
* documented "pin exposing both royals" gap.
*/
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 } from "../rules/check.js";
import { DUAL_CLASSIC_LAYOUT } from "../layouts/dual-classic.js";
import { clearBoard, placePiece } from "./test-utils.js";
const WEAK_DUAL_KING = {
id: "weak-dual-king",
scope: "both" as const,
turnsRemaining: null,
};
/** Resolve royals by calling the hook directly — mirrors the pattern
* used by dual-king.test.ts / knightmate-rules.test.ts. */
function resolveRoyals(
engine: ChessEngine,
color: "white" | "black",
): readonly EntityId[] {
const def = PRESET_REGISTRY.get("weak-dual-king");
if (!def?.getRoyalPieces) throw new Error("weak-dual-king not registered");
return def.getRoyalPieces({ engine, color }) ?? [];
}
/** Retract every fact on a piece entity, simulating its capture. */
function retractPiece(engine: ChessEngine, id: EntityId): void {
for (const attr of engine.effectivePieceAttrs) {
if (engine.session.contains(id, attr)) {
engine.session.retract(id, attr);
}
}
}
describe("weak-dual-king — activation", () => {
it("(a) registers and activates cleanly", () => {
const def = PRESET_REGISTRY.get("weak-dual-king");
expect(def).toBeDefined();
expect(def?.name).toBe("Weak Dual King");
const engine = new ChessEngine();
// Does not throw; hook resolves to 1 royal on FIDE start.
engine.setActivePresets([WEAK_DUAL_KING]);
expect(resolveRoyals(engine, "white")).toHaveLength(1);
expect(resolveRoyals(engine, "black")).toHaveLength(1);
});
});
describe("weak-dual-king — game-result semantics", () => {
it("(b) dual-classic layout + preset active, neither side in check → ongoing", () => {
const engine = new ChessEngine({ layout: DUAL_CLASSIC_LAYOUT });
engine.setActivePresets([WEAK_DUAL_KING]);
expect(resolveRoyals(engine, "white")).toHaveLength(2);
expect(resolveRoyals(engine, "black")).toHaveLength(2);
expect(isInCheck(engine.session, "white", resolveRoyals(engine, "white"))).toBe(false);
expect(engine.checkGameResult()).toBe("ongoing");
});
it("(c) one white king captured, other white king safe → ongoing", () => {
const engine = new ChessEngine({ layout: DUAL_CLASSIC_LAYOUT });
engine.setActivePresets([WEAK_DUAL_KING]);
// Retract one white king to simulate a capture.
const whiteKings = resolveRoyals(engine, "white");
expect(whiteKings).toHaveLength(2);
const victim = whiteKings[0];
if (victim === undefined) throw new Error("no white king to retract");
retractPiece(engine, victim);
const after = resolveRoyals(engine, "white");
expect(after).toHaveLength(1);
// Survivor is on its initial square, not attacked.
expect(isInCheck(engine.session, "white", after)).toBe(false);
expect(engine.checkGameResult()).toBe("ongoing");
});
it("(d) both white kings captured, black still has kings → black-wins", () => {
const engine = new ChessEngine({ layout: DUAL_CLASSIC_LAYOUT });
engine.setActivePresets([WEAK_DUAL_KING]);
for (const id of resolveRoyals(engine, "white")) retractPiece(engine, id);
expect(resolveRoyals(engine, "white")).toHaveLength(0);
expect(engine.checkGameResult()).toBe("black-wins");
});
it("(e) one white king under unstoppable attack, the other safe → ongoing (the player can sacrifice it by playing anywhere)", () => {
// White kings: a1 (attacked + caged) and h1 (safe).
// Black: rook a8, rook b8 (cages a1 on a-file + b-file),
// king h4 (so side-to-move has a real opponent).
//
// a1 has no escape (b1, b2 covered by rook b8; a2 covered by
// rook a8). No white piece can block or capture either rook
// (white has only the two kings). Under STRONG dual-king this
// is immediate checkmate. Under WEAK dual-king the h1 king can
// move freely (e.g. h1→g1) leaving a1 still attacked but h1/g1
// safe → at least one royal survives after the move → hasSavingMove
// returns true → game continues.
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
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([WEAK_DUAL_KING]);
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals).toHaveLength(2);
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
// Weak semantics → NOT checkmate; the h1 king can move.
expect(engine.checkGameResult()).toBe("ongoing");
});
it("(f) single surviving king in a classic mate → checkmate", () => {
// One white king only (second has already been captured),
// standard back-rank mate. Under weak semantics this collapses
// to plain chess: side in check, no legal move saves the sole
// royal → checkmate.
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1"); // only white royal
placePiece(engine, "king", "black", "h8");
placePiece(engine, "rook", "black", "a8"); // attacks a1 down a-file
placePiece(engine, "rook", "black", "b8"); // cages b-file (b1, b2)
// a2 covered by rook a8; b1/b2 covered by rook b8. No escape,
// no blocker, no capture.
engine.setActivePresets([WEAK_DUAL_KING]);
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals).toHaveLength(1);
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
expect(engine.checkGameResult()).toBe("checkmate");
});
it("(g) both kings simultaneously mated (no legal move leaves any royal safe) → checkmate", () => {
// Symmetric cage: a1 king attacked by queen a8, walled in by
// rook b8 (b-file) — h1 king attacked by queen h8, walled in by
// rook g8 (g-file). White has only the two kings. Every move
// leaves at least one king attacked AND the side's legal moves
// are exhausted in attacked squares (king moves to a2, b1, b2,
// g1, g2, h2 — all covered).
//
// Crucially: under weak-dual-king with the filter off, kings
// CAN move to attacked squares — so legal moves exist at the
// move-generation layer. It's `onCheckGameResult`'s job to
// detect that none of them SAVE a royal. Hence checkmate.
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
placePiece(engine, "king", "white", "h1");
placePiece(engine, "king", "black", "d5");
placePiece(engine, "queen", "black", "a8");
placePiece(engine, "queen", "black", "h8");
placePiece(engine, "rook", "black", "b8"); // covers b-file
placePiece(engine, "rook", "black", "g8"); // covers g-file
engine.setActivePresets([WEAK_DUAL_KING]);
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals).toHaveLength(2);
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
expect(engine.checkGameResult()).toBe("checkmate");
});
});
describe("weak-dual-king — self-check opt-out semantics", () => {
it("(h) sacrifice move legal: a move exposing one king (while the other is fine) survives move generation", () => {
// White kings: a1 (attacked by rook a8) and e4 (safe).
// No white pieces can help a1. Under STRONG dual-king the e4
// king's moves (e.g. e4→f4) would be dropped by
// filterSelfCheckMoves because a1 stays attacked afterwards.
// Under WEAK dual-king the filter is OFF → e4 moves are listed
// as legal even though they leave a1 hanging.
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "a1");
const roving = placePiece(engine, "king", "white", "e4");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "rook", "black", "a8");
engine.setActivePresets([WEAK_DUAL_KING]);
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals).toHaveLength(2);
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(true);
// The e4 king should have legal moves (it's on an open board;
// 8 surrounding squares are all empty and not all attacked).
const legal = engine.getAllLegalMoves();
const rovingMoves = legal.filter((m) => m.pieceId === roving);
expect(rovingMoves.length).toBeGreaterThan(0);
// The game is not terminal — such a move exists, so
// hasSavingMove resolves true (the e4 king is safe wherever
// it lands, and at least one royal-after-move is unattacked).
expect(engine.checkGameResult()).toBe("ongoing");
});
it("(i) self-check filter is explicitly disabled for this preset (scope 'both')", () => {
// Guardrail test: if someone ever flips `shouldFilterSelfCheck`
// back to `true`, a swath of semantic tests above would start
// misfiring. Pin the opt-out here for intent-preservation.
const def = PRESET_REGISTRY.get("weak-dual-king");
expect(def?.shouldFilterSelfCheck).toBeDefined();
const engine = new ChessEngine();
const result = def!.shouldFilterSelfCheck!({ engine, color: "white" });
expect(result).toBe(false);
});
it("(j) documented gap: a move exposing BOTH royals is legal at move-gen layer (no filter). Game-over detection still handles it correctly.", () => {
// Setup: white king a1, white king a3, white rook at a2
// blocking the a-file. Black queen at a8 attacks down — the
// rook at a2 shields BOTH kings (queen → a7 → … → a3 [blocked]
// and a3 itself shields a1). Moving the white rook off a2
// would expose BOTH royals to the queen in a single move.
//
// Actually this overlaps with the a3 king already shielding a1,
// so moving rook a2 only exposes a3 — a3 then shields a1. Let's
// pick a cleaner scenario with two independent rays through one
// piece:
//
// - black bishop a1 attacks the a1-h8 diagonal (b2, c3, d4, …).
// - black bishop a7 attacks the a7-g1 diagonal (b6, c5, d4, …).
// - white knight d4 BLOCKS both rays.
// - white king e5 sits on the a1-h8 diagonal past d4.
// - white king e3 sits on the a7-g1 diagonal past d4.
//
// Moving the knight off d4 exposes BOTH bishops simultaneously
// → both royals attacked.
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
const knight = placePiece(engine, "knight", "white", "d4");
placePiece(engine, "king", "white", "e5");
placePiece(engine, "king", "white", "e3");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "bishop", "black", "a1");
placePiece(engine, "bishop", "black", "a7");
engine.setActivePresets([WEAK_DUAL_KING]);
// Not currently in check — the knight is blocking both rays.
const whiteRoyals = resolveRoyals(engine, "white");
expect(whiteRoyals).toHaveLength(2);
expect(isInCheck(engine.session, "white", whiteRoyals)).toBe(false);
expect(engine.checkGameResult()).toBe("ongoing");
// Knight's pseudo-legal moves include jumps off d4 (e.g. d4→e6,
// d4→c6, d4→b5, d4→b3 …). All expose BOTH kings. Under strong
// dual-king the filter would drop them all. Under weak-dual-king
// with the filter off, they survive move generation — this is
// the DOCUMENTED GAP from the preset's docblock.
const legal = engine.getAllLegalMoves();
const knightMoves = legal.filter((m) => m.pieceId === knight);
expect(knightMoves.length).toBeGreaterThan(0);
// After any knight jump, onCheckGameResult would evaluate the
// resulting position (both kings in check). Not terminal until
// the opponent actually captures — that's the "game-over
// detection still handles it correctly" half of the claim.
});
});
describe("weak-dual-king — activation validation", () => {
it("(k) declares incompatibility with dual-king / coregal / knightmate-rules / suicide-chess / capture-all, and activation rejects them", () => {
const def = PRESET_REGISTRY.get("weak-dual-king");
expect(def).toBeDefined();
if (!def) throw new Error("weak-dual-king not registered");
for (const id of [
"dual-king",
"coregal",
"knightmate-rules",
"suicide-chess",
"capture-all",
]) {
expect(def.incompatibleWith).toContain(id);
}
// Runtime: pair with dual-king (which ships in the same window)
// → INCOMPATIBLE at activation. For presets that don't exist
// yet (suicide-chess, capture-all) the validator short-circuits
// to UNKNOWN_PRESET. Either throw shape proves the pair is
// blocked.
for (const conflict of [
"dual-king",
"coregal",
"knightmate-rules",
"suicide-chess",
"capture-all",
]) {
const engine = new ChessEngine();
expect(() =>
engine.setActivePresets([
WEAK_DUAL_KING,
{ id: conflict, scope: "both", turnsRemaining: null },
]),
).toThrow(/INCOMPATIBLE|incompatible|UNKNOWN_PRESET|not registered/i);
}
});
});

View file

@ -0,0 +1,301 @@
/**
* Preset: `weak-dual-king` (Phase C.3 of the rule-variants epic).
*
* The WEAK cousin of `dual-king`: each side has TWO royal kings, but
* losing / mating ONE of them is SURVIVABLE — the other king plays on,
* alone, under normal (single-royal) pressure. The game is only
* terminal when:
*
* (a) a side has ZERO royals left on the board (opponent wins), or
* (b) every remaining royal of the side-to-move is SIMULTANEOUSLY
* mated — i.e. no legal move leaves at least one royal safe
* (checkmate).
*
* This differs from plain `dual-king` (strong semantics), where
* mating the FIRST king already ends the game because the standard
* self-check filter drops any move that leaves "any royal" in check.
*
* ─── Design trade-offs, explicitly ──────────────────────────────────
*
* 1. Self-check filter — OPT OUT.
*
* The engine's `filterSelfCheckMoves` treats the royal set as a
* UNION: a move is illegal if it leaves ANY royal attacked. That is
* perfect for the "strong" dual-king variant (mating any king ends
* the game), but wrong here — under weak semantics, a move that
* exposes one king while the other stays safe is LEGAL (the player
* sacrifices that king and fights on).
*
* We therefore return `shouldFilterSelfCheck: false` so the engine
* skips the default filter. Players CAN walk one king into danger;
* the opponent may capture it next turn, after which only the
* other king remains on the board and standard "lose your last
* king → you lose" semantics kick in (handled in `onCheckGameResult`
* below).
*
* 2. Game-over detection — FULL OVERRIDE.
*
* Because we opted out of the self-check filter, the engine's
* default `isCheckmate` predicate would misfire: it calls
* `filterSelfCheckMoves` internally and would thus declare
* checkmate the moment ONE king is inescapably attacked — the very
* situation weak-dual-king is designed to TOLERATE. So
* `onCheckGameResult` must be authoritative:
*
* - 0 white royals AND 0 black royals → whoever JUST moved wins
* (mirrors piece-hp's tiebreak: `engine.getCurrentTurn()` has
* already flipped, so the losing side is the side-to-move).
* - 0 white royals, ≥1 black royal → "black-wins"
* - ≥1 white royal, 0 black royals → "white-wins"
* - both sides alive, side-to-move NOT in check
* → "ongoing" (default
* checkmate / stalemate suppressed on purpose — they operate
* under strong-dual semantics).
* - side-to-move in check AND every legal move STILL leaves
* every royal attacked → "checkmate".
* - otherwise → "ongoing".
*
* "Legal move" is computed the same way the engine would see it
* (via `engine.getAllLegalMoves()`), which with us in the active
* set means "post-type-registry + extra moves, WITHOUT the
* self-check filter, WITH any aggregate `filterLegalMoves`". That
* is exactly the surface area a player actually has at the table,
* so it's the right denominator for "are all royals mated?".
*
* 3. Known gap — pin exposing BOTH royals.
*
* Under classic chess, `filterSelfCheckMoves` would veto a move
* that exposes the moving side's single king. Under weak-dual-king
* with the filter OFF, a move that simultaneously exposes BOTH
* royals is ALSO legal at the move-generation layer, which is not
* strictly correct — a self-chosen double-mate should be illegal.
* A future refinement could re-add a softer filter via
* `filterLegalMoves` that drops moves where EVERY royal is left
* attacked (a strictly-weaker version of the engine's filter).
* Deferred because:
* - The game-over detection in `onCheckGameResult` still handles
* the case correctly: after such a move the mover would have
* two attacked royals next turn and might still save one, so
* it's not formally wrong — just "unrealistic player input".
* - Users can always decline to make the move; this is an AI /
* input-validation question rather than a correctness one.
* Documented in `weak-dual-king.test.ts` (test "pin exposing both
* royals").
*
* ─── Composition ───────────────────────────────────────────────────
*
* `incompatibleWith` lists every preset that also redefines royalty
* (`dual-king`, `coregal`, `knightmate-rules`) or that also owns
* terminal-state resolution in a way that would confuse ours
* (`suicide-chess` has zero royalty; `capture-all` would declare win
* on first capture, pre-empting our "zero-royals-left" check). At
* most ONE royalty-redefining preset should be active at a time.
*
* `requires: []` — no layout coupling. Pairs naturally with the
* `dual-classic` starting layout (C.2) but works on any board: if
* there's exactly one king per side, the preset degenerates to
* "every king is royal, losing either loses" — functionally identical
* to plain chess, so it's safe to enable speculatively.
*/
import { PRESET_REGISTRY } from "./registry.js";
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import type { GameResult, ChessEngine } from "../engine.js";
import type { PieceColor, Square } from "../schema.js";
import { oppositeColor } from "../schema.js";
import { isSquareAttacked } from "../rules/check.js";
import { isPieceAttr } from "../rules/capture.js";
/**
* Every `PieceType === "king"` of `color` that still has a Position
* fact (i.e. alive on the board). Mirrors `dual-king`'s hook shape so
* callers can swap between strong and weak variants without changing
* their royal-resolution logic.
*/
function liveRoyalIdsOf(
session: Session,
color: PieceColor,
): EntityId[] {
const facts = session.allFacts();
const kingIds: EntityId[] = [];
const colorOf = new Map<EntityId, string>();
const hasPos = new Set<EntityId>();
for (const f of facts) {
if (f.attr === "PieceType" && f.value === "king") kingIds.push(f.id);
else if (f.attr === "Color") colorOf.set(f.id, f.value as string);
else if (f.attr === "Position") hasPos.add(f.id);
}
const out: EntityId[] = [];
for (const id of kingIds) {
if (colorOf.get(id) === color && hasPos.has(id)) out.push(id);
}
return out;
}
/**
* Build a what-if session containing every piece-level fact of the
* current position. Identical in shape to the snapshot used by
* `filterSelfCheckMoves` so results stay consistent across the
* check/mate pipeline.
*/
function snapshotPieces(session: Session): Session {
const temp = new Session({ autoFire: false });
for (const f of session.allFacts()) {
if ((f.id as number) <= 0) continue;
if (!isPieceAttr(f.attr)) continue;
temp.insert(f.id, f.attr, f.value);
}
return temp;
}
/**
* Count the number of `color`'s royals whose current square is
* attacked by the opposite color. Uses the same `isSquareAttacked`
* primitive as `isInCheck`, so "attacked" matches the engine's
* definition exactly.
*/
function attackedRoyalCount(
session: Session,
color: PieceColor,
royalIds: readonly EntityId[],
): number {
const facts = session.allFacts();
const attacker = oppositeColor(color);
let count = 0;
for (const id of royalIds) {
const posFact = facts.find(f => f.id === id && f.attr === "Position");
if (posFact === undefined) continue;
const pos = posFact.value as Square;
if (isSquareAttacked(session, pos, attacker)) count++;
}
return count;
}
/**
* Does `color` have a legal move that results in at least ONE of its
* royals being alive and NOT attacked?
*
* Iterates `engine.getAllLegalMoves()` (which, with us active, has
* the self-check filter disabled) and for each move builds a what-if
* snapshot, applies the move, re-resolves the live royal set in the
* resulting position, and counts attacked royals. Returns true the
* moment any move leaves ≥1 royal safe.
*
* Correct even when a move captures an attacker (removing the check
* source) or when a move moves one of the royals themselves to a
* safer square — both manipulate the snapshot identically.
*/
function hasSavingMove(engine: ChessEngine): boolean {
const color = engine.getCurrentTurn();
const moves = engine.getAllLegalMoves();
const session = engine.session;
for (const move of moves) {
const temp = snapshotPieces(session);
if (move.isCapture) {
// Clear whatever sat on `move.to` (retract all piece-level
// facts). Using allFacts enumeration so preset attrs are swept
// as well — matches `filterSelfCheckMoves.clearSquare`.
const occupant = temp.allFacts().find(
f => f.attr === "Position" && f.value === move.to,
);
if (occupant !== undefined) {
const victim = occupant.id;
for (const f of temp.allFacts()) {
if (f.id === victim && isPieceAttr(f.attr) && temp.contains(victim, f.attr)) {
temp.retract(victim, f.attr);
}
}
}
}
// Apply the mover's position update.
temp.insert(move.pieceId, "Position", move.to);
const royalsAfter = liveRoyalIdsOf(temp, color);
if (royalsAfter.length === 0) continue; // all self-captured (degenerate)
const attacked = attackedRoyalCount(temp, color, royalsAfter);
if (attacked < royalsAfter.length) return true; // at least one safe
}
return false;
}
PRESET_REGISTRY.register({
id: "weak-dual-king",
name: "Weak Dual King",
description:
"Two kings per side; losing one is survivable. Game ends only when a side has zero kings, or every remaining king is simultaneously mated.",
incompatibleWith: ["dual-king", "coregal", "knightmate-rules", "suicide-chess", "capture-all"],
requires: [],
/**
* Every live `PieceType === "king"` of `color` is royal. Empty when
* all of that side's kings have been captured (used by the engine's
* default `isInCheck` short-circuit to report "no royalty left").
*/
getRoyalPieces({ engine, color }): readonly EntityId[] {
return liveRoyalIdsOf(engine.session, color);
},
/**
* Opt out of the default self-check filter. See docblock at top of
* file for the reasoning — in short, the engine's filter treats the
* royal set as a union (a move leaving ANY royal attacked is
* dropped), which is too strict for weak semantics where exposing
* one king while the other stays safe is a valid sacrifice.
*
* The trade-off: players can technically make moves that leave BOTH
* royals attacked. Our `onCheckGameResult` still handles the game
* state correctly afterwards; see the "pin exposing both royals"
* test for the documented behaviour.
*/
shouldFilterSelfCheck(): boolean {
return false;
},
onCheckGameResult({ engine }): GameResult | undefined {
const session = engine.session;
const whiteRoyals = liveRoyalIdsOf(session, "white");
const blackRoyals = liveRoyalIdsOf(session, "black");
// ── (a) Royal-annihilation terminators ───────────────────────
if (whiteRoyals.length === 0 && blackRoyals.length === 0) {
// Simultaneous zero — whoever's turn it is NOW lost (turn
// flipped after the last move). Mirrors piece-hp's tiebreak.
const turn = engine.getCurrentTurn();
return turn === "white" ? "black-wins" : "white-wins";
}
if (whiteRoyals.length === 0) return "black-wins";
if (blackRoyals.length === 0) return "white-wins";
// ── (b) Simultaneous-mate check ──────────────────────────────
// Both sides still have ≥1 royal. Evaluate the side-to-move's
// position.
const sideToMove = engine.getCurrentTurn();
const ownRoyals = sideToMove === "white" ? whiteRoyals : blackRoyals;
const attacked = attackedRoyalCount(session, sideToMove, ownRoyals);
if (attacked === 0) {
// No royal currently attacked. Game continues regardless of
// legal-move count (we deliberately do NOT fire stalemate here
// under weak-dual semantics — the engine's default stalemate
// predicate is tuned for single-royal games and would rely on
// the self-check filter we opted out of).
return "ongoing";
}
// ≥1 royal attacked. We're "in check" in the weak sense. The
// position is mate iff every possible legal move still leaves
// every royal attacked — equivalently, iff no legal move leaves
// at least one royal safe. We ALSO must check for "one king
// alive, mated in the classical sense", because that's a
// single-royal single-mate scenario covered by the same logic:
// no move saves the sole royal → it stays attacked everywhere
// → "at least one royal safe" is false → checkmate.
if (!hasSavingMove(engine)) return "checkmate";
// A move exists that saves ≥1 royal. Game continues. Return
// "ongoing" explicitly to suppress the engine's default
// checkmate/stalemate predicates (they misfire under our
// self-check-off regime — see docblock).
return "ongoing";
},
});