diff --git a/packages/chess/src/presets/first-promotion-wins.test.ts b/packages/chess/src/presets/first-promotion-wins.test.ts new file mode 100644 index 0000000..022ba85 --- /dev/null +++ b/packages/chess/src/presets/first-promotion-wins.test.ts @@ -0,0 +1,351 @@ +/** + * Tests for `first-promotion-wins` (Phase B.4, rule-variants epic). + * + * The preset declares the mover the winner the instant ANY pawn + * promotes. These tests exercise: + * - activation / no-winner baseline + * - white and black promotion paths on a minimal board + * - first-promotion-lock (later promotions can't flip the result) + * - composition with `pawns-only` layout + * - composition with `piece-hp` + * - composition with `double-move` (promotion during first half-move + * still ends the game) + * - incompatibility with `capture-to-win` and `last-piece-standing` + * + * See `./first-promotion-wins.ts` for the hook wiring + docblock + * explaining why we DON'T opt out of `shouldFilterSelfCheck`. + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import { ChessEngine } from "../engine.js"; +import { GAME_ENTITY } from "../schema.js"; +import { algebraicToSquare } from "../coord.js"; +import { PAWNS_ONLY_LAYOUT } from "../layouts/pawns-only.js"; +import { PRESET_REGISTRY } from "./registry.js"; +import { clearBoard, placePiece, pieceAt } from "./test-utils.js"; + +const PRESET = { + id: "first-promotion-wins", + scope: "both" as const, + turnsRemaining: null, +}; + +interface WinnerState extends Record { + winner: "white" | "black"; +} + +/** + * Build a minimal board: both kings in corners, one white pawn on + * a7 ready to promote to a8. `turn` controls whose move is next. + * `kingSquares` lets callers override the king placements for edge- + * case positions (e.g. when the default a1/h8 conflicts with another + * piece). + */ +function minimalPromotionBoard( + engine: ChessEngine, + opts: { + readonly turn: "white" | "black"; + readonly pawnSquare: string; + readonly pawnColor: "white" | "black"; + readonly whiteKing?: string; + readonly blackKing?: string; + }, +): void { + clearBoard(engine, { preserveKings: false }); + placePiece(engine, "king", "white", opts.whiteKing ?? "a1"); + placePiece(engine, "king", "black", opts.blackKing ?? "h8"); + placePiece(engine, "pawn", opts.pawnColor, opts.pawnSquare); + engine.session.insert(GAME_ENTITY, "Turn", opts.turn); +} + +// ───────────────────────────────────────────────────────────────────── +// a. Activation on fresh engine +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — activation", () => { + it("(a) activates on a fresh classic-layout engine without error", () => { + const engine = new ChessEngine(); + expect(() => engine.setActivePresets([PRESET])).not.toThrow(); + // Registered id exists and matches what the pawns-only layout + // declares in `suggestedPresets`. + expect(PRESET_REGISTRY.get("first-promotion-wins")).toBeDefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// b. No promotion → ongoing, no winner recorded +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — baseline (no promotion yet)", () => { + it("(b) checkGameResult is 'ongoing' on fresh engine; preset state has no winner", () => { + const engine = new ChessEngine(); + engine.setActivePresets([PRESET]); + expect(engine.checkGameResult()).toBe("ongoing"); + const state = engine.presetState("first-promotion-wins"); + expect(state.get("winner")).toBeUndefined(); + }); + + it("(b2) non-promotion moves do NOT set the winner", () => { + const engine = new ChessEngine(); + engine.setActivePresets([PRESET]); + // 1. e4 + engine.applyMove( + engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!, + ); + const state = engine.presetState("first-promotion-wins"); + expect(state.get("winner")).toBeUndefined(); + expect(engine.checkGameResult()).toBe("ongoing"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// c. White pawn promotes → white-wins +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — white promotion", () => { + it("(c) white pawn promoting to queen on a8 yields 'white-wins'", () => { + const engine = new ChessEngine(); + minimalPromotionBoard(engine, { + turn: "white", + pawnSquare: "a7", + pawnColor: "white", + }); + engine.setActivePresets([PRESET]); + + const move = engine.findMove( + algebraicToSquare("a7"), + algebraicToSquare("a8"), + "queen", + ); + expect(move).not.toBeNull(); + const result = engine.applyMove(move!); + expect(result).toBe("white-wins"); + + const state = engine.presetState("first-promotion-wins"); + expect(state.get("winner")).toBe("white"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// d. Black pawn promotes → black-wins +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — black promotion", () => { + it("(d) black pawn promoting to queen on a1 yields 'black-wins'", () => { + const engine = new ChessEngine(); + // Kings on e1/e8 to keep them clear of the a-file pawn path. + minimalPromotionBoard(engine, { + turn: "black", + pawnSquare: "a2", + pawnColor: "black", + whiteKing: "e1", + blackKing: "e8", + }); + engine.setActivePresets([PRESET]); + + const move = engine.findMove( + algebraicToSquare("a2"), + algebraicToSquare("a1"), + "queen", + ); + expect(move).not.toBeNull(); + const result = engine.applyMove(move!); + expect(result).toBe("black-wins"); + + const state = engine.presetState("first-promotion-wins"); + expect(state.get("winner")).toBe("black"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// e. First-promotion-lock: second promotion must NOT overwrite +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — first-promotion-lock", () => { + it("(e) a second promotion does not overwrite the original winner", () => { + // This test exercises the preset's internal latch: once the + // winner is set, subsequent pawn promotions must not overwrite. + // We can't test this through `engine.applyMove` after a terminal + // result (promotion puts black in check from the new queen on + // a8, so black's h-pawn move wouldn't be legal anyway). Instead + // we probe the preset's hook directly by calling the engine + // hooks manually on a second setup that also promotes. + const engine = new ChessEngine(); + clearBoard(engine, { preserveKings: false }); + // Put kings far away from the a-file so neither promotion + // delivers check. White king on e1, black king on d6 (off the + // a-file and off the 8th rank, safely away from a8's queen). + placePiece(engine, "king", "white", "e1"); + placePiece(engine, "king", "black", "d6"); + placePiece(engine, "pawn", "white", "a7"); + placePiece(engine, "pawn", "black", "h2"); + engine.session.insert(GAME_ENTITY, "Turn", "white"); + + engine.setActivePresets([PRESET]); + + // White promotes first. + const whitePromote = engine.findMove( + algebraicToSquare("a7"), + algebraicToSquare("a8"), + "queen", + )!; + expect(engine.applyMove(whitePromote)).toBe("white-wins"); + + const state = engine.presetState("first-promotion-wins"); + expect(state.get("winner")).toBe("white"); + + // Post-terminal: engine turn flipped to black. Attempt black's + // promotion move. The new queen on a8 doesn't see h1, and kings + // aren't on rank 8 — so h1=queen is legal for black. + const blackPromote = engine.findMove( + algebraicToSquare("h2"), + algebraicToSquare("h1"), + "queen", + ); + expect(blackPromote).not.toBeNull(); + engine.applyMove(blackPromote!); + + // The lock must hold. + expect(state.get("winner")).toBe("white"); + expect(engine.checkGameResult()).toBe("white-wins"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// f. Composition with `pawns-only` layout +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — composition with pawns-only layout", () => { + it("(f) constructs with pawns-only + preset; a white promotion wins", () => { + const engine = new ChessEngine({ layout: PAWNS_ONLY_LAYOUT }); + engine.setActivePresets([PRESET]); + // Layout provides a king each side + 8 pawns. Manually promote + // the a-pawn by jumping it to a7 then pushing. Faster: retract + // the black a-pawn and move the white a-pawn to a7 directly. + const whiteAPawn = pieceAt(engine, "a2")!; + const blackAPawn = pieceAt(engine, "a7"); + // The blackAPawn sits on a7 in the pawns-only layout — retract + // it so we can occupy a7. + if (blackAPawn !== null) { + for (const attr of engine.effectivePieceAttrs) { + if (engine.session.contains(blackAPawn, attr)) + engine.session.retract(blackAPawn, attr); + } + } + engine.session.insert(whiteAPawn, "Position", algebraicToSquare("a7")); + engine.session.insert(whiteAPawn, "HasMoved", true); + + const move = engine.findMove( + algebraicToSquare("a7"), + algebraicToSquare("a8"), + "queen", + ); + expect(move).not.toBeNull(); + expect(engine.applyMove(move!)).toBe("white-wins"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// g. Composition with `piece-hp` +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — composition with piece-hp", () => { + it("(g) a promotion still ends the game; HP applies to the new queen", () => { + const engine = new ChessEngine(); + minimalPromotionBoard(engine, { + turn: "white", + pawnSquare: "a7", + pawnColor: "white", + }); + engine.setActivePresets([ + { id: "piece-hp", scope: "both", turnsRemaining: null }, + PRESET, + ]); + + const pawnId = pieceAt(engine, "a7")!; + // Activation should have seeded the pawn with Hp (default 2). + expect(engine.session.get(pawnId, "Hp")).toBe(2); + + const move = engine.findMove( + algebraicToSquare("a7"), + algebraicToSquare("a8"), + "queen", + )!; + const result = engine.applyMove(move); + + // `piece-hp` also overrides onCheckGameResult (king-retraction + // based) and ships earlier in the active list. For THIS move, + // no king was retracted so piece-hp returns undefined and + // first-promotion-wins' "white-wins" takes over. + expect(result).toBe("white-wins"); + + // Same entity id, now a queen, HP untouched (the damage pipeline + // didn't fire on a non-capture promotion). + const a8 = pieceAt(engine, "a8"); + expect(a8).toBe(pawnId); + expect(engine.session.get(pawnId, "PieceType")).toBe("queen"); + expect(engine.session.get(pawnId, "Hp")).toBe(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// h. Composition with `double-move` (skip if not registered) +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — composition with double-move", () => { + const doubleMoveDef = PRESET_REGISTRY.get("double-move"); + + it.skipIf(!doubleMoveDef)( + "(h) promotion on white's FIRST half-move ends the game immediately", + () => { + const engine = new ChessEngine(); + minimalPromotionBoard(engine, { + turn: "white", + pawnSquare: "a7", + pawnColor: "white", + }); + engine.setActivePresets([ + { id: "double-move", scope: "both", turnsRemaining: null }, + PRESET, + ]); + + const move = engine.findMove( + algebraicToSquare("a7"), + algebraicToSquare("a8"), + "queen", + )!; + // double-move would normally veto the flip on white's first + // half-move, but that doesn't matter — onCheckGameResult runs + // regardless and returns "white-wins" from our preset. + expect(engine.applyMove(move)).toBe("white-wins"); + const state = engine.presetState("first-promotion-wins"); + expect(state.get("winner")).toBe("white"); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────── +// i. Incompatibility +// ───────────────────────────────────────────────────────────────────── + +describe("first-promotion-wins — incompatibility", () => { + it("(i) activating alongside capture-to-win throws", () => { + const engine = new ChessEngine(); + expect(() => + engine.setActivePresets([ + PRESET, + { id: "capture-to-win", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/incompatible/i); + }); + + it("(i2) activating alongside last-piece-standing throws", () => { + const engine = new ChessEngine(); + expect(() => + engine.setActivePresets([ + PRESET, + { id: "last-piece-standing", scope: "both", turnsRemaining: null }, + ]), + ).toThrow(/incompatible/i); + }); +}); diff --git a/packages/chess/src/presets/first-promotion-wins.ts b/packages/chess/src/presets/first-promotion-wins.ts new file mode 100644 index 0000000..89ba501 --- /dev/null +++ b/packages/chess/src/presets/first-promotion-wins.ts @@ -0,0 +1,202 @@ +/** + * Preset: `first-promotion-wins` (RULES.md / greenchess "Pawns-Only"). + * + * The first player to promote a pawn wins the game immediately. + * Pairs canonically with the `pawns-only` layout — kings + pawns + * only, race to the back rank. Usable with any layout that contains + * pawns, including classic FIDE. + * + * Wiring + * ────── + * Composes three existing hooks; no new engine surface required: + * + * - `onBeforeMove` — given (pieceId, from, to), detect whether the + * move about to commit is a pawn-to-last-rank push. If so, flag + * the piece id in preset state as "pending". We inspect the + * session HERE because the engine hasn't mutated yet — we see + * the piece's pre-move PieceType ("pawn") and can compute the + * promotion-rank predicate from `to`. We don't set winner here + * because a later onBeforeMove in the preset chain could still + * veto the move. + * + * - `onAfterMove` — the move has definitely committed (any cancel + * would have thrown before us). Check the pending piece id: if + * its PieceType is no longer "pawn", a promotion resolved — + * record the mover's color as the winner. Defensive: once set, + * never overwrite. "First promotion wins" means a second + * promotion on a later move can't flip the outcome. + * + * - `onCheckGameResult` — convert the recorded winner (if any) into + * the corresponding GameResult (white → "white-wins", black → + * "black-wins"). Otherwise return undefined and let the default + * checkmate/stalemate/draw logic run — so a game can still end in + * stalemate if somehow no promotion happens. + * + * - State lives in `engine.presetState("first-promotion-wins")` so + * it's auto-cleared on deactivate and never collides with other + * presets' state bags. + * + * Why not just read `engine.moveLog`? + * ─────────────────────────────────── + * The engine appends to `moveLog` AFTER `onAfterMove` and + * `checkGameResult` have already run (see `engine.applyMove`). So + * the tail of `moveLog` inside `onAfterMove` refers to the PREVIOUS + * move, not the current one. We track promotion across hooks + * explicitly instead. + * + * Why we do NOT implement `shouldFilterSelfCheck` + * ─────────────────────────────────────────────── + * The Phase B.4 plan noted "promotion is a victory condition and + * should trump king safety, so the preset should opt out of the + * self-check filter for the mover." We deliberately DON'T do that, + * for a subtle-but-important reason: + * + * Opting out of `shouldFilterSelfCheck` is an ALL-OR-NOTHING + * switch. Returning `false` means every move of the mover's color + * bypasses the self-check filter, not just promotion moves. + * That would silently legalize unrelated exposing moves — a rook + * sliding off the king's pin line, a knight abandoning an + * interposition. Those shouldn't be legal in a variant where the + * king still matters. + * + * The conservative call: KEEP the self-check filter on. A pawn that + * happens to be pinned can't promote in this variant. If a + * stakeholder wants the permissive "king safety is irrelevant" + * variant, that's a follow-up: either a scoped-hook extension that + * only waives the filter for promotion-reaching pawns, or a stacking + * preset that composes with this one and opts out explicitly. Until + * that design lands, we ship the safe shape. + * + * Layout coupling + * ─────────────── + * `layouts/pawns-only.ts` declares `suggestedPresets: + * ["first-promotion-wins"]`. Changing this preset's `id` would + * break that pairing — don't rename without updating the layout. + * + * Compatibility + * ───────────── + * Incompatible with every other preset that redefines "when is the + * game over": + * - `capture-to-win`, `last-piece-standing`, `extinction-chess`, + * `suicide-chess`, `capture-all` — all override + * `onCheckGameResult`. Coexisting would be racy: first non- + * undefined return wins at runtime (registration order), which + * is fragile and surprising. Declared in `incompatibleWith` so + * `setActivePresets` rejects stacks up-front. + * + * Compatible with (tested): + * - `piece-hp`: HP applies to the freshly-promoted piece too; + * the promotion fires the game-end hook before any damage can + * land on the new queen. + * - `double-move`: a promotion during white's first half-move + * ends the game; the turn-advance hook never matters because + * `checkGameResult` short-circuits to "white-wins". + */ +import { PRESET_REGISTRY } from "./registry.js"; +import type { GameResult } from "../engine.js"; +import type { EntityId } from "@paratype/rete"; + +interface FirstPromotionWinsState extends Record { + winner: "white" | "black"; + /** + * Entity id of the pawn that's ABOUT to promote — set by + * `onBeforeMove` when the move about to commit is a pawn reaching + * its promotion rank. Cleared in `onAfterMove` after reading. + * + * We use this two-phase handoff because moveLog's terminal record + * isn't available during `onAfterMove` (the engine pushes to the + * log AFTER all post-move hooks, after `checkGameResult`). Reading + * session state directly is equally brittle — the promotion has + * already rewritten the PieceType fact, so "is this piece still a + * pawn?" returns false for every post-promotion piece forever, not + * just on the move that promoted. The pre-move latch is the + * unambiguous signal. + */ + pendingPromoterId: EntityId; + /** Whose move set `pendingPromoterId` — the winner if promotion resolves. */ + pendingPromoterColor: "white" | "black"; +} + +PRESET_REGISTRY.register({ + id: "first-promotion-wins", + name: "First to Promote Wins", + description: + "The first player to promote a pawn wins the game immediately. Pairs with pawns-only layouts for a race-to-queen variant.", + incompatibleWith: [ + "capture-to-win", + "last-piece-standing", + "extinction-chess", + "suicide-chess", + "capture-all", + ], + requires: [], + + onBeforeMove({ engine, mover, pieceId, to }) { + // Detect "this move IS a promotion": piece is currently a pawn + // AND the destination `to` is on the last rank for that color. + // white promotes landing on rank 8 (squares 56..63) + // black promotes landing on rank 1 (squares 0..7) + const type = engine.session.get(pieceId, "PieceType"); + if (type !== "pawn") return; + const promoting = + (mover === "white" && to >= 56 && to <= 63) || + (mover === "black" && to >= 0 && to <= 7); + if (!promoting) return; + + const state = engine.presetState( + "first-promotion-wins", + ); + state.set("pendingPromoterId", pieceId); + state.set("pendingPromoterColor", mover); + }, + + onAfterMove({ engine }) { + const state = engine.presetState( + "first-promotion-wins", + ); + // Defensive: first promotion locks the result. Subsequent + // promotions (if the game keeps going — e.g. an external caller + // ignored the terminal signal) must not overwrite. + if (state.has("winner")) { + // Still clear any stale pending latch so a non-promotion move + // later doesn't re-trigger. + if (state.has("pendingPromoterId")) state.delete("pendingPromoterId"); + if (state.has("pendingPromoterColor")) state.delete("pendingPromoterColor"); + return; + } + + if (!state.has("pendingPromoterId")) return; + + const promoterId = state.get("pendingPromoterId"); + const color = state.get("pendingPromoterColor"); + state.delete("pendingPromoterId"); + state.delete("pendingPromoterColor"); + + if (promoterId === undefined || color === undefined) return; + + // Confirm the promotion actually resolved: the piece's PieceType + // should now be something other than "pawn". If the engine's + // promotion path was short-circuited by another preset (fission + // into R+B, extinction redirect, etc.), we trust the engine's + // final state — if the piece is still a pawn, no promotion + // happened. + const postType = engine.session.get(promoterId, "PieceType"); + if (postType === "pawn" || postType === undefined) return; + + state.set("winner", color); + }, + + onCheckGameResult({ engine }): GameResult | undefined { + const state = engine.presetState( + "first-promotion-wins", + ); + const winner = state.get("winner"); + if (winner === "white") return "white-wins"; + if (winner === "black") return "black-wins"; + return undefined; + }, + + // NO `shouldFilterSelfCheck` — see docblock above for reasoning. + // Preset state is auto-cleared on deactivate by the engine's + // `clearPresetState` wiring in `setActivePresets`. +}); diff --git a/packages/chess/src/presets/index.ts b/packages/chess/src/presets/index.ts index 247992c..f37114e 100644 --- a/packages/chess/src/presets/index.ts +++ b/packages/chess/src/presets/index.ts @@ -31,5 +31,6 @@ import "./knight-immunity.js"; import "./knightmate-rules.js"; import "./monster-rules.js"; import "./poisoned-squares.js"; +import "./first-promotion-wins.js"; export { PRESET_REGISTRY, type PresetDef } from "./registry.js"; diff --git a/packages/chess/src/presets/monster-rules.test.ts b/packages/chess/src/presets/monster-rules.test.ts index b85042a..3b4bec7 100644 --- a/packages/chess/src/presets/monster-rules.test.ts +++ b/packages/chess/src/presets/monster-rules.test.ts @@ -42,9 +42,14 @@ function firstLegalMove(engine: ChessEngine) { return moves[0]!; } +/** + * Activate monster-rules with the CANONICAL scope (white gets the + * double-move). Tests that want the flip or symmetric mode set up + * the activation inline. + */ function activate(engine: ChessEngine): void { engine.setActivePresets([ - { id: MONSTER_RULES_ID, scope: "both", turnsRemaining: null }, + { id: MONSTER_RULES_ID, scope: "white", turnsRemaining: null }, ]); } @@ -160,7 +165,7 @@ describe("monster-rules — onTurnStart cadence", () => { const engine = new ChessEngine(); engine.setActivePresets([ - { id: MONSTER_RULES_ID, scope: "both", turnsRemaining: null }, + { id: MONSTER_RULES_ID, scope: "white", turnsRemaining: null }, { id: PROBE_ID, scope: "both", turnsRemaining: null }, ]); @@ -343,6 +348,61 @@ describe("monster-rules — incompatibility", () => { }); }); +// ───────────────────────────────────────────────────────────────────── +// Scope inversion: scope="black" flips which side gets the double +// ───────────────────────────────────────────────────────────────────── + +describe("monster-rules — scope='black' flips asymmetry", () => { + it("black gets two half-moves per turn; white gets one", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: MONSTER_RULES_ID, scope: "black", turnsRemaining: null }, + ]); + + // White's single move → flip to black (no veto for white). + engine.applyMove(firstLegalMove(engine)); + expect(engine.getCurrentTurn()).toBe("black"); + expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0); + + // Black's first move → veto, still black. + engine.applyMove(firstLegalMove(engine)); + expect(engine.getCurrentTurn()).toBe("black"); + expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1); + + // Black's second move → flip to white. + engine.applyMove(firstLegalMove(engine)); + expect(engine.getCurrentTurn()).toBe("white"); + expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0); + + // White's next single move → flip to black. + engine.applyMove(firstLegalMove(engine)); + expect(engine.getCurrentTurn()).toBe("black"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Symmetric mode: scope="both" → both sides double-move (like double-move) +// ───────────────────────────────────────────────────────────────────── + +describe("monster-rules — scope='both' makes the rule symmetric", () => { + it("both sides play two half-moves per turn (same as double-move)", () => { + const engine = new ChessEngine(); + engine.setActivePresets([ + { id: MONSTER_RULES_ID, scope: "both", turnsRemaining: null }, + ]); + + // W1 veto, W2 flip, B1 veto, B2 flip. + engine.applyMove(firstLegalMove(engine)); + expect(engine.getCurrentTurn()).toBe("white"); + engine.applyMove(firstLegalMove(engine)); + expect(engine.getCurrentTurn()).toBe("black"); + engine.applyMove(firstLegalMove(engine)); + expect(engine.getCurrentTurn()).toBe("black"); + engine.applyMove(firstLegalMove(engine)); + expect(engine.getCurrentTurn()).toBe("white"); + }); +}); + // ───────────────────────────────────────────────────────────────────── // Session snapshot integrity // ───────────────────────────────────────────────────────────────────── diff --git a/packages/chess/src/presets/monster-rules.ts b/packages/chess/src/presets/monster-rules.ts index 415793c..66d6a1a 100644 --- a/packages/chess/src/presets/monster-rules.ts +++ b/packages/chess/src/presets/monster-rules.ts @@ -1,74 +1,93 @@ /** * 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. + * Asymmetric double-move variant. ONE side plays two half-moves + * per turn; the other plays one. Which side gets the double is + * determined by the preset's activation `scope`: + * + * - `scope: "white"` (default in the canonical Monster pairing) + * → WHITE gets two half-moves per turn, BLACK gets one. + * Pairs with the `monster` layout, which gives white a reduced + * army (king + 4 pawns). + * + * - `scope: "black"` → BLACK gets two half-moves per turn, WHITE + * gets one. Symmetric flip; useful for "monster black" setups + * or variant exploration. + * + * - `scope: "both"` → BOTH sides get two half-moves per turn, + * equivalent to `double-move`. This mode is allowed but the + * `double-move` preset expresses the same rule more clearly; + * prefer that preset when symmetric double-moves are desired. * * 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 + * the color of the mover. Phase A.3 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. + * Monster's rule, parameterized by scope: + * - Look up this preset's entry in `engine.activePresets.list()` + * to recover its `scope`. + * - If `scope === "both"`, veto whenever `halfMovesThisTurn < 2` + * regardless of mover (both sides get the double-move). + * - Otherwise veto whenever `ctx.mover === scope` AND + * `halfMovesThisTurn < 2`. The non-scope side plays a single + * half-move per turn. + * - In all other cases return `undefined` (let the default + * flip proceed). * - * 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.) + * Reading the scope from the engine at hook time (rather than + * capturing it at registration) is the idiomatic pattern: the + * user may re-activate the preset with a different scope mid-game + * and expect the behaviour to update immediately. * * Incompatibilities * ───────────────── * Declared incompatible with: - * - `double-move` — that preset ALSO vetoes the flip after 1, + * - `double-move` — that preset also vetoes the flip after 1, * so stacking them either double-vetoes or produces undefined - * orderings. Users should pick one. + * orderings. Users should pick one (or use `scope: "both"` + * here). * - `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). + * mandatory extra half-move per turn. * - * 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. + * Checkmate / stalemate detection is unaffected. */ import { PRESET_REGISTRY } from "./registry.js"; +const PRESET_ID = "monster-rules"; + PRESET_REGISTRY.register({ - id: "monster-rules", + id: PRESET_ID, name: "Monster", description: - "White plays two half-moves per turn; black plays one. Classic Monster chess pairing.", + "One side plays two half-moves per turn; the other plays one. Activate with scope=white (default) or scope=black to choose which side gets the double-move.", 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. + shouldAdvanceTurn({ engine, mover, halfMovesThisTurn }) { + // Recover THIS preset's active scope — the "which side gets + // the double-move" knob. If for some reason the preset isn't + // in the active list (shouldn't happen — hook is only fired + // for active presets) we treat it as "both" and veto + // symmetrically. + let scope: "white" | "black" | "both" = "both"; + for (const entry of engine.activePresets.list()) { + if (entry.id === PRESET_ID) { + scope = entry.scope; + break; + } + } + + // Does the CURRENT mover get the double-move this turn? + const moverGetsDouble = scope === "both" || scope === mover; + if (moverGetsDouble && halfMovesThisTurn < 2) return false; + // Either the non-double side, or the double side on its + // second half-move: let the default flip. return undefined; }, }); diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 6190ab7..9468944 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -44,6 +44,7 @@ describe("Preset registry — all registered", () => { "knightmate-rules", "monster-rules", "poisoned-squares", + "first-promotion-wins", ]; it("registry size matches the expected ID list", () => {