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 "./knightmate-rules.js";
|
||||||
import "./monster-rules.js";
|
import "./monster-rules.js";
|
||||||
import "./poisoned-squares.js";
|
import "./poisoned-squares.js";
|
||||||
|
import "./first-promotion-wins.js";
|
||||||
|
|
||||||
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";
|
export { PRESET_REGISTRY, type PresetDef } from "./registry.js";
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,14 @@ function firstLegalMove(engine: ChessEngine) {
|
||||||
return moves[0]!;
|
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 {
|
function activate(engine: ChessEngine): void {
|
||||||
engine.setActivePresets([
|
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();
|
const engine = new ChessEngine();
|
||||||
engine.setActivePresets([
|
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 },
|
{ 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
|
// Session snapshot integrity
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,93 @@
|
||||||
/**
|
/**
|
||||||
* Preset: Monster Chess rules (rule-variants epic, Phase B.3).
|
* Preset: Monster Chess rules (rule-variants epic, Phase B.3).
|
||||||
*
|
*
|
||||||
* Asymmetric double-move variant. WHITE plays two half-moves per
|
* Asymmetric double-move variant. ONE side plays two half-moves
|
||||||
* turn; BLACK plays one. Canonically paired with the `monster`
|
* per turn; the other plays one. Which side gets the double is
|
||||||
* layout (see `layouts/monster.ts`), which gives white a reduced
|
* determined by the preset's activation `scope`:
|
||||||
* army (king + 4 pawns) — the "play twice" compensation is what
|
*
|
||||||
* keeps the variant balanced at high level.
|
* - `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
|
* How the veto works
|
||||||
* ──────────────────
|
* ──────────────────
|
||||||
* The engine invokes every active preset's `shouldAdvanceTurn`
|
* The engine invokes every active preset's `shouldAdvanceTurn`
|
||||||
* hook AFTER committing the move and AFTER incrementing
|
* hook AFTER committing the move and AFTER incrementing
|
||||||
* `HalfMovesThisTurn`. The hook sees the post-increment count and
|
* `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
|
* wins": as soon as any preset vetoes, the engine skips the flip
|
||||||
* (and `HalfMovesThisTurn` is NOT reset), so the mover plays
|
* (and `HalfMovesThisTurn` is NOT reset), so the mover plays
|
||||||
* again.
|
* again.
|
||||||
*
|
*
|
||||||
* Monster's rule:
|
* Monster's rule, parameterized by scope:
|
||||||
* - If `mover === "white"` AND `halfMovesThisTurn < 2` → veto
|
* - Look up this preset's entry in `engine.activePresets.list()`
|
||||||
* (return `false`). White gets a second half-move.
|
* to recover its `scope`.
|
||||||
* - Otherwise return `undefined` (no opinion) — the engine's
|
* - If `scope === "both"`, veto whenever `halfMovesThisTurn < 2`
|
||||||
* default flips the turn. In particular BLACK's single move
|
* regardless of mover (both sides get the double-move).
|
||||||
* always flips by default.
|
* - 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
|
* Reading the scope from the engine at hook time (rather than
|
||||||
* ─────────────────────────────────────
|
* capturing it at registration) is the idiomatic pattern: the
|
||||||
* Unlike some scope-aware presets that read `engine.activePresets`
|
* user may re-activate the preset with a different scope mid-game
|
||||||
* to decide behaviour, this preset decides purely from `ctx.mover`.
|
* and expect the behaviour to update immediately.
|
||||||
* That's intentional: Monster's rule is "white has a mandatory
|
|
||||||
* double-move", full stop — it does NOT depend on whether the
|
|
||||||
* preset was activated with `scope: "white"` vs `scope: "both"`.
|
|
||||||
* (The scope still governs duration-ticking and preset-lifecycle
|
|
||||||
* semantics per `ActivePresetSet`, but not the hook's behaviour.)
|
|
||||||
*
|
*
|
||||||
* Incompatibilities
|
* Incompatibilities
|
||||||
* ─────────────────
|
* ─────────────────
|
||||||
* Declared incompatible with:
|
* 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
|
* 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
|
* - `suicide-chess`, `capture-all` — these redefine the notion
|
||||||
* of "legal move" in ways that interact poorly with a
|
* of "legal move" in ways that interact poorly with a
|
||||||
* mandatory extra half-move per turn (e.g. white's first move
|
* mandatory extra half-move per turn.
|
||||||
* might be forced into a suicide capture, leaving no sensible
|
|
||||||
* second move).
|
|
||||||
*
|
*
|
||||||
* Checkmate / stalemate detection is unaffected: the engine
|
* Checkmate / stalemate detection is unaffected.
|
||||||
* continues to poll its default checkmate/stalemate predicates
|
|
||||||
* against the color that is NEXT TO MOVE after each flip. With
|
|
||||||
* monster-rules, a mate delivered by white's first half-move
|
|
||||||
* surfaces as "checkmate" once white's second half-move lands
|
|
||||||
* and the flip actually proceeds. A mate delivered by black on
|
|
||||||
* its single move surfaces immediately on flip, same as default.
|
|
||||||
*/
|
*/
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
|
|
||||||
|
const PRESET_ID = "monster-rules";
|
||||||
|
|
||||||
PRESET_REGISTRY.register({
|
PRESET_REGISTRY.register({
|
||||||
id: "monster-rules",
|
id: PRESET_ID,
|
||||||
name: "Monster",
|
name: "Monster",
|
||||||
description:
|
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"],
|
incompatibleWith: ["double-move", "suicide-chess", "capture-all"],
|
||||||
requires: [],
|
requires: [],
|
||||||
shouldAdvanceTurn({ mover, halfMovesThisTurn }) {
|
shouldAdvanceTurn({ engine, mover, halfMovesThisTurn }) {
|
||||||
// White gets a mandatory second half-move. The engine polls this
|
// Recover THIS preset's active scope — the "which side gets
|
||||||
// hook AFTER incrementing HalfMovesThisTurn, so seeing 1 means
|
// the double-move" knob. If for some reason the preset isn't
|
||||||
// "white just played its first move this turn" — veto.
|
// in the active list (shouldn't happen — hook is only fired
|
||||||
if (mover === "white" && halfMovesThisTurn < 2) return false;
|
// for active presets) we treat it as "both" and veto
|
||||||
// Black, or white's second half-move: let the default flip.
|
// 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;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ describe("Preset registry — all registered", () => {
|
||||||
"knightmate-rules",
|
"knightmate-rules",
|
||||||
"monster-rules",
|
"monster-rules",
|
||||||
"poisoned-squares",
|
"poisoned-squares",
|
||||||
|
"first-promotion-wins",
|
||||||
];
|
];
|
||||||
|
|
||||||
it("registry size matches the expected ID list", () => {
|
it("registry size matches the expected ID list", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue