feat(presets): double-move (both sides play 2 half-moves per turn)
Phase B.2 of the rule-variants epic. Each side plays two consecutive half-moves before the turn flips; turn order is WW BB WW BB … Implementation is a two-line `shouldAdvanceTurn` hook on top of the Phase A.3 `HalfMovesThisTurn` fact — veto the flip while the post- increment count is < 2, let the engine default flip proceed otherwise. No engine, schema, or layout modifications. Check / checkmate composition is free: the self-check filter runs per half-move (so neither half-move may leave own king exposed), and `checkGameResult` runs after every applyMove regardless of flip, so mate on the mover's second half-move lands exactly where it should without any double-move-specific terminal logic. Documented composition with `piece-hp` (independent; damage pipeline doesn't read HalfMovesThisTurn) and `king-heals` (uses onAfterMove, not onTurnStart — fires on every half-move including vetoed ones; pinned by the test). Declared `incompatibleWith: ["monster-rules"]` because both redefine the flip hook in conflicting ways. Test count: 1461 previous → 1479 total (+18). The double-move.test.ts file contributes 18 behavioral tests covering preset definition sanity, WW BB WW counter semantics, onTurnStart cadence, mid-turn check delivery, Fool's-Mate-style and Scholar's-Mate-style checkmate detection, composition with piece-hp, composition with king-heals, and session-snapshot integrity mid-veto. Exceeds the 12-test floor. - packages/chess/src/presets/double-move.ts (new) - packages/chess/src/presets/double-move.test.ts (new, 18 tests)
This commit is contained in:
parent
7b97a77ee3
commit
770d0fd9cf
2 changed files with 548 additions and 0 deletions
455
packages/chess/src/presets/double-move.test.ts
Normal file
455
packages/chess/src/presets/double-move.test.ts
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
/**
|
||||||
|
* Behavioural tests for the `double-move` preset (Phase B.2 of the
|
||||||
|
* rule-variants epic).
|
||||||
|
*
|
||||||
|
* The preset is built on `shouldAdvanceTurn`, which was tested
|
||||||
|
* generically in `turn-advance.test.ts` via inline prototype presets.
|
||||||
|
* THIS file exercises the real `double-move` definition from the
|
||||||
|
* registry and its integration with the rest of the preset suite.
|
||||||
|
*
|
||||||
|
* Coverage
|
||||||
|
* - Activation is non-throwing against a fresh engine.
|
||||||
|
* - HalfMovesThisTurn semantics across WW BB WW cycles.
|
||||||
|
* - Turn / FullmoveNumber bookkeeping.
|
||||||
|
* - onTurnStart firing cadence (fires exactly once per real flip,
|
||||||
|
* i.e. every second half-move).
|
||||||
|
* - Check delivered on the mover's first half-move: flip is still
|
||||||
|
* vetoed, opponent is in check at session level, second half-move
|
||||||
|
* remains available.
|
||||||
|
* - Fool's-Mate-style scenario: mate is detected the moment the
|
||||||
|
* attacking move lands, even though it's the mover's own move
|
||||||
|
* that triggers the flip.
|
||||||
|
* - Composition with `piece-hp`: no interference.
|
||||||
|
* - Composition with `king-heals`: heal fires via `onAfterMove` —
|
||||||
|
* which runs on every half-move, including vetoed ones — so the
|
||||||
|
* test pins down the actual observed cadence rather than the
|
||||||
|
* (incorrect) prior claim that heal is onTurnStart-gated. The
|
||||||
|
* docblock in double-move.ts explains the reasoning.
|
||||||
|
* - Session snapshot includes HalfMovesThisTurn during a vetoed
|
||||||
|
* mid-turn state.
|
||||||
|
*/
|
||||||
|
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 { PRESET_REGISTRY } from "./registry.js";
|
||||||
|
import { pieceAt, hpOf } from "./test-utils.js";
|
||||||
|
import { isInCheck } from "../rules/check.js";
|
||||||
|
|
||||||
|
const DOUBLE_MOVE_ID = "double-move";
|
||||||
|
|
||||||
|
function firstLegalMove(engine: ChessEngine) {
|
||||||
|
const moves = engine.getAllLegalMoves();
|
||||||
|
if (moves.length === 0) throw new Error("no legal moves available");
|
||||||
|
return moves[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateDoubleMove(engine: ChessEngine): void {
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: DOUBLE_MOVE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("double-move — preset definition sanity", () => {
|
||||||
|
it("is registered with the expected id, flip-veto, and metadata", () => {
|
||||||
|
const def = PRESET_REGISTRY.get(DOUBLE_MOVE_ID);
|
||||||
|
expect(def).toBeDefined();
|
||||||
|
expect(def!.name).toBe("Double Move");
|
||||||
|
expect(def!.incompatibleWith).toContain("monster-rules");
|
||||||
|
expect(def!.requires).toEqual([]);
|
||||||
|
expect(typeof def!.shouldAdvanceTurn).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates without throwing on a fresh engine (no layout coupling)", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
expect(() => activateDoubleMove(engine)).not.toThrow();
|
||||||
|
const ids = engine.activePresets.list().map((e) => e.id);
|
||||||
|
expect(ids).toContain(DOUBLE_MOVE_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-move — HalfMovesThisTurn baseline", () => {
|
||||||
|
it("starts at 0 on a fresh engine before any preset is activated", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays at 0 immediately after activating double-move (no move yet)", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-move — WW BB WW cycle", () => {
|
||||||
|
it("white's first move: Turn stays white, count=1, FullmoveNumber=1", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("white's second move: Turn flips to black, count resets to 0, fullmove still 1", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
// FullmoveNumber increments only after black; still 1 here.
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("black's first move: Turn stays black, count=1", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W1
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W2 → flip to black
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // B1 → veto
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("black's second move: Turn flips to white, count=0, FullmoveNumber=2", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W1
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W2
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // B1
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // B2 → flip
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("third full turn (WW BB WW): counter and FullmoveNumber track correctly", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
// WW BB WW BB WW = 10 half-moves
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
}
|
||||||
|
// After 4 half-moves: Turn=white, fullmove=2, count=0
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W1 of turn 3
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W2
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // B1
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
|
||||||
|
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // B2 → flip; fullmove=3
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-move — onTurnStart cadence", () => {
|
||||||
|
const PROBE_ID = "test-double-move-onturnstart-probe";
|
||||||
|
|
||||||
|
it("onTurnStart fires exactly once per real flip (2 times over WW BB)", () => {
|
||||||
|
let invocations = 0;
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: PROBE_ID,
|
||||||
|
name: "Turn-start probe (double-move)",
|
||||||
|
description: "Counts onTurnStart invocations alongside double-move.",
|
||||||
|
incompatibleWith: [],
|
||||||
|
requires: [],
|
||||||
|
onTurnStart() {
|
||||||
|
invocations++;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: DOUBLE_MOVE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: PROBE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(invocations).toBe(0);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W1 veto — no fire
|
||||||
|
expect(invocations).toBe(0);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W2 flip — fire
|
||||||
|
expect(invocations).toBe(1);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // B1 veto — no fire
|
||||||
|
expect(invocations).toBe(1);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // B2 flip — fire
|
||||||
|
expect(invocations).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-move — check mid-turn", () => {
|
||||||
|
it("a check delivered on white's first half-move leaves Turn with white; black is in check at session level; white keeps the second half-move", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
// Build a position where white can deliver check on a single
|
||||||
|
// half-move without needing setup. We'll fast-forward from the
|
||||||
|
// standard layout via a scripted Scholar's-Mate-adjacent line.
|
||||||
|
// White needs to develop a queen giving check to the black king
|
||||||
|
// on one move. Using the classic Scholar's opening:
|
||||||
|
// W1: e2-e4 (white move 1, veto)
|
||||||
|
// W2: (any) (white move 2, flips to black)
|
||||||
|
// B1: (any) (black move 1, veto)
|
||||||
|
// B2: (any) (black move 2, flips to white)
|
||||||
|
// W3: d1-h5 (queen to h5 — check on f7? No, need open path.)
|
||||||
|
//
|
||||||
|
// Simpler: set up a direct check via d2-d4, Qd1-d3+… hard without
|
||||||
|
// moving pieces out of the way. Let's just play the natural
|
||||||
|
// sequence and check delivery arrives sometime. More robust:
|
||||||
|
// manually run moves and on some pair deliver check, but the
|
||||||
|
// tightest way is to use `findMove` explicitly.
|
||||||
|
//
|
||||||
|
// We play: W1 e4; W2 Bc4 → flips to black. B1 d6 (any); B2 a6 →
|
||||||
|
// flips to white. W3 Qh5 → check on f7 via queen+bishop battery.
|
||||||
|
// Now on W3 white has delivered check, it's white's FIRST move of
|
||||||
|
// the turn, flip is vetoed, black is "in check" but black isn't
|
||||||
|
// to move yet.
|
||||||
|
const play = (from: string, to: string) => {
|
||||||
|
const mv = engine.findMove(algebraicToSquare(from), algebraicToSquare(to));
|
||||||
|
expect(mv, `legal move ${from}${to}`).not.toBeNull();
|
||||||
|
engine.applyMove(mv!);
|
||||||
|
};
|
||||||
|
|
||||||
|
play("e2", "e4"); // W1 — veto
|
||||||
|
play("g1", "f3"); // W2 — flip to black
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
play("e7", "e5"); // B1 — veto (keeps c4-f7 diagonal clear)
|
||||||
|
play("a7", "a6"); // B2 — flip to white
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
play("f1", "c4"); // W1 of turn 3 — develops bishop, veto
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
play("c4", "f7"); // W2 of turn 3 — Bxf7+ — capture and check.
|
||||||
|
// W2 is white's SECOND half-move, so the flip proceeds to black.
|
||||||
|
// Black is now in check, and it's black's turn.
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(isInCheck(engine.session, "black")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("check delivered by white's SECOND half-move: black's turn begins in check, black has two half-moves to resolve it", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
const play = (from: string, to: string) => {
|
||||||
|
const mv = engine.findMove(algebraicToSquare(from), algebraicToSquare(to));
|
||||||
|
expect(mv, `legal move ${from}${to}`).not.toBeNull();
|
||||||
|
engine.applyMove(mv!);
|
||||||
|
};
|
||||||
|
|
||||||
|
play("e2", "e4"); // W1
|
||||||
|
play("d1", "h5"); // W2 — flip; queen to h5. No check yet (path blocked by black pawn on f7? no — f7 is pawn, h5 attacks f7 via diagonal. Queen h5 → f7 blocked by g6? no, g6 empty; f7 attacked by queen. But not check since king on e8.)
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(isInCheck(engine.session, "black")).toBe(false);
|
||||||
|
|
||||||
|
play("a7", "a6"); // B1
|
||||||
|
play("a6", "a5"); // B2 — flip
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
|
||||||
|
play("f1", "c4"); // W1 — develops bishop, veto; not check yet
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(isInCheck(engine.session, "black")).toBe(false);
|
||||||
|
|
||||||
|
play("h5", "f7"); // W2 — Qxf7#-style check (supported by bishop). Flip to black.
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
expect(isInCheck(engine.session, "black")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-move — checkmate detection", () => {
|
||||||
|
it("Fool's-Mate-style: mate detected the moment the attacking move lands, even mid-turn", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
// Classic Fool's Mate: 1. f3 e5 2. g4 Qh4#. With double-move:
|
||||||
|
// W1 f3 (veto) W2 g4 (flip)
|
||||||
|
// B1 e5 (veto) B2 Qh4 — delivers mate as black's SECOND
|
||||||
|
// half-move, flip to white, checkmate.
|
||||||
|
const play = (from: string, to: string) => {
|
||||||
|
const mv = engine.findMove(algebraicToSquare(from), algebraicToSquare(to));
|
||||||
|
expect(mv, `legal move ${from}${to}`).not.toBeNull();
|
||||||
|
engine.applyMove(mv!);
|
||||||
|
};
|
||||||
|
|
||||||
|
play("f2", "f3"); // W1
|
||||||
|
play("g2", "g4"); // W2 — flip
|
||||||
|
play("e7", "e5"); // B1
|
||||||
|
const mateMove = engine.findMove(
|
||||||
|
algebraicToSquare("d8"),
|
||||||
|
algebraicToSquare("h4"),
|
||||||
|
);
|
||||||
|
expect(mateMove, "Qh4 must be legal as B2").not.toBeNull();
|
||||||
|
const result = engine.applyMove(mateMove!); // B2 — flip and mate
|
||||||
|
expect(result).toBe("checkmate");
|
||||||
|
expect(engine.checkGameResult()).toBe("checkmate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mate delivered on white's second half-move: checkmate result, black cannot 'sneak' an extra move", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
|
||||||
|
// Scholar's mate with doubled moves on both sides:
|
||||||
|
// W1 e4 W2 Bc4
|
||||||
|
// B1 e5 B2 a6
|
||||||
|
// W1 Qh5 W2 Qxf7# — checkmate on white's second half-move.
|
||||||
|
// After the flip to black, checkGameResult sees black in check
|
||||||
|
// with no legal responses → checkmate. Black does NOT get a
|
||||||
|
// "bonus half-move" to escape.
|
||||||
|
const play = (from: string, to: string) => {
|
||||||
|
const mv = engine.findMove(algebraicToSquare(from), algebraicToSquare(to));
|
||||||
|
expect(mv, `legal move ${from}${to}`).not.toBeNull();
|
||||||
|
engine.applyMove(mv!);
|
||||||
|
};
|
||||||
|
|
||||||
|
play("e2", "e4"); // W1
|
||||||
|
play("f1", "c4"); // W2 — flip
|
||||||
|
play("e7", "e5"); // B1
|
||||||
|
play("a7", "a6"); // B2 — flip
|
||||||
|
play("d1", "h5"); // W1 (veto)
|
||||||
|
const mateMove = engine.findMove(
|
||||||
|
algebraicToSquare("h5"),
|
||||||
|
algebraicToSquare("f7"),
|
||||||
|
);
|
||||||
|
expect(mateMove, "Qxf7 must be legal as W2").not.toBeNull();
|
||||||
|
const result = engine.applyMove(mateMove!); // W2 — flip and mate
|
||||||
|
expect(result).toBe("checkmate");
|
||||||
|
expect(engine.getCurrentTurn()).toBe("black");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-move — composition with piece-hp", () => {
|
||||||
|
it("activating both presets succeeds and a normal WW BB cycle plays through", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: DOUBLE_MOVE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Every piece should have Hp=2 seeded by piece-hp.
|
||||||
|
const e2 = pieceAt(engine, "e2")!;
|
||||||
|
expect(hpOf(engine, e2)).toBe(2);
|
||||||
|
|
||||||
|
// Play WW BB without incident.
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
}
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HP damage fires on capture regardless of halfMovesThisTurn", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: DOUBLE_MOVE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// W1 e4, W2 (any) → flips; B1 d5 (expose pawn), B2 any → flips;
|
||||||
|
// W1 exd5 — first half-move, capture. Target loses 1 HP
|
||||||
|
// regardless of halfMovesThisTurn.
|
||||||
|
const play = (from: string, to: string) => {
|
||||||
|
const mv = engine.findMove(algebraicToSquare(from), algebraicToSquare(to));
|
||||||
|
expect(mv, `legal move ${from}${to}`).not.toBeNull();
|
||||||
|
engine.applyMove(mv!);
|
||||||
|
};
|
||||||
|
|
||||||
|
play("e2", "e4"); // W1
|
||||||
|
play("g1", "f3"); // W2 flip
|
||||||
|
play("d7", "d5"); // B1
|
||||||
|
play("a7", "a6"); // B2 flip
|
||||||
|
const d5 = pieceAt(engine, "d5")!;
|
||||||
|
expect(hpOf(engine, d5)).toBe(2);
|
||||||
|
|
||||||
|
play("e4", "d5"); // W1 — exd5 as first half-move; non-lethal capture under piece-hp.
|
||||||
|
// With piece-hp active the d5 pawn survives at Hp=1, attacker stays.
|
||||||
|
// Still white's turn (count=1, veto).
|
||||||
|
expect(engine.getCurrentTurn()).toBe("white");
|
||||||
|
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
|
||||||
|
const d5After = pieceAt(engine, "d5")!;
|
||||||
|
expect(hpOf(engine, d5After)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-move — composition with king-heals", () => {
|
||||||
|
it("activates alongside piece-hp + king-heals without error; king heals up to MAX on quiet moves", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.setActivePresets([
|
||||||
|
{ id: DOUBLE_MOVE_ID, scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "king-heals", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Both kings seeded at Hp=2. Damage white's king down to Hp=1,
|
||||||
|
// then play a few uneventful half-moves and verify the healing
|
||||||
|
// cadence is at least SOMETHING — king-heals uses onAfterMove
|
||||||
|
// (fires every half-move, including vetoed ones), so after the
|
||||||
|
// first non-mover half-move white's king should reach Hp=2 and
|
||||||
|
// stay capped there (MAX_KING_HP=3, and since the king isn't
|
||||||
|
// in check it keeps ticking up until capped).
|
||||||
|
const facts = engine.session.allFacts();
|
||||||
|
const whiteKingId =
|
||||||
|
facts.find(
|
||||||
|
(f) =>
|
||||||
|
f.attr === "PieceType" &&
|
||||||
|
f.value === "king" &&
|
||||||
|
facts.find((c) => c.id === f.id && c.attr === "Color")?.value ===
|
||||||
|
"white",
|
||||||
|
)?.id ?? null;
|
||||||
|
expect(whiteKingId).not.toBeNull();
|
||||||
|
engine.session.insert(whiteKingId!, "Hp", 1);
|
||||||
|
|
||||||
|
// W1 (veto): mover is white, so king-heals targets black king
|
||||||
|
// (non-mover). White king NOT healed on this half-move.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.session.get(whiteKingId!, "Hp")).toBe(1);
|
||||||
|
|
||||||
|
// W2 (flip): still mover=white, target of heal = black king.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.session.get(whiteKingId!, "Hp")).toBe(1);
|
||||||
|
|
||||||
|
// B1 (veto): mover=black, heal targets white king → +1 → Hp=2.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.session.get(whiteKingId!, "Hp")).toBe(2);
|
||||||
|
|
||||||
|
// B2 (flip): mover=black, heal targets white king → cap at 3.
|
||||||
|
engine.applyMove(firstLegalMove(engine));
|
||||||
|
expect(engine.session.get(whiteKingId!, "Hp")).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-move — session snapshot integrity", () => {
|
||||||
|
it("HalfMovesThisTurn appears in allFacts() during a vetoed mid-turn state", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
activateDoubleMove(engine);
|
||||||
|
engine.applyMove(firstLegalMove(engine)); // W1 — veto
|
||||||
|
|
||||||
|
const facts = engine.session.allFacts();
|
||||||
|
const halfMoveFact = facts.find(
|
||||||
|
(f) => f.id === GAME_ENTITY && f.attr === "HalfMovesThisTurn",
|
||||||
|
);
|
||||||
|
expect(halfMoveFact).toBeDefined();
|
||||||
|
expect(halfMoveFact!.value).toBe(1);
|
||||||
|
|
||||||
|
// Turn fact still reflects "white" (not flipped yet).
|
||||||
|
const turnFact = facts.find(
|
||||||
|
(f) => f.id === GAME_ENTITY && f.attr === "Turn",
|
||||||
|
);
|
||||||
|
expect(turnFact?.value).toBe("white");
|
||||||
|
});
|
||||||
|
});
|
||||||
93
packages/chess/src/presets/double-move.ts
Normal file
93
packages/chess/src/presets/double-move.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/**
|
||||||
|
* Preset: `double-move` (RULES.md double-move / greenchess cat=4)
|
||||||
|
*
|
||||||
|
* Each side plays TWO half-moves in a row before the turn flips.
|
||||||
|
* Turn order is WW BB WW BB … — white plays two, then black plays two,
|
||||||
|
* and so on. On each half-move the mover picks from their CURRENT
|
||||||
|
* legal-move list as normal; the preset does not alter move generation
|
||||||
|
* in any way, only whether the turn counter advances afterward.
|
||||||
|
*
|
||||||
|
* Implementation
|
||||||
|
* ──────────────
|
||||||
|
* Built entirely on the `shouldAdvanceTurn` hook + `HalfMovesThisTurn`
|
||||||
|
* game fact shipped in Phase A.3. The hook is invoked AFTER the engine
|
||||||
|
* increments `HalfMovesThisTurn`, so the post-increment count reads 1
|
||||||
|
* on the first half-move of a turn and 2 on the second. Predicate:
|
||||||
|
*
|
||||||
|
* halfMovesThisTurn < 2 → return false (veto the flip; mover plays
|
||||||
|
* again on the next applyMove call)
|
||||||
|
* halfMovesThisTurn ≥ 2 → return undefined (no opinion; defaults
|
||||||
|
* proceed, flip happens, HalfMovesThisTurn
|
||||||
|
* resets to 0, FullmoveNumber increments
|
||||||
|
* after black's completed turn)
|
||||||
|
*
|
||||||
|
* Check handling
|
||||||
|
* ──────────────
|
||||||
|
* The self-check filter runs on EVERY half-move (A.1 + existing engine
|
||||||
|
* behaviour), so a player whose king would be exposed by their first
|
||||||
|
* half-move cannot choose that move in the first place — the move
|
||||||
|
* isn't in the legal set. If a player's first half-move delivers
|
||||||
|
* check against the opponent but the move itself is otherwise fine,
|
||||||
|
* the engine simply records the mover's piece moves and leaves the
|
||||||
|
* turn with the mover (veto). The opponent is "in check" at the
|
||||||
|
* session level, but since it's still the mover's turn, the mover's
|
||||||
|
* SECOND half-move is unconstrained — they can pile on, back off,
|
||||||
|
* whatever. If they deliver mate on move 2, `checkGameResult` after
|
||||||
|
* the flip reports `checkmate` as usual.
|
||||||
|
*
|
||||||
|
* Conversely, if the opponent is put in check, they respond when
|
||||||
|
* their turn arrives: they have TWO half-moves to resolve it, though
|
||||||
|
* standard self-check filtering means each of those moves must ALSO
|
||||||
|
* leave their king safe. There's no "special mid-turn response to
|
||||||
|
* check" mechanic — the regular legal-move machinery composes
|
||||||
|
* naturally.
|
||||||
|
*
|
||||||
|
* Checkmate
|
||||||
|
* ─────────
|
||||||
|
* The engine's `checkGameResult` runs after EVERY `applyMove`,
|
||||||
|
* regardless of whether the turn flipped. `isCheckmate` queries the
|
||||||
|
* side-to-move (post-flip `Turn` fact): when white vetoes into a
|
||||||
|
* second move the side-to-move is still white, so white can't be
|
||||||
|
* mated on their own move; when the flip proceeds to black and black
|
||||||
|
* has no legal response to check, the result is `checkmate`. In short,
|
||||||
|
* double-move composes with standard terminal detection — no
|
||||||
|
* additional logic needed.
|
||||||
|
*
|
||||||
|
* Composition
|
||||||
|
* ───────────
|
||||||
|
* - `piece-hp`: INDEPENDENT. HP damage is dispatched by the damage
|
||||||
|
* pipeline, which does not read `HalfMovesThisTurn`. Captures deal
|
||||||
|
* 1 HP on each half-move just like base chess; fully composes.
|
||||||
|
* - `king-heals`: king-heals uses `onAfterMove`, which fires on
|
||||||
|
* EVERY half-move including vetoed ones (see engine.applyMove).
|
||||||
|
* Consequently heal will trigger up to 4 times per full WW-BB
|
||||||
|
* cycle (once per half-move, non-mover's king heals +1 if not in
|
||||||
|
* check). This is existing king-heals behaviour — the preset
|
||||||
|
* doesn't discriminate based on flip; if that becomes undesirable
|
||||||
|
* for a specific ruleset, a follow-up can gate king-heals on
|
||||||
|
* `HalfMovesThisTurn === 0` (i.e. "only heal after a real flip").
|
||||||
|
* - `monster-rules`: INCOMPATIBLE. Both presets override the flip
|
||||||
|
* hook; monster-rules wants asymmetric white-plays-2 / black-plays-1,
|
||||||
|
* double-move wants symmetric 2/2. Declared in `incompatibleWith`.
|
||||||
|
*
|
||||||
|
* Layout coupling: NONE. Activate on any starting position.
|
||||||
|
*/
|
||||||
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
|
|
||||||
|
PRESET_REGISTRY.register({
|
||||||
|
id: "double-move",
|
||||||
|
name: "Double Move",
|
||||||
|
description:
|
||||||
|
"Each side plays two half-moves in a row before the turn flips. Turn order: WW BB WW BB …",
|
||||||
|
incompatibleWith: ["monster-rules"],
|
||||||
|
requires: [],
|
||||||
|
|
||||||
|
shouldAdvanceTurn({ halfMovesThisTurn }) {
|
||||||
|
// Post-increment count: 1 after the first half-move of this turn,
|
||||||
|
// 2 after the second. Veto only on the first so the mover plays
|
||||||
|
// once more; on the second return undefined to let the engine's
|
||||||
|
// default flip proceed.
|
||||||
|
if (halfMovesThisTurn < 2) return false;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue