feat(engine): overridePieceMoves hook

Phase A.4 of the rule-variants epic — adds the 'replace the default
move generator for this piece' extension point. First non-undefined
wins; later matches emit a dev-mode console.warn. The winning set
SKIPS default-generator + transformMoveGenerator + getExtraMoves +
en-passant/castling/promotion synthesis; filterMoves still composes
on top.

- Adds overridePieceMoves hook on PresetDef with full docs covering
  execution order, skipped downstream steps, filterMoves layering,
  and collision semantics.
- engine.getAllLegalMoves per-piece loop now checks overrides first;
  override path replaces moves entirely; default path unchanged when
  every preset returns undefined.
- Collision warning is dev-only (suppressed when NODE_ENV=production).
  First registered preset wins; second's return is ignored but
  console.warn names both preset ids.
- New override-piece-moves.test.ts (7 tests): default knight count
  baseline, lame-knight single-square override, undefined-declines
  fallthrough, collision warning fires with both preset ids in the
  message, production-mode suppression, filterMoves composes on top
  of overrides, unrelated piece types unaffected.

Tests: 1448 passing (was 1441, +7 new). Blocks: unblocks E.1
(berolina-pawns), E.2 (berolina-pawns-2).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-20 20:18:03 -06:00
commit 1a11491a15
No known key found for this signature in database
4 changed files with 488 additions and 61 deletions

View file

@ -194,3 +194,53 @@ section).
- `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.
## [2026-04-20 20:17] Task: A.4 — overridePieceMoves hook
**Shipped**:
- `overridePieceMoves` hook on `PresetDef`
(`packages/chess/src/presets/registry.ts`). Signature:
`(engine, pieceId) => readonly LegalMove[] | undefined`.
- `engine.getAllLegalMoves` restructured for per-piece dispatch:
1. Poll every scoped preset's `overridePieceMoves`. First
non-undefined wins.
2. If override wins → replace moves entirely. SKIP: default
type-registry generator, `transformMoveGenerator` chain,
`getExtraMoves` for this piece, en-passant/castling/promotion
synthesis (preset owns those mechanics).
3. If no override → unchanged default path (type registry +
transforms + en-passant + castling + promotion +
`getExtraMoves`).
4. `filterMoves` runs REGARDLESS (layering stays useful).
5. Collision: second non-undefined return triggers
`console.warn` (dev only — suppressed when `NODE_ENV=production`).
First winner stands.
- New test file: `override-piece-moves.test.ts` (7 tests). Covers:
baseline knight count unchanged, lame-knight prototype, undefined-
return fallthrough, collision warning fires, production-mode
suppression, `filterMoves` composes on top of overrides, unrelated
piece types unaffected.
**Verification**: `bun run check` green. 1448 tests (was 1441, +7).
**Gotchas**:
- `engine.getPieceAt` is private. Test presets need occupancy
checks → use `engine.session.allFacts().find(f => f.attr === "Position" && f.value === sq)`.
Same friction every future preset test will hit. Flagged for
Phase A.5 docs consideration but leaving the private as-is per
"don't refactor during features".
- Override collisions are a guardrail, not opt-in. The design
choice is "warn, don't throw" because the incompatibleWith graph
is the authoritative contract — crashing on collision would
make it harder to iteratively develop two competing overrides.
## Phase A status at end of 20:17
All 4 hooks landed on master:
- A.1: 4d05473 — getRoyalPieces + engine royal dispatch
- A.2: db8145f — filterLegalMoves
- A.3: f9475e9 — shouldAdvanceTurn + HalfMovesThisTurn
- A.4: (next commit) — overridePieceMoves
Remaining in Phase A: A.5 — verification gate + PRESET-API.md
hook-ordering doc + phase summary.