diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index dab6bf4..5a14fee 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -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"; diff --git a/packages/chess/src/presets/monster-rules.test.ts b/packages/chess/src/presets/monster-rules.test.ts new file mode 100644 index 0000000..b85042a --- /dev/null +++ b/packages/chess/src/presets/monster-rules.test.ts @@ -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"); + }); +}); diff --git a/packages/chess/src/presets/monster-rules.ts b/packages/chess/src/presets/monster-rules.ts new file mode 100644 index 0000000..415793c --- /dev/null +++ b/packages/chess/src/presets/monster-rules.ts @@ -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; + }, +}); diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index cd4f6b5..e7dc15b 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -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;