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.