feat(presets): suicide-chess (lose all pieces to win)

Antichess/losing-chess preset: captures are compulsory, kings are
not royal, and the side that first empties its army WINS. Implements
the FICS/lichess stalemate-wins rule (side-to-move with zero legal
moves wins). Composes with piece-hp (non-lethal captures still
satisfy compulsion) and double-move (two captures in a row when
available). Declared incompatible with every other terminal-state
and royalty-redefining preset.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 08:12:21 -06:00
commit 8efdd8a4e7
No known key found for this signature in database
2 changed files with 715 additions and 0 deletions

View file

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

View file

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