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:
Joey Yakimowich-Payne 2026-04-21 07:15:59 -06:00
commit 770d0fd9cf
No known key found for this signature in database
2 changed files with 548 additions and 0 deletions

View 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");
});
});

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