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:
parent
7171dfdd5e
commit
f74e204211
6 changed files with 677 additions and 43 deletions
351
packages/chess/src/presets/first-promotion-wins.test.ts
Normal file
351
packages/chess/src/presets/first-promotion-wins.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
202
packages/chess/src/presets/first-promotion-wins.ts
Normal file
202
packages/chess/src/presets/first-promotion-wins.ts
Normal 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`.
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue