houserules/packages/chess/docs/PRESET-API.md
Joey Yakimowich-Payne da436d5650
docs(chess): document 7 new trigger primitives + target/event context (T27)
Extends RULES.md with a full Trigger Primitives section covering the
Wave 2 additions, and extends PRESET-API.md with the PrimitiveApplyContext
target/event extension introduced in T1.

RULES.md additions:
- Hard caps table (MAX_RECURSION_DEPTH=3, MAX_PRIMITIVE_COUNT=50,
  descriptor version=1) restated so authors know the boundaries.
- Metis-locked 12-stage dispatch order documented as a numbered list
  so users composing multi-trigger descriptors know the relative
  firing order.
- Pre-Wave-2 triggers table (on-turn-start, on-capture, on-damaged)
  for quick reference.
- Per-new-trigger section with firing semantics + 2 params examples:
  * on-move — fires on any Position WME change
  * on-turn-end — end of matching color turn, before opponent
    on-turn-start; carries color param
  * on-promotion — fires AFTER PieceType flip; ctx.event supplies
    promotedFrom + promotedTo
  * on-check-received — EDGE-triggered (explicit callout contrasting
    with level-triggered), royals only
  * on-check-delivered — discovered-check attribution to revealing
    slider; double-check fires on both attackers
  * on-moved-onto-square — {kind:squares} and {kind:predicate} filter
    shapes documented with 0..63 Square numeric convention
  * on-captured — per-hook target redirection table with
    self/attacker/defender/squares/relation options; reads
    event.attackerId + event.defenderId

PRESET-API.md additions:
- Primitive context: target redirection + event section documenting
  the two new required-with-defaults fields on PrimitiveApplyContext
- Verbatim TypeScript excerpts of PrimitiveEvent, TargetResolver, and
  PrimitiveApplyContext copied from context.ts/types.ts
- resolveTargets(ctx, target) signature + usage snippet + resolution-
  rules table for all 5 target shapes
- Construction sites must default note explaining why target is
  required (not optional) on the type
- Currently-redirecting triggers matrix showing which of the 11
  trigger evaluators honour ctx.target and which populate ctx.event

Note: the plan brief said 4 existing + 7 new = 11 triggers, but only
3 pre-Wave-2 trigger primitives exist in the source tree
(on-turn-start, on-capture, on-damaged). Docs reflect the actual
3 + 7 = 10.

Authors of new sections use commas/colons instead of em-dashes to
match the style guideline for new prose; pre-existing em-dashes in
the surrounding text are left as-is.
2026-04-21 18:59:54 -06:00

32 KiB

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.

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

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.
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.

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.

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.

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.

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.

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.

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.

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).

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.

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 MoveRecords. 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.

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

// 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 EffectPrimitives 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:

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:

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:

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<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

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:

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.