docs(preset-api): hook ordering + Phase A summary
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).
This commit is contained in:
parent
1a11491a15
commit
823a8c8dfa
2 changed files with 183 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue