# 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(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 `` 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. ### Primitive context: target redirection + event Primitive descriptors (the `EffectPrimitive`s consumed by custom-modifier descriptors) receive a `PrimitiveApplyContext` rather than a hook context. As of Wave 2 (commit `3b6f79a`) that context carries two extra fields used by the trigger-primitive dispatcher: - **`target: TargetResolver`**: where the apply's effect should be aimed. Defaults to `'self'` at every construction site, which means `ctx.pieceId` (the current apply's subject). Trigger dispatchers that honour per-hook redirection (currently `on-captured`) populate this with the hook's stored `target` before invoking each inner primitive. - **`event: PrimitiveEvent | undefined`**: discriminated-union payload populated by the trigger dispatcher that invoked the primitive. `undefined` for profile-time applies and for triggers that don't carry a per-event payload. #### Type excerpts From `packages/chess/src/modifiers/primitives/context.ts`: ```ts export type PrimitiveEvent = | { readonly kind: "promotion"; readonly promotedFrom: PieceType; readonly promotedTo: PieceType; } | { readonly kind: "capture"; readonly attackerId: EntityId; readonly defenderId: EntityId; }; export type TargetResolver = | "self" | "attacker" | "defender" | { readonly squares: readonly Square[] } | { readonly relation: "ally" | "enemy"; readonly filter?: { readonly pieceType?: PieceType }; }; ``` From `packages/chess/src/modifiers/primitives/types.ts`: ```ts export interface PrimitiveApplyContext { readonly engine: ChessEngine; readonly session: Session; readonly pieceId: EntityId; readonly depth: number; readonly descriptor: CustomModifierDescriptorRef; /** Where this apply's effect should be aimed. Defaults to 'self'. */ readonly target: TargetResolver; /** Trigger-supplied event metadata, or undefined. */ readonly event: PrimitiveEvent | undefined; } ``` #### `resolveTargets(ctx, target): readonly EntityId[]` The single canonical resolver. Primitives never walk `session.allFacts()` directly to find alternate targets. They call this helper instead: ```ts import { resolveTargets } from "./context.js"; apply(ctx: PrimitiveApplyContext, params: Params): void { const targets = resolveTargets(ctx, ctx.target); for (const id of targets) { // ...mutate id } } ``` Resolution rules: | `target` | Resolves to | Notes | |---|---|---| | `'self'` | `[ctx.pieceId]` | Always a singleton; primitives that only ever act on self may keep reading `ctx.pieceId` directly for efficiency. | | `'attacker'` | `[ctx.event.attackerId]` | Throws if `ctx.event` is missing or not `kind: 'capture'`. Dispatcher misconfiguration is a programmer error, not a silent `[]`. | | `'defender'` | `[ctx.event.defenderId]` | Same throw contract as `'attacker'`. | | `{ squares }` | every piece whose `Position` is in the set | Square is the numeric index 0..63 (a1=0, h8=63), not algebraic. | | `{ relation: 'ally', filter? }` | same-color pieces, **excluding** `ctx.pieceId` | A modifier that buffs "allies" doesn't double-dip on the caster. | | `{ relation: 'enemy', filter? }` | opposite-color pieces | | The optional `filter.pieceType` narrows by `PieceType` attribute. #### Construction sites must default `target` is REQUIRED at the type level. Every dispatcher and every test that builds a `PrimitiveApplyContext` populates `target: 'self'` and `event: undefined` as defaults. Forcing the field keeps future dispatchers honest: they cannot silently forget to thread the target through. #### Currently-redirecting triggers | Trigger | Honours `target` | Populates `ctx.event` | |---|---|---| | `on-captured` | YES (per-hook `target` field, default `'self'`) | YES: `{ kind: "capture", attackerId, defenderId }` | | `on-promotion` | NO | YES: `{ kind: "promotion", promotedFrom, promotedTo }` | | All other triggers | NO | NO | Future triggers that need either capability follow the same pattern: store the resolver in the seeded hook entry, then thread it into `ctx.target` at fire time. ### Don't use `GAME_ENTITY` for preset state The reserved `PRESET_STATE_ENTITY` + `engine.presetState(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 --- ## Rule Variants Gallery (2026 epic) The following 14 presets shipped in the rule-variants epic, mirroring variants from greenchess.net/variants.php?cat=4. All compose with the existing presets via the hook surface documented above, no engine changes beyond the 4 new hooks shipped in Phase A. **King Variants**, redefine what counts as "royal": - `knightmate-rules`, Every knight of `color` is royal; kings are ordinary pieces. - `coregal`, King AND queen are royal; mate on either ends the game. - `dual-king`, Multiple kings per side; mate on any one ends the game ("strong"). - `weak-dual-king`, Multiple kings per side; mating one is survivable; opts out of self-check filter so "sacrifice" moves on one king are legal. **Objectives**, redefine the terminal-state condition: - `first-promotion-wins`, First pawn to promote ends the game. Pairs with `pawns-only` layout. - `suicide-chess`, Lose all pieces to win. Captures compulsory (via `filterLegalMoves`). Empty royal set. Stalemate-wins. - `capture-all`, Inverse of suicide-chess: capture every enemy to win. Captures not compulsory. - `extinction-chess`, Wipe out every enemy of a configurable TARGET TYPE. Target set via `engine.presetState<{ targetType: PieceType }>("extinction-chess")`; default `"pawn"`. King remains royal; default mate rules still apply until extinction triggers. **Multi-move**, redefine the turn flip: - `double-move`, Both sides play 2 half-moves per turn. - `monster-rules`, Asymmetric; **scope-aware**. `scope: "white"` → white plays 2, black plays 1 (canonical Monster pairing). `scope: "black"` flips which side gets the double. `scope: "both"` ≡ `double-move`. **Movement**, redefine piece-specific move generation: - `berolina-pawns`, **scope-aware**; pawns push diagonally, capture orthogonally. `scope: "white"|"black"|"both"` picks which side(s) use the rule. - `berolina-pawns-2`, Berolina plus SIDEWAYS captures. - `bouncing-pieces`, Bishop/queen diagonals reflect off file edges (a-file, h-file) once. - `bouncing-pieces-2`, Reflects off ALL four edges with a 2-bounce cap. ### Scope-flip pattern (reusable) Two asymmetric presets (`monster-rules`, `berolina-pawns`, `berolina-pawns-2`) implement **scope-flippable** semantics by inspecting their own `scope` live at hook time: ```ts let scope: "white" | "black" | "both" = "both"; for (const entry of engine.activePresets.list()) { if (entry.id === MY_ID) { scope = entry.scope; break; } } // Then branch on `scope === "both" || scope === relevantColor`. ``` This makes the activation's `scope` field a first-class rule-authoring knob, not just a duration/lifecycle filter. Any future asymmetric rule that has a "which side does this apply to" question should follow the same pattern. ### `onCheckGameResult` composition with `"ongoing"` The engine implements a two-phase protocol (fixed in commit `7171dfd`): a TERMINAL GameResult from any preset immediately wins; `"ongoing"` sets a soft "suppress defaults" flag and CONTINUES polling; `undefined` means no opinion. This is what lets `piece-hp` (returns `"ongoing"` to suppress FIDE checkmate when HP kings can survive attacks) compose with `first-promotion-wins` / `capture-to-win` / `last-piece-standing` / `extinction-chess` without any preset short-circuiting the others. See `engine.checkGameResult` for the implementation. --- ## 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. - Extinction-chess multiplayer target sync, solo cycler shipped in post-epic Feature 2; MP target is fixed at room creation until a `preset-config.update` WS message lands. File issues or propose extensions via pull request.