feat(engine): shouldAdvanceTurn hook + HalfMovesThisTurn fact

Phase A.3 of the rule-variants epic — preset-controlled turn flip
gating. Enables double-move (white plays 2, then black plays 2) and
monster (scope-aware: white plays 2, black plays 1) without engine
changes at the preset layer.

- Adds HalfMovesThisTurn: number to ChessAttrMap. Seeded to 0 in
  applyLayout. Distinct from HalfmoveClock (FIDE 50-move rule) —
  this counter is within-turn and resets on flip.
- Adds TurnAdvanceContext + shouldAdvanceTurn hook to PresetDef.
  Hook receives post-increment count via ctx.halfMovesThisTurn so
  'play N half-moves before flipping' reads as the predicate
  'halfMovesThisTurn < N -> false'.
- engine.applyMove: increments HalfMovesThisTurn BEFORE polling
  shouldAdvanceTurn; first false wins; on flip resets to 0 and
  increments FullmoveNumber after black; onTurnStart is gated on
  shouldAdvance (vetoed flips don't fire turn-start hooks, so
  mid-turn stamina regen bugs are prevented by construction).
- starting-position.test.ts updated with HalfMovesThisTurn=0 seed
  assertion.
- New turn-advance.test.ts (7 tests): seeding, baseline unchanged,
  never-flip veto, flip-after-two (double-move surface), onTurnStart
  gating across vetoed + non-vetoed flips, FullmoveNumber increments
  only on actual flips after black.

Tests: 1441 passing (was 1434, +7 new). Blocks: unblocks B.2
(double-move), B.3 (monster-rules), B.4 (first-promotion-wins).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-20 20:13:51 -06:00
commit f9475e9739
No known key found for this signature in database
7 changed files with 395 additions and 12 deletions

View file

@ -153,3 +153,44 @@ section).
would be a visible diff. Keeping enforcement out for now
because the 1 call site per legal-move request is hot-path-
adjacent and a `Set`-based prune would show up.
## [2026-04-20 20:13] Task: A.3 — shouldAdvanceTurn hook + HalfMovesThisTurn fact
**Shipped**:
- `HalfMovesThisTurn: number` added to `ChessAttrMap`
(`packages/chess/src/schema.ts`). Seeded to 0 alongside `Turn` in
`applyLayout` (`packages/chess/src/starting-position.ts`).
- `TurnAdvanceContext` type + `shouldAdvanceTurn` hook on
`PresetDef`. Hook receives post-increment count. First `false`
wins; iteration stops.
- `engine.applyMove`:
1. Increments `HalfMovesThisTurn` BEFORE polling (so hooks see
count including the move just committed).
2. Polls every active preset's `shouldAdvanceTurn`; first `false`
cancels the flip.
3. On flip: updates `Turn`, resets `HalfMovesThisTurn` to 0,
increments `FullmoveNumber` after black.
4. On skip: leaves `Turn` and `HalfMovesThisTurn` alone (count
accumulates).
5. `onTurnStart` is GATED on `shouldAdvance === true`. Vetoed
flips don't fire turn-start (so double-move stamina doesn't
regen mid-turn).
- `starting-position.test.ts` updated to assert the new fact seed.
- New `turn-advance.test.ts` — 7 tests: seeding, baseline unchanged,
never-flip veto accumulates count, flip-after-two double-move
surface, `onTurnStart` gating against vetoes, FullmoveNumber only
on real flips.
**Verification**: `bun run check` green. 1441 tests (was 1434, +7).
**Gotchas**:
- Two DISTINCT half-move counters now exist. `HalfmoveClock`
(FIDE 50-move rule) vs `HalfMovesThisTurn` (this epic's
within-turn counter). Documented in schema comment; hook docs
emphasize the distinction.
- `tickAfterMove(color)` runs on the mover's color regardless of
flip decision — correct: a 3-turn buff ticks per actual move
made, not per Turn flip. Matches existing semantics.
- `session.allFacts()` automatically carries the new fact across
serialization boundaries (snapshot/restore) without protocol
changes — no server-side work needed for the A.3 addition.

View file

@ -58,6 +58,7 @@ import type {
PieceSpawnReason,
RoyalContext,
SelfCheckFilterContext,
TurnAdvanceContext,
TurnStartContext,
} from "./presets/registry.js";
import type { ChessAttrKey } from "./schema.js";
@ -1176,18 +1177,63 @@ export class ChessEngine {
clearEnPassantTarget(this.session);
}
// Update halfmove clock
// Update halfmove clock (50-move rule). This is the FIDE clock —
// distinct from the rule-variants `HalfMovesThisTurn` counter
// below, which tracks within-turn move count for presets like
// double-move.
updateHalfmoveClock(this.session, move, movingType === "pawn");
// Switch turn
const nextColor: PieceColor = color === "white" ? "black" : "white";
this.session.insert(GAME_ENTITY, "Turn", nextColor);
// Phase A.3: increment HalfMovesThisTurn BEFORE polling
// `shouldAdvanceTurn`. The hook sees the POST-increment count so
// a "play N half-moves before flipping" predicate reads as
// `ctx.halfMovesThisTurn < N → false`.
const prevHalfMovesThisTurn =
(this.session.get(GAME_ENTITY, "HalfMovesThisTurn") as number) ?? 0;
const nextHalfMovesThisTurn = prevHalfMovesThisTurn + 1;
this.session.insert(
GAME_ENTITY,
"HalfMovesThisTurn",
nextHalfMovesThisTurn,
);
// Increment fullmove number after black's move
if (color === "black") {
const fn =
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
// Poll every active preset's shouldAdvanceTurn hook. FIRST FALSE
// WINS — as soon as one veto lands, the engine stops polling and
// skips the turn flip. Scope-unaware: a `scope=white` preset that
// vetoes on black's moves is a bug the preset must guard against
// (check `ctx.mover`).
const turnAdvanceCtx: TurnAdvanceContext = {
engine: this,
mover: color,
halfMovesThisTurn: nextHalfMovesThisTurn,
};
let shouldAdvance = true;
for (const entry of this.activePresets.list()) {
const def = PRESET_REGISTRY.get(entry.id);
const verdict = def?.shouldAdvanceTurn?.(turnAdvanceCtx);
if (verdict === false) {
shouldAdvance = false;
break;
}
}
// Switch turn iff no preset vetoed. When skipped, `Turn` and
// `HalfMovesThisTurn` are left as-is (HalfMovesThisTurn was
// already incremented above; the next move's poll sees it grow).
const nextColor: PieceColor = shouldAdvance
? (color === "white" ? "black" : "white")
: color;
if (shouldAdvance) {
this.session.insert(GAME_ENTITY, "Turn", nextColor);
// Reset within-turn counter on every actual flip.
this.session.insert(GAME_ENTITY, "HalfMovesThisTurn", 0);
// Increment fullmove number after black's completed turn.
if (color === "black") {
const fn =
((this.session.get(GAME_ENTITY, "FullmoveNumber") as number) ?? 1) + 1;
this.session.insert(GAME_ENTITY, "FullmoveNumber", fn);
}
}
// Record position for threefold repetition
@ -1223,9 +1269,15 @@ export class ChessEngine {
// cooldown ticks — runs BEFORE the player asks for their legal
// moves, so regenerated stamina / refreshed cooldowns are
// reflected in the first legal-move query.
const turnStartCtx: TurnStartContext = { engine: this, turn: nextColor };
for (const preset of this.activePresets.getForColor(nextColor)) {
preset.onTurnStart?.(turnStartCtx);
//
// Skipped when `shouldAdvanceTurn` vetoed the flip — no new turn
// began, so "start of turn" hooks must NOT fire (otherwise
// stamina regens mid-turn for double-move, etc.).
if (shouldAdvance) {
const turnStartCtx: TurnStartContext = { engine: this, turn: nextColor };
for (const preset of this.activePresets.getForColor(nextColor)) {
preset.onTurnStart?.(turnStartCtx);
}
}
// Determine terminal state BEFORE we log, so the MoveRecord

View file

@ -251,6 +251,27 @@ export interface FilterLegalMovesContext extends HookContext {
readonly moves: readonly LegalMove[];
}
/**
* Context passed to `shouldAdvanceTurn`. Fires from `engine.applyMove`
* AFTER the move is committed AND AFTER `HalfMovesThisTurn` has been
* incremented, but BEFORE the engine decides whether to flip `Turn`.
*
* - `mover` is the color that just moved.
* - `halfMovesThisTurn` is the post-increment count (i.e. how many
* consecutive half-moves this side has played in the CURRENT turn,
* INCLUDING the one that just committed). First-move queries see
* `1`, second-move queries see `2`.
*
* Phase A.3 (rule-variants epic): lets presets like Double-Move
* (white plays 2, black plays 1) and Monster (white plays 2, black
* plays 1 scope-aware white-only variant) gate the turn flip
* without touching the engine.
*/
export interface TurnAdvanceContext extends HookContext {
readonly mover: "white" | "black";
readonly halfMovesThisTurn: number;
}
/**
* Why a piece is being spawned. Used by `onPieceSpawn` hooks to
* decide whether to participate (e.g., a preset that resurrects
@ -546,6 +567,36 @@ export interface PresetDef {
ctx: FilterLegalMovesContext,
) => readonly LegalMove[];
/**
* Decide whether the current turn should flip after this move.
*
* Called from `engine.applyMove` AFTER all mutations (move applied,
* captures resolved, damage dispatched, en-passant/castling side
* effects landed) and AFTER `HalfMovesThisTurn` has been
* incremented. The engine iterates every active preset's hook; if
* ANY returns `false`, the turn flip is SKIPPED (the mover plays
* again). `undefined` / `true` = no opinion, let subsequent
* presets / the default flip proceed.
*
* "First false wins" semantics:
* - As soon as one preset vetoes the flip, the engine stops
* polling and skips the flip.
* - `HalfMovesThisTurn` is NOT reset when the flip is skipped,
* so the next move's poll sees an incremented count.
* - `HalfMovesThisTurn` IS reset to 0 when the flip proceeds.
*
* The engine reads `ctx.halfMovesThisTurn` AFTER the increment, so
* a "play N half-moves before flipping" preset uses the predicate
* `ctx.halfMovesThisTurn < N → return false`.
*
* Canonical users: `double-move` (flip after 2 for both colors),
* `monster-rules` (flip after 2 for white, 1 for black scope
* aware via `mover` inspection).
*/
readonly shouldAdvanceTurn?: (
ctx: TurnAdvanceContext,
) => boolean | undefined;
/**
* Phase hook: fires AFTER the move has been confirmed legal but
* BEFORE any state mutation. Return `{ cancel: true, reason }` to

View file

@ -0,0 +1,221 @@
/**
* Tests for the `shouldAdvanceTurn` hook + `HalfMovesThisTurn` game
* fact (Phase A.3 of the rule-variants epic).
*
* Semantics under test:
* - `HalfMovesThisTurn` is seeded to 0 by `applyLayout`.
* - `engine.applyMove` increments `HalfMovesThisTurn` BEFORE polling
* `shouldAdvanceTurn`. Hooks see the post-increment count.
* - FIRST FALSE from any active preset vetoes the flip. When vetoed
* the mover plays again; `Turn` stays; `HalfMovesThisTurn` is
* NOT reset.
* - When the flip proceeds, `HalfMovesThisTurn` resets to 0 and
* `FullmoveNumber` increments after black.
* - `onTurnStart` only fires when the flip actually happened no
* mid-turn regen for double-move stamina etc.
*
* Coverage:
* - Baseline (no preset with the hook): every move flips, count
* always 0 after applyMove. Protects the 1434-test regression.
* - "Never flip" prototype: white keeps moving; `Turn` stays white;
* count accumulates.
* - "Flip after 2" prototype (the double-move surface): white plays
* 2, then black plays 2, then white plays 2,
* - Skip-flip does NOT fire `onTurnStart`.
* - `FullmoveNumber` only increments on actual flips after black's
* turn.
*/
import { describe, it, expect, beforeEach } from "vitest";
import "./index.js";
import { ChessEngine } from "../engine.js";
import { PRESET_REGISTRY } from "./registry.js";
import { GAME_ENTITY } from "../schema.js";
const NEVER_FLIP_ID = "test-never-flip";
const FLIP_AFTER_TWO_ID = "test-flip-after-two";
const TURN_START_COUNTER_ID = "test-turn-start-counter";
// Use module-level counters captured by closure so the hook can
// observe how many times onTurnStart was invoked.
let turnStartInvocations = 0;
beforeEach(() => {
turnStartInvocations = 0;
PRESET_REGISTRY.register({
id: NEVER_FLIP_ID,
name: "Never flip (test)",
description: "Always veto the turn flip — mover plays forever.",
incompatibleWith: [],
requires: [],
shouldAdvanceTurn() {
return false;
},
});
PRESET_REGISTRY.register({
id: FLIP_AFTER_TWO_ID,
name: "Flip after two (test)",
description: "Flip only after both colors have played 2 half-moves.",
incompatibleWith: [],
requires: [],
shouldAdvanceTurn({ halfMovesThisTurn }) {
// halfMovesThisTurn is POST-increment: seeing 1 means "mover
// just played their first move in this turn"; seeing 2 means
// "mover just played their second — OK to flip now".
if (halfMovesThisTurn < 2) return false;
return undefined; // no opinion — let defaults flip
},
});
PRESET_REGISTRY.register({
id: TURN_START_COUNTER_ID,
name: "Turn start counter (test)",
description: "Counts how many times onTurnStart fires.",
incompatibleWith: [],
requires: [],
onTurnStart() {
turnStartInvocations++;
},
});
});
function firstLegalMove(engine: ChessEngine) {
const moves = engine.getAllLegalMoves();
if (moves.length === 0) throw new Error("no legal moves available");
return moves[0]!;
}
describe("HalfMovesThisTurn — seeding", () => {
it("seeded to 0 by applyLayout", () => {
const engine = new ChessEngine();
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
});
});
describe("shouldAdvanceTurn — baseline (no hook)", () => {
it("every move flips and resets HalfMovesThisTurn to 0", () => {
const engine = new ChessEngine();
expect(engine.getCurrentTurn()).toBe("white");
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("black");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
// FullmoveNumber went from 1 → 2 after black's move.
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
});
});
describe("shouldAdvanceTurn — never-flip veto", () => {
it("white plays forever; Turn never flips; count accumulates", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: NEVER_FLIP_ID, scope: "both", turnsRemaining: null },
]);
expect(engine.getCurrentTurn()).toBe("white");
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(2);
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(3);
// FullmoveNumber never moves — it only increments on actual
// flips after black.
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
});
});
describe("shouldAdvanceTurn — flip-after-two prototype (double-move surface)", () => {
it("each side plays 2 half-moves before the flip", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: FLIP_AFTER_TWO_ID, scope: "both", turnsRemaining: null },
]);
// Move 1: white plays → count=1, veto. Still white's turn.
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
// Move 2: white plays → count=2, no veto. Flip to black.
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("black");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
// Move 3: black plays → count=1, veto. Still black's turn.
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("black");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(1);
// Move 4: black plays → count=2, no veto. Flip to white.
engine.applyMove(firstLegalMove(engine));
expect(engine.getCurrentTurn()).toBe("white");
expect(engine.session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
// FullmoveNumber incremented exactly once (after black's full
// 2-half-move turn completed at move 4).
expect(engine.session.get(GAME_ENTITY, "FullmoveNumber")).toBe(2);
});
});
describe("shouldAdvanceTurn — onTurnStart gating", () => {
it("onTurnStart does NOT fire when flip is vetoed", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: NEVER_FLIP_ID, scope: "both", turnsRemaining: null },
{ id: TURN_START_COUNTER_ID, scope: "both", turnsRemaining: null },
]);
// Baseline: no move played → no turnStart fires.
expect(turnStartInvocations).toBe(0);
// Every move is vetoed → onTurnStart never fires.
engine.applyMove(firstLegalMove(engine));
engine.applyMove(firstLegalMove(engine));
engine.applyMove(firstLegalMove(engine));
expect(turnStartInvocations).toBe(0);
});
it("onTurnStart fires once per actual flip (no veto)", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: TURN_START_COUNTER_ID, scope: "both", turnsRemaining: null },
]);
expect(turnStartInvocations).toBe(0);
engine.applyMove(firstLegalMove(engine)); // flip → +1
expect(turnStartInvocations).toBe(1);
engine.applyMove(firstLegalMove(engine)); // flip → +1
expect(turnStartInvocations).toBe(2);
});
it("onTurnStart fires once per flip even with flip-after-two", () => {
const engine = new ChessEngine();
engine.setActivePresets([
{ id: FLIP_AFTER_TWO_ID, scope: "both", turnsRemaining: null },
{ id: TURN_START_COUNTER_ID, scope: "both", turnsRemaining: null },
]);
expect(turnStartInvocations).toBe(0);
engine.applyMove(firstLegalMove(engine)); // white move 1 — veto, no fire
expect(turnStartInvocations).toBe(0);
engine.applyMove(firstLegalMove(engine)); // white move 2 — flip, fire
expect(turnStartInvocations).toBe(1);
engine.applyMove(firstLegalMove(engine)); // black move 1 — veto, no fire
expect(turnStartInvocations).toBe(1);
engine.applyMove(firstLegalMove(engine)); // black move 2 — flip, fire
expect(turnStartInvocations).toBe(2);
});
});

View file

@ -55,6 +55,18 @@ export interface ChessAttrMap {
Turn: PieceColor;
HalfmoveClock: number;
FullmoveNumber: number;
/**
* Number of half-moves (single player moves) completed within the
* CURRENT turn. Reset to 0 whenever `Turn` flips. Incremented in
* `engine.applyMove` BEFORE the turn-flip decision is polled via
* `shouldAdvanceTurn`, so preset hooks see the count AFTER their
* current move has been accounted for.
*
* FIDE chess never exceeds 1 (every applyMove flips). Variants like
* Double-Move (white plays 2, black plays 1) and Monster (white
* plays 2, black plays 1) use this counter to gate the flip.
*/
HalfMovesThisTurn: number;
EnPassantTarget: Square | null;
GameStatus: GameStatus;
Winner: GameResult | null;

View file

@ -119,6 +119,7 @@ describe("generateStartingPosition()", () => {
expect(session.get(GAME_ENTITY, "Turn")).toBe("white");
expect(session.get(GAME_ENTITY, "HalfmoveClock")).toBe(0);
expect(session.get(GAME_ENTITY, "FullmoveNumber")).toBe(1);
expect(session.get(GAME_ENTITY, "HalfMovesThisTurn")).toBe(0);
expect(session.get(GAME_ENTITY, "EnPassantTarget")).toBe(null);
expect(session.get(GAME_ENTITY, "GameStatus")).toBe("active");
expect(session.get(GAME_ENTITY, "Winner")).toBe(null);

View file

@ -182,6 +182,11 @@ export function applyLayout(
session.insert(GAME_ENTITY, "Turn", "white");
session.insert(GAME_ENTITY, "HalfmoveClock", 0);
session.insert(GAME_ENTITY, "FullmoveNumber", 1);
// `HalfMovesThisTurn` counts the number of half-moves played since
// the last `Turn` flip. Seeded alongside `Turn` so both start in
// the same committed-state. Incremented in `engine.applyMove`
// BEFORE the turn-flip decision; reset to 0 on flip.
session.insert(GAME_ENTITY, "HalfMovesThisTurn", 0);
session.insert(GAME_ENTITY, "EnPassantTarget", null);
session.insert(GAME_ENTITY, "GameStatus", "active");
session.insert(GAME_ENTITY, "Winner", null);