diff --git a/packages/chess/src/presets/extinction-chess.test.ts b/packages/chess/src/presets/extinction-chess.test.ts new file mode 100644 index 0000000..b4df007 --- /dev/null +++ b/packages/chess/src/presets/extinction-chess.test.ts @@ -0,0 +1,440 @@ +/** + * Tests for `extinction-chess` (Phase D.3, rule-variants epic). + * + * Covers: + * (a) onActivate seeds default targetType = "pawn". + * (b) Consumer can override targetType at runtime via presetState. + * (c) Default target pawn — wiping all enemy pawns → black-wins. + * (d) Target knight — wiping both enemy knights → win. + * (e) Target bishop — wiping both enemy bishops → win. + * (f) Target rook — wiping both enemy rooks → win. + * (g) Target queen (1 per side) — wiping the queen → win. + * (h) Target king — wiping the king → win. Uses a direct board + * state (king Position retracted) because normally the self- + * check filter prevents king captures without HP composition; + * this test verifies that IF the king is off the board, the + * extinction oracle fires regardless. + * (i) No extinction yet → undefined fallthrough → default + * checkmate/stalemate continues to work (fool's-mate variant). + * (j) King is still royal — ordinary checkmate still ends the game + * even when extinction hasn't happened. + * (k) Composition with piece-hp: non-lethal hit on the last target- + * type piece does NOT decrement count → game stays "ongoing". + * (l) Incompatibility: extinction-chess + suicide-chess throws. + * (m) Registered correctly and exposes the expected metadata. + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import { ChessEngine } from "../engine.js"; +import { GAME_ENTITY } from "../schema.js"; +import type { PieceType } from "../schema.js"; +import { algebraicToSquare } from "../coord.js"; +import { PRESET_REGISTRY } from "./registry.js"; +import { clearBoard, placePiece, pieceAt, exists } from "./test-utils.js"; + +const PRESET = { + id: "extinction-chess", + scope: "both" as const, + turnsRemaining: null, +}; + +interface ExtinctionState extends Record { + targetType: PieceType; +} + +// ───────────────────────────────────────────────────────────────────── +// (a) Default target is "pawn" after activation +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — activation + default target", () => { + it("(a) onActivate seeds targetType = 'pawn'", () => { + const engine = new ChessEngine(); + engine.setActivePresets([PRESET]); + const state = engine.presetState("extinction-chess"); + expect(state.get("targetType")).toBe("pawn"); + }); + + it("(m) registered with expected metadata", () => { + const def = PRESET_REGISTRY.get("extinction-chess"); + expect(def).toBeDefined(); + expect(def?.name).toBe("Extinction"); + expect(def?.description).toMatch(/wipe out.*type/i); + expect(def?.incompatibleWith).toEqual( + expect.arrayContaining([ + "suicide-chess", + "capture-all", + "first-promotion-wins", + ]), + ); + expect(def?.requires).toEqual([]); + // No royal override — king must stay royal. + expect(def?.getRoyalPieces).toBeUndefined(); + // No self-check opt-out — filter stays on. + expect(def?.shouldFilterSelfCheck).toBeUndefined(); + // No compulsory capture. + expect(def?.filterLegalMoves).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (b) Consumer override of target type +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — configurable target type", () => { + it("(b) consumer can change targetType to 'knight' via presetState", () => { + const engine = new ChessEngine(); + engine.setActivePresets([PRESET]); + const state = engine.presetState("extinction-chess"); + state.set("targetType", "knight"); + expect(state.get("targetType")).toBe("knight"); + // A fresh classic layout has 2 knights each — not yet extinct, + // so the result must still be "ongoing". + expect(engine.checkGameResult()).toBe("ongoing"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (c) Default target=pawn: black wipes all white pawns → black-wins +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — target=pawn (default)", () => { + it("(c) black captures the last white pawn → 'black-wins'", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + // Exactly one white pawn, sitting next to a black rook that can + // capture it this turn. After the capture: white has 0 pawns → + // black wins by pawn extinction. + // + // Black must also have ≥1 pawn so the double-zero draw path + // doesn't fire. Put a black pawn on h7 (out of the way). + placePiece(engine, "pawn", "white", "a5"); + placePiece(engine, "pawn", "black", "h7"); + placePiece(engine, "rook", "black", "a8"); + engine.session.insert(GAME_ENTITY, "Turn", "black"); + engine.setActivePresets([PRESET]); + + expect(engine.checkGameResult()).toBe("ongoing"); + + const rxa5 = engine.findMove( + algebraicToSquare("a8"), + algebraicToSquare("a5"), + ); + expect(rxa5).not.toBeNull(); + expect(rxa5?.isCapture).toBe(true); + expect(engine.applyMove(rxa5!)).toBe("black-wins"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (d) Target=knight +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — target=knight", () => { + it("(d) black captures both white knights → 'black-wins'", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + // One white knight remains on b5; a black queen on b8 can take + // it on black's move. Set up board as though the 2nd knight was + // already captured earlier in the game. Black also keeps a + // knight on g8 so the initial count isn't 0-vs-1 (which would + // fire extinction before we got a chance to capture). + placePiece(engine, "knight", "white", "b5"); + placePiece(engine, "knight", "black", "g8"); + placePiece(engine, "queen", "black", "b8"); + engine.session.insert(GAME_ENTITY, "Turn", "black"); + engine.setActivePresets([PRESET]); + engine + .presetState("extinction-chess") + .set("targetType", "knight"); + + expect(engine.checkGameResult()).toBe("ongoing"); + + const qxb5 = engine.findMove( + algebraicToSquare("b8"), + algebraicToSquare("b5"), + ); + expect(qxb5).not.toBeNull(); + expect(engine.applyMove(qxb5!)).toBe("black-wins"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (e) Target=bishop +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — target=bishop", () => { + it("(e) white captures both black bishops → 'white-wins'", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + // Only 1 black bishop left on d5; white rook on d1 can take it. + // White keeps a bishop of its own on f1 so extinction doesn't + // trigger for white on the pre-move count (0 vs 1 would fire). + placePiece(engine, "bishop", "black", "d5"); + placePiece(engine, "bishop", "white", "f1"); + placePiece(engine, "rook", "white", "d1"); + engine.session.insert(GAME_ENTITY, "Turn", "white"); + engine.setActivePresets([PRESET]); + engine + .presetState("extinction-chess") + .set("targetType", "bishop"); + + const rxd5 = engine.findMove( + algebraicToSquare("d1"), + algebraicToSquare("d5"), + ); + expect(rxd5).not.toBeNull(); + expect(engine.applyMove(rxd5!)).toBe("white-wins"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (f) Target=rook +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — target=rook", () => { + it("(f) white captures both black rooks → 'white-wins'", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + // Last black rook on h4; white queen on h1 can slide up the + // h-file and capture. White also has a rook on a1 so the + // pre-move count isn't 0 for white (which would auto-win + // black before we exercised the capture). + placePiece(engine, "rook", "black", "h4"); + placePiece(engine, "rook", "white", "a1"); + placePiece(engine, "queen", "white", "h1"); + engine.session.insert(GAME_ENTITY, "Turn", "white"); + engine.setActivePresets([PRESET]); + engine + .presetState("extinction-chess") + .set("targetType", "rook"); + + const qxh4 = engine.findMove( + algebraicToSquare("h1"), + algebraicToSquare("h4"), + ); + expect(qxh4).not.toBeNull(); + expect(engine.applyMove(qxh4!)).toBe("white-wins"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (g) Target=queen (1 per side) +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — target=queen", () => { + it("(g) black captures white's only queen → 'black-wins'", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + // Black must have a queen too so extinction doesn't pre-fire + // (0-vs-1 would immediately win white on the pre-move count). + placePiece(engine, "queen", "white", "d4"); + placePiece(engine, "queen", "black", "a1"); + placePiece(engine, "rook", "black", "d8"); + engine.session.insert(GAME_ENTITY, "Turn", "black"); + engine.setActivePresets([PRESET]); + engine + .presetState("extinction-chess") + .set("targetType", "queen"); + + const rxd4 = engine.findMove( + algebraicToSquare("d8"), + algebraicToSquare("d4"), + ); + expect(rxd4).not.toBeNull(); + expect(engine.applyMove(rxd4!)).toBe("black-wins"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (h) Target=king: stretch goal. Normally uncapturable under the +// default self-check filter + royal king, so we exercise the +// oracle directly by retracting the king's Position fact. +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — target=king (stretch)", () => { + it("(h) when enemy king has no Position fact, extinction fires", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + // Ensure we have SOME non-royal black material so "losing the + // king" is the distinctive event (rather than a trivial empty + // board that could be read as insufficient-material). + placePiece(engine, "rook", "black", "a8"); + // Insert a black king, then immediately retract its Position to + // simulate the "extinction target was killed" endpoint. We go + // through the session directly because no legal FIDE move can + // capture a royal king under the default self-check filter. + const blackKingId = placePiece(engine, "king", "black", "e8"); + engine.setActivePresets([PRESET]); + engine + .presetState("extinction-chess") + .set("targetType", "king"); + + // With both kings on the board, the result is ongoing. + expect(engine.checkGameResult()).toBe("ongoing"); + + // Remove the black king's Position — it is now "off the board". + engine.session.retract(blackKingId, "Position"); + + // Extinction fires for the surviving side. The king is still + // royal, but our terminal oracle runs BEFORE the default + // checkmate/stalemate predicates, so "white-wins" locks first. + expect(engine.checkGameResult()).toBe("white-wins"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (i) No extinction yet → undefined fallthrough → default defaults run +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — default logic still runs when not extinct", () => { + it("(i) fresh classic position → 'ongoing' (not suppressed)", () => { + const engine = new ChessEngine(); + engine.setActivePresets([PRESET]); + // Fresh board: 8 pawns per side, 2 knights each, etc. — nobody + // is extinct, so `onCheckGameResult` returns undefined and the + // engine's default pathway fires. With no checkmate/stalemate, + // the result is "ongoing". + expect(engine.checkGameResult()).toBe("ongoing"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (j) King still royal — ordinary checkmate ends the game +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — king remains royal, checkmate still works", () => { + it("(j) back-rank mate on black king → 'checkmate', not extinction", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + // Mate black king on a8 via white rook on a1 (file open), black + // pawn wall on b7/c7 prevents king escape. Ensure white also has + // a pawn so pawn-extinction isn't a concern either way. + placePiece(engine, "king", "white", "h1"); + placePiece(engine, "pawn", "white", "h2"); + placePiece(engine, "king", "black", "a8"); + placePiece(engine, "pawn", "black", "a7"); // seals a7 escape + placePiece(engine, "pawn", "black", "b7"); // seals b7 escape + // White rook sitting on b8 attacks the black king along rank 8. + placePiece(engine, "rook", "white", "b8"); + engine.session.insert(GAME_ENTITY, "Turn", "black"); + engine.setActivePresets([PRESET]); + + // Black to move, king on a8, attacked by rook b8. + // Escapes: a7 (blocked, own pawn), b7 (blocked, own pawn). No + // legal captures of the rook (king can't step into b8 because + // nothing defends... actually b8 IS a legal king capture since + // b8 isn't defended). Let's defend b8 too. + // + // Add a white pawn on c7 to defend b8? c7 attacks b8 and d8 — + // yes. But a c7 pawn is pass-through-promotion area; add a white + // knight on d7 that covers b8 instead (d7 is where a knight can + // attack b8 via d7→b8 — knight reach: d7 reaches b6, b8, c5, e5, + // f6, f8). Yes, d7 covers b8. + placePiece(engine, "knight", "white", "d7"); + + // Now kxb8 is illegal (defended), a7/b7 blocked, no blockers or + // captures available. Checkmate. + expect(engine.checkGameResult()).toBe("checkmate"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (k) Composition with piece-hp — non-lethal hit doesn't decrement +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — composition with piece-hp", () => { + it("(k) pokes on last pawn don't win; only the killing hit wins", () => { + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "e8"); + // Exactly one black pawn on a5, white rook on a1 to attack it. + // White also has a pawn tucked away on h2 so the pre-move count + // isn't 0 for white (otherwise black pre-wins by extinction). + placePiece(engine, "pawn", "black", "a5"); + placePiece(engine, "pawn", "white", "h2"); + placePiece(engine, "rook", "white", "a1"); + engine.session.insert(GAME_ENTITY, "Turn", "white"); + engine.setActivePresets([ + { id: "piece-hp", scope: "both", turnsRemaining: null }, + PRESET, + ]); + + const blackPawnId = pieceAt(engine, "a5")!; + expect(engine.session.get(blackPawnId, "Hp")).toBe(2); + + // First hit: deals 1 dmg, pawn survives with 1 HP. PieceType is + // still "pawn" and Position still a5 → count unchanged → not yet + // extinct → "ongoing". + const firstHit = engine.findMove( + algebraicToSquare("a1"), + algebraicToSquare("a5"), + )!; + const r1 = engine.applyMove(firstHit); + expect(r1).toBe("ongoing"); + expect(exists(engine, blackPawnId)).toBe(true); + expect(engine.session.get(blackPawnId, "Hp")).toBe(1); + + // Black shuffles king so it's white's turn again. + const blackShuffle = engine.findMove( + algebraicToSquare("e8"), + algebraicToSquare("e7"), + )!; + engine.applyMove(blackShuffle); + + // Second hit: pawn dies → black has 0 pawns → 'white-wins'. + const killingHit = engine.findMove( + algebraicToSquare("a1"), + algebraicToSquare("a5"), + )!; + expect(engine.applyMove(killingHit)).toBe("white-wins"); + expect(exists(engine, blackPawnId)).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// (l) Incompatibility +// ───────────────────────────────────────────────────────────────────── + +describe("extinction-chess — incompatibility", () => { + it("(l) activating alongside suicide-chess throws", () => { + const engine = new ChessEngine(); + expect(() => + engine.setActivePresets([ + PRESET, + { id: "suicide-chess", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/incompatible/i); + }); + + it("(l2) activating alongside capture-all throws", () => { + const engine = new ChessEngine(); + expect(() => + engine.setActivePresets([ + PRESET, + { id: "capture-all", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/incompatible/i); + }); + + it("(l3) activating alongside first-promotion-wins throws", () => { + const engine = new ChessEngine(); + expect(() => + engine.setActivePresets([ + PRESET, + { id: "first-promotion-wins", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/incompatible/i); + }); +}); diff --git a/packages/chess/src/presets/extinction-chess.ts b/packages/chess/src/presets/extinction-chess.ts new file mode 100644 index 0000000..5874223 --- /dev/null +++ b/packages/chess/src/presets/extinction-chess.ts @@ -0,0 +1,238 @@ +/** + * Preset: `extinction-chess` (Phase D.3 of the rule-variants epic). + * + * Victory condition: wipe out every enemy piece of a specific + * configurable piece type ("target type"). The default target is + * "pawn" — "extinction by pawns" is the canonical flavour ("if your + * opponent runs out of pawns, you win"). Any FIDE PieceType is a + * valid target. + * + * This is the "type-slice" cousin of `capture-all`: + * - `capture-all` → wipe every enemy piece to win (inverted + * suicide, king not royal, no check). + * - `extinction-chess` → wipe every enemy piece of ONE type to + * win. King REMAINS royal; checkmate / + * stalemate / self-check filter all stay + * on. Losing your king still ends the + * game the normal way. + * + * Dual terminal conditions: a game ends the MOMENT either + * (a) a side's target-type count hits 0 (extinction win), OR + * (b) the default FIDE checkmate/stalemate/draw logic fires + * (because we return `undefined` on the non-extinction path, + * NOT `"ongoing"`, which is the preset-state signal that opts + * out of the default predicates). + * + * Configurability + * ─────────────── + * + * The target type lives in preset-scoped state so it's + * - auto-cleared on deactivation (no cross-activation leaks), + * - writable at runtime without touching the board, and + * - serialised with the rest of the engine state. + * + * Consumers set it via: + * + * engine + * .presetState<{ targetType: PieceType }>("extinction-chess") + * .set("targetType", "knight"); + * + * If nothing is set, `onCheckGameResult` falls back to "pawn". The + * `onActivate` hook also seeds "pawn" on first activation so a + * freshly-activated preset has a visible default when the state bag + * is introspected (useful for the UI panel / rule-legend rendering). + * This is idempotent: re-activating doesn't stomp a target the + * consumer already set during the same activation-cycle. + * + * UI wiring: intentionally deferred. Phase F cycling a target type + * via a UI chip is explicitly out of scope for D.3 per the + * rule-variants plan. The API is configuration-driven today; a + * future UI patch can wire a dropdown / radio onto this same + * presetState slot. + * + * Hook wiring + * ─────────── + * + * - `onActivate({ engine })` — seed default targetType="pawn" if + * unset. Idempotent. + * + * - `onCheckGameResult({ engine })` — read the target type, count + * entities per color where PieceType === targetType AND the + * entity still has a live Position fact (the canonical + * "on-the-board" predicate; Position is retracted by the capture/ + * damage path when a piece dies). If black's count is 0 → + * "white-wins"; if white's is 0 → "black-wins"; else + * `undefined` — CRUCIALLY NOT `"ongoing"`. + * + * Why `undefined`, not `"ongoing"`? Two-phase protocol (see + * `PresetDef.onCheckGameResult`): + * - `undefined` = no opinion; run the default checkmate / + * stalemate / draw predicates. + * - `"ongoing"` = SUPPRESS those defaults, don't declare + * terminal, poll continues. + * Extinction-chess does NOT opt out of royalty; the king is still + * royal, checkmate is still a valid way to end the game. We want + * those defaults to fire whenever extinction hasn't happened — + * hence `undefined`, letting the engine fall through to its + * standard terminal-detection path. + * + * - NO `getRoyalPieces` — king stays royal via the FIDE default. + * - NO `shouldFilterSelfCheck` — self-check filter stays on. + * - NO `filterLegalMoves` — captures are optional, no compulsion. + * + * Target-type edge cases + * ────────────────────── + * + * - "pawn" (default, 8 per side in FIDE) — typical variant + * - "knight" (2 per side) — fast + * - "bishop" (2 per side) — fast + * - "rook" (2 per side) — fast + * - "queen" (1 per side; "queen extinction" = capture-the-queen) + * - "king" (1 per side, ROYAL) — a king can only be "captured" + * if you've also disabled the self-check filter, or + * composed with `piece-hp` so the king takes lethal + * damage. Standalone extinction-chess targetType=king + * behaves like "first checkmate wins" in practice + * because the default FIDE flow ends the game before + * the king's Position is ever retracted. + * + * Composition notes + * ───────────────── + * + * - `piece-hp`: non-lethal damage leaves the target piece's + * PieceType + Position intact — the count doesn't drop until HP + * hits 0 and the retract fires. Pokes don't win the game; only + * the killing blow does. + * - `double-move`: more half-moves per turn = faster wipeout. + * Composes cleanly. + * - `piece-hp` + `explosive-rook`: AoE can wipe multiple target- + * type pieces in one swing, potentially ending the game in a + * single move. + * - `knightmate-rules` / `coregal` / `dual-king`: redefine what's + * royal but don't change PieceType counts, so extinction still + * works over any target type. Not declared incompatible — feel + * free to stack. + * + * Incompatibility + * ─────────────── + * + * - `suicide-chess`, `capture-all`, `first-promotion-wins` — each + * owns `onCheckGameResult` with competing terminal semantics. + * Stacking would make the winner depend on registration order, + * which is fragile. Declared incompatible so `setActivePresets` + * rejects the combo up-front. + * + * NOTE: `capture-to-win` and `last-piece-standing` do NOT appear + * here — they already declare `extinction-chess` incompatible + * from their own side, and the incompatibility relation is + * symmetric at the validation layer (see `ActivePresetSet`), so + * duplicating them here would be redundant. Keeping the list + * minimal reduces drift when presets are renamed. + */ +import { PRESET_REGISTRY } from "./registry.js"; +import type { GameResult } from "../engine.js"; +import type { PieceType } from "../schema.js"; + +interface ExtinctionChessState extends Record { + /** + * Which PieceType's zero-count triggers the win. Default "pawn". + * Writable at any time via presetState("extinction-chess"). + */ + targetType: PieceType; +} + +const DEFAULT_TARGET_TYPE: PieceType = "pawn"; + +PRESET_REGISTRY.register({ + id: "extinction-chess", + name: "Extinction", + description: + "Wipe out every enemy piece of a specific type (default: pawns) to win.", + incompatibleWith: [ + "suicide-chess", + "capture-all", + "first-promotion-wins", + ], + requires: [], + + /** + * Seed the default target type on first activation so downstream + * readers (UI, presetState.all()) see a concrete value immediately. + * Idempotent: if the consumer already set `targetType` before + * `setActivePresets` ran (rare but legal — preset-state survives + * across deactivate/reactivate only WITHIN the engine's explicit + * clearPresetState boundary), we leave their value alone. + */ + onActivate({ engine }) { + const state = engine.presetState("extinction-chess"); + if (!state.has("targetType")) { + state.set("targetType", DEFAULT_TARGET_TYPE); + } + }, + + /** + * Extinction terminal-state oracle. Reads target type from preset + * state (fallback "pawn") then counts live target-type pieces per + * color. Returns `undefined` in the non-extinction path so default + * checkmate/stalemate/draw logic still runs — king is royal in + * this variant, and ordinary FIDE victory conditions MUST keep + * working. + */ + onCheckGameResult({ engine }): GameResult | undefined { + const state = engine.presetState("extinction-chess"); + const targetType = state.get("targetType") ?? DEFAULT_TARGET_TYPE; + + // Single pass over facts: for every entity with PieceType === + // targetType, check whether it still has a Position fact (alive + // on the board). Position is the canonical "alive" gate — the + // capture/damage path retracts Position before other attrs. + const facts = engine.session.allFacts(); + const targetPieceIds: number[] = []; + const colorById = new Map(); + const hasPosition = new Set(); + + for (const f of facts) { + const id = f.id as number; + if (id <= 0) continue; + if (f.attr === "PieceType" && f.value === targetType) { + targetPieceIds.push(id); + } else if (f.attr === "Color") { + colorById.set(id, f.value as string); + } else if (f.attr === "Position") { + hasPosition.add(id); + } + } + + let whiteCount = 0; + let blackCount = 0; + for (const id of targetPieceIds) { + if (!hasPosition.has(id)) continue; + const c = colorById.get(id); + if (c === "white") whiteCount += 1; + else if (c === "black") blackCount += 1; + } + + // Both-sides-zero: simultaneous wipeout (AoE with explosive-rook + // + piece-hp, etc). Treat as a draw — neither side strictly + // achieved the objective, and declaring one arbitrary winner + // would be unfair. Matches `capture-all`'s double-zero handling. + if (whiteCount === 0 && blackCount === 0) { + return "draw-insufficient"; + } + if (blackCount === 0) return "white-wins"; + if (whiteCount === 0) return "black-wins"; + + // Non-extinction path: return undefined (NOT "ongoing") so the + // engine runs default checkmate / stalemate / draw predicates. + // The king is still royal in this variant; default victory + // conditions must continue to fire. + return undefined; + }, + + // NO getRoyalPieces — default "every king is royal" stays. + // NO shouldFilterSelfCheck — default self-check filter stays on. + // NO filterLegalMoves — no compulsory capture. + // + // Preset state is auto-cleared on deactivate via the engine's + // clearPresetState wiring in `setActivePresets`. +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index 53a5d30..ba2618c 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -37,5 +37,6 @@ import "./poisoned-squares.js"; import "./first-promotion-wins.js"; import "./capture-all.js"; import "./suicide-chess.js"; +import "./extinction-chess.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 8c13c18..2c3b9aa 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -50,6 +50,7 @@ describe("Preset registry — all registered", () => { "first-promotion-wins", "capture-all", "suicide-chess", + "extinction-chess", ]; it("registry size matches the expected ID list", () => {