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:
parent
770d0fd9cf
commit
7171dfdd5e
4 changed files with 500 additions and 13 deletions
|
|
@ -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";
|
||||
|
|
|
|||
368
packages/chess/src/presets/monster-rules.test.ts
Normal file
368
packages/chess/src/presets/monster-rules.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
74
packages/chess/src/presets/monster-rules.ts
Normal file
74
packages/chess/src/presets/monster-rules.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue