From 5d64ae4c372da806f5dc7c84b4c002c512f9deca Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 08:08:12 -0600 Subject: [PATCH] feat(presets): capture-all (capture every enemy piece to win) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../chess/src/presets/capture-all.test.ts | 308 ++++++++++++++++++ packages/chess/src/presets/capture-all.ts | 167 ++++++++++ packages/chess/src/presets/index.ts | 2 + packages/chess/src/presets/presets.test.ts | 2 + 4 files changed, 479 insertions(+) create mode 100644 packages/chess/src/presets/capture-all.test.ts create mode 100644 packages/chess/src/presets/capture-all.ts diff --git a/packages/chess/src/presets/capture-all.test.ts b/packages/chess/src/presets/capture-all.test.ts new file mode 100644 index 0000000..13a2f47 --- /dev/null +++ b/packages/chess/src/presets/capture-all.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/presets/capture-all.ts b/packages/chess/src/presets/capture-all.ts new file mode 100644 index 0000000..eaa68ae --- /dev/null +++ b/packages/chess/src/presets/capture-all.ts @@ -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(); + 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"; + }, +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index 0bd2cdd..33f7b31 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -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"; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 51fa49b..55df251 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -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", () => {