diff --git a/packages/chess/RULES.md b/packages/chess/RULES.md index 92a5e0d..8059520 100644 --- a/packages/chess/RULES.md +++ b/packages/chess/RULES.md @@ -492,3 +492,274 @@ Like `bouncing-pieces` but rays also reflect off TOP (rank 8) and BOTTOM (rank 1 **ID**: `dual-classic` (source: premade) · Added in the 2026 epic. Variant of FIDE with queens removed and replaced by a second king per side. White: kings on d1 + e1, rooks/bishops/knights/pawns standard. Black: mirror. Canonical pairing with `dual-king` (strong) or `weak-dual-king`. + +--- + +## Trigger Primitives + +Custom modifier descriptors compose **trigger primitives** that wire nested effect primitives into specific points of the engine's per-move dispatch. Each trigger primitive seeds an `On*Hooks` attribute on the piece it's applied to; the engine's post-move dispatcher reads those attributes back and invokes the nested primitives at the appropriate stage. + +This section documents the **11 trigger primitives** (4 existing + 7 added in Wave 2), the order in which the engine fires them, and the `PrimitiveApplyContext` extensions (target redirection + event payload) that nested primitives can read. + +For the broader context-object types referenced below (`PrimitiveApplyContext`, `TargetResolver`, `PrimitiveEvent`, `resolveTargets`), see [`docs/PRESET-API.md`](./docs/PRESET-API.md#primitive-context-target-redirection--event). + +### Hard caps (unchanged) + +| Cap | Value | Source | +|---|---|---| +| `MAX_RECURSION_DEPTH` | 3 | `custom/apply.ts` (matches validator) | +| `MAX_PRIMITIVE_COUNT` | 50 | `custom/schema.ts` (per descriptor) | +| Descriptor `version` | 1 | `custom/types.ts` | + +Adding a new trigger does **not** raise these caps. A descriptor that nests trigger primitives more than 3 levels deep, or that exceeds 50 total primitive nodes across the descriptor, is rejected at validation time. + +### Dispatch order (Metis-locked, 12 stages) + +After every successful `applyMove`, the engine runs the following dispatch sequence in `apply.ts#onAfterMove`. Stages must remain in this exact order. Later stages depend on observable state set by earlier stages, and reorderings would silently change semantics. + +1. **`computeAuraFacts`** recomputes aura WMEs (T28). +2. **`fireOnDamagedHooks`** uses the pre-move HP snapshot to detect drops. +3. **`fireOnCaptureHooks`** fires on the **attacker** piece. +4. **`fireOnCapturedHooks`** fires on the **defender** (dying) piece, BEFORE its facts are otherwise gone from the dispatcher's perspective. +5. **`fireOnPromotionHooks`** fires on pawns whose `PieceType` flipped this move. +6. **`fireOnMoveHooks`** fires on every piece whose `Position` WME changed (mover plus castling rook plus en-passant pawn relocation). +7. **`fireOnMovedOntoSquareHooks`** evaluates each moved piece's destination against the hook's filter. +8. **`fireOnCheckReceivedHooks`** uses the pre/post check-state diff; royal pieces only; **edge-triggered**. +9. **`fireOnCheckDeliveredHooks`** uses the pre/post attacker-set diff; attributes discovered checks to the revealing piece. +10. **`fireConditionalHooks`** branches on current facts (the `conditional` primitive). +11. **`fireOnTurnEndHooks`** fires on pieces whose `OnTurnEndHooks` color matches the **mover's color** (the turn that just ended). +12. **`fireOnTurnStartHooks`** fires on pieces whose `OnTurnStartHooks` color matches the **next side to move**. + +Composition implication: an `on-turn-end` hook on the moving side runs **before** any `on-turn-start` hook on the opponent. A descriptor that decays HP at end-of-turn and then heals on next-turn-start sees the decay applied first, then the heal, in that order. + +### Existing triggers (pre-Wave 2) + +| Kind | Fires when | Seeds | +|---|---|---| +| `on-turn-start` | At the start of a matching-color turn (stage 12). Params carry `color: 'white'\|'black'\|'both'`. | `OnTurnStartHooks` | +| `on-capture` | On the **attacker** when a move resolves as a capture (stage 3). | `OnCaptureHooks` | +| `on-damaged` | When this piece's `Hp` fact decreases (stage 2). | `OnDamagedHooks` | + +(These were shipped pre-Wave 2; their semantics are unchanged.) + +### New triggers (Wave 2) + +#### on-move + +**Fires when**: this piece's `Position` WME changes. Any move the piece makes counts, including captures, the rook leg of castling, and the pawn-relocation half of en-passant. Stage 6. + +**Seeds**: `OnMoveHooks` + +**Params**: + +```json +{ "primitives": [ /* nested EffectPrimitiveNode[] */ ] } +``` + +**Examples**: + +```json +// Berserker: +1 AttackBonus on every move +{ "primitives": [ + { "kind": "add-to-attribute", "params": { "attr": "AttackBonus", "delta": 1 } } +] } +``` + +```json +// Nomad: heals 1 HP per step +{ "primitives": [ + { "kind": "add-to-attribute", "params": { "attr": "Hp", "delta": 1 } } +] } +``` + +#### on-turn-end + +**Fires when**: at the end of a matching-color turn (stage 11), **before** the opponent's `on-turn-start`. The `color` param filters which side's turn-end matters; `both` fires on either. + +**Seeds**: `OnTurnEndHooks` + +**Params**: + +```json +{ "color": "white" | "black" | "both", "primitives": [ /* … */ ] } +``` + +**Examples**: + +```json +// Decay: lose 1 HP at every turn end (either color) +{ "color": "both", + "primitives": [ { "kind": "add-to-attribute", "params": { "attr": "Hp", "delta": -1 } } ] } +``` + +```json +// Re-arm flag at the end of white's own turn +{ "color": "white", + "primitives": [ { "kind": "set-capture-flag", "params": { "flag": 0 } } ] } +``` + +#### on-promotion + +**Fires when**: AFTER this piece's `PieceType` flips from `pawn` to its promotion target (stage 5). The dispatcher populates `ctx.event = { kind: "promotion", promotedFrom: "pawn", promotedTo: PieceType }` so nested primitives can branch on what the pawn became. + +**Seeds**: `OnPromotionHooks` + +**Params**: + +```json +{ "primitives": [ /* … */ ] } +``` + +**Examples**: + +```json +// Promotion Feast: gain 5 HP on promote +{ "primitives": [ + { "kind": "seed-attribute", "params": { "attr": "Hp", "value": 5 } } +] } +``` + +```json +// Stay-As-Pawn: revert PieceType immediately after the engine flips it +{ "primitives": [ + { "kind": "seed-attribute", "params": { "attr": "PieceType", "value": "pawn" } } +] } +``` + +#### on-check-received + +**Fires when**: the moment this piece transitions from **not-in-check** to **in-check** (stage 8). **Edge-triggered**: a royal that stays in check across consecutive moves does NOT re-fire until the check is broken and re-delivered. Applies only to **royal** pieces (as resolved by the active preset's royalty set, defaulting to kings). + +> ⚠️ **Edge vs. level semantics.** This trigger is the edge of the in-check state, NOT the level. If the same attacker pins the king for three turns in a row, `on-check-received` fires **once** (when the first attack lands). To run logic every turn while in check, combine `on-turn-start` + a `conditional` that reads the current check state. + +**Seeds**: `OnCheckReceivedHooks` + +**Params**: + +```json +{ "primitives": [ /* … */ ] } +``` + +**Examples**: + +```json +// Panic Mode: gain 2 Shield the first time we're checked +{ "primitives": [ + { "kind": "add-to-attribute", "params": { "attr": "Shield", "delta": 2 } } +] } +``` + +```json +// Berserker King: +1 DamageBonus per fresh check +{ "primitives": [ + { "kind": "add-to-attribute", "params": { "attr": "DamageBonus", "delta": 1 } } +] } +``` + +#### on-check-delivered + +**Fires when**: this piece's threat-line newly reaches the enemy royal after the move resolves (stage 9). Edge-triggered like `on-check-received`. **Discovered-check attribution**: when a piece moves out of the way and reveals a slider behind it, the trigger fires on the **revealing slider** (the piece whose line-of-sight became unblocked), NOT on the piece that physically moved. Double-check fires the trigger on **both** newly-attacking pieces. + +**Seeds**: `OnCheckDeliveredHooks` + +**Params**: + +```json +{ "primitives": [ /* … */ ] } +``` + +**Examples**: + +```json +// Vampire: gain 1 HP every time we newly deliver check +{ "primitives": [ + { "kind": "add-to-attribute", "params": { "attr": "Hp", "delta": 1 } } +] } +``` + +```json +// Stack a stun counter on the deliverer (combine with target redirect for the royal) +{ "primitives": [ + { "kind": "add-to-attribute", "params": { "attr": "StunCounter", "delta": 1 } } +] } +``` + +#### on-moved-onto-square + +**Fires when**: this piece finishes a move on a square selected by `filter` (stage 7). Plain moves, captures, castling and en-passant all qualify so long as the destination matches. + +**Seeds**: `OnMovedOntoSquareHooks` + +**Filter shape** (discriminated union): + +```ts +type SquareFilter = + | { kind: "squares"; squares: Square[] } // explicit list, 0..63 (a1=0, h8=63) + | { kind: "predicate"; file?: 0..7; rank?: 0..7 }; // file 0=a..7=h, rank 0=rank1..7=rank8 +``` + +A `predicate` with both `file` and `rank` set fires only on that exact square; a predicate with just one fires for the whole file or rank. At least one of `file` / `rank` must be present. + +**Params**: + +```json +{ "filter": { "kind": "squares", "squares": [27, 28, 35, 36] }, + "primitives": [ /* … */ ] } +``` + +**Examples**: + +```json +// Center Bonus: +1 HP on entering d4/e4/d5/e5 +{ "filter": { "kind": "squares", "squares": [27, 28, 35, 36] }, + "primitives": [ { "kind": "add-to-attribute", "params": { "attr": "Hp", "delta": 1 } } ] } +``` + +```json +// King Row: project a +1 HpBonus aura when reaching rank 8 +{ "filter": { "kind": "predicate", "rank": 7 }, + "primitives": [ + { "kind": "add-aura", "params": { "radius": 1, "targetAttr": "HpBonus", "delta": 1 } } + ] } +``` + +#### on-captured + +**Fires when**: this piece is lethally captured (stage 4). Per dispatch contract, the hook list is invoked **before** any later stage can re-seed or mutate the dying piece's facts; nested primitives that need defender attrs should read them from `ctx.event.defenderId` rather than via `session.get(ctx.pieceId, ...)` (the engine's actual fact retraction has already occurred inside `applyMove` by the time this stage runs). + +The hook supports **per-hook target redirection** so a death-rattle can buff allies, debuff the attacker, or hit a specific square, without special-casing the dispatcher. + +**Seeds**: `OnCapturedHooks` + +**Params**: + +```ts +{ + target?: TargetResolver; // default 'self' + primitives: EffectPrimitiveNode[]; +} +``` + +`TargetResolver` (see PRESET-API.md for full type): + +- `'self'` (default): the dying piece. +- `'attacker'`: the capturing piece (`event.attackerId`). +- `'defender'`: the captured piece (`event.defenderId`); equivalent to `'self'` in this trigger but provided for symmetry. +- `{ squares: Square[] }`: every piece currently on one of the listed squares. +- `{ relation: 'ally' | 'enemy', filter?: { pieceType?: PieceType } }`: every ally (excluding self) or enemy of the dying piece, optionally narrowed to a piece type. + +The dispatcher populates `ctx.event = { kind: "capture", attackerId, defenderId }` and sets `ctx.target = hook.target` before invoking each inner primitive. Nested primitives that opt into redirection call `resolveTargets(ctx, ctx.target)` from `modifiers/primitives/context.ts`. + +**Examples**: + +```json +// Kamikaze: damage the attacker on death +{ "target": "attacker", + "primitives": [ { "kind": "add-to-attribute", "params": { "attr": "Hp", "delta": -2 } } ] } +``` + +```json +// Martyr: heal every allied queen by 1 HP on death +{ "target": { "relation": "ally", "filter": { "pieceType": "queen" } }, + "primitives": [ { "kind": "add-to-attribute", "params": { "attr": "Hp", "delta": 1 } } ] } +``` diff --git a/packages/chess/docs/PRESET-API.md b/packages/chess/docs/PRESET-API.md index 8d62c32..e3be91b 100644 --- a/packages/chess/docs/PRESET-API.md +++ b/packages/chess/docs/PRESET-API.md @@ -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(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(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.