diff --git a/packages/chess/src/presets/suicide-chess.test.ts b/packages/chess/src/presets/suicide-chess.test.ts new file mode 100644 index 0000000..26984df --- /dev/null +++ b/packages/chess/src/presets/suicide-chess.test.ts @@ -0,0 +1,440 @@ +/** + * Tests for the `suicide-chess` preset (Phase D.1 of the rule-variants + * epic). + * + * Ruleset under test (see `./suicide-chess.ts` docblock): + * - Compulsory capture — if any capture is legal, only captures are + * legal. + * - Kings are NOT royal — no check, no checkmate. Moves that would + * expose the king are legal; the king can even be captured. + * - Zero-pieces WINS — reducing your own piece count to 0 hands you + * the victory. + * - Stalemate wins the side to move (FICS / lichess rule). + * + * Test strategy: each scenario constructs a minimal board via + * `placePiece` + `clearBoard({ preserveKings: false })`, activates the + * preset with `scope: "both"` permanent, and probes the relevant + * engine surface. We avoid driving the whole starting position + * through applyMove (too slow, noise from unrelated preset wiring) — + * focused positions let each test assert exactly the invariant in + * scope. + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import { ChessEngine } from "../engine.js"; +import { PRESET_REGISTRY } from "./registry.js"; +import { isInCheck } from "../rules/check.js"; +import { clearBoard, placePiece } from "./test-utils.js"; +import { algebraicToSquare } from "../coord.js"; +import { GAME_ENTITY } from "../schema.js"; +import { ActivePresetSet } from "./active-set.js"; +import type { PieceColor } from "../schema.js"; + +/** Quick helper: activate suicide-chess on the engine at scope=both. */ +function activate(engine: ChessEngine): void { + engine.setActivePresets([ + { id: "suicide-chess", scope: "both", turnsRemaining: null }, + ]); +} + +/** Force the side to move. clearBoard doesn't touch the Turn fact. */ +function setTurn(engine: ChessEngine, color: PieceColor): void { + engine.session.insert(GAME_ENTITY, "Turn", color); +} + +describe("suicide-chess — registration + activation", () => { + it("is registered with the expected id, name, and incompatibility set", () => { + const def = PRESET_REGISTRY.get("suicide-chess"); + expect(def).toBeDefined(); + expect(def?.name).toMatch(/suicide/i); + // Every terminal-state preset is declared incompatible (first + // terminal return wins, stacking them would be a race). + for (const id of [ + "capture-to-win", + "last-piece-standing", + "first-promotion-wins", + "capture-all", + ]) { + expect(def?.incompatibleWith).toContain(id); + } + // Royalty redefiners are also blocked (our empty-royal contribution + // would otherwise union with theirs into a non-empty set). + for (const id of ["knightmate-rules", "coregal", "dual-king", "weak-dual-king"]) { + expect(def?.incompatibleWith).toContain(id); + } + }); + + it("activation on a fresh engine succeeds without throwing", () => { + const engine = new ChessEngine(); + expect(() => activate(engine)).not.toThrow(); + expect(engine.activePresets.has("suicide-chess")).toBe(true); + }); +}); + +describe("suicide-chess — no royalty (getRoyalPieces returns [])", () => { + it("empty royal set short-circuits isInCheck even when the king is attacked", () => { + // Baseline position: white king on e1 attacked by black rook on + // e8 along the open file. Without the preset this is "check"; + // with it, suicide-chess contributes [] for both colors, so + // isInCheck(..., []) is false. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "rook", "black", "e8"); + + // Sanity: default rules say white is in check. + expect(isInCheck(engine.session, "white")).toBe(true); + + activate(engine); + + // With the preset: isInCheck threaded with the empty royal set + // (what the engine resolves internally) returns false — the + // canonical "no royalty" probe documented in the task spec. + expect(isInCheck(engine.session, "white", [])).toBe(false); + }); + + it("terminal-state query does NOT report checkmate under a would-be-mate position", () => { + // Back-rank mate setup: white king g1, own pawns blocking f2/g2/h2, + // black rook a1 sweeping rank 1. Default rules = "checkmate". + // Under suicide-chess this must be some non-checkmate result + // (either "ongoing" or one of the win variants — never + // "checkmate", "stalemate", or a draw). + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "g1"); + placePiece(engine, "pawn", "white", "f2"); + placePiece(engine, "pawn", "white", "g2"); + placePiece(engine, "pawn", "white", "h2"); + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "rook", "black", "a1"); + + // Baseline: checkmate on white. + expect(engine.checkGameResult()).toBe("checkmate"); + + activate(engine); + + // No more "checkmate" / "stalemate" / default-draw verdicts. + const result = engine.checkGameResult(); + expect(result).not.toBe("checkmate"); + expect(result).not.toBe("stalemate"); + expect(result).not.toBe("draw-50"); + expect(result).not.toBe("draw-3fold"); + expect(result).not.toBe("draw-insufficient"); + }); + + it("king can legally move onto an attacked square (self-check filter is off)", () => { + // White king on e1, black rook on d8 attacks d-file. Moving the + // king to d1 would be illegal under default rules (self-check) + // but legal under suicide-chess. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + const wk = placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "rook", "black", "d8"); + setTurn(engine, "white"); + + activate(engine); + + const d1 = algebraicToSquare("d1"); + const moves = engine.getAllLegalMoves(); + const kingToD1 = moves.find((m) => m.pieceId === wk && m.to === d1); + // No captures on the board → compulsory-capture is a no-op → + // king-to-d1 stays in the legal list even though d1 is attacked. + expect(kingToD1).toBeDefined(); + }); +}); + +describe("suicide-chess — compulsory capture", () => { + it("when a capture exists, NON-capture moves are dropped", () => { + // White rook on a1 can capture the black pawn on a5 OR slide + // elsewhere along rank 1 / a-file. The compulsion must keep only + // captures. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + placePiece(engine, "rook", "white", "a1"); + placePiece(engine, "pawn", "black", "a5"); + setTurn(engine, "white"); + + activate(engine); + + const moves = engine.getAllLegalMoves(); + expect(moves.length).toBeGreaterThan(0); + expect(moves.every((m) => m.isCapture === true)).toBe(true); + // The specific capture must be present. + const a5 = algebraicToSquare("a5"); + expect(moves.some((m) => m.to === a5)).toBe(true); + }); + + it("when NO capture is available, all non-capture moves survive", () => { + // Purely quiet position: kings far apart, nothing adjacent. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + placePiece(engine, "rook", "white", "a1"); + setTurn(engine, "white"); + + activate(engine); + + const moves = engine.getAllLegalMoves(); + expect(moves.length).toBeGreaterThan(0); + // Every move is a non-capture. + expect(moves.every((m) => m.isCapture !== true)).toBe(true); + }); + + it("compulsory capture overrides pawn-to-back-rank promotion pushes", () => { + // White pawn on a7 has a non-capture push to a8 (promotion) AND + // a diagonal capture to b8 (capturing a black rook on b8). Only + // the capture should be offered — every non-capture promotion + // variant (4 pieces × the quiet push) gets dropped. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "pawn", "white", "a7"); + placePiece(engine, "rook", "black", "b8"); + setTurn(engine, "white"); + + activate(engine); + + const moves = engine.getAllLegalMoves(); + // All surviving moves must be captures (because a capture IS + // available: pawn takes rook on b8). + expect(moves.length).toBeGreaterThan(0); + expect(moves.every((m) => m.isCapture === true)).toBe(true); + // Specifically: no quiet push to a8. + const a8 = algebraicToSquare("a8"); + expect(moves.some((m) => m.to === a8 && m.isCapture !== true)).toBe(false); + // And the capture-to-b8 promotion move IS present. + const b8 = algebraicToSquare("b8"); + expect(moves.some((m) => m.to === b8 && m.isCapture === true)).toBe(true); + }); + + it("pawn promotion is still legal when no capture is available", () => { + // Pawn on a7 with no enemy adjacent — capture list is empty → + // compulsion is a no-op → all four promotion variants survive. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "h8"); + placePiece(engine, "pawn", "white", "a7"); + setTurn(engine, "white"); + + activate(engine); + + const moves = engine.getAllLegalMoves(); + const a8 = algebraicToSquare("a8"); + const promotions = moves.filter((m) => m.to === a8); + // Four distinct promoteTo values: queen, rook, bishop, knight. + expect(promotions.length).toBe(4); + const promoteTargets = new Set(promotions.map((m) => m.promoteTo)); + expect(promoteTargets).toEqual(new Set(["queen", "rook", "bishop", "knight"])); + }); +}); + +describe("suicide-chess — zero-pieces wins", () => { + it("black has 0 pieces after capture → BLACK wins (lost all pieces)", () => { + // Setup: white to move, white rook on a1, black has exactly ONE + // piece (a knight on a8). White's rook captures the knight. Post- + // capture: black has zero pieces, white has > 0 → black wins. + // + // Note: `placePiece` doesn't create royal kings here because + // kings are irrelevant in this variant; but our preset counts + // pieces regardless of type, so any PieceType fact counts. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "rook", "white", "a1"); + placePiece(engine, "knight", "black", "a8"); + setTurn(engine, "white"); + + activate(engine); + + // Before: both sides have pieces, no terminal. + expect(engine.checkGameResult()).toBe("ongoing"); + + // Execute the capture. Compulsion ensures this IS the only legal + // move (rook on empty file with a capture at the end). + const moves = engine.getAllLegalMoves(); + expect(moves.length).toBeGreaterThan(0); + const a8 = algebraicToSquare("a8"); + const capture = moves.find((m) => m.to === a8 && m.isCapture === true); + expect(capture).toBeDefined(); + const result = engine.applyMove(capture!); + + // Black now has 0 pieces (knight was their only one) → BLACK wins. + expect(result).toBe("black-wins"); + }); + + it("white has 0 pieces after capture → WHITE wins (lost all pieces)", () => { + // Same setup, mirrored: white has a single piece (rook on a1), + // black has a rook on a8, black moves. Black captures white's + // rook → white has 0 → white wins. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "rook", "white", "a1"); + placePiece(engine, "rook", "black", "a8"); + setTurn(engine, "black"); + + activate(engine); + + expect(engine.checkGameResult()).toBe("ongoing"); + + const moves = engine.getAllLegalMoves(); + const a1 = algebraicToSquare("a1"); + const capture = moves.find((m) => m.to === a1 && m.isCapture === true); + expect(capture).toBeDefined(); + const result = engine.applyMove(capture!); + + expect(result).toBe("white-wins"); + }); +}); + +describe("suicide-chess — stalemate-wins", () => { + it("side to move with zero legal moves WINS (FICS / lichess rule)", () => { + // Construct a position where white-to-move has NO legal moves: + // a single white pawn on a2 with a black piece directly in front + // (a3) and no diagonal captures available. With no captures and + // no forward push, the pawn can't move. No other pieces for + // white. + // + // Why this is stalemate-wins and not zero-pieces: white still has + // 1 piece (the pawn). So the zero-pieces check returns undefined + // first. Then the stalemate-wins branch fires: white can't move + // → white WINS. (The inverted goal makes being "stuck" a + // feature.) + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "pawn", "white", "a2"); + // Block the pawn's forward push with a black piece, and ensure + // no capture is possible on b3 (leave b3 empty). The pawn has + // no legal move. + placePiece(engine, "pawn", "black", "a3"); + // Give black a piece far away so black isn't at zero (otherwise + // zero-pieces fires first). + placePiece(engine, "rook", "black", "h8"); + setTurn(engine, "white"); + + activate(engine); + + // Sanity: zero legal moves for white. + expect(engine.getAllLegalMoves().length).toBe(0); + // Stalemate-wins → white wins. + expect(engine.checkGameResult()).toBe("white-wins"); + }); + + it("zero-pieces terminal takes precedence over stalemate-wins (zero is checked first)", () => { + // White has zero pieces, black has pieces but also happens to + // have no moves (imagine every piece is blocked). The zero- + // pieces branch must fire FIRST so the verdict is "white wins" + // (they emptied their army) rather than "black wins by + // stalemate". Order in onCheckGameResult matters. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + // White: no pieces at all. + // Black: one pawn on a7 blocked by itself (a6 empty, can push) — + // actually let's give black a piece that definitely CAN move so + // we're solely testing the "white has 0" branch, not the + // stalemate interaction. + placePiece(engine, "rook", "black", "h8"); + setTurn(engine, "black"); + + activate(engine); + + expect(engine.checkGameResult()).toBe("white-wins"); + }); +}); + +describe("suicide-chess — composition with other presets", () => { + it("composes with piece-hp: a non-lethal capture still satisfies compulsion", () => { + // With `piece-hp` active, a capture deals 1 HP and typically + // doesn't kill (HP = 2 by default). The `LegalMove.isCapture` + // flag is set at move-generation time independent of whether + // the hit will be lethal — so our compulsory-capture filter + // still fires, and the mover is forced into the "poke" even + // though the target survives. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "rook", "white", "a1"); + placePiece(engine, "pawn", "black", "a5"); + // Give white a quiet alternative elsewhere to prove the + // compulsion kicks in. + placePiece(engine, "knight", "white", "b1"); + setTurn(engine, "white"); + + engine.setActivePresets([ + { id: "piece-hp", scope: "both", turnsRemaining: null }, + { id: "suicide-chess", scope: "both", turnsRemaining: null }, + ]); + + const moves = engine.getAllLegalMoves(); + // Every legal move must be a capture (the rook-takes-a5 capture + // is the only capture on the board). + expect(moves.length).toBeGreaterThan(0); + expect(moves.every((m) => m.isCapture === true)).toBe(true); + // And the knight's quiet moves are gone. + const knightMoves = moves.filter((m) => m.pieceId === moves[0]!.pieceId) + .concat(); + // Simpler: just assert no non-captures remain in the aggregate. + expect(moves.filter((m) => m.isCapture !== true)).toHaveLength(0); + // (knightMoves used only to document the shape; we don't assert + // anything further on it.) + void knightMoves; + }); + + it("composes with double-move: two captures in a row when available", () => { + // Stack double-move + suicide-chess. White has two capture + // targets. After white's FIRST capture (compulsory), the + // double-move preset vetoes the turn flip, so white is still + // on move. A second capture remains → compulsion forces white + // to take it too. Two consecutive white captures. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "rook", "white", "a1"); // can capture a5 + placePiece(engine, "bishop", "white", "c1"); // can capture g5 diagonally + placePiece(engine, "pawn", "black", "a5"); + placePiece(engine, "pawn", "black", "g5"); + // Give black a reserve piece so the zero-pieces terminal doesn't + // fire after the first capture. + placePiece(engine, "rook", "black", "h8"); + setTurn(engine, "white"); + + engine.setActivePresets([ + { id: "double-move", scope: "both", turnsRemaining: null }, + { id: "suicide-chess", scope: "both", turnsRemaining: null }, + ]); + + // First capture: rook takes a5. + let moves = engine.getAllLegalMoves(); + const a5 = algebraicToSquare("a5"); + const firstCapture = moves.find((m) => m.to === a5 && m.isCapture === true); + expect(firstCapture).toBeDefined(); + engine.applyMove(firstCapture!); + + // It's STILL white's turn (double-move vetoed the flip). + expect(engine.getCurrentTurn()).toBe("white"); + + // Compulsion still applies: the bishop's capture on g5 is + // available, so the next legal set must again be captures only. + moves = engine.getAllLegalMoves(); + expect(moves.every((m) => m.isCapture === true)).toBe(true); + const g5 = algebraicToSquare("g5"); + const secondCapture = moves.find((m) => m.to === g5 && m.isCapture === true); + expect(secondCapture).toBeDefined(); + }); + + it("cannot be activated alongside capture-to-win (incompatibility)", () => { + // `ActivePresetSet.replaceAll` validates incompatibilities at + // config time. Stacking suicide-chess + capture-to-win must + // throw because both claim `onCheckGameResult`. + const set = new ActivePresetSet(); + expect(() => + set.replaceAll([ + { id: "suicide-chess", scope: "both", turnsRemaining: null }, + { id: "capture-to-win", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(); + }); +}); diff --git a/packages/chess/src/presets/suicide-chess.ts b/packages/chess/src/presets/suicide-chess.ts new file mode 100644 index 0000000..090c827 --- /dev/null +++ b/packages/chess/src/presets/suicide-chess.ts @@ -0,0 +1,275 @@ +/** + * Preset: `suicide-chess` (aka "losing chess" / "antichess", + * RULES.md rule-variants epic Phase D.1) + * + * A complete inversion of standard chess objectives: LOSE all your + * pieces to WIN. Captures are compulsory — if any capture is legal, + * the mover MUST capture. Kings are NOT royal: they can be moved into + * "attacked" squares, captured like any other piece, and there is no + * such thing as check, checkmate, or stalemate in the normal sense. + * + * Terminal conditions (first match wins, in order): + * 1. ZERO pieces: the side whose pieces have all been captured WINS. + * (Inverted: emptying your own army is the goal. Bizarre but + * internally consistent — the compulsory-capture rule forces + * opponents to keep eating you whether they like it or not.) + * 2. Stalemate-win: the side to move has NO legal moves → that side + * WINS. This matches FICS / lichess antichess rules. (Without + * this rule the compulsory-capture rule would occasionally + * deadlock on positions where neither side can capture and the + * side to move has no other legal moves — by awarding the win to + * the side that's "stuck", no-legal-moves becomes the second + * goal-scoring pattern after zero-pieces.) + * + * Hook wiring + * ─────────── + * + * 1. `filterLegalMoves` — COMPULSORY CAPTURE. Given the aggregated + * legal-move list, if ANY move has `isCapture === true`, return + * ONLY the captures. Otherwise pass through unchanged. This + * composes as a subset filter (see `PresetDef.filterLegalMoves` + * contract), so stacking with other post-aggregation filters is + * safe. Note: a non-lethal capture (e.g. HP-absorbed hit with + * `piece-hp` active) still carries `isCapture: true` on the + * `LegalMove` — the compulsion is satisfied by any `isCapture` + * move regardless of whether the target actually dies. + * + * 2. `getRoyalPieces` — returns an EXPLICIT EMPTY ARRAY (not + * undefined) for every color. The engine recognizes this as "no + * royalty for this side" and short-circuits: + * - `isInCheck(session, color, [])` → false (no royal under + * attack because there's no royal) + * - `isCheckmate(session, color, [])` → false + * - `isStalemate(session, color, [])` → !hasAnyLegalMove + * (but our `onCheckGameResult` returns before the engine + * reaches that predicate — see stalemate-wins handling below) + * - `filterSelfCheckMoves(session, moves, color, [])` → all + * moves pass (nothing to protect) + * An empty CONTRIBUTION (not `undefined`) is what signals + * participation in the royal-set computation; the engine unions + * non-`undefined` returns. + * + * 3. `shouldFilterSelfCheck` — returns `false`. Opts out of the + * engine's self-check filter for both colors. Redundant in theory + * given (2) already gives us an empty royal set (which short- + * circuits the filter to a no-op copy), but explicit is cheaper + * than leaving per-move snapshot churn running "just in case" the + * royal-set plumbing is ever changed. Belt + braces. + * + * 4. `onCheckGameResult` — terminal-state oracle. Order of checks: + * a. Count live pieces per color (entities with PieceType, + * Position, and Color). If exactly one side has 0 pieces + * → that side wins ("white-wins" if white has 0, etc.). + * The double-zero case falls through to (c) — in practice + * unreachable via normal captures because capturing is a + * single action per move, so the move-that-empties-white + * happens before any move-that-empties-black could. + * b. Stalemate-wins: compute the side-to-move's legal moves + * via `engine.getAllLegalMoves()`. If 0, the SIDE-TO-MOVE + * WINS (not the opponent). We use `getAllLegalMoves` + * directly because it already runs our `filterLegalMoves` + * hook AND the empty-royal self-check short-circuit — so + * "no legal moves" here means "really zero, including + * under compulsory-capture". RECURSION CHECK: the engine's + * `getAllLegalMoves` does NOT call `checkGameResult` + * (verified in engine.ts: lines 903-1079 have no such + * invocation), so no mutual recursion is possible. If that + * ever changes, swap to calling `rules/stalemate.ts`'s + * `isStalemate(session, color, [])` and handling the + * compulsory-capture leg manually. + * c. Else → return `"ongoing"`. This is a deliberate signal to + * the engine's `checkGameResult` (see two-phase protocol in + * `PresetDef.onCheckGameResult` docs): "SUPPRESS the default + * checkmate/stalemate/draw predicates". Without this + * suppression, `isStalemate(session, nextColor, [])` would + * fire on every move (empty-royal semantics make the "not in + * check" precondition trivially true) and could produce + * unintended "stalemate" verdicts whenever the side to move + * had only captures to pick from. We own the terminal state + * space entirely. + * + * Stalemate-wins rule (IMPLEMENTED) + * ───────────────────────────────── + * Most modern antichess rulesets (FICS, lichess) award the win to the + * side that cannot move. Without this the compulsory-capture rule + * produces occasional awkward "neither side can capture, side-to-move + * has no other moves" deadlocks — the stalemate-wins rule converts + * those into unambiguous wins for the stuck side. Less common + * variants (some postal correspondence rulesets) treat stalemate as a + * draw instead; a future preset could surface that as a config knob, + * but we ship the lichess rule because it's the widely-understood + * default. + * + * Incompatibilities + * ───────────────── + * Every preset that redefines "when is the game over" is declared + * incompatible because `onCheckGameResult` is called in registration + * order and the first terminal return wins — stacking two terminal + * definers is racy and surprising. Royalty-redefining presets are + * also incompatible because our empty royal-set contribution would + * union with theirs into a non-empty set, breaking the "no royalty" + * invariant the whole variant hinges on. + * + * Compatible with (tested): + * - `piece-hp`: HP-absorbed captures still carry `isCapture: true`, + * so compulsion fires. Piece-hp's `onCheckGameResult` declares + * winners based on king-presence, which is moot here (no kings + * are royal) — but the two hooks' return-values don't fight + * because zero-pieces happens before the king-count path + * matters. + * - `double-move`: the compulsory-capture rule applies INDEPENDENTLY + * on each half-move, so white plays two captures in a row if two + * are available; double-move has no `onCheckGameResult` hook so + * terminal detection stays with us. Fully composes. + */ +import { PRESET_REGISTRY } from "./registry.js"; +import type { ChessEngine, GameResult } from "../engine.js"; + +PRESET_REGISTRY.register({ + id: "suicide-chess", + name: "Suicide Chess (Antichess)", + description: + "Captures are compulsory; lose all your pieces to win. Kings are not royal — no check, no checkmate. If you have no legal moves, you win (stalemate-wins rule).", + incompatibleWith: [ + // Every other preset that owns `onCheckGameResult` — stacking + // two terminal oracles is a race condition. + "capture-to-win", + "last-piece-standing", + "extinction-chess", + "capture-all", + "first-promotion-wins", + // Turn-flip redefiners that break the per-half-move compulsory- + // capture semantics (monster wants asymmetric flip, which + // doesn't interact cleanly with "this side must capture"). + // double-move DOES compose — left off this list deliberately. + "monster-rules", + // Royalty redefiners — would union non-empty royal sets with our + // empty contribution, resurrecting check/mate detection we've + // deliberately disabled. + "knightmate-rules", + "coregal", + "dual-king", + "weak-dual-king", + ], + requires: [], + + /** + * Compulsory capture: if any move in the aggregated list is a + * capture, drop everything else. Otherwise pass through. Matches + * the FICS / lichess antichess compulsion rule: the mover has NO + * CHOICE when a capture is available. + */ + filterLegalMoves({ moves }) { + const anyCapture = moves.some((m) => m.isCapture === true); + if (!anyCapture) return moves; + return moves.filter((m) => m.isCapture === true); + }, + + /** + * Contribute an explicit EMPTY royal set for every color. The + * engine unions non-undefined contributions, so an empty array + * here (not `undefined`) participates in the royal-set + * computation and — assuming no other royalty-redefining preset + * stacks (we block that via `incompatibleWith`) — yields an empty + * final set. Empty → no check/mate/stalemate via the default + * predicates. + */ + getRoyalPieces() { + return []; + }, + + /** + * Opt out of the engine's self-check filter. Redundant given the + * empty-royal contribution above (the filter short-circuits to a + * no-op for empty royal sets), but explicit is cheaper than + * relying on the downstream short-circuit holding across future + * refactors. Also documents the intent clearly at the hook surface. + */ + shouldFilterSelfCheck() { + return false; + }, + + /** + * Terminal-state oracle. See docblock at top of file for the + * full protocol — in short: zero-pieces wins, then stalemate-wins, + * else suppress defaults by returning "ongoing". + */ + onCheckGameResult({ engine }): GameResult | undefined { + // 1. Zero-pieces terminal. + const counts = countLivePieces(engine); + const whiteZero = counts.white === 0; + const blackZero = counts.black === 0; + if (whiteZero && !blackZero) return "white-wins"; + if (blackZero && !whiteZero) return "black-wins"; + // Double-zero (unreachable via normal captures but guarded for + // sanity): fall through to stalemate-wins, which will return + // whichever side's turn it is — that side technically has zero + // moves (empty board) so wins by the stalemate rule. + + // 2. Stalemate-wins: side-to-move with zero legal moves wins. + // + // Safe to call getAllLegalMoves here — see recursion note in the + // docblock. The call internally runs OUR filterLegalMoves hook + // (so "no legal moves" correctly accounts for compulsory-capture + // semantics) and respects the empty-royal set we contribute. + const side = engine.getCurrentTurn(); + const legal = engine.getAllLegalMoves(); + if (legal.length === 0) { + return side === "white" ? "white-wins" : "black-wins"; + } + + // 3. Non-terminal: suppress default checkmate/stalemate/draw + // predicates. With empty royal sets, `isCheckmate` always + // returns false, but `isStalemate` could fire on any "no + // legal moves" state and return "stalemate" — we want our + // stalemate-wins rule to own that outcome, not the engine + // default. Returning "ongoing" is the two-phase-protocol + // signal for "suppress defaults but don't declare terminal". + return "ongoing"; + }, + + // No onActivate / onDeactivate needed — preset-state is unused + // here (all state derives directly from session facts), and the + // engine's default clearPresetState on deactivate is a no-op for + // presets with no state bag. +}); + +/** + * Count ALIVE pieces of each color. A piece is alive iff it has all + * three core identity facts: PieceType, Position, and Color. The + * default capture path retracts Position alongside the other piece- + * level attrs (see `engine.dealDamage`'s default branch), so a + * captured piece is excluded from the count cleanly without special- + * casing. + * + * Walks `allFacts()` in a single pass collecting per-entity flags, + * then does a second pass to tally — avoids O(N²) find-by-id lookups + * that would appear if we folded per-color counting into the first + * pass directly. + */ +function countLivePieces(engine: ChessEngine): { white: number; black: number } { + const hasType = new Set(); + const hasPosition = new Set(); + const colorById = new Map(); + + for (const f of engine.session.allFacts()) { + if ((f.id as number) <= 0) continue; + if (f.attr === "PieceType") { + hasType.add(f.id as number); + } else if (f.attr === "Position") { + hasPosition.add(f.id as number); + } else if (f.attr === "Color") { + colorById.set(f.id as number, f.value as string); + } + } + + let white = 0; + let black = 0; + for (const id of hasType) { + if (!hasPosition.has(id)) continue; + const c = colorById.get(id); + if (c === "white") white += 1; + else if (c === "black") black += 1; + } + return { white, black }; +}