From f9475e9739e490b4125a2775bb0f561be7050e65 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 20 Apr 2026 20:13:51 -0600 Subject: [PATCH] feat(engine): shouldAdvanceTurn hook + HalfMovesThisTurn fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .sisyphus/notepads/rule-variants/learnings.md | 41 ++++ packages/chess/src/engine.ts | 76 +++++- packages/chess/src/presets/registry.ts | 51 ++++ .../chess/src/presets/turn-advance.test.ts | 221 ++++++++++++++++++ packages/chess/src/schema.ts | 12 + packages/chess/src/starting-position.test.ts | 1 + packages/chess/src/starting-position.ts | 5 + 7 files changed, 395 insertions(+), 12 deletions(-) create mode 100644 packages/chess/src/presets/turn-advance.test.ts diff --git a/.sisyphus/notepads/rule-variants/learnings.md b/.sisyphus/notepads/rule-variants/learnings.md index 85b8303..a111d83 100644 --- a/.sisyphus/notepads/rule-variants/learnings.md +++ b/.sisyphus/notepads/rule-variants/learnings.md @@ -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. diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 99fe9d7..fb569a2 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -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 diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index 3c729f9..ef04027 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -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 diff --git a/packages/chess/src/presets/turn-advance.test.ts b/packages/chess/src/presets/turn-advance.test.ts new file mode 100644 index 0000000..2112391 --- /dev/null +++ b/packages/chess/src/presets/turn-advance.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index 0eb92db..7fb5385 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -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; diff --git a/packages/chess/src/starting-position.test.ts b/packages/chess/src/starting-position.test.ts index f7b0a67..2ac87dd 100644 --- a/packages/chess/src/starting-position.test.ts +++ b/packages/chess/src/starting-position.test.ts @@ -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); diff --git a/packages/chess/src/starting-position.ts b/packages/chess/src/starting-position.ts index e037ec8..629a258 100644 --- a/packages/chess/src/starting-position.ts +++ b/packages/chess/src/starting-position.ts @@ -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);