feat(presets): weak-dual-king (mating one king isn't terminal)
This commit is contained in:
parent
5d64ae4c37
commit
d456e87a04
4 changed files with 625 additions and 0 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
322
packages/chess/src/presets/weak-dual-king.test.ts
Normal file
322
packages/chess/src/presets/weak-dual-king.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
301
packages/chess/src/presets/weak-dual-king.ts
Normal file
301
packages/chess/src/presets/weak-dual-king.ts
Normal 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";
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue