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:
parent
d456e87a04
commit
8efdd8a4e7
2 changed files with 715 additions and 0 deletions
440
packages/chess/src/presets/suicide-chess.test.ts
Normal file
440
packages/chess/src/presets/suicide-chess.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
275
packages/chess/src/presets/suicide-chess.ts
Normal file
275
packages/chess/src/presets/suicide-chess.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue