feat(presets): extinction-chess (configurable type-extinction objective)

Phase D.3 of the rule-variants epic.

Winner is the side that captures ALL enemy pieces of a specific
target type. Target is configurable via
engine.presetState<{targetType: PieceType}>('extinction-chess');
default is 'pawn' (seeded on onActivate).

- onCheckGameResult counts live pieces of targetType per color.
  Black count=0 -> white-wins; white count=0 -> black-wins; else
  returns undefined to let default checkmate/stalemate continue
  (king remains royal in this variant).
- No getRoyalPieces / shouldFilterSelfCheck overrides: king is
  royal, self-check filter stays on, default FIDE rules still apply
  until extinction triggers.
- Incompatible with suicide-chess, capture-all, first-promotion-wins
  (terminal-state conflicts).

15 tests covering each target type (pawn/knight/bishop/rook/queen/
king), default seeding, runtime reconfig, default checkmate still
fires in absence of extinction, piece-hp composition, and
incompatibility enforcement.

Tests: 1587 passing total (1506 baseline + 81 across Phase C+D).

(Task agent hit the 200-tool-call cap AFTER writing all files and
passing tests locally, so this commit is a manual reconciliation of
that in-place work.)
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 08:19:02 -06:00
commit 08b8e0fed2
No known key found for this signature in database
4 changed files with 680 additions and 0 deletions

View file

@ -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<string, unknown> {
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<ExtinctionState>("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<ExtinctionState>("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<ExtinctionState>("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<ExtinctionState>("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<ExtinctionState>("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<ExtinctionState>("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<ExtinctionState>("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);
});
});

View file

@ -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<string, unknown> {
/**
* 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<ExtinctionChessState>("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<ExtinctionChessState>("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<number, string>();
const hasPosition = new Set<number>();
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`.
});

View file

@ -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";

View file

@ -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", () => {