feat(presets): suicide-chess (lose all pieces to win)
Antichess/losing-chess preset: captures are compulsory, kings are not royal, and the side that first empties its army WINS. Implements the FICS/lichess stalemate-wins rule (side-to-move with zero legal moves wins). Composes with piece-hp (non-lethal captures still satisfy compulsion) and double-move (two captures in a row when available). Declared incompatible with every other terminal-state and royalty-redefining preset.
This commit is contained in:
parent
d456e87a04
commit
8efdd8a4e7
2 changed files with 715 additions and 0 deletions
440
packages/chess/src/presets/suicide-chess.test.ts
Normal file
440
packages/chess/src/presets/suicide-chess.test.ts
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
/**
|
||||||
|
* Tests for the `suicide-chess` preset (Phase D.1 of the rule-variants
|
||||||
|
* epic).
|
||||||
|
*
|
||||||
|
* Ruleset under test (see `./suicide-chess.ts` docblock):
|
||||||
|
* - Compulsory capture — if any capture is legal, only captures are
|
||||||
|
* legal.
|
||||||
|
* - Kings are NOT royal — no check, no checkmate. Moves that would
|
||||||
|
* expose the king are legal; the king can even be captured.
|
||||||
|
* - Zero-pieces WINS — reducing your own piece count to 0 hands you
|
||||||
|
* the victory.
|
||||||
|
* - Stalemate wins the side to move (FICS / lichess rule).
|
||||||
|
*
|
||||||
|
* Test strategy: each scenario constructs a minimal board via
|
||||||
|
* `placePiece` + `clearBoard({ preserveKings: false })`, activates the
|
||||||
|
* preset with `scope: "both"` permanent, and probes the relevant
|
||||||
|
* engine surface. We avoid driving the whole starting position
|
||||||
|
* through applyMove (too slow, noise from unrelated preset wiring) —
|
||||||
|
* focused positions let each test assert exactly the invariant in
|
||||||
|
* scope.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import "./index.js";
|
||||||
|
import { ChessEngine } from "../engine.js";
|
||||||
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
|
import { isInCheck } from "../rules/check.js";
|
||||||
|
import { clearBoard, placePiece } from "./test-utils.js";
|
||||||
|
import { algebraicToSquare } from "../coord.js";
|
||||||
|
import { GAME_ENTITY } from "../schema.js";
|
||||||
|
import { ActivePresetSet } from "./active-set.js";
|
||||||
|
import type { PieceColor } from "../schema.js";
|
||||||
|
|
||||||
|
/** Quick helper: activate suicide-chess on the engine at scope=both. */
|
||||||
|
function activate(engine: ChessEngine): void {
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: "suicide-chess", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force the side to move. clearBoard doesn't touch the Turn fact. */
|
||||||
|
function setTurn(engine: ChessEngine, color: PieceColor): void {
|
||||||
|
engine.session.insert(GAME_ENTITY, "Turn", color);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("suicide-chess — registration + activation", () => {
|
||||||
|
it("is registered with the expected id, name, and incompatibility set", () => {
|
||||||
|
const def = PRESET_REGISTRY.get("suicide-chess");
|
||||||
|
expect(def).toBeDefined();
|
||||||
|
expect(def?.name).toMatch(/suicide/i);
|
||||||
|
// Every terminal-state preset is declared incompatible (first
|
||||||
|
// terminal return wins, stacking them would be a race).
|
||||||
|
for (const id of [
|
||||||
|
"capture-to-win",
|
||||||
|
"last-piece-standing",
|
||||||
|
"first-promotion-wins",
|
||||||
|
"capture-all",
|
||||||
|
]) {
|
||||||
|
expect(def?.incompatibleWith).toContain(id);
|
||||||
|
}
|
||||||
|
// Royalty redefiners are also blocked (our empty-royal contribution
|
||||||
|
// would otherwise union with theirs into a non-empty set).
|
||||||
|
for (const id of ["knightmate-rules", "coregal", "dual-king", "weak-dual-king"]) {
|
||||||
|
expect(def?.incompatibleWith).toContain(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activation on a fresh engine succeeds without throwing", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
expect(() => activate(engine)).not.toThrow();
|
||||||
|
expect(engine.activePresets.has("suicide-chess")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suicide-chess — no royalty (getRoyalPieces returns [])", () => {
|
||||||
|
it("empty royal set short-circuits isInCheck even when the king is attacked", () => {
|
||||||
|
// Baseline position: white king on e1 attacked by black rook on
|
||||||
|
// e8 along the open file. Without the preset this is "check";
|
||||||
|
// with it, suicide-chess contributes [] for both colors, so
|
||||||
|
// isInCheck(..., []) is false.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "king", "white", "e1");
|
||||||
|
placePiece(engine, "king", "black", "h8");
|
||||||
|
placePiece(engine, "rook", "black", "e8");
|
||||||
|
|
||||||
|
// Sanity: default rules say white is in check.
|
||||||
|
expect(isInCheck(engine.session, "white")).toBe(true);
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
// With the preset: isInCheck threaded with the empty royal set
|
||||||
|
// (what the engine resolves internally) returns false — the
|
||||||
|
// canonical "no royalty" probe documented in the task spec.
|
||||||
|
expect(isInCheck(engine.session, "white", [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminal-state query does NOT report checkmate under a would-be-mate position", () => {
|
||||||
|
// Back-rank mate setup: white king g1, own pawns blocking f2/g2/h2,
|
||||||
|
// black rook a1 sweeping rank 1. Default rules = "checkmate".
|
||||||
|
// Under suicide-chess this must be some non-checkmate result
|
||||||
|
// (either "ongoing" or one of the win variants — never
|
||||||
|
// "checkmate", "stalemate", or a draw).
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "king", "white", "g1");
|
||||||
|
placePiece(engine, "pawn", "white", "f2");
|
||||||
|
placePiece(engine, "pawn", "white", "g2");
|
||||||
|
placePiece(engine, "pawn", "white", "h2");
|
||||||
|
placePiece(engine, "king", "black", "h8");
|
||||||
|
placePiece(engine, "rook", "black", "a1");
|
||||||
|
|
||||||
|
// Baseline: checkmate on white.
|
||||||
|
expect(engine.checkGameResult()).toBe("checkmate");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
// No more "checkmate" / "stalemate" / default-draw verdicts.
|
||||||
|
const result = engine.checkGameResult();
|
||||||
|
expect(result).not.toBe("checkmate");
|
||||||
|
expect(result).not.toBe("stalemate");
|
||||||
|
expect(result).not.toBe("draw-50");
|
||||||
|
expect(result).not.toBe("draw-3fold");
|
||||||
|
expect(result).not.toBe("draw-insufficient");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("king can legally move onto an attacked square (self-check filter is off)", () => {
|
||||||
|
// White king on e1, black rook on d8 attacks d-file. Moving the
|
||||||
|
// king to d1 would be illegal under default rules (self-check)
|
||||||
|
// but legal under suicide-chess.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
const wk = placePiece(engine, "king", "white", "e1");
|
||||||
|
placePiece(engine, "king", "black", "h8");
|
||||||
|
placePiece(engine, "rook", "black", "d8");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
const d1 = algebraicToSquare("d1");
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
const kingToD1 = moves.find((m) => m.pieceId === wk && m.to === d1);
|
||||||
|
// No captures on the board → compulsory-capture is a no-op →
|
||||||
|
// king-to-d1 stays in the legal list even though d1 is attacked.
|
||||||
|
expect(kingToD1).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suicide-chess — compulsory capture", () => {
|
||||||
|
it("when a capture exists, NON-capture moves are dropped", () => {
|
||||||
|
// White rook on a1 can capture the black pawn on a5 OR slide
|
||||||
|
// elsewhere along rank 1 / a-file. The compulsion must keep only
|
||||||
|
// captures.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "king", "white", "e1");
|
||||||
|
placePiece(engine, "king", "black", "e8");
|
||||||
|
placePiece(engine, "rook", "white", "a1");
|
||||||
|
placePiece(engine, "pawn", "black", "a5");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
expect(moves.length).toBeGreaterThan(0);
|
||||||
|
expect(moves.every((m) => m.isCapture === true)).toBe(true);
|
||||||
|
// The specific capture must be present.
|
||||||
|
const a5 = algebraicToSquare("a5");
|
||||||
|
expect(moves.some((m) => m.to === a5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when NO capture is available, all non-capture moves survive", () => {
|
||||||
|
// Purely quiet position: kings far apart, nothing adjacent.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "king", "white", "e1");
|
||||||
|
placePiece(engine, "king", "black", "e8");
|
||||||
|
placePiece(engine, "rook", "white", "a1");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
expect(moves.length).toBeGreaterThan(0);
|
||||||
|
// Every move is a non-capture.
|
||||||
|
expect(moves.every((m) => m.isCapture !== true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compulsory capture overrides pawn-to-back-rank promotion pushes", () => {
|
||||||
|
// White pawn on a7 has a non-capture push to a8 (promotion) AND
|
||||||
|
// a diagonal capture to b8 (capturing a black rook on b8). Only
|
||||||
|
// the capture should be offered — every non-capture promotion
|
||||||
|
// variant (4 pieces × the quiet push) gets dropped.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "king", "white", "e1");
|
||||||
|
placePiece(engine, "king", "black", "h8");
|
||||||
|
placePiece(engine, "pawn", "white", "a7");
|
||||||
|
placePiece(engine, "rook", "black", "b8");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
// All surviving moves must be captures (because a capture IS
|
||||||
|
// available: pawn takes rook on b8).
|
||||||
|
expect(moves.length).toBeGreaterThan(0);
|
||||||
|
expect(moves.every((m) => m.isCapture === true)).toBe(true);
|
||||||
|
// Specifically: no quiet push to a8.
|
||||||
|
const a8 = algebraicToSquare("a8");
|
||||||
|
expect(moves.some((m) => m.to === a8 && m.isCapture !== true)).toBe(false);
|
||||||
|
// And the capture-to-b8 promotion move IS present.
|
||||||
|
const b8 = algebraicToSquare("b8");
|
||||||
|
expect(moves.some((m) => m.to === b8 && m.isCapture === true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pawn promotion is still legal when no capture is available", () => {
|
||||||
|
// Pawn on a7 with no enemy adjacent — capture list is empty →
|
||||||
|
// compulsion is a no-op → all four promotion variants survive.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "king", "white", "e1");
|
||||||
|
placePiece(engine, "king", "black", "h8");
|
||||||
|
placePiece(engine, "pawn", "white", "a7");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
const a8 = algebraicToSquare("a8");
|
||||||
|
const promotions = moves.filter((m) => m.to === a8);
|
||||||
|
// Four distinct promoteTo values: queen, rook, bishop, knight.
|
||||||
|
expect(promotions.length).toBe(4);
|
||||||
|
const promoteTargets = new Set(promotions.map((m) => m.promoteTo));
|
||||||
|
expect(promoteTargets).toEqual(new Set(["queen", "rook", "bishop", "knight"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suicide-chess — zero-pieces wins", () => {
|
||||||
|
it("black has 0 pieces after capture → BLACK wins (lost all pieces)", () => {
|
||||||
|
// Setup: white to move, white rook on a1, black has exactly ONE
|
||||||
|
// piece (a knight on a8). White's rook captures the knight. Post-
|
||||||
|
// capture: black has zero pieces, white has > 0 → black wins.
|
||||||
|
//
|
||||||
|
// Note: `placePiece` doesn't create royal kings here because
|
||||||
|
// kings are irrelevant in this variant; but our preset counts
|
||||||
|
// pieces regardless of type, so any PieceType fact counts.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "rook", "white", "a1");
|
||||||
|
placePiece(engine, "knight", "black", "a8");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
// Before: both sides have pieces, no terminal.
|
||||||
|
expect(engine.checkGameResult()).toBe("ongoing");
|
||||||
|
|
||||||
|
// Execute the capture. Compulsion ensures this IS the only legal
|
||||||
|
// move (rook on empty file with a capture at the end).
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
expect(moves.length).toBeGreaterThan(0);
|
||||||
|
const a8 = algebraicToSquare("a8");
|
||||||
|
const capture = moves.find((m) => m.to === a8 && m.isCapture === true);
|
||||||
|
expect(capture).toBeDefined();
|
||||||
|
const result = engine.applyMove(capture!);
|
||||||
|
|
||||||
|
// Black now has 0 pieces (knight was their only one) → BLACK wins.
|
||||||
|
expect(result).toBe("black-wins");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("white has 0 pieces after capture → WHITE wins (lost all pieces)", () => {
|
||||||
|
// Same setup, mirrored: white has a single piece (rook on a1),
|
||||||
|
// black has a rook on a8, black moves. Black captures white's
|
||||||
|
// rook → white has 0 → white wins.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "rook", "white", "a1");
|
||||||
|
placePiece(engine, "rook", "black", "a8");
|
||||||
|
setTurn(engine, "black");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
expect(engine.checkGameResult()).toBe("ongoing");
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
const a1 = algebraicToSquare("a1");
|
||||||
|
const capture = moves.find((m) => m.to === a1 && m.isCapture === true);
|
||||||
|
expect(capture).toBeDefined();
|
||||||
|
const result = engine.applyMove(capture!);
|
||||||
|
|
||||||
|
expect(result).toBe("white-wins");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suicide-chess — stalemate-wins", () => {
|
||||||
|
it("side to move with zero legal moves WINS (FICS / lichess rule)", () => {
|
||||||
|
// Construct a position where white-to-move has NO legal moves:
|
||||||
|
// a single white pawn on a2 with a black piece directly in front
|
||||||
|
// (a3) and no diagonal captures available. With no captures and
|
||||||
|
// no forward push, the pawn can't move. No other pieces for
|
||||||
|
// white.
|
||||||
|
//
|
||||||
|
// Why this is stalemate-wins and not zero-pieces: white still has
|
||||||
|
// 1 piece (the pawn). So the zero-pieces check returns undefined
|
||||||
|
// first. Then the stalemate-wins branch fires: white can't move
|
||||||
|
// → white WINS. (The inverted goal makes being "stuck" a
|
||||||
|
// feature.)
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "pawn", "white", "a2");
|
||||||
|
// Block the pawn's forward push with a black piece, and ensure
|
||||||
|
// no capture is possible on b3 (leave b3 empty). The pawn has
|
||||||
|
// no legal move.
|
||||||
|
placePiece(engine, "pawn", "black", "a3");
|
||||||
|
// Give black a piece far away so black isn't at zero (otherwise
|
||||||
|
// zero-pieces fires first).
|
||||||
|
placePiece(engine, "rook", "black", "h8");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
// Sanity: zero legal moves for white.
|
||||||
|
expect(engine.getAllLegalMoves().length).toBe(0);
|
||||||
|
// Stalemate-wins → white wins.
|
||||||
|
expect(engine.checkGameResult()).toBe("white-wins");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zero-pieces terminal takes precedence over stalemate-wins (zero is checked first)", () => {
|
||||||
|
// White has zero pieces, black has pieces but also happens to
|
||||||
|
// have no moves (imagine every piece is blocked). The zero-
|
||||||
|
// pieces branch must fire FIRST so the verdict is "white wins"
|
||||||
|
// (they emptied their army) rather than "black wins by
|
||||||
|
// stalemate". Order in onCheckGameResult matters.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
// White: no pieces at all.
|
||||||
|
// Black: one pawn on a7 blocked by itself (a6 empty, can push) —
|
||||||
|
// actually let's give black a piece that definitely CAN move so
|
||||||
|
// we're solely testing the "white has 0" branch, not the
|
||||||
|
// stalemate interaction.
|
||||||
|
placePiece(engine, "rook", "black", "h8");
|
||||||
|
setTurn(engine, "black");
|
||||||
|
|
||||||
|
activate(engine);
|
||||||
|
|
||||||
|
expect(engine.checkGameResult()).toBe("white-wins");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suicide-chess — composition with other presets", () => {
|
||||||
|
it("composes with piece-hp: a non-lethal capture still satisfies compulsion", () => {
|
||||||
|
// With `piece-hp` active, a capture deals 1 HP and typically
|
||||||
|
// doesn't kill (HP = 2 by default). The `LegalMove.isCapture`
|
||||||
|
// flag is set at move-generation time independent of whether
|
||||||
|
// the hit will be lethal — so our compulsory-capture filter
|
||||||
|
// still fires, and the mover is forced into the "poke" even
|
||||||
|
// though the target survives.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "rook", "white", "a1");
|
||||||
|
placePiece(engine, "pawn", "black", "a5");
|
||||||
|
// Give white a quiet alternative elsewhere to prove the
|
||||||
|
// compulsion kicks in.
|
||||||
|
placePiece(engine, "knight", "white", "b1");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "suicide-chess", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
// Every legal move must be a capture (the rook-takes-a5 capture
|
||||||
|
// is the only capture on the board).
|
||||||
|
expect(moves.length).toBeGreaterThan(0);
|
||||||
|
expect(moves.every((m) => m.isCapture === true)).toBe(true);
|
||||||
|
// And the knight's quiet moves are gone.
|
||||||
|
const knightMoves = moves.filter((m) => m.pieceId === moves[0]!.pieceId)
|
||||||
|
.concat();
|
||||||
|
// Simpler: just assert no non-captures remain in the aggregate.
|
||||||
|
expect(moves.filter((m) => m.isCapture !== true)).toHaveLength(0);
|
||||||
|
// (knightMoves used only to document the shape; we don't assert
|
||||||
|
// anything further on it.)
|
||||||
|
void knightMoves;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes with double-move: two captures in a row when available", () => {
|
||||||
|
// Stack double-move + suicide-chess. White has two capture
|
||||||
|
// targets. After white's FIRST capture (compulsory), the
|
||||||
|
// double-move preset vetoes the turn flip, so white is still
|
||||||
|
// on move. A second capture remains → compulsion forces white
|
||||||
|
// to take it too. Two consecutive white captures.
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
clearBoard(engine, { preserveKings: false });
|
||||||
|
placePiece(engine, "rook", "white", "a1"); // can capture a5
|
||||||
|
placePiece(engine, "bishop", "white", "c1"); // can capture g5 diagonally
|
||||||
|
placePiece(engine, "pawn", "black", "a5");
|
||||||
|
placePiece(engine, "pawn", "black", "g5");
|
||||||
|
// Give black a reserve piece so the zero-pieces terminal doesn't
|
||||||
|
// fire after the first capture.
|
||||||
|
placePiece(engine, "rook", "black", "h8");
|
||||||
|
setTurn(engine, "white");
|
||||||
|
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: "double-move", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "suicide-chess", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First capture: rook takes a5.
|
||||||
|
let moves = engine.getAllLegalMoves();
|
||||||
|
const a5 = algebraicToSquare("a5");
|
||||||
|
const firstCapture = moves.find((m) => m.to === a5 && m.isCapture === true);
|
||||||
|
expect(firstCapture).toBeDefined();
|
||||||
|
engine.applyMove(firstCapture!);
|
||||||
|
|
||||||
|
// It's STILL white's turn (double-move vetoed the flip).
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
|
||||||
|
// Compulsion still applies: the bishop's capture on g5 is
|
||||||
|
// available, so the next legal set must again be captures only.
|
||||||
|
moves = engine.getAllLegalMoves();
|
||||||
|
expect(moves.every((m) => m.isCapture === true)).toBe(true);
|
||||||
|
const g5 = algebraicToSquare("g5");
|
||||||
|
const secondCapture = moves.find((m) => m.to === g5 && m.isCapture === true);
|
||||||
|
expect(secondCapture).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be activated alongside capture-to-win (incompatibility)", () => {
|
||||||
|
// `ActivePresetSet.replaceAll` validates incompatibilities at
|
||||||
|
// config time. Stacking suicide-chess + capture-to-win must
|
||||||
|
// throw because both claim `onCheckGameResult`.
|
||||||
|
const set = new ActivePresetSet();
|
||||||
|
expect(() =>
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "suicide-chess", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
|
||||||
|
]),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
275
packages/chess/src/presets/suicide-chess.ts
Normal file
275
packages/chess/src/presets/suicide-chess.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
/**
|
||||||
|
* Preset: `suicide-chess` (aka "losing chess" / "antichess",
|
||||||
|
* RULES.md rule-variants epic Phase D.1)
|
||||||
|
*
|
||||||
|
* A complete inversion of standard chess objectives: LOSE all your
|
||||||
|
* pieces to WIN. Captures are compulsory — if any capture is legal,
|
||||||
|
* the mover MUST capture. Kings are NOT royal: they can be moved into
|
||||||
|
* "attacked" squares, captured like any other piece, and there is no
|
||||||
|
* such thing as check, checkmate, or stalemate in the normal sense.
|
||||||
|
*
|
||||||
|
* Terminal conditions (first match wins, in order):
|
||||||
|
* 1. ZERO pieces: the side whose pieces have all been captured WINS.
|
||||||
|
* (Inverted: emptying your own army is the goal. Bizarre but
|
||||||
|
* internally consistent — the compulsory-capture rule forces
|
||||||
|
* opponents to keep eating you whether they like it or not.)
|
||||||
|
* 2. Stalemate-win: the side to move has NO legal moves → that side
|
||||||
|
* WINS. This matches FICS / lichess antichess rules. (Without
|
||||||
|
* this rule the compulsory-capture rule would occasionally
|
||||||
|
* deadlock on positions where neither side can capture and the
|
||||||
|
* side to move has no other legal moves — by awarding the win to
|
||||||
|
* the side that's "stuck", no-legal-moves becomes the second
|
||||||
|
* goal-scoring pattern after zero-pieces.)
|
||||||
|
*
|
||||||
|
* Hook wiring
|
||||||
|
* ───────────
|
||||||
|
*
|
||||||
|
* 1. `filterLegalMoves` — COMPULSORY CAPTURE. Given the aggregated
|
||||||
|
* legal-move list, if ANY move has `isCapture === true`, return
|
||||||
|
* ONLY the captures. Otherwise pass through unchanged. This
|
||||||
|
* composes as a subset filter (see `PresetDef.filterLegalMoves`
|
||||||
|
* contract), so stacking with other post-aggregation filters is
|
||||||
|
* safe. Note: a non-lethal capture (e.g. HP-absorbed hit with
|
||||||
|
* `piece-hp` active) still carries `isCapture: true` on the
|
||||||
|
* `LegalMove` — the compulsion is satisfied by any `isCapture`
|
||||||
|
* move regardless of whether the target actually dies.
|
||||||
|
*
|
||||||
|
* 2. `getRoyalPieces` — returns an EXPLICIT EMPTY ARRAY (not
|
||||||
|
* undefined) for every color. The engine recognizes this as "no
|
||||||
|
* royalty for this side" and short-circuits:
|
||||||
|
* - `isInCheck(session, color, [])` → false (no royal under
|
||||||
|
* attack because there's no royal)
|
||||||
|
* - `isCheckmate(session, color, [])` → false
|
||||||
|
* - `isStalemate(session, color, [])` → !hasAnyLegalMove
|
||||||
|
* (but our `onCheckGameResult` returns before the engine
|
||||||
|
* reaches that predicate — see stalemate-wins handling below)
|
||||||
|
* - `filterSelfCheckMoves(session, moves, color, [])` → all
|
||||||
|
* moves pass (nothing to protect)
|
||||||
|
* An empty CONTRIBUTION (not `undefined`) is what signals
|
||||||
|
* participation in the royal-set computation; the engine unions
|
||||||
|
* non-`undefined` returns.
|
||||||
|
*
|
||||||
|
* 3. `shouldFilterSelfCheck` — returns `false`. Opts out of the
|
||||||
|
* engine's self-check filter for both colors. Redundant in theory
|
||||||
|
* given (2) already gives us an empty royal set (which short-
|
||||||
|
* circuits the filter to a no-op copy), but explicit is cheaper
|
||||||
|
* than leaving per-move snapshot churn running "just in case" the
|
||||||
|
* royal-set plumbing is ever changed. Belt + braces.
|
||||||
|
*
|
||||||
|
* 4. `onCheckGameResult` — terminal-state oracle. Order of checks:
|
||||||
|
* a. Count live pieces per color (entities with PieceType,
|
||||||
|
* Position, and Color). If exactly one side has 0 pieces
|
||||||
|
* → that side wins ("white-wins" if white has 0, etc.).
|
||||||
|
* The double-zero case falls through to (c) — in practice
|
||||||
|
* unreachable via normal captures because capturing is a
|
||||||
|
* single action per move, so the move-that-empties-white
|
||||||
|
* happens before any move-that-empties-black could.
|
||||||
|
* b. Stalemate-wins: compute the side-to-move's legal moves
|
||||||
|
* via `engine.getAllLegalMoves()`. If 0, the SIDE-TO-MOVE
|
||||||
|
* WINS (not the opponent). We use `getAllLegalMoves`
|
||||||
|
* directly because it already runs our `filterLegalMoves`
|
||||||
|
* hook AND the empty-royal self-check short-circuit — so
|
||||||
|
* "no legal moves" here means "really zero, including
|
||||||
|
* under compulsory-capture". RECURSION CHECK: the engine's
|
||||||
|
* `getAllLegalMoves` does NOT call `checkGameResult`
|
||||||
|
* (verified in engine.ts: lines 903-1079 have no such
|
||||||
|
* invocation), so no mutual recursion is possible. If that
|
||||||
|
* ever changes, swap to calling `rules/stalemate.ts`'s
|
||||||
|
* `isStalemate(session, color, [])` and handling the
|
||||||
|
* compulsory-capture leg manually.
|
||||||
|
* c. Else → return `"ongoing"`. This is a deliberate signal to
|
||||||
|
* the engine's `checkGameResult` (see two-phase protocol in
|
||||||
|
* `PresetDef.onCheckGameResult` docs): "SUPPRESS the default
|
||||||
|
* checkmate/stalemate/draw predicates". Without this
|
||||||
|
* suppression, `isStalemate(session, nextColor, [])` would
|
||||||
|
* fire on every move (empty-royal semantics make the "not in
|
||||||
|
* check" precondition trivially true) and could produce
|
||||||
|
* unintended "stalemate" verdicts whenever the side to move
|
||||||
|
* had only captures to pick from. We own the terminal state
|
||||||
|
* space entirely.
|
||||||
|
*
|
||||||
|
* Stalemate-wins rule (IMPLEMENTED)
|
||||||
|
* ─────────────────────────────────
|
||||||
|
* Most modern antichess rulesets (FICS, lichess) award the win to the
|
||||||
|
* side that cannot move. Without this the compulsory-capture rule
|
||||||
|
* produces occasional awkward "neither side can capture, side-to-move
|
||||||
|
* has no other moves" deadlocks — the stalemate-wins rule converts
|
||||||
|
* those into unambiguous wins for the stuck side. Less common
|
||||||
|
* variants (some postal correspondence rulesets) treat stalemate as a
|
||||||
|
* draw instead; a future preset could surface that as a config knob,
|
||||||
|
* but we ship the lichess rule because it's the widely-understood
|
||||||
|
* default.
|
||||||
|
*
|
||||||
|
* Incompatibilities
|
||||||
|
* ─────────────────
|
||||||
|
* Every preset that redefines "when is the game over" is declared
|
||||||
|
* incompatible because `onCheckGameResult` is called in registration
|
||||||
|
* order and the first terminal return wins — stacking two terminal
|
||||||
|
* definers is racy and surprising. Royalty-redefining presets are
|
||||||
|
* also incompatible because our empty royal-set contribution would
|
||||||
|
* union with theirs into a non-empty set, breaking the "no royalty"
|
||||||
|
* invariant the whole variant hinges on.
|
||||||
|
*
|
||||||
|
* Compatible with (tested):
|
||||||
|
* - `piece-hp`: HP-absorbed captures still carry `isCapture: true`,
|
||||||
|
* so compulsion fires. Piece-hp's `onCheckGameResult` declares
|
||||||
|
* winners based on king-presence, which is moot here (no kings
|
||||||
|
* are royal) — but the two hooks' return-values don't fight
|
||||||
|
* because zero-pieces happens before the king-count path
|
||||||
|
* matters.
|
||||||
|
* - `double-move`: the compulsory-capture rule applies INDEPENDENTLY
|
||||||
|
* on each half-move, so white plays two captures in a row if two
|
||||||
|
* are available; double-move has no `onCheckGameResult` hook so
|
||||||
|
* terminal detection stays with us. Fully composes.
|
||||||
|
*/
|
||||||
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
|
import type { ChessEngine, GameResult } from "../engine.js";
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: "suicide-chess",
|
||||||
|
name: "Suicide Chess (Antichess)",
|
||||||
|
description:
|
||||||
|
"Captures are compulsory; lose all your pieces to win. Kings are not royal — no check, no checkmate. If you have no legal moves, you win (stalemate-wins rule).",
|
||||||
|
incompatibleWith: [
|
||||||
|
// Every other preset that owns `onCheckGameResult` — stacking
|
||||||
|
// two terminal oracles is a race condition.
|
||||||
|
"capture-to-win",
|
||||||
|
"last-piece-standing",
|
||||||
|
"extinction-chess",
|
||||||
|
"capture-all",
|
||||||
|
"first-promotion-wins",
|
||||||
|
// Turn-flip redefiners that break the per-half-move compulsory-
|
||||||
|
// capture semantics (monster wants asymmetric flip, which
|
||||||
|
// doesn't interact cleanly with "this side must capture").
|
||||||
|
// double-move DOES compose — left off this list deliberately.
|
||||||
|
"monster-rules",
|
||||||
|
// Royalty redefiners — would union non-empty royal sets with our
|
||||||
|
// empty contribution, resurrecting check/mate detection we've
|
||||||
|
// deliberately disabled.
|
||||||
|
"knightmate-rules",
|
||||||
|
"coregal",
|
||||||
|
"dual-king",
|
||||||
|
"weak-dual-king",
|
||||||
|
],
|
||||||
|
requires: [],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compulsory capture: if any move in the aggregated list is a
|
||||||
|
* capture, drop everything else. Otherwise pass through. Matches
|
||||||
|
* the FICS / lichess antichess compulsion rule: the mover has NO
|
||||||
|
* CHOICE when a capture is available.
|
||||||
|
*/
|
||||||
|
filterLegalMoves({ moves }) {
|
||||||
|
const anyCapture = moves.some((m) => m.isCapture === true);
|
||||||
|
if (!anyCapture) return moves;
|
||||||
|
return moves.filter((m) => m.isCapture === true);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contribute an explicit EMPTY royal set for every color. The
|
||||||
|
* engine unions non-undefined contributions, so an empty array
|
||||||
|
* here (not `undefined`) participates in the royal-set
|
||||||
|
* computation and — assuming no other royalty-redefining preset
|
||||||
|
* stacks (we block that via `incompatibleWith`) — yields an empty
|
||||||
|
* final set. Empty → no check/mate/stalemate via the default
|
||||||
|
* predicates.
|
||||||
|
*/
|
||||||
|
getRoyalPieces() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt out of the engine's self-check filter. Redundant given the
|
||||||
|
* empty-royal contribution above (the filter short-circuits to a
|
||||||
|
* no-op for empty royal sets), but explicit is cheaper than
|
||||||
|
* relying on the downstream short-circuit holding across future
|
||||||
|
* refactors. Also documents the intent clearly at the hook surface.
|
||||||
|
*/
|
||||||
|
shouldFilterSelfCheck() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal-state oracle. See docblock at top of file for the
|
||||||
|
* full protocol — in short: zero-pieces wins, then stalemate-wins,
|
||||||
|
* else suppress defaults by returning "ongoing".
|
||||||
|
*/
|
||||||
|
onCheckGameResult({ engine }): GameResult | undefined {
|
||||||
|
// 1. Zero-pieces terminal.
|
||||||
|
const counts = countLivePieces(engine);
|
||||||
|
const whiteZero = counts.white === 0;
|
||||||
|
const blackZero = counts.black === 0;
|
||||||
|
if (whiteZero && !blackZero) return "white-wins";
|
||||||
|
if (blackZero && !whiteZero) return "black-wins";
|
||||||
|
// Double-zero (unreachable via normal captures but guarded for
|
||||||
|
// sanity): fall through to stalemate-wins, which will return
|
||||||
|
// whichever side's turn it is — that side technically has zero
|
||||||
|
// moves (empty board) so wins by the stalemate rule.
|
||||||
|
|
||||||
|
// 2. Stalemate-wins: side-to-move with zero legal moves wins.
|
||||||
|
//
|
||||||
|
// Safe to call getAllLegalMoves here — see recursion note in the
|
||||||
|
// docblock. The call internally runs OUR filterLegalMoves hook
|
||||||
|
// (so "no legal moves" correctly accounts for compulsory-capture
|
||||||
|
// semantics) and respects the empty-royal set we contribute.
|
||||||
|
const side = engine.getCurrentTurn();
|
||||||
|
const legal = engine.getAllLegalMoves();
|
||||||
|
if (legal.length === 0) {
|
||||||
|
return side === "white" ? "white-wins" : "black-wins";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Non-terminal: suppress default checkmate/stalemate/draw
|
||||||
|
// predicates. With empty royal sets, `isCheckmate` always
|
||||||
|
// returns false, but `isStalemate` could fire on any "no
|
||||||
|
// legal moves" state and return "stalemate" — we want our
|
||||||
|
// stalemate-wins rule to own that outcome, not the engine
|
||||||
|
// default. Returning "ongoing" is the two-phase-protocol
|
||||||
|
// signal for "suppress defaults but don't declare terminal".
|
||||||
|
return "ongoing";
|
||||||
|
},
|
||||||
|
|
||||||
|
// No onActivate / onDeactivate needed — preset-state is unused
|
||||||
|
// here (all state derives directly from session facts), and the
|
||||||
|
// engine's default clearPresetState on deactivate is a no-op for
|
||||||
|
// presets with no state bag.
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count ALIVE pieces of each color. A piece is alive iff it has all
|
||||||
|
* three core identity facts: PieceType, Position, and Color. The
|
||||||
|
* default capture path retracts Position alongside the other piece-
|
||||||
|
* level attrs (see `engine.dealDamage`'s default branch), so a
|
||||||
|
* captured piece is excluded from the count cleanly without special-
|
||||||
|
* casing.
|
||||||
|
*
|
||||||
|
* Walks `allFacts()` in a single pass collecting per-entity flags,
|
||||||
|
* then does a second pass to tally — avoids O(N²) find-by-id lookups
|
||||||
|
* that would appear if we folded per-color counting into the first
|
||||||
|
* pass directly.
|
||||||
|
*/
|
||||||
|
function countLivePieces(engine: ChessEngine): { white: number; black: number } {
|
||||||
|
const hasType = new Set<number>();
|
||||||
|
const hasPosition = new Set<number>();
|
||||||
|
const colorById = new Map<number, string>();
|
||||||
|
|
||||||
|
for (const f of engine.session.allFacts()) {
|
||||||
|
if ((f.id as number) <= 0) continue;
|
||||||
|
if (f.attr === "PieceType") {
|
||||||
|
hasType.add(f.id as number);
|
||||||
|
} else if (f.attr === "Position") {
|
||||||
|
hasPosition.add(f.id as number);
|
||||||
|
} else if (f.attr === "Color") {
|
||||||
|
colorById.set(f.id as number, f.value as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let white = 0;
|
||||||
|
let black = 0;
|
||||||
|
for (const id of hasType) {
|
||||||
|
if (!hasPosition.has(id)) continue;
|
||||||
|
const c = colorById.get(id);
|
||||||
|
if (c === "white") white += 1;
|
||||||
|
else if (c === "black") black += 1;
|
||||||
|
}
|
||||||
|
return { white, black };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue