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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 18:59:54 -06:00
commit da436d5650
No known key found for this signature in database
2 changed files with 398 additions and 34 deletions

View file

@ -516,6 +516,99 @@ If your preset needs to run before / after another, document the expectation. Th
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<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.
@ -526,46 +619,46 @@ The reserved `PRESET_STATE_ENTITY` + `engine.presetState<T>(id)` API is namespac
`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).
- `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
- `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.
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.
**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.
**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`.
**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.
**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)
@ -594,12 +687,12 @@ The engine implements a two-phase protocol (fixed in commit `7171dfd`): a TERMIN
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.
- `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
- 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.