feat(presets): capture-all (capture every enemy piece to win)

Phase D.2 of the rule-variants epic. Inverse objective of
suicide-chess: you win by driving the opponent's piece count to 0.
Kings are not royal, captures are NOT compulsory.

Wiring uses three existing hooks:
  - getRoyalPieces → [] per color (no royalty).
  - shouldFilterSelfCheck → false (opt out of the filter; kings can
    legally wander into attacked squares).
  - onCheckGameResult → count live Position facts per color; 0 for
    the opponent wins for the mover. Otherwise returns 'ongoing' to
    suppress the default checkmate/stalemate polls (which are
    ill-defined under empty-royal semantics).

No filterLegalMoves — captures are optional, which is the chief
rule-text difference from suicide-chess.

Incompatible with every other terminal-decider preset
(capture-to-win, last-piece-standing, first-promotion-wins,
suicide-chess, extinction-chess) and with presets that redefine
royalty (knightmate-rules, coregal, dual-king, weak-dual-king).
monster-rules already declares capture-all incompatible on its
side; mirror it here.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 08:08:12 -06:00
commit 5d64ae4c37
No known key found for this signature in database
4 changed files with 479 additions and 0 deletions

View file

@ -0,0 +1,308 @@
/**
* Tests for `capture-all` (Phase D.2, rule-variants epic).
*
* The preset inverts suicide-chess's objective: you win by wiping the
* OPPONENT's pieces rather than your own. Captures are optional.
* These tests exercise:
*
* (a) activation on a fresh engine
* (b) empty-royal semantics isInCheck is false even under
* king-attack
* (c) a king can legally move into an attacked square (self-check
* filter opted out + empty royals)
* (d) captures are NOT compulsory (contrast with suicide-chess):
* both captures and non-captures are legal side-by-side
* (e) white captures the last black piece "white-wins"
* (f) black captures the last white piece "black-wins"
* (g) fresh engine (16 vs 16) "ongoing"
* (h) composition with piece-hp: non-lethal damage does NOT
* decrement the count; only lethal damage progresses
* (i) incompatibility: capture-all + suicide-chess throws
*/
import { describe, it, expect } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { GAME_ENTITY } from "../schema.js";
import { algebraicToSquare } from "../coord.js";
import { isInCheck } from "../rules/check.js";
import { PRESET_REGISTRY } from "./registry.js";
import { clearBoard, placePiece, pieceAt, exists } from "./test-utils.js";
const PRESET = {
id: "capture-all",
scope: "both" as const,
turnsRemaining: null,
};
// ─────────────────────────────────────────────────────────────────────
// (a) Activation
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — activation", () => {
it("(a) activates on a fresh classic-layout engine without error", () => {
const engine = new ChessEngine();
expect(() => engine.setActivePresets([PRESET])).not.toThrow();
expect(PRESET_REGISTRY.get("capture-all")).toBeDefined();
});
});
// ─────────────────────────────────────────────────────────────────────
// (b) Empty-royal semantics: isInCheck(..., []) is false even under
// king-attack
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — empty-royal semantics", () => {
it("(b) isInCheck(..., []) is false even with the king under attack", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
// Black rook directly attacking the white king — classic "check"
// in FIDE; under capture-all, no check applies.
placePiece(engine, "rook", "black", "e2");
engine.setActivePresets([PRESET]);
// With the preset's contributed empty royal set, the engine
// treats every isInCheck(color) as false.
expect(isInCheck(engine.session, "white", [])).toBe(false);
// And checkGameResult reflects that — neither side has 0 pieces
// yet, so we stay "ongoing".
expect(engine.checkGameResult()).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// (c) King legally moves into an attacked square
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — self-check filter opt-out", () => {
it("(c) king legally steps onto a square attacked by an enemy rook", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "a8");
// Black rook on d8 covers the d-file; white king steps onto d1
// → under FIDE that move is illegal (king moves into check).
// Under capture-all, no royals → filter is a no-op → move legal.
placePiece(engine, "rook", "black", "d8");
engine.session.insert(GAME_ENTITY, "Turn", "white");
engine.setActivePresets([PRESET]);
const move = engine.findMove(
algebraicToSquare("e1"),
algebraicToSquare("d1"),
);
expect(move).not.toBeNull();
expect(() => engine.applyMove(move!)).not.toThrow();
// King made it to d1.
expect(pieceAt(engine, "d1")).not.toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────
// (d) Captures not compulsory — both captures and non-captures are
// legal side-by-side
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — captures are optional (inverse of suicide-chess)", () => {
it("(d) position with 1 capture + 1 non-capture → both remain legal", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
// White rook on a1 can capture black pawn on a5, but can ALSO
// shuffle to b1. Both must be legal.
placePiece(engine, "rook", "white", "a1");
placePiece(engine, "pawn", "black", "a5");
engine.session.insert(GAME_ENTITY, "Turn", "white");
engine.setActivePresets([PRESET]);
const allMoves = engine.getAllLegalMoves();
const rookMoves = allMoves.filter(
(m) => m.from === algebraicToSquare("a1"),
);
// Capture move present:
const rxa5 = rookMoves.find((m) => m.to === algebraicToSquare("a5"));
expect(rxa5).toBeDefined();
expect(rxa5?.isCapture).toBe(true);
// Non-capture move present (rook → b1 is a simple shuffle):
const rb1 = rookMoves.find((m) => m.to === algebraicToSquare("b1"));
expect(rb1).toBeDefined();
expect(rb1?.isCapture).not.toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────
// (e) White captures last black piece → "white-wins"
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — white victory", () => {
it("(e) white captures the last black piece and wins", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// White has king + rook. Black has ONLY a king (no other pieces).
// White's capture of the black king drops black's count to 0.
placePiece(engine, "king", "white", "e1");
placePiece(engine, "rook", "white", "a8");
placePiece(engine, "king", "black", "h8");
engine.session.insert(GAME_ENTITY, "Turn", "white");
engine.setActivePresets([PRESET]);
const move = engine.findMove(
algebraicToSquare("a8"),
algebraicToSquare("h8"),
);
expect(move).not.toBeNull();
expect(move?.isCapture).toBe(true);
const result = engine.applyMove(move!);
expect(result).toBe("white-wins");
});
});
// ─────────────────────────────────────────────────────────────────────
// (f) Black captures last white piece → "black-wins"
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — black victory", () => {
it("(f) black captures the last white piece and wins", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
// Black has king + rook, white has ONLY a king. Black plays.
placePiece(engine, "king", "black", "e8");
placePiece(engine, "rook", "black", "a1");
placePiece(engine, "king", "white", "h1");
engine.session.insert(GAME_ENTITY, "Turn", "black");
engine.setActivePresets([PRESET]);
const move = engine.findMove(
algebraicToSquare("a1"),
algebraicToSquare("h1"),
);
expect(move).not.toBeNull();
expect(move?.isCapture).toBe(true);
expect(engine.applyMove(move!)).toBe("black-wins");
});
});
// ─────────────────────────────────────────────────────────────────────
// (g) Fresh engine: 16 vs 16 → "ongoing"
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — fresh engine", () => {
it("(g) default classic layout = 16 pieces per side → ongoing", () => {
const engine = new ChessEngine();
engine.setActivePresets([PRESET]);
expect(engine.checkGameResult()).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// (h) Composition with piece-hp: non-lethal damage ≠ count decrement
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — composition with piece-hp", () => {
it("(h) non-lethal capture does NOT remove the target; lethal does", () => {
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "e1");
placePiece(engine, "king", "black", "e8");
// White rook on a1 vs a lone black pawn on a5. Under
// capture-all alone, one capture ends the game (black count hits
// 0). Under capture-all + piece-hp, the pawn has 2 HP — the
// first "capture" deals 1 damage and the pawn survives; white
// must hit again to remove it.
placePiece(engine, "rook", "white", "a1");
placePiece(engine, "pawn", "black", "a5");
engine.session.insert(GAME_ENTITY, "Turn", "white");
// piece-hp first so it seeds Hp=2 on both pieces at activation;
// capture-all's onCheckGameResult runs second but that order is
// irrelevant here — only the final count matters.
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
PRESET,
]);
const blackPawnId = pieceAt(engine, "a5")!;
expect(engine.session.get(blackPawnId, "Hp")).toBe(2);
// First rook-attack on the pawn. In piece-hp semantics the
// attacker does NOT advance on a non-lethal hit; the pawn
// absorbs 1 damage and survives.
const rxa5_first = engine.findMove(
algebraicToSquare("a1"),
algebraicToSquare("a5"),
)!;
const r1 = engine.applyMove(rxa5_first);
// The game must NOT have ended: the pawn is still alive, black
// still has 1 piece (pawn) + 1 king = 2 pieces on the board.
expect(r1).toBe("ongoing");
expect(exists(engine, blackPawnId)).toBe(true);
expect(engine.session.get(blackPawnId, "Hp")).toBe(1);
// Now it's black's turn — shuffle a non-interacting move to
// return the turn to white. The black king on e8 has legal
// moves; move to e7.
const blackShuffle = engine.findMove(
algebraicToSquare("e8"),
algebraicToSquare("e7"),
)!;
engine.applyMove(blackShuffle);
// White attacks the pawn again — this time it should die and
// black's piece count (after king) drops to 1. Game remains
// ongoing because the black king still has a Position fact.
const rxa5_second = engine.findMove(
algebraicToSquare("a1"),
algebraicToSquare("a5"),
)!;
const r2 = engine.applyMove(rxa5_second);
expect(exists(engine, blackPawnId)).toBe(false);
// Black king still alive → game continues.
expect(r2).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// (i) Incompatibility: capture-all + suicide-chess throws
// ─────────────────────────────────────────────────────────────────────
describe("capture-all — incompatibility", () => {
it("(i) activating alongside capture-to-win throws", () => {
// suicide-chess isn't in the registry yet (lands in Phase D.1 / sibling
// task); use a currently-registered preset that capture-all ALSO
// declares incompatible to exercise the same code path. capture-to-win
// is the canonical "other terminal-decider" we conflict with.
const engine = new ChessEngine();
expect(() =>
engine.setActivePresets([
PRESET,
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
]),
).toThrow(/incompatible/i);
});
it("(i2) if suicide-chess is registered, activating with it throws", () => {
const suicide = PRESET_REGISTRY.get("suicide-chess");
// Only run this assertion when suicide-chess has been registered
// (it may land in a sibling task). When absent, we've already
// validated the incompatibility code path above.
if (suicide === undefined) return;
const engine = new ChessEngine();
expect(() =>
engine.setActivePresets([
PRESET,
{ id: "suicide-chess", scope: "both", turnsRemaining: null },
]),
).toThrow(/incompatible/i);
});
it("(i3) activating alongside last-piece-standing throws", () => {
const engine = new ChessEngine();
expect(() =>
engine.setActivePresets([
PRESET,
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
]),
).toThrow(/incompatible/i);
});
});

View file

@ -0,0 +1,167 @@
/**
* Preset: `capture-all` (Phase D.2 of the rule-variants epic).
*
* Capture every enemy piece to win. The inverse objective of
* `suicide-chess`:
*
* - suicide-chess: lose all your pieces FIRST YOU win (compulsory
* capture forces the game forward).
* - capture-all: drive the OPPONENT to zero pieces YOU win. No
* compulsory capture; each side plays normal-looking chess, the
* first to wipe the board of enemies wins.
*
* Kings are NOT royal. Checkmate is undefined in this variant the
* win condition is a piece-count floor, not king-attack. The self-
* check filter is also disabled so a king can legally wander into
* (and sit on) attacked squares; it's just another piece.
*
* Wiring (three hooks, no new engine surface)
*
*
* - `getRoyalPieces({ color })` `[]` empty set per color. The
* engine treats an empty royal set as "no check detection
* applies", so `isInCheck` / `isCheckmate` / `isStalemate` all
* return false unconditionally for either side.
*
* - `shouldFilterSelfCheck({ color })` `false` opt out of the
* engine's default self-check filter. Without this, the engine
* would still screen moves that leave an "empty-royal-set" color
* in check (the filter is a no-op on empty royals, but we skip
* it on principle and to keep the behaviour explicit for future
* reviewers).
*
* - `onCheckGameResult({ engine })` scan `Position` facts to
* count how many live pieces each color has on the board:
* - black piece count === 0 `"white-wins"`
* - white piece count === 0 `"black-wins"`
* - both === 0 (degenerate simultaneous wipeout) `"draw-insufficient"`
* - otherwise `"ongoing"` (suppresses the default
* checkmate/stalemate polls, which would misfire against the
* empty-royal-set predicate e.g. default `isCheckmate` on a
* color with no royals returns false but default `isStalemate`
* can still flag a no-legal-moves position as stalemate, which
* isn't meaningful here).
*
* No `filterLegalMoves` captures are OPTIONAL, unlike suicide-chess.
* A player who prefers to develop rather than capture is free to do
* so; this is the chief rule-text difference between the two
* presets. Implementing compulsory capture here would collapse
* capture-all into suicide-chess with an inverted win condition.
*
* State: none. Everything derives from live `Position` facts each
* time `onCheckGameResult` is polled no preset-state bag needed.
*
* Composition notes
*
*
* - `piece-hp`: non-lethal captures don't decrement the count. Each
* hit deals 1 damage; only when a piece hits 0 HP does it get
* retracted, removing its Position and dropping the count. So
* a piece-hp-composed capture-all game is "deal enough damage to
* wipe every enemy piece" each HP-kill progresses the victory
* counter.
*
* - `double-move` / `monster-rules`: gets you to zero faster.
* Monster-rules in particular is a NATURAL fit (white plays 2
* half-moves per turn + starts with only 5 pieces vs 16 except
* `monster-rules` declares capture-all incompatible, so this
* pairing is explicitly walled off for the moment to avoid dual
* "when does the game end" semantics).
*
* - `piece-hp` + `explosive-rook`: AoE blasts can kill the last
* enemy piece and win the game in one swing. Works out of the
* box because every death reduces `Position` count.
*
* Incompatibility
*
*
* - `suicide-chess` inverted objective + different capture-
* compulsion semantics; stacking both makes the winner
* ambiguous.
* - `capture-to-win`, `last-piece-standing`, `first-promotion-wins`,
* `extinction-chess` all override `onCheckGameResult` with
* their own victory condition. Multiple terminal-returners race
* by registration order, which is fragile; declare incompatible
* so `setActivePresets` rejects the combo up-front.
* - `knightmate-rules`, `coregal`, `dual-king`, `weak-dual-king`
* all contribute a non-empty royal set. Union with our empty set
* produces "some things are royal", contradicting the variant.
* - `monster-rules` monster declares `capture-all` incompatible
* already (see `./monster-rules.ts`); mirror it here for
* symmetry.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { GameResult } from "../engine.js";
PRESET_REGISTRY.register({
id: "capture-all",
name: "Capture All",
description: "Capture every enemy piece to win. Kings are not royal.",
incompatibleWith: [
"suicide-chess",
"capture-to-win",
"last-piece-standing",
"extinction-chess",
"first-promotion-wins",
"knightmate-rules",
"coregal",
"dual-king",
"weak-dual-king",
"monster-rules",
],
requires: [],
/**
* Empty royal set per color. Engine treats this as "no check
* detection applies" in `isInCheck` / `isCheckmate` / `isStalemate`.
*/
getRoyalPieces(): readonly never[] {
return [];
},
/**
* Opt out of the self-check filter. With no royals, the filter is
* a no-op anyway, but the explicit `false` documents the intent
* and guards against a future engine change that would reintroduce
* the filter for empty-royal colors.
*/
shouldFilterSelfCheck(): boolean {
return false;
},
onCheckGameResult({ engine }): GameResult | undefined {
// Count live pieces by color. "Live" = has a Position fact; the
// capture/damage path retracts Position before PieceType, so
// Position is the canonical "still on the board" gate.
const facts = engine.session.allFacts();
const colorById = new Map<number, string>();
for (const f of facts) {
if (f.attr === "Color") colorById.set(f.id as number, f.value as string);
}
let whiteCount = 0;
let blackCount = 0;
for (const f of facts) {
if (f.attr !== "Position") continue;
if ((f.id as number) <= 0) continue;
const color = colorById.get(f.id as number);
if (color === "white") whiteCount++;
else if (color === "black") blackCount++;
}
if (whiteCount === 0 && blackCount === 0) {
// Simultaneous wipeout (AoE preset stack, etc.). Call it a
// draw — neither side achieved the win condition strictly.
return "draw-insufficient";
}
if (blackCount === 0) return "white-wins";
if (whiteCount === 0) return "black-wins";
// Neither side wiped. Return "ongoing" to suppress default
// checkmate/stalemate/draw polls — with empty royals those
// default predicates are ill-defined (isCheckmate trivially
// false, but isStalemate could fire if the mover has no legal
// moves, and we don't want that to end the game here).
return "ongoing";
},
});

View file

@ -31,8 +31,10 @@ import "./knight-immunity.js";
import "./knightmate-rules.js";
import "./coregal.js";
import "./dual-king.js";
import "./weak-dual-king.js";
import "./monster-rules.js";
import "./poisoned-squares.js";
import "./first-promotion-wins.js";
import "./capture-all.js";
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";

View file

@ -44,9 +44,11 @@ describe("Preset registry — all registered", () => {
"knightmate-rules",
"coregal",
"dual-king",
"weak-dual-king",
"monster-rules",
"poisoned-squares",
"first-promotion-wins",
"capture-all",
];
it("registry size matches the expected ID list", () => {