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:
parent
8efdd8a4e7
commit
08b8e0fed2
4 changed files with 680 additions and 0 deletions
440
packages/chess/src/presets/extinction-chess.test.ts
Normal file
440
packages/chess/src/presets/extinction-chess.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
238
packages/chess/src/presets/extinction-chess.ts
Normal file
238
packages/chess/src/presets/extinction-chess.ts
Normal 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`.
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue