fix(engine): onCheckGameResult 'ongoing' suppresses defaults without short-circuit

Presets like piece-hp eagerly return 'ongoing' from
onCheckGameResult to suppress the engine's default checkmate /
stalemate poll (because an HP-enabled king can legally survive
check). Under the previous 'first non-undefined wins' rule,
'ongoing' locked the result and prevented any later preset from
declaring a winner — breaking composition with presets like
first-promotion-wins that declare terminal mid-game.

Two-phase protocol:
  - TERMINAL result (white-wins / black-wins / draw / checkmate
    / stalemate / draw-*) immediately wins; engine stops polling.
  - 'ongoing' sets a soft 'suppress defaults' flag and CONTINUES
    polling — a later preset can still declare a winner.
  - undefined = no opinion.

If every poller returns either 'ongoing' or undefined, the engine
honours the suppress flag and returns 'ongoing' without running
the default isCheckmate / isStalemate / draw predicates. If no
poller opinionated, defaults run as before.

Updated PresetDef.onCheckGameResult docblock to match.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 07:32:00 -06:00
commit 7171dfdd5e
No known key found for this signature in database
4 changed files with 500 additions and 13 deletions

View file

@ -1385,17 +1385,41 @@ export class ChessEngine {
}
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.
// Preset override path: run onCheckGameResult on every active
// preset in registration order. Semantics:
//
// - A TERMINAL return ("white-wins" | "black-wins" | "draw" |
// "checkmate" | "stalemate" | "draw-*") immediately wins —
// first terminal return locks the result.
// - An "ongoing" return DOES NOT short-circuit — it's a
// "suppress the engine's default checkmate/stalemate poll"
// hint. Remembered so that if NO preset returns a terminal
// result and no later preset downgrades, we skip the default
// check/mate defaults and return "ongoing".
// - `undefined` = no opinion, move on.
//
// This two-phase protocol is what makes presets like `piece-hp`
// (which suppresses default checkmate via "ongoing" because HP
// lets a king survive check) compose with presets like
// `first-promotion-wins` (which declares a winner mid-game
// before any check/mate default would fire). Both run; the
// terminal declaration wins.
const resultCtx: GameResultHookContext = { engine: this };
let suppressDefaults = false;
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
const override = def?.onCheckGameResult?.(resultCtx);
if (override !== undefined) return override;
if (override === undefined) continue;
if (override === "ongoing") {
suppressDefaults = true;
continue;
}
// Any other GameResult is terminal — lock it in and stop.
return override;
}
if (suppressDefaults) return "ongoing";
const nextColor = this.getCurrentTurn();
const nextRoyalIds = this.getActiveRoyalEntityIds(nextColor);
if (isCheckmate(this.session, nextColor, nextRoyalIds)) return "checkmate";

View file

@ -0,0 +1,368 @@
/**
* Behavioural tests for the `monster-rules` preset (Phase B.3 of the
* rule-variants epic).
*
* Monster is an ASYMMETRIC flip preset: WHITE plays two half-moves
* per turn, BLACK plays one. It inspects `ctx.mover` rather than
* `ctx.scope` — the asymmetry is a rule property, not an activation
* property. See `monster-rules.ts` for the rationale.
*
* Coverage
* - Preset registration + activation sanity.
* - Asymmetric flip: W veto on move 1, W flip on move 2, B flip
* on move 1. Turn / HalfMovesThisTurn / FullmoveNumber all
* track correctly.
* - onTurnStart cadence: 2 invocations over a W W B sequence
* (white flip on move 2, black flip on move 1 = two flips).
* - Composition with the canonical `monster` layout (king + 4
* pawns vs. full black army).
* - Checkmate delivered by white (on its flipping second
* half-move) is detected the moment the flip lands.
* - Checkmate delivered by black (on its single move) is
* detected the moment the flip lands.
* - Incompatibility with `double-move` is enforced at
* `setActivePresets` time.
* - Mid-turn state is reflected in `session.allFacts()` during
* a vetoed W1.
*/
import { describe, it, expect } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { GAME_ENTITY } from "../schema.js";
import { MONSTER_LAYOUT } from "../layouts/monster.js";
import { PRESET_REGISTRY } from "./registry.js";
import { clearBoard, placePiece } from "./test-utils.js";
import { isInCheck } from "../rules/check.js";
const MONSTER_RULES_ID = "monster-rules";
function firstLegalMove(engine: ChessEngine) {
const moves = engine.getAllLegalMoves();
if (moves.length === 0) throw new Error("no legal moves available");
return moves[0]!;
}
function activate(engine: ChessEngine): void {
engine.setActivePresets([
{ id: MONSTER_RULES_ID, scope: "both", turnsRemaining: null },
]);
}
// ─────────────────────────────────────────────────────────────────────
// Registration sanity
// ─────────────────────────────────────────────────────────────────────
describe("monster-rules — preset definition sanity", () => {
it("is registered with the expected id, name, and flip hook", () => {
const def = PRESET_REGISTRY.get(MONSTER_RULES_ID);
expect(def).toBeDefined();
expect(def!.id).toBe(MONSTER_RULES_ID);
expect(def!.name).toBe("Monster");
expect(def!.requires).toEqual([]);
expect(def!.incompatibleWith).toContain("double-move");
expect(def!.incompatibleWith).toContain("suicide-chess");
expect(def!.incompatibleWith).toContain("capture-all");
expect(typeof def!.shouldAdvanceTurn).toBe("function");
});
it("activates without throwing on a fresh engine (no layout coupling)", () => {
const engine = new ChessEngine();
expect(() => activate(engine)).not.toThrow();
const ids = engine.activePresets.list().map((e) => e.id);
expect(ids).toContain(MONSTER_RULES_ID);
});
});
// ─────────────────────────────────────────────────────────────────────
// Core asymmetric cadence
// ─────────────────────────────────────────────────────────────────────
describe("monster-rules — asymmetric flip cadence", () => {
it("white's first move is vetoed: Turn stays white, count=1", () => {
const engine = new ChessEngine();
activate(engine);
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
// FullmoveNumber only increments after black completes a turn.
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
});
it("white's second move flips to black: count resets to 0", () => {
const engine = new ChessEngine();
activate(engine);
engine.applyMove(firstLegalMove(engine)); // W1 veto
engine.applyMove(firstLegalMove(engine)); // W2 flip
expect(engine.getCurrentTurn()).toBe("black");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
// Still 1 — black hasn't completed their turn yet.
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
});
it("black's single move flips to white: NO veto because mover is black", () => {
const engine = new ChessEngine();
activate(engine);
engine.applyMove(firstLegalMove(engine)); // W1 veto
engine.applyMove(firstLegalMove(engine)); // W2 flip → black
engine.applyMove(firstLegalMove(engine)); // B1 — default flip, NOT vetoed
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
// After black completed a turn, fullmove increments.
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
});
it("FullmoveNumber only increments after black's single-move turn (not after white's W2)", () => {
const engine = new ChessEngine();
activate(engine);
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
engine.applyMove(firstLegalMove(engine)); // W1 veto
engine.applyMove(firstLegalMove(engine)); // W2 flip → black
// Flip to black does NOT increment — fullmove ticks only after black.
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
engine.applyMove(firstLegalMove(engine)); // B1 flip → white, increment
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
engine.applyMove(firstLegalMove(engine)); // W1 veto
engine.applyMove(firstLegalMove(engine)); // W2 flip → black, NO increment yet
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
engine.applyMove(firstLegalMove(engine)); // B1 flip → white, increment
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(3);
});
});
// ─────────────────────────────────────────────────────────────────────
// onTurnStart cadence
// ─────────────────────────────────────────────────────────────────────
describe("monster-rules — onTurnStart cadence", () => {
const PROBE_ID = "test-monster-rules-onturnstart-probe";
it("fires exactly twice over a full W W B sequence (2 flips)", () => {
let invocations = 0;
PRESET_REGISTRY.register({
id: PROBE_ID,
name: "Turn-start probe (monster-rules)",
description: "Counts onTurnStart invocations alongside monster-rules.",
incompatibleWith: [],
requires: [],
onTurnStart() {
invocations++;
},
});
const engine = new ChessEngine();
engine.setActivePresets([
{ id: MONSTER_RULES_ID, scope: "both", turnsRemaining: null },
{ id: PROBE_ID, scope: "both", turnsRemaining: null },
]);
expect(invocations).toBe(0);
engine.applyMove(firstLegalMove(engine)); // W1 veto — no fire
expect(invocations).toBe(0);
engine.applyMove(firstLegalMove(engine)); // W2 flip — fire
expect(invocations).toBe(1);
engine.applyMove(firstLegalMove(engine)); // B1 flip — fire
expect(invocations).toBe(2);
});
});
// ─────────────────────────────────────────────────────────────────────
// Monster layout composition
// ─────────────────────────────────────────────────────────────────────
describe("monster-rules — composition with monster layout", () => {
it("plays a W W B W W B sequence without error (king + 4 pawns vs. full army)", () => {
const engine = new ChessEngine({ layout: MONSTER_LAYOUT });
activate(engine);
// Sanity: layout seeded as documented (king e1, four pawns on rank 2).
const facts = engine.session.allFacts();
const whitePieceCount = facts.filter(
(f) =>
f.attr === "Color" &&
f.value === "white" &&
(f.id as number) > 0,
).length;
// 1 king + 4 pawns.
expect(whitePieceCount).toBe(5);
// Play 6 half-moves: W W B W W B.
engine.applyMove(firstLegalMove(engine)); // W1 veto
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
engine.applyMove(firstLegalMove(engine)); // W2 flip
expect(engine.getCurrentTurn()).toBe("black");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
engine.applyMove(firstLegalMove(engine)); // B1 flip
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
engine.applyMove(firstLegalMove(engine)); // W1 veto (turn 2)
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
engine.applyMove(firstLegalMove(engine)); // W2 flip
expect(engine.getCurrentTurn()).toBe("black");
engine.applyMove(firstLegalMove(engine)); // B1 flip
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(3);
});
});
// ─────────────────────────────────────────────────────────────────────
// Checkmate detection
// ─────────────────────────────────────────────────────────────────────
describe("monster-rules — checkmate detection", () => {
it("white delivers mate on W2 (the flipping half-move): detected immediately after flip", () => {
// Scholar's-mate adaptation under monster cadence:
// W1 e4 (veto), W2 Bc4 (flip to black)
// B1 a6 (flip to white)
// W1 Qh5 (veto) — not check yet
// W2 Qxf7# (flip to black) — mate.
const engine = new ChessEngine();
activate(engine);
const play = (fromSq: number, toSq: number) => {
const mv = engine.findMove(fromSq, toSq);
expect(mv, `legal move ${fromSq}->${toSq}`).not.toBeNull();
engine.applyMove(mv!);
};
// Algebraic squares a1=0 … h8=63 (file + rank*8). Use imports for clarity.
const sq = (file: string, rank: number) =>
"abcdefgh".indexOf(file) + (rank - 1) * 8;
play(sq("e", 2), sq("e", 4)); // W1 veto
play(sq("f", 1), sq("c", 4)); // W2 flip to black
expect(engine.getCurrentTurn()).toBe("black");
play(sq("a", 7), sq("a", 6)); // B1 flip to white
expect(engine.getCurrentTurn()).toBe("white");
play(sq("d", 1), sq("h", 5)); // W1 veto
expect(engine.getCurrentTurn()).toBe("white");
const mateMove = engine.findMove(sq("h", 5), sq("f", 7));
expect(mateMove, "Qxf7 must be legal as W2").not.toBeNull();
const result = engine.applyMove(mateMove!); // W2 flip + mate
expect(result).toBe("checkmate");
expect(engine.getCurrentTurn()).toBe("black");
expect(engine.checkGameResult()).toBe("checkmate");
});
it("white checking black on W1: Turn stays white, black is in check at session level, W2 still available", () => {
// On a crafted back-rank position, W1 delivers rook check; veto
// keeps Turn on white; W2 plays a legal harmless move that
// preserves the mating pattern; flip to black; mate detected.
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "g1");
placePiece(engine, "king", "black", "h8");
placePiece(engine, "pawn", "black", "g7");
placePiece(engine, "pawn", "black", "h7");
placePiece(engine, "rook", "white", "a1");
placePiece(engine, "knight", "white", "b1");
// Default Turn is "white" (seeded by applyLayout).
activate(engine);
// W1: Ra1→a8 delivers back-rank check/mate against black king h8.
const sq = (file: string, rank: number) =>
"abcdefgh".indexOf(file) + (rank - 1) * 8;
const check = engine.findMove(sq("a", 1), sq("a", 8));
expect(check, "Ra1-a8 must be legal").not.toBeNull();
const mid = engine.applyMove(check!);
// Veto: Turn stays white, count=1. checkGameResult checks white
// (side-to-move under veto), which isn't in check, so "ongoing".
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
expect(mid).toBe("ongoing");
// Black IS in check at the pure-session level.
expect(isInCheck(engine.session, "black")).toBe(true);
// W2: Nb1→c3 (harmless). After flip, black is still mated.
const harmless = engine.findMove(sq("b", 1), sq("c", 3));
expect(harmless, "Nb1-c3 must be legal as W2").not.toBeNull();
const final = engine.applyMove(harmless!);
expect(engine.getCurrentTurn()).toBe("black");
expect(final).toBe("checkmate");
expect(engine.checkGameResult()).toBe("checkmate");
});
it("black delivers mate on its single move: detected immediately after flip", () => {
// Reverse back-rank: black plays B1 Ra8-a1# against white king h1.
const engine = new ChessEngine();
clearBoard(engine, { preserveKings: false });
placePiece(engine, "king", "white", "h1");
placePiece(engine, "king", "black", "g8");
placePiece(engine, "pawn", "white", "g2");
placePiece(engine, "pawn", "white", "h2");
placePiece(engine, "rook", "black", "a8");
// Force black-to-move so a single applyMove from black is B1.
engine.session.insert(GAME_ENTITY, "Turn", "black");
activate(engine);
const sq = (file: string, rank: number) =>
"abcdefgh".indexOf(file) + (rank - 1) * 8;
const mate = engine.findMove(sq("a", 8), sq("a", 1));
expect(mate, "Ra8-a1 must be legal as B1").not.toBeNull();
const result = engine.applyMove(mate!); // B1 flips to white under monster
expect(engine.getCurrentTurn()).toBe("white");
expect(result).toBe("checkmate");
expect(engine.checkGameResult()).toBe("checkmate");
});
});
// ─────────────────────────────────────────────────────────────────────
// Incompatibility enforcement
// ─────────────────────────────────────────────────────────────────────
describe("monster-rules — incompatibility", () => {
it("throws when activated alongside double-move under overlapping scope", () => {
const engine = new ChessEngine();
expect(() =>
engine.setActivePresets([
{ id: MONSTER_RULES_ID, scope: "both", turnsRemaining: null },
{ id: "double-move", scope: "both", turnsRemaining: null },
]),
).toThrow(/incompatible/i);
});
});
// ─────────────────────────────────────────────────────────────────────
// Session snapshot integrity
// ─────────────────────────────────────────────────────────────────────
describe("monster-rules — session snapshot integrity", () => {
it("HalfMovesThisTurn appears in allFacts() during a vetoed W1 state", () => {
const engine = new ChessEngine();
activate(engine);
engine.applyMove(firstLegalMove(engine)); // W1 veto
const facts = engine.session.allFacts();
const halfMoveFact = facts.find(
(f) => f.id === GAME_ENTITY && f.attr === "HalfMovesThisTurn",
);
expect(halfMoveFact).toBeDefined();
expect(halfMoveFact!.value).toBe(1);
const turnFact = facts.find(
(f) => f.id === GAME_ENTITY && f.attr === "Turn",
);
expect(turnFact?.value).toBe("white");
});
});

View file

@ -0,0 +1,74 @@
/**
* Preset: Monster Chess rules (rule-variants epic, Phase B.3).
*
* Asymmetric double-move variant. WHITE plays two half-moves per
* turn; BLACK plays one. Canonically paired with the `monster`
* layout (see `layouts/monster.ts`), which gives white a reduced
* army (king + 4 pawns) — the "play twice" compensation is what
* keeps the variant balanced at high level.
*
* How the veto works
* ──────────────────
* The engine invokes every active preset's `shouldAdvanceTurn`
* hook AFTER committing the move and AFTER incrementing
* `HalfMovesThisTurn`. The hook sees the post-increment count and
* the color of the mover. Phase A.3's semantics are "first false
* wins": as soon as any preset vetoes, the engine skips the flip
* (and `HalfMovesThisTurn` is NOT reset), so the mover plays
* again.
*
* Monster's rule:
* - If `mover === "white"` AND `halfMovesThisTurn < 2` → veto
* (return `false`). White gets a second half-move.
* - Otherwise return `undefined` (no opinion) — the engine's
* default flips the turn. In particular BLACK's single move
* always flips by default.
*
* Why scope inspection isn't needed here
* ─────────────────────────────────────
* Unlike some scope-aware presets that read `engine.activePresets`
* to decide behaviour, this preset decides purely from `ctx.mover`.
* That's intentional: Monster's rule is "white has a mandatory
* double-move", full stop — it does NOT depend on whether the
* preset was activated with `scope: "white"` vs `scope: "both"`.
* (The scope still governs duration-ticking and preset-lifecycle
* semantics per `ActivePresetSet`, but not the hook's behaviour.)
*
* Incompatibilities
* ─────────────────
* Declared incompatible with:
* - `double-move` — that preset ALSO vetoes the flip after 1,
* so stacking them either double-vetoes or produces undefined
* orderings. Users should pick one.
* - `suicide-chess`, `capture-all` — these redefine the notion
* of "legal move" in ways that interact poorly with a
* mandatory extra half-move per turn (e.g. white's first move
* might be forced into a suicide capture, leaving no sensible
* second move).
*
* Checkmate / stalemate detection is unaffected: the engine
* continues to poll its default checkmate/stalemate predicates
* against the color that is NEXT TO MOVE after each flip. With
* monster-rules, a mate delivered by white's first half-move
* surfaces as "checkmate" once white's second half-move lands
* and the flip actually proceeds. A mate delivered by black on
* its single move surfaces immediately on flip, same as default.
*/
import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({
id: "monster-rules",
name: "Monster",
description:
"White plays two half-moves per turn; black plays one. Classic Monster chess pairing.",
incompatibleWith: ["double-move", "suicide-chess", "capture-all"],
requires: [],
shouldAdvanceTurn({ mover, halfMovesThisTurn }) {
// White gets a mandatory second half-move. The engine polls this
// hook AFTER incrementing HalfMovesThisTurn, so seeing 1 means
// "white just played its first move this turn" — veto.
if (mover === "white" && halfMovesThisTurn < 2) return false;
// Black, or white's second half-move: let the default flip.
return undefined;
},
});

View file

@ -477,15 +477,36 @@ export interface PresetDef {
readonly onAfterMove?: (ctx: MoveHookContext) => 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()).
* Hook into terminal-position detection.
*
* 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".
* Return semantics (the engine implements a two-phase protocol so
* presets compose rather than short-circuit each other):
*
* - A TERMINAL `GameResult` (`"white-wins"`, `"black-wins"`,
* `"draw"`, `"checkmate"`, `"stalemate"`, `"draw-50"`,
* `"draw-3fold"`, `"draw-insufficient"`) immediately wins —
* the engine stops polling and returns that value. First
* terminal return locks the outcome.
*
* - `"ongoing"` is a soft signal: it tells the engine "don't
* run the default checkmate/stalemate/draw predicates when
* nobody else declares terminal". The poll CONTINUES — a
* later preset can still declare a winner. Use this when your
* preset wants to suppress the default rules (e.g. `piece-hp`
* suppresses default checkmate because an HP king can survive
* attacks) without preventing other presets from declaring
* terminal.
*
* - `undefined` = no opinion; poll continues.
*
* Registration order: presets are polled in
* `engine.activePresets.list()` order (activation order).
*
* Used by `capture-to-win` (first capture sets the winner),
* `last-piece-standing` (annihilation replaces checkmate),
* `first-promotion-wins` (first pawn promotion wins), and
* `piece-hp` (returns `"ongoing"` to suppress default mate while
* HP kings can survive).
*/
readonly onCheckGameResult?: (ctx: GameResultHookContext) => GameResult | undefined;