diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index 33f7b31..53a5d30 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -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"; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 55df251..8c13c18 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -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", () => { diff --git a/packages/chess/src/presets/weak-dual-king.test.ts b/packages/chess/src/presets/weak-dual-king.test.ts new file mode 100644 index 0000000..efe9fb4 --- /dev/null +++ b/packages/chess/src/presets/weak-dual-king.test.ts @@ -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); + } + }); +}); diff --git a/packages/chess/src/presets/weak-dual-king.ts b/packages/chess/src/presets/weak-dual-king.ts new file mode 100644 index 0000000..3fab211 --- /dev/null +++ b/packages/chess/src/presets/weak-dual-king.ts @@ -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(); + const hasPos = new Set(); + 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"; + }, +});