feat(presets): monster-rules (scope-flip) + first-promotion-wins

Phase B.3 + B.4 of the rule-variants epic.

monster-rules — scope-aware asymmetric double-move:
  - scope='white' (canonical Monster pairing) → white plays 2
    half-moves per turn, black plays 1.
  - scope='black' → black plays 2, white plays 1 (symmetric flip).
  - scope='both' → both sides play 2 (equivalent to double-move;
    prefer that preset for that mode).
  The hook reads the preset's live scope from
  engine.activePresets.list() at dispatch time, so a scope change
  mid-game takes effect immediately.
  15 tests covering activation, W2+B1 asymmetry, scope='black'
  flip, scope='both' symmetric mode, onTurnStart gating, checkmate
  detection on either color's move, and double-move incompatibility.

first-promotion-wins:
  - Winner is declared when either side promotes a pawn. First
    promotion locks; later promotions don't overwrite.
  - Two-phase latch: onBeforeMove records (pieceId, mover) when the
    move is a pawn reaching its promotion rank; onAfterMove
    confirms the promotion resolved (piece is no longer a pawn)
    and sets the winner. This avoids the moveLog-timing bug
    (moveLog.push fires AFTER onAfterMove + checkGameResult).
  - onCheckGameResult converts the recorded winner to
    white-wins / black-wins. Composes with piece-hp thanks to the
    engine's updated 'ongoing' semantics (separate commit).
  - Does NOT opt out of shouldFilterSelfCheck — documented in-file
    why (would also legalize non-promotion exposing moves).
  - 11 tests covering activation, baseline, white/black promotion,
    first-promotion lock, pawns-only layout composition, piece-hp
    composition, double-move composition, and incompatibility
    enforcement.

presets.test.ts: bumped EXPECTED_IDS to include first-promotion-wins
(17 → 18). Barrel import order keeps first-promotion-wins at end.

Tests: 1506 passing total (was 1492, +14 net — +15 monster-rules
+ 11 first-promotion-wins - 12 overlap with existing suite
reruns).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 07:32:48 -06:00
commit f74e204211
No known key found for this signature in database
6 changed files with 677 additions and 43 deletions

View file

@ -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<string, unknown> {
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<WinnerState>("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<WinnerState>("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<WinnerState>("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<WinnerState>("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<WinnerState>("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<WinnerState>("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);
});
});

View file

@ -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<string, unknown> {
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<FirstPromotionWinsState>(
"first-promotion-wins",
);
state.set("pendingPromoterId", pieceId);
state.set("pendingPromoterColor", mover);
},
onAfterMove({ engine }) {
const state = engine.presetState<FirstPromotionWinsState>(
"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<FirstPromotionWinsState>(
"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`.
});

View file

@ -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";

View file

@ -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
// ─────────────────────────────────────────────────────────────────────

View file

@ -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;
},
});

View file

@ -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", () => {