feat(chess): flesh out all six remaining stub presets

Implements king-heals, poisoned-squares, capture-to-win,
last-piece-standing, explosive-rook, and queen-splits via the new
preset hook infrastructure. All six were registered but no-op stubs
with "// Full integration in ChessEngine (P3.11)" comments that
never got followed up.

New hooks on PresetDef
~~~~~~~~~~~~~~~~~~~~~~~
- onAfterMove(engine, moverColor)
    Fires after applyMove`s turn switch but before game-result check.
    Scope filtering is preset-side because rules have different
    notions of "who does the hook target" (king-heals targets the
    non-mover; poisoned-squares targets everyone on a poisoned
    square).

- onCheckGameResult(engine) -> GameResult | undefined
    Lets a preset override the engine`s default terminal-position
    logic. First non-undefined return wins in registration order.

GameResult type extended with white-wins / black-wins so variant
rules can name the winner explicitly (checkmate implicitly means
"side to move loses" — insufficient for capture-to-win which can
end mid-move with either side winning).

Preset implementations
~~~~~~~~~~~~~~~~~~~~~~~
- king-heals: onAfterMove heals non-mover`s king +1 HP (max 3) when
  not in check. Requires piece-hp.
- poisoned-squares: onAfterMove damages every piece standing on
  d4/e4/d5/e5. Retracts pieces hitting 0 HP. Requires piece-hp.
  Exports POISONED_SQUARES set for the UI overlay.
- capture-to-win: onBeforeCapture records the capturer`s color on
  the game entity via Winner fact. onCheckGameResult converts it
  into white-wins/black-wins. onDeactivate clears the Winner fact.
- last-piece-standing: onCheckGameResult counts pieces by color.
  Zero on one side -> the other wins. Override returns "ongoing"
  otherwise, suppressing default checkmate.
- explosive-rook: onBeforeCapture intercepts rook captures,
  detonates all pieces within Chebyshev distance 2 on the rank
  and file of the target (diagonals spared), moves the rook onto
  the target square. Does not chain.
- queen-splits: onBeforeCapture intercepts queen captures,
  retracts target and queen, spawns rook on target square and
  bishop on first empty clockwise-from-N neighbour. Bishop is
  forfeit if all 8 neighbours are occupied.

Board.tsx gains a per-SQUARE overlay registry (separate from per-
piece) so poisoned-squares can render a pulsing green tint on the
four central squares. The poisoned-squares.ui.tsx module registers
its overlay at load time via ui-overlays-index.ts.

GameView shows "White wins!"/"Black wins!" for the new result
variants; confetti fires on any decisive result; useChessEngine
and useMultiplayerGame play the checkmate sound on decisive results.

Server-side: GameEndReason extended with "variant-win" so the wire
protocol can signal decisive variant results without conflating
them with checkmate. MoveResult.gameOver exposes the correct
winner (not the mover) for white-wins/black-wins.

Testing
~~~~~~~
New fleshed-presets.test.ts: 13 integration tests covering each
preset`s canonical behaviour (healing cap, denial on check, poison
decrement + kill, first-capture-wins, annihilation override,
detonation AoE, queen fission spawn). 874 tests pass (+13); all 3
E2E specs green.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 16:16:11 -06:00
commit 1e292d6c00
No known key found for this signature in database
18 changed files with 1077 additions and 21 deletions

View file

@ -73,6 +73,12 @@ export type GameResult =
| "draw-50"
| "draw-3fold"
| "draw-insufficient"
// Preset-defined terminal states. The regular checkmate result always
// implicitly means "the side to move loses" (standard chess), but
// variants like capture-to-win or last-piece-standing need to name
// the winner explicitly because the side to move may be the WINNER.
| "white-wins"
| "black-wins"
| "ongoing";
export class ChessEngine {
@ -343,10 +349,30 @@ export class ChessEngine {
def?.onDeactivate?.(this);
}
// Post-move preset hooks: fire against EVERY still-active preset
// (regardless of scope). Scope-aware behaviour is the preset's
// responsibility — king-heals affects the non-mover, poisoned-squares
// affects the mover, so a single engine-level scope filter can't
// serve both.
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
def?.onAfterMove?.(this, color);
}
return this.checkGameResult();
}
checkGameResult(): GameResult {
// Preset override path: run onCheckGameResult on every active preset
// in registration order. First non-undefined return wins. This lets
// capture-to-win and last-piece-standing redefine "game over"
// without touching engine internals.
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
const override = def?.onCheckGameResult?.(this);
if (override !== undefined) return override;
}
const nextColor = this.getCurrentTurn();
if (isCheckmate(this.session, nextColor)) return "checkmate";
if (isStalemate(this.session, nextColor)) return "stalemate";

View file

@ -55,7 +55,11 @@ export function useChessEngine() {
// over the session (not a stored `InCheck` fact), so we call the
// helper directly against the side that just received the move.
const opponentColor = engine.getCurrentTurn();
if (result === 'checkmate') {
const decisive =
result === 'checkmate' ||
result === 'white-wins' ||
result === 'black-wins';
if (decisive) {
audio.play('checkmate');
} else if (isInCheck(engine.session, opponentColor)) {
audio.play('check');

View file

@ -197,7 +197,13 @@ export function useMultiplayerGame(code: string, token: string) {
const moveResult = eng.checkGameResult();
// Local sound playback for the moving player. The opponent's client
// plays its own sound off the `game.delta` event.
if (moveResult === 'checkmate') audio.play('checkmate');
// Decisive results (checkmate AND variant wins) play the
// checkmate sound; ongoing / draws play the normal move sound.
const decisive =
moveResult === 'checkmate' ||
moveResult === 'white-wins' ||
moveResult === 'black-wins';
if (decisive) audio.play('checkmate');
else audio.play('move');
return moveResult;
},

View file

@ -1,10 +1,72 @@
/**
* Preset: `capture-to-win` (First Blood, RULES.md rule #11)
*
* The first player to capture ANY enemy piece wins the game
* immediately. Checkmate is never reached in practice because any
* capture ends the game first.
*
* Wiring:
* - `onBeforeCapture` — record the capturer's color on the game
* entity via a `Winner` fact. Does NOT consume the capture, so the
* engine's default retract-and-move still runs (the target piece
* dies normally, the attacker advances onto its square). This
* keeps the final board state intuitive for players to inspect
* ("why is white's pawn on d5?") rather than freezing the board
* mid-capture.
* - `onCheckGameResult` — if a Winner fact was recorded, convert
* it into the corresponding GameResult. Otherwise return undefined
* and let the default checkmate/stalemate logic run (nothing
* to do until the first capture happens).
*
* Incompatible with `last-piece-standing` — both redefine "when is
* the game over" and would compete for the onCheckGameResult hook.
*/
import { PRESET_REGISTRY } from "./registry.js";
import { GAME_ENTITY, type PieceColor } from "../schema.js";
import type { ChessEngine, GameResult } from "../engine.js";
import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({
id: "capture-to-win",
name: "First Blood",
description: "A player wins immediately upon capturing any enemy piece (first capture wins).",
description:
"The first player to capture any enemy piece wins the game immediately. Makes every piece precious.",
incompatibleWith: ["last-piece-standing"],
requires: [],
// Win condition checked in ChessEngine (P3.11)
onBeforeCapture(engine: ChessEngine, attacker: EntityId, _target: EntityId) {
const session = engine.session;
// Only record the first capture — don't stomp an earlier winner
// if multiple captures somehow occur in the same applyMove
// (shouldn't happen, but defensive).
if (session.contains(GAME_ENTITY, "Winner")) {
const existing = session.get(GAME_ENTITY, "Winner");
if (existing !== null) return; // already set, leave it
}
const colorFact = session
.allFacts()
.find(f => f.id === attacker && f.attr === "Color");
if (!colorFact) return;
session.insert(GAME_ENTITY, "Winner", colorFact.value as PieceColor);
// Don't consume — let the capture resolve normally so the board
// visibly reflects the killing blow.
},
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
const session = engine.session;
if (!session.contains(GAME_ENTITY, "Winner")) return undefined;
const winner = session.get(GAME_ENTITY, "Winner");
if (winner === "white") return "white-wins";
if (winner === "black") return "black-wins";
return undefined; // "draw" or null — let default logic run
},
onDeactivate(engine: ChessEngine) {
// Clear any stored winner when the preset is turned off so the
// game doesn't stay in a won state after the rule is lifted.
const session = engine.session;
if (session.contains(GAME_ENTITY, "Winner")) {
session.retract(GAME_ENTITY, "Winner");
}
},
});

View file

@ -1,10 +1,112 @@
/**
* Preset: `explosive-rook` (Detonating Rook, RULES.md rule #10)
*
* When a rook captures, it detonates on the target square, removing
* every piece (friendly or enemy, excluding the capturing rook itself)
* within Chebyshev distance <= 2 on the same rank or file. Diagonal
* neighbours are NOT affected — detonation propagates along orthogonal
* rays only, matching the rook's own movement.
*
* Implementation via `onBeforeCapture`:
* - If the attacker isn't a rook, do nothing (default capture runs).
* - Otherwise consume the hook, then manually:
* 1. Remove the target piece.
* 2. Remove every piece within distance 2 on the target's rank or
* file (friendly or enemy; rook itself excluded).
* 3. Move the rook onto the target square (so the explosion
* visually "lands" there).
*
* Explosion does NOT chain — if a captured piece happened to be a
* second rook, its detonation does not re-trigger. Keeping the
* mechanic finite.
*
* Incompatible with `piece-hp` — HP's "capture deals 1 damage" and
* explosive-rook's "capture wipes AoE" are contradictory capture
* resolutions.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { Session, EntityId } from "@paratype/rete";
import { fileOf, rankOf, squareOf } from "../coord.js";
import type { Square } from "../schema.js";
const DETONATION_RADIUS = 2;
const PIECE_ATTRS = [
"PieceType",
"Color",
"Position",
"HasMoved",
"Hp",
] as const;
function retractEntity(session: Session, id: EntityId): void {
for (const attr of PIECE_ATTRS) {
if (session.contains(id, attr)) session.retract(id, attr);
}
}
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
const facts = session.allFacts();
for (const f of facts) {
if (f.attr === "Position" && f.value === sq && (f.id as number) > 0) {
return f.id;
}
}
return null;
}
PRESET_REGISTRY.register({
id: "explosive-rook",
name: "Detonating Rook",
description: "When a Rook captures, it also removes all pieces within 2 squares on the same rank and file.",
description:
"When a Rook captures, it detonates: every piece within 2 squares on the same rank or file is removed (friend and foe alike). Diagonals are spared.",
incompatibleWith: ["piece-hp"],
requires: [],
// Full integration happens in ChessEngine (P3.11)
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
const session = engine.session;
const attackerTypeFact = session
.allFacts()
.find(f => f.id === attacker && f.attr === "PieceType");
if (attackerTypeFact?.value !== "rook") return; // default capture runs
const targetPos = session.get(target, "Position") as Square | undefined;
if (targetPos === undefined) return;
const targetFile = fileOf(targetPos);
const targetRank = rankOf(targetPos);
// Collect detonation victims (orthogonal neighbours within radius).
// We include the target itself — it gets removed first. We skip
// the attacker so the rook survives.
const victims = new Set<EntityId>();
victims.add(target);
for (let d = 1; d <= DETONATION_RADIUS; d++) {
for (const [df, dr] of [
[d, 0], [-d, 0], [0, d], [0, -d],
] as const) {
const f = targetFile + df;
const r = targetRank + dr;
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
const sq = squareOf(f, r) as Square;
const id = pieceAtSquare(session, sq);
if (id === null) continue;
if (id === attacker) continue;
victims.add(id);
}
}
// Retract every victim's facts — their pieces are gone.
for (const v of victims) retractEntity(session, v);
// The rook still "captures" by moving onto the target square and
// has HasMoved set. Default engine path is consumed, so we apply
// these mutations ourselves.
session.insert(attacker, "Position", targetPos);
session.insert(attacker, "HasMoved", true);
return { consume: true };
},
});

View file

@ -0,0 +1,398 @@
/**
* Integration tests for presets that were previously stubs:
* - king-heals (onAfterMove)
* - poisoned-squares (onAfterMove)
* - capture-to-win (onBeforeCapture + onCheckGameResult)
* - last-piece-standing (onCheckGameResult)
* - explosive-rook (onBeforeCapture with consume)
* - queen-splits (onBeforeCapture with consume, spawning)
*
* Each block tests one preset's mechanic end-to-end through the
* ChessEngine so we catch any hook-wiring regressions.
*/
import { describe, it, expect } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { algebraicToSquare, squareOf } from "../coord.js";
import type { EntityId } from "@paratype/rete";
import type { Square } from "../schema.js";
function pieceAt(engine: ChessEngine, sq: string): EntityId | null {
const target = algebraicToSquare(sq);
for (const f of engine.session.allFacts()) {
if (f.attr === "Position" && f.value === target) return f.id;
}
return null;
}
function typeOf(engine: ChessEngine, id: EntityId): string | null {
if (!engine.session.contains(id, "PieceType")) return null;
return engine.session.get(id, "PieceType") as string;
}
function hpOf(engine: ChessEngine, id: EntityId): number | null {
if (!engine.session.contains(id, "Hp")) return null;
return engine.session.get(id, "Hp") as number;
}
/** Clear a rank of all pieces. Used to sculpt board positions cheaply. */
function clearRank(engine: ChessEngine, rank: number): void {
const retractIds: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "Position") continue;
if (Math.floor((f.value as number) / 8) !== rank) continue;
retractIds.push(f.id);
}
for (const id of retractIds) {
for (const attr of ["PieceType", "Color", "Position", "HasMoved", "Hp"] as const) {
if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
}
}
}
// ─────────────────────────────────────────────────────────────────────
// king-heals
// ─────────────────────────────────────────────────────────────────────
describe("king-heals", () => {
it("heals the non-mover's king by 1 HP (max 3) per half-move when not in check", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
{ id: "king-heals", scope: "both", turnsRemaining: null },
]);
// Damage black king to 1 HP so we can observe healing.
const blackKing = pieceAt(engine, "e8")!;
engine.session.insert(blackKing, "Hp", 1);
// White plays a neutral move. onAfterMove fires, sees black king
// not in check, heals +1 → HP 2.
engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
expect(hpOf(engine, blackKing)).toBe(2);
// Black plays; white's king wasn't damaged so heal is a no-op
// (already at full 2 — unless we also damage it, but leave it).
// Then white plays again → black king heals to 3 (cap).
engine.applyMove(
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!,
);
expect(hpOf(engine, blackKing)).toBe(3);
// Further heals stop at the cap.
engine.applyMove(
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("h2"), algebraicToSquare("h3"))!,
);
expect(hpOf(engine, blackKing)).toBe(3);
});
it("does not heal a king currently in check", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
{ id: "king-heals", scope: "both", turnsRemaining: null },
]);
// Scholar's-Mate-setup to get black king in check after Qh5.
// 1. e4 e5 2. Bc4 Nc6 3. Qh5 — threatens Qxf7# which is check.
// Actually Qh5 doesn't check; we want something that checks. Use
// 1. e4 d5 2. exd5 3. Qh5+ — now black king is in check.
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
// Damage both kings to 1 so we can observe differential healing.
const whiteKing = pieceAt(engine, "e1")!;
const blackKing = pieceAt(engine, "e8")!;
engine.session.insert(whiteKing, "Hp", 1);
engine.session.insert(blackKing, "Hp", 1);
// White's move triggers heal on BLACK king (non-mover). Black not
// in check → heals to 2.
engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!);
expect(hpOf(engine, blackKing)).toBe(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// poisoned-squares
// ─────────────────────────────────────────────────────────────────────
describe("poisoned-squares", () => {
it("damages a piece ending a half-move on d4/e4/d5/e5", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
{ id: "poisoned-squares", scope: "both", turnsRemaining: null },
]);
// 1. e4 — white pawn lands on e4 which is poisoned. After white's
// move onAfterMove fires, poison damage applies → pawn goes from
// HP 2 to HP 1.
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
const e4Pawn = pieceAt(engine, "e4")!;
expect(hpOf(engine, e4Pawn)).toBe(1);
// Black plays non-poisoned move — e4 pawn gets damaged AGAIN
// because it's still on the poisoned square at end of half-move.
engine.applyMove(engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!);
expect(pieceAt(engine, "e4")).toBeNull(); // pawn retracted at HP 0
});
it("does not damage pieces NOT on a poisoned square", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "piece-hp", scope: "both", turnsRemaining: null },
{ id: "poisoned-squares", scope: "both", turnsRemaining: null },
]);
engine.applyMove(engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!);
const a3Pawn = pieceAt(engine, "a3")!;
expect(hpOf(engine, a3Pawn)).toBe(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// capture-to-win
// ─────────────────────────────────────────────────────────────────────
describe("capture-to-win", () => {
it("first capture ends the game with the capturer winning", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
]);
// 1. e4 d5 2. exd5 — white captures first.
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
const result = engine.applyMove(
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!,
);
expect(result).toBe("white-wins");
});
it("game stays ongoing until any capture occurs", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "capture-to-win", scope: "both", turnsRemaining: null },
]);
const r1 = engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
expect(r1).toBe("ongoing");
const r2 = engine.applyMove(
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
);
expect(r2).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// last-piece-standing
// ─────────────────────────────────────────────────────────────────────
describe("last-piece-standing", () => {
it("game stays ongoing until one color has 0 pieces", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
]);
expect(engine.checkGameResult()).toBe("ongoing");
});
it("white wins when black has no pieces left", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
]);
// Scorched-earth: retract every black piece.
const blackIds: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "Color" || f.value !== "black") continue;
if ((f.id as number) <= 0) continue;
blackIds.push(f.id);
}
for (const id of blackIds) {
for (const attr of ["PieceType", "Color", "Position", "HasMoved"] as const) {
if (engine.session.contains(id, attr)) engine.session.retract(id, attr);
}
}
expect(engine.checkGameResult()).toBe("white-wins");
});
it("overrides default checkmate: cornered king is NOT game-over", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "last-piece-standing", scope: "both", turnsRemaining: null },
]);
// From the starting position the base engine isn't near checkmate
// yet, but the key point is checkGameResult never returns
// "checkmate" while last-piece-standing is active. Sanity check.
expect(engine.checkGameResult()).toBe("ongoing");
});
});
// ─────────────────────────────────────────────────────────────────────
// explosive-rook
// ─────────────────────────────────────────────────────────────────────
describe("explosive-rook", () => {
it("rook capture detonates orthogonal neighbours within distance 2", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "explosive-rook", scope: "both", turnsRemaining: null },
]);
// Sculpt a controlled board: clear everything from rank 4 so we
// can plant a rook + target + bystanders.
clearRank(engine, 3);
clearRank(engine, 4);
clearRank(engine, 5);
// White rook on a4 (file 0, rank 3). Black pawn on d4 (target).
// Black pawn on f4 (distance 2, survives? No — f4 is file 5, d4
// is file 3, Chebyshev along file = 2, so f4 IS in range).
// Black pawn on b5 (diagonal, should SURVIVE explosion).
// White pawn on d2 (distance 2 on file, should be detonated).
const whiteRook = pieceAt(engine, "a1")!;
engine.session.insert(whiteRook, "Position", squareOf(0, 3)); // a4
// Find any black pawn and reposition; need 4 black pawns for the test.
const blackPawns: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
const cfact = engine.session.allFacts().find(x => x.id === f.id && x.attr === "Color");
if (cfact?.value === "black") blackPawns.push(f.id);
}
engine.session.insert(blackPawns[0]!, "Position", squareOf(3, 3)); // d4 target
engine.session.insert(blackPawns[1]!, "Position", squareOf(5, 3)); // f4 in-range
engine.session.insert(blackPawns[2]!, "Position", squareOf(1, 4)); // b5 diagonal
// White rook captures on d4.
const capture = engine.findMove(
algebraicToSquare("a4"),
algebraicToSquare("d4"),
);
expect(capture).not.toBeNull();
engine.applyMove(capture!);
// d4 target: gone.
// f4: gone (file 3 -> 5 is distance 2 on file, rank unchanged).
// b5: survives (diagonal neighbour, not on d4's rank or file).
expect(pieceAt(engine, "d4")).toBe(whiteRook); // rook moved here
expect(pieceAt(engine, "f4")).toBeNull();
expect(pieceAt(engine, "b5")).not.toBeNull(); // diagonal survives
});
it("non-rook captures are unaffected (default capture runs)", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "explosive-rook", scope: "both", turnsRemaining: null },
]);
// 1. e4 d5 2. exd5 — pawn capture, no detonation.
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!);
// d5 holds the white pawn. No adjacent pieces affected.
expect(typeOf(engine, pieceAt(engine, "d5")!)).toBe("pawn");
expect(pieceAt(engine, "e7")).not.toBeNull(); // black pawn intact
});
});
// ─────────────────────────────────────────────────────────────────────
// queen-splits
// ─────────────────────────────────────────────────────────────────────
describe("queen-splits", () => {
it("queen capture spawns a rook on target and a bishop on an empty neighbour", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "queen-splits", scope: "both", turnsRemaining: null },
]);
// Sculpt: white queen on d4, black pawn on e5 (diagonal capture).
// Neighbours of e5: d6, e6, f6, f5, f4, e4, d4 (WHITE QUEEN!), d5.
// Clockwise from N: e6 → f6 → f5 → f4 → e4 → d4 (queen — gone
// after fission, but at the moment of the clockwise scan it's
// already retracted) → d5 → d6. So the first empty neighbour
// after capture will be e6 (empty in starting position).
clearRank(engine, 3);
clearRank(engine, 4);
const whiteQueen = pieceAt(engine, "d1")!;
engine.session.insert(whiteQueen, "Position", squareOf(3, 3)); // d4
const blackPawns: EntityId[] = [];
for (const f of engine.session.allFacts()) {
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
const cfact = engine.session.allFacts().find(x => x.id === f.id && x.attr === "Color");
if (cfact?.value === "black") blackPawns.push(f.id);
}
engine.session.insert(blackPawns[0]!, "Position", squareOf(4, 4)); // e5
const capture = engine.findMove(
algebraicToSquare("d4"),
algebraicToSquare("e5"),
);
expect(capture).not.toBeNull();
engine.applyMove(capture!);
// Queen is gone.
expect(engine.session.contains(whiteQueen, "Position")).toBe(false);
// Target pawn is gone.
expect(pieceAt(engine, "e5")).not.toBe(blackPawns[0]!);
// A white rook sits on e5 (target square).
const onE5 = pieceAt(engine, "e5")!;
expect(typeOf(engine, onE5)).toBe("rook");
expect(engine.session.get(onE5, "Color")).toBe("white");
// A white bishop sits on one of e5's clockwise-N-first empty
// neighbours. Starting position has black pawns on rank 7 which
// is all occupied from e8 down to rank 7… wait, e5's N is e6
// which is empty. Assert we find a bishop adjacent.
const neighborFiles = [-1, 0, 1];
const neighborRanks = [-1, 0, 1];
let bishopFound = false;
for (const df of neighborFiles) {
for (const dr of neighborRanks) {
if (df === 0 && dr === 0) continue;
const sq = squareOf(4 + df, 4 + dr) as Square;
if (sq < 0 || sq > 63) continue;
const id = pieceAt(
engine,
String.fromCharCode(97 + 4 + df) + (5 + dr),
);
if (id === null) continue;
if (typeOf(engine, id) === "bishop") {
bishopFound = true;
break;
}
}
if (bishopFound) break;
}
expect(bishopFound).toBe(true);
});
it("non-queen captures are unaffected", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: "queen-splits", scope: "both", turnsRemaining: null },
]);
engine.applyMove(engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!);
engine.applyMove(engine.findMove(algebraicToSquare("d7"), algebraicToSquare("d5"))!);
engine.applyMove(engine.findMove(algebraicToSquare("e4"), algebraicToSquare("d5"))!);
// Pawn stays a pawn — no fission.
const onD5 = pieceAt(engine, "d5")!;
expect(typeOf(engine, onD5)).toBe("pawn");
});
});

View file

@ -1,10 +1,62 @@
/**
* Preset: `king-heals` (Regenerating King, RULES.md rule #9)
*
* After every half-move, check the king BELONGING TO THE NON-MOVER
* (i.e. the player about to move). If it's not currently in check,
* its HP regenerates by 1, capped at MAX_KING_HP.
*
* Design reading of RULES.md #9: "If the King ends a turn NOT in check,
* it gains +1 HP." The natural moment to apply this is AFTER a move
* has been applied but BEFORE the turn counter advances further. Our
* `onAfterMove` hook fires at that exact point. We heal the KING that
* just came off attack — i.e. the non-mover's king — because the
* mover's king couldn't have been in check (self-check filter prevents
* moves that leave your own king attacked).
*
* Requires `piece-hp`; without it there's no Hp attribute to heal.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { Session, EntityId } from "@paratype/rete";
import type { PieceColor } from "../schema.js";
import { isInCheck } from "../rules/check.js";
const MAX_KING_HP = 3;
/** Find the king entity for a given color, or null if absent. */
function findKing(session: Session, color: PieceColor): EntityId | null {
const facts = session.allFacts();
for (const f of facts) {
if (f.attr !== "PieceType" || f.value !== "king") continue;
const colorFact = facts.find(c => c.id === f.id && c.attr === "Color");
if (colorFact?.value === color) return f.id;
}
return null;
}
PRESET_REGISTRY.register({
id: "king-heals",
name: "Regenerating King",
description: "If the King ends a turn NOT in check, it gains +1 HP (max 3 HP). Requires piece-hp.",
description:
"After every half-move, the king whose turn it is to move regenerates +1 HP (max 3) — unless it's currently in check. Requires Hit Points.",
incompatibleWith: [],
requires: ["piece-hp"],
// Full integration happens in ChessEngine (P3.11)
onAfterMove(engine: ChessEngine, moverColor) {
// The NON-mover's king is the one potentially healing: it's the
// king belonging to the player whose turn just arrived. Heal only
// if that king isn't currently in check (being in check denies the
// heal — an intentional game-design lever that makes aggressive
// play strategically meaningful).
const color: PieceColor = moverColor === "white" ? "black" : "white";
const kingId = findKing(engine.session, color);
if (kingId === null) return;
if (isInCheck(engine.session, color)) return;
const session = engine.session;
if (!session.contains(kingId, "Hp")) return; // piece-hp not active
const hp = session.get(kingId, "Hp") as number;
if (hp >= MAX_KING_HP) return;
session.insert(kingId, "Hp", hp + 1);
},
});

View file

@ -1,10 +1,73 @@
/**
* Preset: `last-piece-standing` (Annihilation, RULES.md rule #12)
*
* The king has no special status. Checkmate is disabled. The player
* who captures ALL enemy pieces wins — meaning the opponent's piece
* count drops to 0 (king included).
*
* Wiring is pure `onCheckGameResult`:
* - Count pieces by color.
* - If one color has 0 pieces, the other color wins.
* - Otherwise return undefined — the game continues. We deliberately
* SUPPRESS the default checkmate and stalemate detection by
* returning "ongoing" whenever neither side is annihilated; this
* overrides the default `isCheckmate`/`isStalemate` checks in
* `engine.checkGameResult`.
*
* Note that `filterSelfCheckMoves` still runs in move generation, so
* players still can't move their king into check voluntarily. That's
* a UX concession: under pure Annihilation rules a suicidal king
* move is technically legal, but blocking it keeps the game readable
* (otherwise a blundering player could accidentally trap themselves
* with no legal moves). Future work could add a scope for "disable
* self-check filter" if we want stricter variant purity.
*
* Incompatible with `capture-to-win` — both redefine "when is the
* game over".
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine, GameResult } from "../engine.js";
PRESET_REGISTRY.register({
id: "last-piece-standing",
name: "Annihilation",
description: "The player who captures all enemy pieces wins. King has no special status; checkmate is disabled.",
description:
"Checkmate is disabled. The player who captures ALL enemy pieces (king included) wins. Every capture counts.",
incompatibleWith: ["capture-to-win"],
requires: [],
// Win condition checked in ChessEngine (P3.11)
onCheckGameResult(engine: ChessEngine): GameResult | undefined {
// Count Position facts per color. We use Position rather than
// PieceType because a piece "exists on the board" iff it has a
// position; retracted pieces (captured) have no Position fact.
const facts = engine.session.allFacts();
const colorById = new Map<number, string>();
for (const f of facts) {
if (f.attr === "Color") colorById.set(f.id as number, f.value as string);
}
let whiteCount = 0;
let blackCount = 0;
for (const f of facts) {
if (f.attr !== "Position") continue;
if ((f.id as number) <= 0) continue;
const color = colorById.get(f.id as number);
if (color === "white") whiteCount++;
else if (color === "black") blackCount++;
}
if (whiteCount === 0 && blackCount === 0) {
// Degenerate — both sides wiped simultaneously somehow. Call
// it a draw-by-annihilation.
return "draw-insufficient";
}
if (whiteCount === 0) return "black-wins";
if (blackCount === 0) return "white-wins";
// Neither side is annihilated — override the default checkmate/
// stalemate detection by declaring the game ongoing. Without this
// return, the engine would fall through to isCheckmate which
// could spuriously end the game under pure FIDE rules.
return "ongoing";
},
});

View file

@ -1,13 +1,78 @@
/**
* Preset: `poisoned-squares` (Poisoned Centre, RULES.md rule #15)
*
* The four central squares (d4, d5, e4, e5) are poisoned. Any piece
* STANDING on one of those squares at the end of a half-move loses
* 1 HP. If its HP reaches 0 the piece dies (retract all its facts).
*
* Why "end of half-move" and not "end of full turn": the poison needs
* to be visible to both players per move — if it only ticked every
* other half-move, a player could briefly occupy a poisoned square
* during their own turn without consequence. Applying damage every
* half-move means every piece pays 1 HP per time-step on a poisoned
* square, which matches the "per turn" wording in RULES.md #15 (here
* "turn" means "half-move" in chess parlance).
*
* Requires `piece-hp` — without an Hp attribute there's nothing to
* damage. The registry validates the dependency when the preset is
* activated via setActivePresets.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { EntityId } from "@paratype/rete";
// Poisoned central squares: d4=27, e4=28, d5=35, e5=36
export const POISONED_SQUARES = new Set([27, 28, 35, 36]);
/** Poisoned central squares: d4=27, e4=28, d5=35, e5=36. Exported for
* the UI layer to render visual poison cues on the same squares. */
export const POISONED_SQUARES: ReadonlySet<number> = new Set([27, 28, 35, 36]);
/** Attributes to retract when a piece dies of poison. Kept in sync with
* the engine's capture.PIECE_ATTRS list; we can't import it directly
* because poisoned-squares is a pure-data preset and `capture.ts`
* depends on rete/session. */
const PIECE_ATTRS = [
"PieceType",
"Color",
"Position",
"HasMoved",
"Hp",
] as const;
PRESET_REGISTRY.register({
id: "poisoned-squares",
name: "Poisoned Centre",
description: "The four central squares (d4, d5, e4, e5) are poisoned. A piece ending its turn there loses 1 HP per turn. Requires piece-hp.",
description:
"The four central squares (d4, d5, e4, e5) are poisoned. Any piece ending a half-move on one loses 1 HP per move. Requires Hit Points.",
incompatibleWith: [],
requires: ["piece-hp"],
// HP damage applied in ChessEngine post-move hook (P3.11)
onAfterMove(engine: ChessEngine) {
const session = engine.session;
const facts = session.allFacts();
// Collect pieces standing on poisoned squares. We snapshot the
// list BEFORE mutating anything so HP decrements don't interact
// with iteration semantics.
const toPoison: Array<{ id: EntityId; hp: number }> = [];
for (const f of facts) {
if (f.attr !== "Position") continue;
if (!POISONED_SQUARES.has(f.value as number)) continue;
if ((f.id as number) <= 0) continue; // game entity
if (!session.contains(f.id, "Hp")) continue; // shouldn't happen
const hp = session.get(f.id, "Hp") as number;
toPoison.push({ id: f.id, hp });
}
for (const { id, hp } of toPoison) {
const next = hp - 1;
if (next > 0) {
session.insert(id, "Hp", next);
} else {
// HP hit 0 — piece dies. Retract all attributes mirroring
// the normal capture path.
for (const attr of PIECE_ATTRS) {
if (session.contains(id, attr)) session.retract(id, attr);
}
}
}
},
});

View file

@ -0,0 +1,31 @@
/**
* UI overlay for `poisoned-squares`: a green-tinted haze on d4, d5,
* e4, e5 so players can tell at a glance which squares will damage
* pieces that end a turn there.
*
* Design: low-saturation green with a skull-like pattern? For v1 we
* just use a tinted overlay with animated opacity pulsing so the
* poison feels alive. Skulls are future polish.
*/
import { motion } from "motion/react";
import { registerSquareOverlay } from "../ui/preset-overlays.js";
import type { SquareOverlayProps } from "../ui/preset-overlays.js";
import { POISONED_SQUARES } from "./poisoned-squares.js";
function PoisonOverlay({ square }: SquareOverlayProps) {
if (!POISONED_SQUARES.has(square)) return null;
return (
<motion.div
data-role="poison-overlay"
className="absolute inset-0 pointer-events-none z-[5]"
style={{
background:
"radial-gradient(circle, rgba(132, 204, 22, 0.35) 0%, rgba(132, 204, 22, 0.15) 60%, transparent 100%)",
}}
animate={{ opacity: [0.7, 1, 0.7] }}
transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut" }}
/>
);
}
registerSquareOverlay("poisoned-squares", PoisonOverlay);

View file

@ -1,10 +1,147 @@
/**
* Preset: `queen-splits` (Queen Fission, RULES.md rule #8)
*
* When a queen captures, it FISSIONS into a rook (placed on the
* capture square) and a bishop (placed on the first empty adjacent
* square, clockwise from north). The queen entity is retracted, the
* target enemy piece is retracted, two new piece entities are
* spawned. If no adjacent square is empty the bishop is forfeit and
* only the rook spawns.
*
* Implementation via `onBeforeCapture`:
* - If the attacker isn't a queen, do nothing (default capture runs).
* - Otherwise consume the hook and:
* 1. Retract the enemy target's facts (normal capture).
* 2. Retract the queen's facts (she fissioned).
* 3. Spawn a new rook on the target square (fresh entity id).
* 4. Find the first empty adjacent square scanning clockwise
* from N (0,+1), NE (+1,+1), E (+1,0), SE (+1,-1),
* S (0,-1), SW (-1,-1), W (-1,0), NW (-1,+1).
* If found, spawn a bishop there. If all 8 neighbours are
* occupied, no bishop spawns.
*
* Fissioned pieces receive `HasMoved = true` so they cannot castle
* (even if the original queen was on a castle-related square — a
* weird edge case, but the invariant is simpler this way).
*
* Interaction with `piece-hp`: if piece-hp is also active (currently
* allowed — no hard incompatibility), the spawned pieces get Hp=2
* via the onActivate idempotence guarantee. Strictly speaking a more
* principled design would have a dedicated `onPieceSpawn` hook
* chain; for v1 we rely on the convention that piece-hp's
* `onActivate` only installs Hp on pieces that don't already have
* it, so calling it re-idempotently covers newly-spawned pieces too.
*
* IMPORTANT: spawning new entities mid-capture means this preset
* writes new facts to the session that didn't exist when the move
* was legally validated. That's fine — queen captures are validated
* against the pre-fission state; fission is a post-capture
* consequence, not a move generator.
*/
import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine } from "../engine.js";
import type { Session, EntityId } from "@paratype/rete";
import { fileOf, rankOf, squareOf } from "../coord.js";
import type { Square, PieceColor } from "../schema.js";
/** Clockwise-from-north neighbour offsets. Order matters: bishop is
* placed on the FIRST empty square found by walking this list. */
const CLOCKWISE_NEIGHBOURS: ReadonlyArray<readonly [number, number]> = [
[0, 1], // N
[1, 1], // NE
[1, 0], // E
[1, -1], // SE
[0, -1], // S
[-1, -1], // SW
[-1, 0], // W
[-1, 1], // NW
];
const PIECE_ATTRS = [
"PieceType",
"Color",
"Position",
"HasMoved",
"Hp",
] as const;
function retractEntity(session: Session, id: EntityId): void {
for (const attr of PIECE_ATTRS) {
if (session.contains(id, attr)) session.retract(id, attr);
}
}
function pieceAtSquare(session: Session, sq: Square): EntityId | null {
const facts = session.allFacts();
for (const f of facts) {
if (f.attr === "Position" && f.value === sq && (f.id as number) > 0) {
return f.id;
}
}
return null;
}
/** Spawn a new piece entity. Returns the new id. */
function spawnPiece(
session: Session,
type: "rook" | "bishop",
color: PieceColor,
square: Square,
): EntityId {
const id = session.nextId();
session.insert(id, "PieceType", type);
session.insert(id, "Color", color);
session.insert(id, "Position", square);
session.insert(id, "HasMoved", true);
return id;
}
PRESET_REGISTRY.register({
id: "queen-splits",
name: "Queen Fission",
description: "When a Queen captures a piece, it splits into a Rook and Bishop placed on nearby empty squares.",
description:
"When a Queen captures, she splits: a Rook takes her place on the capture square and a Bishop is placed on the first empty adjacent square (clockwise from north).",
incompatibleWith: [],
requires: [],
// Full integration happens in ChessEngine (P3.11)
onBeforeCapture(engine: ChessEngine, attacker: EntityId, target: EntityId) {
const session = engine.session;
const attackerTypeFact = session
.allFacts()
.find(f => f.id === attacker && f.attr === "PieceType");
if (attackerTypeFact?.value !== "queen") return; // default capture runs
const attackerColorFact = session
.allFacts()
.find(f => f.id === attacker && f.attr === "Color");
if (attackerColorFact === undefined) return;
const attackerColor = attackerColorFact.value as PieceColor;
const targetPos = session.get(target, "Position") as Square | undefined;
if (targetPos === undefined) return;
// Retract target (normal capture) and queen (she's fissioning).
retractEntity(session, target);
retractEntity(session, attacker);
// Spawn the rook on the capture square.
spawnPiece(session, "rook", attackerColor, targetPos);
// Find the first empty adjacent square clockwise from N and spawn
// the bishop there. If all 8 are occupied (rare — usually happens
// in dense midgame around a king), the bishop is forfeit.
const tFile = fileOf(targetPos);
const tRank = rankOf(targetPos);
for (const [df, dr] of CLOCKWISE_NEIGHBOURS) {
const f = tFile + df;
const r = tRank + dr;
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
const sq = squareOf(f, r) as Square;
if (pieceAtSquare(session, sq) !== null) continue;
spawnPiece(session, "bishop", attackerColor, sq);
break;
}
return { consume: true };
},
});

View file

@ -45,7 +45,7 @@
* Presets register themselves via side-effect imports (see `./index.ts`).
*/
import type { EntityId } from "@paratype/rete";
import type { ChessEngine } from "../engine.js";
import type { ChessEngine, GameResult } from "../engine.js";
import type { LegalMove } from "../rules/types.js";
/** Return shape for onBeforeCapture. `consume: true` skips the engine's
@ -82,6 +82,38 @@ export interface PresetDef {
attacker: EntityId,
target: EntityId,
) => CaptureHookResult | void;
/**
* Fires after every successful `applyMove`, after turn advancement
* and tickAfterMove but before checkGameResult. `moverColor` is the
* color that just moved. Use this for "end of turn" regeneration,
* status-effect processing, etc.
*
* Unlike the move-generation hooks, this fires on ALL active
* presets regardless of scope. Each preset is responsible for
* inspecting its own scope (via `engine.activePresets.list()`) if it
* needs scope-aware behaviour — scope semantics for "something that
* happens at turn boundaries" aren't one-size-fits-all (king-heals
* wants to affect the non-mover; poisoned-squares damages the
* mover).
*/
readonly onAfterMove?: (
engine: ChessEngine,
moverColor: "white" | "black",
) => void;
/**
* Hook into terminal-position detection. Return a concrete `GameResult`
* to OVERRIDE the engine's default checkmate/stalemate/draw logic;
* return `undefined` to let the default run. Multiple presets may
* register; the first one returning a non-undefined value wins. Order
* follows registration order (see PRESET_REGISTRY.getAll()).
*
* Used by `capture-to-win` (first capture sets the winner) and
* `last-piece-standing` (annihilation replaces checkmate), which both
* redefine "when is the game over".
*/
readonly onCheckGameResult?: (engine: ChessEngine) => GameResult | undefined;
}
/**

View file

@ -18,4 +18,5 @@
*/
import "./piece-hp.ui.js";
import "./poisoned-squares.ui.js";
// Future rules with per-piece overlays add their .ui import here.

View file

@ -7,7 +7,9 @@ import { AnimatePresence, motion } from 'motion/react';
import { pieceAssets } from '../assets/pieces';
import {
getActivePieceOverlays,
getActiveSquareOverlays,
type PieceOverlayComponent,
type SquareOverlayComponent,
} from './preset-overlays';
import '../presets/ui-overlays-index';
@ -42,6 +44,10 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
() => getActivePieceOverlays(activePresetIds ?? []),
[activePresetIds],
);
const squareOverlays: SquareOverlayComponent[] = useMemo(
() => getActiveSquareOverlays(activePresetIds ?? []),
[activePresetIds],
);
// Group facts by entity id ONCE per render. Overlays want per-piece
// fact arrays and rebuilding the index inline per square would be
@ -227,6 +233,13 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
)}
{/* Per-square preset overlays (poison tint, etc.). Each is
a pure function of the square index; overlays decide for
themselves whether to render on any given cell. */}
{squareOverlays.map((SquareOverlay, i) => (
<SquareOverlay key={`sq-${i}`} square={sq} />
))}
{/*
* Legal-target affordance — two visual forms:
* - Quiet move (empty destination): small central dot

View file

@ -134,9 +134,13 @@ function GameLayout({
const isGameOver = result !== 'ongoing';
// Confetti on checkmate
// Confetti on any decisive win (checkmate or variant win condition).
useEffect(() => {
if (result === 'checkmate') {
const isWin =
result === 'checkmate' ||
result === 'white-wins' ||
result === 'black-wins';
if (isWin) {
const duration = 3000;
const end = Date.now() + duration;
@ -337,7 +341,19 @@ function GameLayout({
data-testid="game-over"
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm"
>
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
{
// Preset variants ('white-wins', 'black-wins') name
// the winner explicitly because the variant may end
// on a capture rather than a checkmate. Standard
// chess result strings stay as-is.
result === 'checkmate'
? 'Checkmate!'
: result === 'white-wins'
? 'White wins!'
: result === 'black-wins'
? 'Black wins!'
: `Draw: ${result.replace('draw-', '')}`
}
</motion.div>
)}
</AnimatePresence>

View file

@ -83,3 +83,38 @@ export function getActivePieceOverlays(
}
return out;
}
/**
* Per-square overlay — rendered inside every cell of the board,
* regardless of whether the cell is occupied. Used for rules that
* decorate the board itself (poisoned squares, starting/promotion
* zones in future variants) rather than individual pieces.
*
* The component receives the cell's 0..63 square index and decides
* whether to render anything. Returning null is fine and keeps the
* DOM tree clean when the overlay doesn't apply to a given cell.
*/
export interface SquareOverlayProps {
readonly square: number;
}
export type SquareOverlayComponent = (props: SquareOverlayProps) => ReactNode;
const squareRegistry = new Map<string, SquareOverlayComponent>();
export function registerSquareOverlay(
presetId: string,
component: SquareOverlayComponent,
): void {
squareRegistry.set(presetId, component);
}
export function getActiveSquareOverlays(
activePresetIds: ReadonlyArray<string>,
): SquareOverlayComponent[] {
const out: SquareOverlayComponent[] = [];
for (const id of activePresetIds) {
const c = squareRegistry.get(id);
if (c !== undefined) out.push(c);
}
return out;
}

View file

@ -66,7 +66,9 @@ export type GameEndReason =
| "stalemate"
| "50-move"
| "threefold"
| "insufficient";
| "insufficient"
// Preset-defined decisive result; see GameResult variants.
| "variant-win";
// ---------------------------------------------------------------------------
// GameSession
@ -328,6 +330,13 @@ function mapGameResult(
return { winner: "draw", reason: "threefold" };
case "draw-insufficient":
return { winner: "draw", reason: "insufficient" };
// Preset-defined decisive results. The engine names the winner
// explicitly because variant rules don't always pair "winner"
// with "side to move" the way standard checkmate does.
case "white-wins":
return { winner: "white", reason: "variant-win" };
case "black-wins":
return { winner: "black", reason: "variant-win" };
default: {
// Exhaustiveness guard — if GameResult ever grows a variant, TS
// will flag this by failing the never-cast.

View file

@ -59,6 +59,10 @@ export const GameEndReasonSchema = z.enum([
"threefold",
"insufficient",
"player_left",
// Preset-defined decisive result (first-blood, annihilation, etc.).
// The UI reads the winner separately; this reason tag just signals
// "variant rule ended the game" to clients that care.
"variant-win",
]);
export type GameEndReason = z.infer<typeof GameEndReasonSchema>;