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).
553 lines
25 KiB
Markdown
553 lines
25 KiB
Markdown
# Preset API Reference
|
|
|
|
This document is the contract between the chess engine core and preset authors. Every extension point is listed here. If a capability you need isn't in this doc, it either isn't supported or hasn't landed yet.
|
|
|
|
**Audience**: developers adding new presets (either in-tree in `packages/chess/src/presets/` or as an external package that imports `@paratype/chess`).
|
|
|
|
**Philosophy**: the engine is a FIDE-minimum chess runtime with a compositional plugin surface. New mechanics — HP, custom pieces, visual effects, move history, resource tracking — attach via registries and hooks. Engine core should stay stable; extensions stay additive.
|
|
|
|
---
|
|
|
|
## Architecture at a glance
|
|
|
|
```
|
|
┌──────────────────┐ declares attrs, registers hooks
|
|
│ PresetDef │──────────┐
|
|
└──────────────────┘ │
|
|
▼
|
|
┌────────────────────────────────────────────────────┐
|
|
│ PRESET_REGISTRY │
|
|
│ • getAll() / get(id) │
|
|
└────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌──────────────────┐ applyMove / dealDamage / spawnPiece / emitEffect
|
|
│ ChessEngine │──┬───────────────────────────────┐
|
|
│ │ │ PIECE_TYPE_REGISTRY │
|
|
│ • session │ │ moveGenerator / attackProbe │
|
|
│ • moveLog │ │ assets │
|
|
│ • activePresets │ └───────────────────────────────┘
|
|
│ • presetState │
|
|
│ • emitEffect │
|
|
└──────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Writing a preset
|
|
|
|
Minimal preset — registers itself at module load via a side-effect import. Consumers add an import line in `packages/chess/src/presets/index.ts`.
|
|
|
|
```ts
|
|
import { PRESET_REGISTRY } from "./registry.js";
|
|
|
|
PRESET_REGISTRY.register({
|
|
id: "my-preset",
|
|
name: "My Preset",
|
|
description: "What this preset does in one sentence.",
|
|
incompatibleWith: [],
|
|
requires: [],
|
|
// ... hooks below
|
|
});
|
|
```
|
|
|
|
### Required fields
|
|
|
|
- `id: string` — stable identifier. Used for UI toggles, persistence, `engine.presetState`, hook dispatch order. Cannot be renamed without breaking saved games.
|
|
- `name: string` — display name for UI. Can change freely.
|
|
- `description: string` — one-line explainer shown in the rules drawer. Include variant-defining details ("captures deal 1 damage instead of killing").
|
|
- `incompatibleWith: string[]` — preset ids that must NOT be active under overlapping scope. Mutual — if A declares B incompatible, B must declare A too.
|
|
- `requires: string[]` — preset ids that MUST be active under overlapping scope. Examples: `king-heals` requires `piece-hp`.
|
|
|
|
### Optional: declared attributes
|
|
|
|
```ts
|
|
pieceAttributes: ["Shield"],
|
|
```
|
|
|
|
When your preset owns a piece-level attribute (HP, Shield, Stamina, Mana, Armor), declare it here. The engine unions `pieceAttributes` from every active preset into `engine.effectivePieceAttrs`. That list drives:
|
|
|
|
- The default damage-retract path (dead pieces lose all effective attrs).
|
|
- `queen-splits`'s attacker retraction during fission.
|
|
- Save-state serialization (all facts captured, including preset-declared attrs).
|
|
|
|
The attr MUST be declared in `ChessAttrMap` (via `declare module "../schema"` augmentation or by editing `schema.ts` directly). Adding a brand-new fact-type requires one schema edit + one preset edit.
|
|
|
|
---
|
|
|
|
## Hook reference
|
|
|
|
Hooks take a single context object (rather than positional args) so new fields are additive. All hooks are optional — implement only what your preset needs.
|
|
|
|
### Lifecycle
|
|
|
|
#### `onActivate(ctx: LifecycleContext)`
|
|
|
|
Fires when the preset transitions inactive → active. Use for:
|
|
- Seeding initial state (e.g. `Hp=2` on every existing piece).
|
|
- Mutating the session to reflect the preset's requirements.
|
|
|
|
```ts
|
|
onActivate({ engine }) {
|
|
for (const id of piecesOnBoard(engine)) {
|
|
engine.session.insert(id, "Hp", 2);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### `onDeactivate(ctx: LifecycleContext)`
|
|
|
|
Symmetric cleanup. Fires when:
|
|
- User toggles preset off via the UI.
|
|
- Turn-timer expires (`turnsRemaining: 0`).
|
|
- Server issues `setPresets` that excludes this preset.
|
|
|
|
Retract anything `onActivate` inserted. Preset-state is cleared automatically AFTER this hook returns.
|
|
|
|
```ts
|
|
onDeactivate({ engine }) {
|
|
for (const id of piecesOnBoard(engine)) {
|
|
if (engine.session.contains(id, "Hp")) {
|
|
engine.session.retract(id, "Hp");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 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. 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). 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
|
|
|
|
#### `onDamage(ctx: DamageHookContext): DamageHookResult | void`
|
|
|
|
The damage pipeline. Called by `engine.dealDamage(target, amount, ctx)`. First preset to return `{ consume: true, died }` wins — further presets are not consulted.
|
|
|
|
```ts
|
|
onDamage({ engine, target, amount, kind }) {
|
|
if (!engine.session.contains(target, "Hp")) return undefined;
|
|
const current = engine.session.get(target, "Hp") as number;
|
|
const next = current - amount;
|
|
if (next > 0) {
|
|
engine.session.insert(target, "Hp", next);
|
|
return { consume: true, died: false };
|
|
}
|
|
// Lethal — retract all effective attrs and report death.
|
|
for (const attr of engine.effectivePieceAttrs) {
|
|
if (engine.session.contains(target, attr)) engine.session.retract(target, attr);
|
|
}
|
|
return { consume: true, died: true };
|
|
}
|
|
```
|
|
|
|
Scope-aware: only fires for presets whose scope covers the target's color. A `scope=white` preset won't absorb damage on black pieces.
|
|
|
|
**Damage kinds** (extend as needed): `"capture"` (default from applyMove), `"explosion"` (explosive-rook), `"poison"` (poisoned-squares). Arbitrary strings allowed — document what YOUR preset emits.
|
|
|
|
### Capture
|
|
|
|
#### `onBeforeCapture(ctx: CaptureHookContext): CaptureHookResult | void`
|
|
|
|
Fires BEFORE the engine resolves a capture. Return `{ consume: true }` to REPLACE the default capture mechanic entirely — queen-splits fission, explosive-rook AoE, capture-to-win winner-recording. The consuming preset owns ALL post-capture behavior, including whether the attacker moves.
|
|
|
|
Unconsumed captures fall through to `engine.dealDamage` with `kind: "capture"`.
|
|
|
|
### Spawn
|
|
|
|
#### `onPieceSpawn(ctx: PieceSpawnContext)`
|
|
|
|
Fires when `engine.spawnPiece()` creates a new entity. Every active preset gets a chance to tag the piece (seed Hp, mark Shield, add custom attrs). Runs AFTER core attrs are inserted.
|
|
|
|
```ts
|
|
onPieceSpawn({ engine, pieceId }) {
|
|
engine.session.insert(pieceId, "Hp", DEFAULT_HP);
|
|
}
|
|
```
|
|
|
|
### Phase hooks
|
|
|
|
#### `onBeforeMove(ctx: BeforeMoveContext): BeforeMoveResult | void`
|
|
|
|
Fires after move legality confirmed, BEFORE any mutation. Can veto by returning `{ cancel: true, reason: "..." }`. The engine throws `MoveCancelledError` which the caller surfaces via toast.
|
|
|
|
**Scope-aware**: only fires for presets whose scope covers the mover.
|
|
|
|
Use cases: stamina systems, action-point economies, cooldowns.
|
|
|
|
```ts
|
|
onBeforeMove({ engine, pieceId }) {
|
|
const stamina = engine.session.get(pieceId, "Stamina") as number ?? 0;
|
|
if (stamina < 1) return { cancel: true, reason: "Piece too tired" };
|
|
engine.session.insert(pieceId, "Stamina", stamina - 1);
|
|
}
|
|
```
|
|
|
|
#### `onAfterMove(ctx: MoveHookContext)`
|
|
|
|
Fires AFTER all move mutations but BEFORE terminal-state check. Used by king-heals, poisoned-squares.
|
|
|
|
Fires for EVERY active preset regardless of scope — the preset decides based on `ctx.mover`. (This asymmetry is historical — changing it would break existing presets. See Design Notes.)
|
|
|
|
#### `onTurnStart(ctx: TurnStartContext)`
|
|
|
|
Fires AFTER turn counter flips and AFTER `onAfterMove`, BEFORE legal-move computation for the new side. Ideal for:
|
|
- Regenerating per-piece resources (+1 Stamina up to max).
|
|
- Decrementing cooldowns.
|
|
- Refreshing auras / blessings.
|
|
|
|
**Scope-aware**: only fires for presets whose scope covers the new side-to-move.
|
|
|
|
### Terminal state
|
|
|
|
#### `onCheckGameResult(ctx: GameResultHookContext): GameResult | undefined`
|
|
|
|
Override the engine's default checkmate/stalemate/draw logic. First preset to return a non-undefined value wins. Used by:
|
|
- `capture-to-win` — first capture ends the game.
|
|
- `last-piece-standing` — annihilation replaces checkmate.
|
|
- `piece-hp` — suppresses default terminal check when kings can survive attacks.
|
|
|
|
### Self-check filter
|
|
|
|
#### `shouldFilterSelfCheck(ctx: SelfCheckFilterContext): boolean | undefined`
|
|
|
|
Opt out of the engine's "you can't leave your king in check" move filter. Used by `piece-hp` — with HP active, a king in check just takes damage, it doesn't lose.
|
|
|
|
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`
|
|
|
|
Contribute to the `MoveRecord.presetEffects` array. Move-list UI, PGN export, and replay systems read these for human-readable annotations.
|
|
|
|
```ts
|
|
describeMoveEffect({ mover, movingType, capturedType }) {
|
|
if (movingType === "queen" && capturedType !== null) {
|
|
return `${mover} queen fissioned into R+B`;
|
|
}
|
|
return undefined;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Engine APIs available from hooks
|
|
|
|
Every hook receives `ctx.engine: ChessEngine`. Useful methods:
|
|
|
|
### `engine.session`
|
|
|
|
The rete Session — direct insert/retract/get. Read preset-declared facts here.
|
|
|
|
### `engine.spawnPiece(type, color, square, opts?): EntityId`
|
|
|
|
Create a piece. Fires `onPieceSpawn` on every active preset. Use this instead of direct session inserts when creating pieces mid-game (fission, summon, resurrection).
|
|
|
|
### `engine.dealDamage(target, amount, { kind, attacker? })`
|
|
|
|
Canonical damage primitive. Routes through the `onDamage` pipeline (scope-aware). Returns `{ died: boolean }`. Use this instead of direct retraction — HP-like absorber presets get a chance to intercept.
|
|
|
|
### `engine.emitEffect({ kind, square, ttl, data? })`
|
|
|
|
Push a visual effect to subscribers. UI renders it. Presets emit from inside their hooks; no subscribers = no-op.
|
|
|
|
### `engine.presetState<T>(id)`
|
|
|
|
Typed per-preset state bag. `{ get, set, delete, has, all }`. Backed by session facts, so serialization is free. Auto-cleared on deactivate.
|
|
|
|
### `engine.moveLog`
|
|
|
|
Read-only chronological list of `MoveRecord`s. Use from UI components or tests; presets typically don't need it.
|
|
|
|
### `engine.effectivePieceAttrs`
|
|
|
|
Full list of attribute keys currently treated as "piece state" — core + preset-declared. Use when you need to retract every attribute on an entity (e.g. custom death handling).
|
|
|
|
### `engine.setActivePresets(requests)`
|
|
|
|
Replace the active set. Fires `onDeactivate` for dropped ids and `onActivate` for new ids. Validates `incompatibleWith` + `requires` constraints.
|
|
|
|
---
|
|
|
|
## PieceTypeRegistry
|
|
|
|
Register new piece types.
|
|
|
|
```ts
|
|
import { PIECE_TYPE_REGISTRY } from "./piece-type-registry.js";
|
|
|
|
PIECE_TYPE_REGISTRY.register({
|
|
id: "cannon",
|
|
displayName: "Cannon",
|
|
moveGenerator: (session, id) => [/* LegalMove[] */],
|
|
attackProbe: (session, id, target) => /* boolean */,
|
|
assets: { white: cannonWhiteSvg, black: cannonBlackSvg },
|
|
});
|
|
```
|
|
|
|
The engine + check detection + UI all dispatch through the registry. Once registered, your custom type:
|
|
- Can be spawned via `engine.spawnPiece("cannon", ...)`.
|
|
- Participates in `engine.getAllLegalMoves()`.
|
|
- Contributes to `isSquareAttacked` / `isInCheck` / `isCheckmate`.
|
|
- Renders via UI `<Piece>` using the declared `assets`.
|
|
|
|
Register from a module-level side-effect import. Core FIDE types register from `presets/core-piece-types.ts`.
|
|
|
|
---
|
|
|
|
## Walkthrough: Building a Shield preset
|
|
|
|
```ts
|
|
// 1. Declare the attribute in ChessAttrMap (schema.ts):
|
|
declare module "../schema" {
|
|
interface ChessAttrMap {
|
|
Shield: number;
|
|
}
|
|
}
|
|
|
|
// 2. Register the preset:
|
|
PRESET_REGISTRY.register({
|
|
id: "piece-shield",
|
|
name: "Shield",
|
|
description: "Every piece has Shield=1. Absorbs one hit before HP.",
|
|
incompatibleWith: [],
|
|
requires: [],
|
|
pieceAttributes: ["Shield"],
|
|
|
|
onActivate({ engine }) {
|
|
for (const f of engine.session.allFacts()) {
|
|
if (f.attr !== "PieceType") continue;
|
|
if ((f.id as number) <= 0) continue;
|
|
if (!engine.session.contains(f.id, "Shield")) {
|
|
engine.session.insert(f.id, "Shield", 1);
|
|
}
|
|
}
|
|
},
|
|
|
|
onPieceSpawn({ engine, pieceId }) {
|
|
if (!engine.session.contains(pieceId, "Shield")) {
|
|
engine.session.insert(pieceId, "Shield", 1);
|
|
}
|
|
},
|
|
|
|
onDamage({ engine, target, amount }) {
|
|
if (!engine.session.contains(target, "Shield")) return undefined;
|
|
const current = engine.session.get(target, "Shield") as number;
|
|
if (current <= 0) return undefined;
|
|
engine.session.insert(target, "Shield", current - 1);
|
|
if (amount - 1 <= 0) return { consume: true, died: false };
|
|
return undefined; // let piece-hp / default handle the remainder
|
|
},
|
|
});
|
|
```
|
|
|
|
That's a full Shield preset in ~30 lines, zero edits to engine code. Activate it BEFORE piece-hp in the active set so it intercepts damage first.
|
|
|
|
---
|
|
|
|
## Design notes and gotchas
|
|
|
|
### Hook firing 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. `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
|
|
|
|
For hooks that iterate presets (`onDamage`, `onCheckGameResult`), the first preset to consume wins. Order follows the active-set list, which follows the order in which presets were activated via `setActivePresets`. Registration order in `presets/index.ts` sets the DEFAULT activation order when all presets are enabled at once, but user-toggle order during a game is what actually matters.
|
|
|
|
If your preset needs to run before / after another, document the expectation. There's no priority system.
|
|
|
|
### Scope-aware vs unscoped
|
|
|
|
| Hook | Scope-aware? | Why |
|
|
|---|---|---|
|
|
| `onDamage` | Yes (target color) | A `scope=white` Shield only protects white pieces. |
|
|
| `onBeforeMove` | Yes (mover) | A `scope=white` veto doesn't block black's moves. |
|
|
| `onTurnStart` | Yes (new side to move) | Regen hooks should only fire for the color they care about. |
|
|
| `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
|
|
|
|
All hook contexts are interface-defined and additive. Adding a field in a later release won't break existing hook implementations — they'll simply ignore the new field.
|
|
|
|
### Don't use `GAME_ENTITY` for preset state
|
|
|
|
The reserved `PRESET_STATE_ENTITY` + `engine.presetState<T>(id)` API is namespaced, serializes automatically, and clears on deactivate. Putting preset-specific data on `GAME_ENTITY` (like the old `capture-to-win.Winner` fact) works today but collides with future presets and doesn't auto-clear.
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
`packages/chess/src/presets/test-utils.ts` exports:
|
|
|
|
- `pieceAt(engine, square)` — find piece on algebraic square.
|
|
- `hpOf(engine, id)` / `typeOf(engine, id)` / `exists(engine, id)` — attribute queries.
|
|
- `clearBoard(engine, { preserveKings })` — strip the board to minimal pieces.
|
|
- `placePiece(engine, type, color, square, opts?)` — direct session insert (bypasses spawnPiece).
|
|
|
|
For preset tests that exercise hooks, see:
|
|
- `damage-pipeline.test.ts` — damage + composition
|
|
- `preset-state.test.ts` — per-preset state bag
|
|
- `move-log.test.ts` — MoveRecord / describeMoveEffect
|
|
- `visual-effect.test.ts` — emitEffect / subscribeEffects
|
|
- `phase-hooks.test.ts` — onBeforeMove / onTurnStart
|
|
- `integration.test.ts` — Shield / Cannon / Berserker / Stamina end-to-end
|
|
|
|
---
|
|
|
|
## Post-landing backlog
|
|
|
|
Documented but NOT yet available:
|
|
|
|
- `onPieceRetract` — symmetric counterpart to `onPieceSpawn`. Needed for "death rattle" mechanics.
|
|
- Per-preset UI panels (not just overlays) — extension slot for sidebar widgets.
|
|
- Server-side piece-type manifest echo — when custom types are authored outside the shared `packages/chess`.
|
|
- Save-state migration — versioning for saves that predate attribute additions.
|
|
|
|
File issues or propose extensions via pull request.
|