From 823a8c8dfa4af67eb39bc57af494ca29eb34e296 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 20 Apr 2026 20:52:51 -0600 Subject: [PATCH] docs(preset-api): hook ordering + Phase A summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A.5 of the rule-variants epic — the documentation gate. - Movement section now leads with a 7-step dispatch diagram covering the full per-piece-to-aggregate pipeline. Every new hook points at its step explicitly so future preset authors know where each hook slots in. - New hook reference entries with canonical-user callouts: overridePieceMoves, filterLegalMoves, getRoyalPieces (new Royalty section), shouldAdvanceTurn (new Turn flip section). Every entry has a concrete code snippet illustrating typical usage. - Design Notes 'Hook firing order' split into two sequences — the legal-move query (1-4) and move application (1-11). Application sequence now correctly reflects HalfMovesThisTurn increment + shouldAdvanceTurn poll + onTurnStart gating. - Scope-aware table extended with the 4 new hooks. - Notepad appended with Phase A close-out: 1417 → 1448 unit tests, 80/80 Playwright green, zero lint/type errors, four commits on master (4d05473, db8145f, f9475e9, 1a11491). --- .sisyphus/notepads/rule-variants/learnings.md | 49 +++++- packages/chess/docs/PRESET-API.md | 143 +++++++++++++++++- 2 files changed, 183 insertions(+), 9 deletions(-) diff --git a/.sisyphus/notepads/rule-variants/learnings.md b/.sisyphus/notepads/rule-variants/learnings.md index 5d54f1d..0754db5 100644 --- a/.sisyphus/notepads/rule-variants/learnings.md +++ b/.sisyphus/notepads/rule-variants/learnings.md @@ -240,7 +240,54 @@ 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 +- A.4: 1a11491 — overridePieceMoves Remaining in Phase A: A.5 — verification gate + PRESET-API.md hook-ordering doc + phase summary. + +## [2026-04-20 20:40] Task: A.5 — Phase A verification gate + PRESET-API.md + +**Shipped**: +- `packages/chess/docs/PRESET-API.md` extensively updated: + - Movement section now leads with a 7-step dispatch diagram + covering both per-piece and aggregate stages. Each of the 4 + new hooks references its step explicitly. + - New hook reference entries: `overridePieceMoves`, + `filterLegalMoves`, `getRoyalPieces` (new "Royalty" section), + `shouldAdvanceTurn` (new "Turn flip" section). Every entry + includes a concrete code example + canonical-user callout. + - "Hook firing order" in Design Notes split into two sequences: + LEGAL-MOVE QUERY (1-4) + MOVE APPLICATION (1-11). The + application sequence now correctly reflects + `HalfMovesThisTurn += 1` → `shouldAdvanceTurn` poll → flip or + veto path, and documents that `onTurnStart` is gated on + actual flip. + - Scope-aware table extended with the 4 new hooks. + +**Verification**: +- `bun run check` green at every Phase A commit. +- Full Playwright regression ran 80/80 passed after all 4 hooks + landed. No behavioural changes observed in any existing e2e. + +**Phase A closed.** +- Total test delta: 1417 → 1448 (+31 new unit tests across 4 test + files: royal-pieces.test.ts, filter-legal-moves.test.ts, + turn-advance.test.ts, override-piece-moves.test.ts). +- Zero lint / type errors. +- Zero regressions (unit + e2e). +- Master @ (about to be) the A.5 commit. + +**Next**: Phase B — Tier 1 presets. B.1-B.4 can run concurrently +now. Recommended order per plan: + - B.1 (knightmate-rules) — simplest, getRoyalPieces only. + Unblocks immediately. + - B.2 (double-move) — shouldAdvanceTurn straightforward. + - B.3 (monster-rules) — scope-aware shouldAdvanceTurn. Depends + on A.3 only; can run parallel with B.2. + - B.4 (first-promotion-wins) — needs no new hooks, composes + existing onAfterMove + onCheckGameResult + shouldFilterSelfCheck. + +Before firing B.1-B.4 in parallel: confirm disjoint file sets +(each preset = 1 file + 1 test file + maybe 1 layout update). Each +delegation should be 1 preset per agent per the T3-learned tool-cap +rule. diff --git a/packages/chess/docs/PRESET-API.md b/packages/chess/docs/PRESET-API.md index fc08fad..413c44e 100644 --- a/packages/chess/docs/PRESET-API.md +++ b/packages/chess/docs/PRESET-API.md @@ -116,13 +116,71 @@ onDeactivate({ engine }) { ### Movement +The engine runs a 7-step dispatch for every legal-move query. Every hook below slots into a specific step. **Read this before authoring a movement preset** — conflating the steps is the #1 source of "my preset fires but its moves don't show up" bugs. + +``` +Per-piece dispatch (inside engine.getAllLegalMoves): + 1. overridePieceMoves — first non-undefined wins, REPLACES default + 2. PIECE_TYPE_REGISTRY — default per-type generator (skipped if 1 won) + 3. transformMoveGenerator — wrap chain around 2 (skipped if 1 won) + 4. getExtraMoves — each preset APPENDS to the piece's moves + (skipped if 1 won; collision-free because + it's additive) + 5. En-passant / castling / promotion synthesis — skipped if 1 won; the + override owns those mechanics if it replaces pawns or kings. + 6. filterMoves — each preset filters this piece's moves + (ALWAYS runs, even with override) + +Aggregate dispatch (after all pieces collected): + 7. Self-check filter — drops king-exposing moves. + Suppressible via shouldFilterSelfCheck. + 8. filterLegalMoves — each preset filters the full list. + Runs in activePresets.list() order; + each sees the prior's output. +``` + +#### `overridePieceMoves(engine, pieceId): readonly LegalMove[] | undefined` + +REPLACE the default move generator for a specific piece. First preset to return non-undefined wins. The winning set SKIPS: the type-registry generator, the `transformMoveGenerator` chain, `getExtraMoves` for THIS piece, and en-passant/castling/promotion synthesis. `filterMoves` and downstream filters still run. + +Collision semantics: when two presets both return non-undefined for the same piece, the first wins and the engine emits a dev-mode `console.warn` naming both. This is a guardrail, not an opt-in — conflicts should be caught at config time via `incompatibleWith`. Warnings are suppressed when `NODE_ENV=production`. + +```ts +overridePieceMoves(engine, pieceId) { + const type = engine.session.get(pieceId, "PieceType"); + if (type !== "pawn") return undefined; + // Berolina pawns: forward diagonals push, forward orthogonal captures. + // Return the full replacement set; filter layers still compose on top. + return buildBerolinaMoves(engine, pieceId); +} +``` + +Canonical user: `berolina-pawns` (pawn redefinition). + #### `getExtraMoves(engine, pieceId): LegalMove[]` -Contribute additional legal moves for a specific piece. Used by wrap-board, knights-leap-twice, etc. NOT a context-object hook because it's called in a tight loop per piece per legal-move query — positional args are the perf-friendly choice. +Contribute additional legal moves for a specific piece. Used by wrap-board, knights-leap-twice, etc. NOT a context-object hook because it's called in a tight loop per piece per legal-move query — positional args are the perf-friendly choice. SKIPPED for pieces that have an active `overridePieceMoves`. #### `filterMoves(moves, engine, pieceId): LegalMove[]` -Remove or modify moves from the aggregated list. Used by knight-immunity (filters captures of knights). +Remove or modify moves from the aggregated list. Used by knight-immunity (filters captures of knights). ALWAYS runs, even on top of `overridePieceMoves` output. + +#### `filterLegalMoves(ctx: FilterLegalMovesContext): readonly LegalMove[]` + +Post-aggregation filter on the full per-color legal-move list. Runs AFTER the self-check filter, so this hook sees moves that are guaranteed to not leave the king in check. Iterated in `activePresets.list()` order; each preset sees the prior's output. + +The returned array MUST be a subset of `ctx.moves`. Use `getExtraMoves` to CONTRIBUTE moves and this hook to DROP them. The engine does not enforce the subset contract at runtime — adding additional moves here works today but is considered a bug by contract. + +```ts +filterLegalMoves({ moves }) { + // Suicide-chess: compulsory capture. + const anyCapture = moves.some(m => m.isCapture); + if (!anyCapture) return moves; + return moves.filter(m => m.isCapture); +} +``` + +Canonical user: `suicide-chess` (compulsory capture). ### Damage @@ -221,6 +279,55 @@ Opt out of the engine's "you can't leave your king in check" move filter. Used b Return `false` to skip the filter, `true` / `undefined` to keep it. +### Royalty + +#### `getRoyalPieces(ctx: RoyalContext): readonly EntityId[] | undefined` + +Declare which piece entities count as "royal" (attack/mate on them ends the game) for `ctx.color`. The engine UNIONS contributions across every active preset and threads the resolved set through `isInCheck` / `isCheckmate` / `isStalemate` / the self-check filter. + +Return semantics: +- `undefined` → this preset has no opinion; fall through. +- `readonly EntityId[]` → contribute these entities. Deduped via `Set` across presets. +- Empty array `[]` → valid non-undefined contribution. When the UNION is empty, the engine treats the position as having NO royalty — `isInCheck` short-circuits to false, self-check filtering is a no-op. Used by suicide-chess / capture-all where king safety doesn't apply. + +When NO preset contributes, the engine falls back to "every `PieceType===king` of this color" — the FIDE default. + +```ts +getRoyalPieces({ engine, color }) { + // Knightmate: every knight is royal. + const ids: EntityId[] = []; + for (const f of engine.session.allFacts()) { + if (f.attr !== "PieceType" || f.value !== "knight") continue; + if (engine.session.get(f.id, "Color") === color) ids.push(f.id); + } + return ids; +} +``` + +Canonical users: `knightmate-rules` (knight royal), `coregal` (king + queen), `dual-king` / `weak-dual-king` (multiple kings), `suicide-chess` / `capture-all` (empty-royalty). + +### Turn flip + +#### `shouldAdvanceTurn(ctx: TurnAdvanceContext): boolean | undefined` + +Decide whether the current turn should flip after this move. The engine increments `HalfMovesThisTurn` BEFORE polling this hook, so `ctx.halfMovesThisTurn` is a 1-indexed count of half-moves in the CURRENT turn INCLUDING the move that just committed. + +Return semantics: +- `false` → VETO the flip. First veto wins; engine stops polling. Mover plays again, `HalfMovesThisTurn` is NOT reset (next move's poll sees an incremented count). +- `true` / `undefined` → no opinion; subsequent presets and the default flip proceed. + +When the flip happens: `Turn` updates, `HalfMovesThisTurn` resets to 0, `FullmoveNumber` increments after black. When vetoed, none of these change; `onTurnStart` does NOT fire (so mid-turn resource regen doesn't trigger on the mover's second half-move). + +```ts +shouldAdvanceTurn({ mover, halfMovesThisTurn }) { + // Monster-rules: white plays 2 half-moves before flipping, black plays 1. + if (mover === "white" && halfMovesThisTurn < 2) return false; + return undefined; +} +``` + +Canonical users: `double-move` (both sides play 2), `monster-rules` (white plays 2, black plays 1). + ### Description #### `describeMoveEffect(ctx: DescribeMoveEffectContext): string | undefined` @@ -356,17 +463,33 @@ That's a full Shield preset in ~30 lines, zero edits to engine code. Activate it ### Hook firing order -Within a single `applyMove`, hooks fire in this order: +Two distinct dispatch sequences. Don't confuse them. + +**Legal-move query** (every `engine.getAllLegalMoves()` call): + +1. `getRoyalPieces` — union across active presets; resolves the royal set for this color. +2. Per-piece loop: + 1. `overridePieceMoves` — first non-undefined wins, skips steps 2.2-2.5. + 2. Type-registry generator + `transformMoveGenerator` chain. + 3. En-passant / castling / promotion synthesis (type-specific). + 4. `getExtraMoves` — each preset appends. + 5. `filterMoves` — each preset filters (ALWAYS runs, even with override). +3. Self-check filter — uses the royal set from step 1. Suppressible via `shouldFilterSelfCheck`. +4. `filterLegalMoves` — each preset filters the aggregate list in registration order. + +**Move application** (every `engine.applyMove()` call): 1. `onBeforeMove` — scope-aware, can cancel. 2. `onBeforeCapture` — fires if the move is a capture. First consumer owns the capture mechanic. 3. `onDamage` — if no `onBeforeCapture` consumed, the engine routes target through this. Scope-aware by target color. 4. Move mutations (position update, promotion, en-passant, castling). -5. Duration tick (`turnsRemaining -= 1`), `onDeactivate` for expired presets. -6. `onAfterMove` — unscoped, all active presets. -7. `onTurnStart` — scope-aware by new side-to-move. -8. `onCheckGameResult` — all presets, first non-undefined wins. -9. `describeMoveEffect` — all presets contribute `presetEffects` to the MoveRecord. +5. `HalfMovesThisTurn += 1` BEFORE polling the flip hook. +6. `shouldAdvanceTurn` — first `false` vetoes the flip. If no veto: `Turn` flips, `HalfMovesThisTurn` resets to 0, `FullmoveNumber` increments after black. +7. Duration tick (`turnsRemaining -= 1`), `onDeactivate` for expired presets. +8. `onAfterMove` — unscoped, all active presets. +9. `onTurnStart` — scope-aware by new side-to-move. **SKIPPED** when step 6 vetoed the flip. +10. `onCheckGameResult` — all presets, first non-undefined wins. Uses `getRoyalPieces` union for default checkmate/stalemate detection. +11. `describeMoveEffect` — all presets contribute `presetEffects` to the MoveRecord. ### Registration order determines dispatch order @@ -384,6 +507,10 @@ If your preset needs to run before / after another, document the expectation. Th | `onBeforeCapture` | Yes (mover) | Inherited from pre-refactor semantics. | | `onAfterMove` | No | Presets read `ctx.mover` and decide themselves. Breaking-change to flip. | | `onCheckGameResult` | No | Terminal state is global. | +| `getRoyalPieces` | No (per-color via ctx) | Hook receives `ctx.color`; caller (engine) resolves per-color separately. | +| `filterLegalMoves` | No (per-color via ctx) | Fires once per color at aggregation time; receives the color in `ctx.color`. | +| `shouldAdvanceTurn` | No | First `false` wins regardless of scope; presets that only care about one color check `ctx.mover` themselves (e.g. monster-rules). | +| `overridePieceMoves` | Yes (mover / piece owner color via scoped iteration) | Runs only on presets scoped to the piece's color. | ### Context objects are growable