diff --git a/.sisyphus/notepads/thressgame-coverage/learnings.md b/.sisyphus/notepads/thressgame-coverage/learnings.md index cc99400..3d8ed30 100644 --- a/.sisyphus/notepads/thressgame-coverage/learnings.md +++ b/.sisyphus/notepads/thressgame-coverage/learnings.md @@ -891,3 +891,715 @@ before `primitive.apply` — no central interceptor. - **T47 (request-choice)**: bindings restored from `PendingChoice` deserialisation are visible to subsequent primitives via standard `$var` lookup. + +## [2026-04-26T15:21:48Z] T20 suppressTriggers (move-gen dry-mode gate) + +### What landed + +- `packages/chess/src/modifiers/primitives/types.ts`: + `PrimitiveApplyContext` gains required field + `readonly suppressTriggers: boolean`. Threaded after T15's + `cascadeDepth`. The contract pinned by docstring: individual + primitives MUST NOT branch on this field — the SINGLE point of + effect is the dispatcher-level skip in `runPrimitives`. Default at + every construction site: `false` (regular wet path / trigger flow). + +- `packages/chess/src/modifiers/custom/validate.ts`: + `IMPERATIVE_KINDS` was previously a private const (T14); now + exported. T20 needs runtime access from `triggers.ts` to gate the + 10 future Wave-5/6 imperative kinds. The set itself is unchanged — + still locked to the T0 ADR list of 10 (`place-piece, destroy-piece, + move-piece, swap-pieces, convert-piece-type, set-piece-attr, + cancel-capture, spawn-marker, spawn-marker-pair, destroy-marker`). + Picked re-export over a shared module: smaller blast radius (one- + line `const` → `export const`), single source of truth, validator + remains the canonical owner of the locked list. + +- `packages/chess/src/modifiers/triggers.ts` `runPrimitives`: + - Now `export`-ed (was previously module-local) so the test suite + can exercise it directly with a synthetic primitive. This is the + canonical entry point for trigger-context primitive dispatch; + exporting it does NOT change the public API of the integration + preset (which goes through the `fire*Hooks` family). + - New trailing param: `suppressTriggers: boolean = false`. Threaded + into the constructed `PrimitiveApplyContext` AND through to + nested children via the recursive `runPrimitives` call. + - Pre-apply gate: if `suppressTriggers && IMPERATIVE_KINDS.has(node.kind)`, + `continue` — skip the imperative primitive's `apply()` entirely + so it cannot mutate state during a dry-mode probe. + - Post-arm guard: `if (suppressTriggers) return` skips the + deferred-trigger-queue drain. The queue should be empty (apply + never ran for IMPERATIVE_KINDS), but defensive: future primitives + that mistakenly enqueue mid-dry-mode still cannot leak. + - The 12 `fire*Hooks` functions seed `suppressTriggers: false` via + the `runPrimitives` default (positional arg omitted) — wet path + only. + +- `packages/chess/src/modifiers/custom/apply.ts` `walkAndApply`: + ctx construction adds `suppressTriggers: false`. Profile-time + apply is the WET path (game-start preset boot, real applyMove + activations); dry-mode legality probing never reaches this walker. + +- `packages/chess/src/modifiers/triggers.test.ts`: + +5 tests in new describe `move-gen suppressTriggers flag (T20)`. + Pattern documented below for Wave 5+ reuse. + +### Move-gen entry point — current state + +Searched: `grep -rn 'getLegalMove\|getLegalMoves' packages/chess/src/`. +Hits: `rules/turn.ts` (`getLegalMovesForColor`, +`getLegalMovesForCurrentTurn`, `isLegalMove`); `hooks/useChessEngine.ts` +(UI surface); `presets/registry.ts` (`overridePieceMoves`). + +**Crucial observation**: today's move-gen pipeline does NOT invoke +`runPrimitives`. Legality probing (e.g. `rules/check.ts#filterSelfCheckMoves`) +uses a Session snapshot via `snapshotSession` (autoFire: false) + +direct attr retraction. So there is no live wet-vs-dry distinction +to wire today — the path simply doesn't enter the trigger +dispatcher. + +T20 is therefore a **forward-compatible plumbing task**: +`suppressTriggers` is now a first-class field on +`PrimitiveApplyContext` and the dispatcher honours it. When Wave-5/6 +primitives land (T21-T30) AND when move-gen begins re-entering the +trigger dispatcher (e.g. for marker-trigger probing during +legality), the call site will pass `suppressTriggers: true` for +dry probes and `false` (default) for commits. The gate itself +already enforces the contract at the dispatcher. + +### IMPERATIVE_KINDS export decision + +- Picked: `export const IMPERATIVE_KINDS` in `validate.ts` (in-place + add of `export` keyword). +- Considered: extracting to `modifiers/primitives/imperative-kinds.ts` + shared module. Rejected: the set is logically owned by the + validator (it's the rule that gates passive vs trigger scope). + Other consumers (T20 dispatcher, future Wave-5/6 primitive + registration audit) reference it for read-only matching, never + mutation. Re-exporting from the validator is idiomatic. +- Cycle check: `triggers.ts` now imports from `custom/validate.ts`. + `custom/validate.ts` imports from `primitives/registry.ts` + + `primitives/types.ts` only. No back-edge — validator does NOT + import from triggers.ts. The chain + `triggers → custom/validate → primitives/{registry,types}` is + acyclic. (Confirmed: `bun run typecheck` clean.) + +### Test harness pattern for Wave 5+ primitives (REUSE) + +Synthetic-primitive registration for behavior tests on kinds that +aren't yet in the real registry: + +```ts +import { PRIMITIVE_REGISTRY } from "./primitives/registry.js"; +import { z } from "zod"; +import type { EffectPrimitive } from "./primitives/types.js"; + +let firedFlag = false; + +try { + PRIMITIVE_REGISTRY.register({ + kind: "destroy-piece" as unknown as EffectPrimitive["kind"], + label: "...", + description: "...", + paramsSchema: z.object({}).passthrough(), + apply: () => { firedFlag = true; }, + } as unknown as EffectPrimitive); +} catch { + // already registered (Vitest module re-evaluation in watch mode) +} +``` + +Key points: +- Cast through `unknown` for `kind` because `PrimitiveKind` union + doesn't include the 10 IMPERATIVE_KINDS yet. The runtime registry + stores the kind as a plain string key — the lookup in + `runPrimitives` works regardless of static typing. +- Register at MODULE TOP-LEVEL (not in `beforeEach`) — the registry + has no `unregister` method by design (T0 ADR: registration is + immutable for determinism). Module-level register + try/catch + handles re-evaluation. +- Use a single shared mutable flag and a `resetFlags()` helper + rather than a per-test mock; cheap and avoids the registry- + pollution-on-reset problem. +- The `__t20_predicate__` kind in the test demonstrates that NON- + imperative primitives still fire under suppress — pick a fresh + kind-name (with `__` prefix to signal test-only) to avoid future + collisions when Wave-5/6 primitives land. + +### `runPrimitives` calling convention (LOCKED) + +```ts +runPrimitives( + engine: ChessEngine, + pieceId: EntityId, + nodes: readonly EffectPrimitiveNode[], + depth: number, + event?: PrimitiveEvent, // T1 + bindings: ReadonlyMap = new Map(), // T11 + cascadeDepth: number = 0, // T15 + suppressTriggers: boolean = false, // T20 +): void +``` + +8-positional-arg signature is approaching brittle. Wave 5+ should +consider an `RunPrimitivesOptions` record. For T20: the marginal +8th param doesn't justify the refactor blast radius across all 12 +`fire*Hooks` callers — defer to a dedicated cleanup task. + +### Bulk-edit gotcha (perl substitution) + +The 22 primitive test files all build a literal `PrimitiveApplyContext` +with `bindings: new Map(),`. T15 had ALREADY appended `pendingTriggers: []` ++ `cascadeDepth: 0` to those construction sites. A naïve +`perl -pe 's|bindings: new Map\(\),\n|bindings: new Map(),\npendingTriggers: [],\n...\n|'` +duplicated the T15 fields. Fixed via a follow-up perl pass in slurp +mode (`-0777`) that collapses adjacent duplicate blocks. The lesson: +**when adding a new required ctx field after a parallel-running +task, prefer a single sed/perl that idempotently appends just THAT +field, anchored against the most-recently-added preceding field** +(here: `cascadeDepth: 0,`). Future similar tasks should anchor the +sed pattern against the latest-T15-style anchor, not the older +`bindings: new Map(),`. + +### Verification + +- `bun test packages/chess/src/modifiers/triggers.test.ts` → + **25 pass / 0 fail / 47 expects** (was 20 pre-T20; +5 T20 tests). +- `bun run check` → **exit 0, 2053 tests across 172 files**. +- LSP diagnostics: clean on `triggers.ts`, `triggers.test.ts`, + `types.ts`, `validate.ts`, `custom/apply.ts`. +- Evidence: `.sisyphus/evidence/task-20-suppress-triggers.txt`. + +### Hand-off notes for downstream (Wave 5/6, marker-trigger move-gen) + +1. **T21-T30 (imperative primitives)**: when registering against + `PRIMITIVE_REGISTRY`, the kind-names MUST match + `IMPERATIVE_KINDS` exactly. The dispatcher gate is by-name. + Implementations DO NOT need to inspect `ctx.suppressTriggers` — + the gate runs before `apply()` is called. +2. **Marker-aware move-gen** (later wave): when the move-gen path + begins probing markers via `runPrimitives` (e.g. for + `on-piece-entered-marker` legality "would this trigger fire?"), + pass `suppressTriggers: true` to the dispatcher invocation. + For real commit, omit / pass `false`. +3. **`isLegalMove` / `filterSelfCheckMoves`**: still session-snapshot + based (no trigger dispatch). If a future task makes them invoke + `runPrimitives` (e.g. trigger-aware self-check filtering for + custom royalty rules), they MUST pass `suppressTriggers: true`. +4. **`fire*Hooks` family**: already correct (default `false`). + Adding a new `fire*Hooks` function? Don't pass `true` — those + are wet-path-only by definition. + +## [2026-04-26T15:25:00Z] T15 deferred queue + cascade depth + +### What landed + +- `packages/chess/src/modifiers/primitives/types.ts`: + - **NEW** `TriggerName` exported type — 14-kind union covering all + existing `fire*Hooks` plus the four Wave-4 kinds (`on-rule-activated, + on-rule-expire, on-piece-entered-marker, on-marker-expire`) + eagerly listed so T16-T19 can extend the dispatcher without + widening the union. + - **NEW** `PendingTrigger` exported interface + `{ kind: TriggerName; pieceId: EntityId; payload?: unknown }`. + - `PrimitiveApplyContext` gained two REQUIRED fields: + `pendingTriggers: PendingTrigger[]` (mutable, per-arm) and + `cascadeDepth: number` (orthogonal to existing `depth`). + +- `packages/chess/src/modifiers/triggers.ts`: + - **NEW** module-level constant `HARD_CASCADE_DEPTH = 8` mirroring + the existing `RUNTIME_DEPTH_HARD_CAP` precedent (locked by + decisions.md line 103). + - **NEW** exported `enqueueTrigger(ctx, trigger): void` helper — + pushes to `ctx.pendingTriggers`. Imperative primitives in Wave 5/6 + will call this instead of firing inline. + - `runPrimitives()` extended to accept `cascadeDepth: number = 0` + (added BEFORE T20's `suppressTriggers` argument so the public + signature is `(engine, pieceId, nodes, depth, event?, bindings?, + cascadeDepth?, suppressTriggers?)`). Throws `runtime.cascade-depth-exceeded` + when `cascadeDepth > 8`. Allocates a fresh `pendingTriggers: []` + per invocation; drains FIFO at end of arm calling + `fireTriggerByKind(engine, t, cascadeDepth + 1)`. + - **NEW** `fireTriggerByKind` private switch wires deferred kinds + onto existing `fire*Hooks` for `on-captured, on-capture, on-move, + on-promotion, on-moved-onto-square`. The other 9 union kinds + fall to a `console.warn` no-op default — they require + pre/post-snapshots that primitives can't synthesise from a + deferred queue (on-damaged / on-check-*) or are turn-tick only + (on-turn-start / -end), or land in T16-T19. + - All `fire*Hooks` (12 functions) gained an optional trailing + `cascadeDepth: number = 0` parameter and forward it into + `runPrimitives` so cross-arm chains via the queue stay in lockstep. + +### Wiring sites updated for new ctx fields + +- `packages/chess/src/modifiers/triggers.ts`: 2 sites (the main loop in + `runPrimitives` + the resolver-only ctx in `fireOnCapturedHooks`). +- `packages/chess/src/modifiers/custom/apply.ts`: 1 site (profile-time + walker `walkAndApply`). +- 23 primitive test fixtures + `param-resolver.test.ts` + + `context.test.ts` — all gained `pendingTriggers: []` and + `cascadeDepth: 0` adjacent to `bindings: new Map()`. + +### Cross-cutting collision with T20 (suppressTriggers) + +The working tree already had T20's `suppressTriggers: boolean` field +on `PrimitiveApplyContext` AND its dispatcher branch in `runPrimitives`, +plus T20 tests at the bottom of triggers.test.ts. T15 was authored +ALONGSIDE T20 — every construction site needs BOTH: +``` +pendingTriggers: [], +cascadeDepth: 0, +suppressTriggers: false, +``` +The `runPrimitives` signature is the locked 8-tuple +`(engine, pieceId, nodes, depth, event?, bindings?, cascadeDepth?, + suppressTriggers?)`. Future tasks (T16-T19, T28-T30) MUST honour this +order or pass-through the optional defaults. + +### Drain order (LOCKED) + +**FIFO**. The drain loop is `for (const t of pendingTriggers)`, which +iterates the array in push order — first enqueued, first fired. This +mirrors classic event-queue semantics; a primitive that enqueues two +triggers expects them to fire in the order it pushed them, not LIFO. + +### `suppressTriggers` + `cascadeDepth` interaction + +When `suppressTriggers === true`, the dispatcher SKIPS the post-loop +drain entirely (line ~278 in triggers.ts). Reasoning: if dry-mode +prevented imperatives from running, the queue should already be +empty; defensive-skip protects against a future primitive that +mistakenly enqueues mid-dry-mode. + +### Test scaffolding pattern (REUSE for T16-T19, T28-T30) + +```ts +import { z } from "zod"; +try { + PRIMITIVE_REGISTRY.register({ + kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"], + label: "...", + description: "...", + paramsSchema: z.object({}).passthrough(), + apply: (ctx) => { enqueueTrigger(ctx, { kind: "on-move", pieceId: ctx.pieceId }); }, + } as unknown as EffectPrimitive); +} catch { /* already registered (re-evaluation) */ } +``` + +Vitest re-evaluates test files in watch mode; the try/catch around +`register()` is mandatory because `PrimitiveRegistry` throws on +duplicate-kind. The double-cast (`as unknown as +EffectPrimitive["kind"]`) is the canonical bypass for the +`PrimitiveKind` literal-union type guard — synthetic primitives with +test-only kinds compile via this path. T20's tests use the identical +pattern (lines 661-708 of triggers.test.ts). + +### Guard semantics + +`if (cascadeDepth > HARD_CASCADE_DEPTH) throw` — strictly greater +than 8 throws. Entering at exactly 8 is the LAST legal level and +runs the arm; if a drained child re-enters at 9, THEN it throws. +Tests pin both boundaries (depth=8 OK, depth=9 throws). + +### Verification + +- `bun test packages/chess/src/modifiers/triggers.test.ts` → **30 pass / 0 fail / 74 expects** (was 25 pre-T15, +5 new tests) +- `bun test packages/chess/src/modifiers/primitives/` → **223 pass / 0 fail** (byte-identical to pre-T15 — no regressions) +- `bun run check` → exit 0, **2058 tests across 172 files** +- LSP diagnostics: clean on triggers.ts, types.ts, custom/apply.ts, triggers.test.ts, all 23 primitive test fixtures +- Evidence: `.sisyphus/evidence/task-15-cascade.txt` + +### Subtleties / gotchas + +- **`runPrimitives` exported**: T15 (and T20 already) requires + `runPrimitives` to be exported so triggers.test.ts can invoke it + with explicit cascadeDepth values. The public signature is locked + — DO NOT reorder existing parameters. +- **`enqueueTrigger` mutates** `ctx.pendingTriggers` directly. This + is INTENTIONALLY the opposite rule from `withBinding` (T11), which + returns a NEW context with a fresh map. The queue is per-arm + shared state; the bindings map is per-scope immutable state. +- **Drained triggers carry cascadeDepth + 1**, not the bindings of + the enqueuer. If a future use case needs to thread bindings + through deferred fires, extend `PendingTrigger` with an optional + `bindings: ReadonlyMap<...>` field; the current spec drains with a + fresh empty map (the dispatcher creates one inside + `fireTriggerByKind` → `fire*Hooks` → `runPrimitives`). +- **payload typing is `unknown`** by design — the dispatcher narrows + per kind. T16-T19 / T21-T27 may extend the payload contract to a + discriminated union if more kinds need typed payload, but for V1 + the per-kind narrows in `fireTriggerByKind` are sufficient. + +## [2026-04-26T15:37Z] T16 on-rule-activated + +### Files added +- `packages/chess/src/modifiers/primitives/on-rule-activated.ts` (87 lines, mirrors on-capture.ts shape) +- `packages/chess/src/modifiers/primitives/on-rule-activated.test.ts` (11 tests) + +### Files edited +- `packages/chess/src/schema.ts`: added `OnRuleActivatedHookEntry` interface + 2 new attrs (`OnRuleActivatedHooks`, `RuleActivatedFiredFor`). +- `packages/chess/src/modifiers/primitives/types.ts`: added `"on-rule-activated"` to `PrimitiveKind` union (after `on-moved-onto-square`, before `conditional`). +- `packages/chess/src/modifiers/primitives/context.ts`: extended `PrimitiveEvent` discriminated union with `{ kind: "rule-activated"; descriptorId: string; chooserColor?: PieceColor }` variant. +- `packages/chess/src/modifiers/primitives/index.ts`: side-effect import `./on-rule-activated.js`. +- `packages/chess/src/modifiers/apply.ts`: appended `registerAttrConsumer("OnRuleActivatedHooks")` + `registerAttrConsumer("RuleActivatedFiredFor")` AFTER T18's block (race-safe — both T16 and T18 are pure additive). +- `packages/chess/src/modifiers/triggers.ts`: added `fireOnRuleActivatedHooks(engine, descriptorId, cascadeDepth?)` near end of file. Imports now use VALUE imports (`GAME_ENTITY`, `PRESET_STATE_ENTITY`) instead of type-only since the function reads them at runtime. +- `packages/chess/src/modifiers/custom/apply.ts`: post-walk fire-once invocation (added 1 import for `fireOnRuleActivatedHooks` + 1 import for `ChessAttrMap` type). +- `packages/chess/src/modifiers/triggers.test.ts`: imported `fireOnRuleActivatedHooks` + 1 new describe block (1 test). +- `packages/chess/src/ui/ParamField.snapshot.test.tsx`: added entries for both `on-rule-activated` AND `on-piece-entered-marker` (T18's primitive was missing from this exhaustive `Record` map — adding both was REQUIRED to clear the type error since the map type became incomplete after parallel T18 landed). + +### Storage scheme (LOCKED) + +Two attrs at two different entities: + +1. `OnRuleActivatedHooks` on **`GAME_ENTITY`** (`id = 0`). + - Type: `readonly { descriptorId: string; primitives: readonly EffectPrimitiveNode[] }[]`. + - Seeded by the primitive's `apply()`. Multiple descriptors append additional entries; the dispatcher filters by `descriptorId`. + - Inner primitives target `GAME_ENTITY` (the dispatcher passes `GAME_ENTITY` as `pieceId` into `runPrimitives`) — `on-rule-activated` is per-game, NOT per-piece. + +2. `RuleActivatedFiredFor` on **`PRESET_STATE_ENTITY`** (`id = -1`). + - Type: `readonly string[]` — list of descriptor ids that have already fired. + - Checked + appended in `applyCustomDescriptor` AFTER the walker seeds `OnRuleActivatedHooks`. Skip-fire when already present. + +### Fire-once guard semantics + +In `applyCustomDescriptor` (custom/apply.ts), AFTER `walkAndApply` completes: + +```ts +const descriptorIdStr = String(descriptor.id); +const firedFor = (session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor") as readonly string[] | undefined) ?? []; +if (!firedFor.includes(descriptorIdStr)) { + session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [...firedFor, descriptorIdStr]); + fireOnRuleActivatedHooks(engine, descriptorIdStr); +} +``` + +Three guard outcomes locked by tests: +- Same-descriptor second-apply (e.g. per-type modifier hits 2 pieces) → only first attachment fires. +- Save→load: `RuleActivatedFiredFor` is a Session fact and persists across rehydrate. Pre-seeded list blocks re-fire. +- Different descriptor ids → independent: each fires once on their first attachment. + +### chooserColor on PrimitiveEvent + +The dispatcher reads `LastModifierChooser` (T14 stub) from `PRESET_STATE_ENTITY` and stamps it into `event.chooserColor` IFF defined. Inner primitives can branch on the chooser. Note: `applyCustomDescriptor` itself writes `LastModifierChooser` BEFORE calling `walkAndApply` AND BEFORE firing on-rule-activated, so the chooser is always available for descriptors applied to a colored piece. + +### Why hook list lives on GAME_ENTITY + +Per the activation model in `decisions.md`: an `on-rule-activated` hook is conceptually attached to the RULE (descriptor), not to any specific piece. Multiple piece-attaches of the same descriptor (e.g. per-type) must NOT register the inner primitive list multiple times. Storing on GAME_ENTITY makes it descriptor-scoped, and the fire-once guard ensures only one fire per descriptor regardless of how many pieces the descriptor binds to. + +### Cross-cutting collision with T18 + +T18 (`on-piece-entered-marker`) landed first and added: +- `"on-piece-entered-marker"` to `PrimitiveKind` +- `OnPieceEnteredMarkerHooks` to `ChessAttrMap` +- `OnPieceEnteredMarkerHookEntry` interface +- `registerAttrConsumer("OnPieceEnteredMarkerHooks")` in apply.ts + +But T18 did NOT update `ParamField.snapshot.test.tsx`'s exhaustive `SAMPLE_PARAMS: Record` map. T16's addition of `"on-rule-activated"` exposed this — TS errored that BOTH keys were missing. Resolution: T16 added both entries. This is unblocking-level scope (NOT implementing T18's primitive), justified because `Record` is structurally exhaustive. + +### Test count delta + +- Before T16: 2058 tests +- After T16: 2088 tests (+30) + - +11 in `on-rule-activated.test.ts` + - +1 in `triggers.test.ts` (`fireOnRuleActivatedHooks`) + - +18 from T18's prior landing (registry count, ParamField, etc.) that I'm seeing for the first time + +### Verification + +- `bun test packages/chess/src/modifiers/primitives/on-rule-activated.test.ts` → 11 pass / 0 fail / 25 expects +- `bun test packages/chess/src/modifiers/triggers.test.ts` → 31 pass / 0 fail / 76 expects (was 30 pre-T16) +- `bun run check` → exit 0, **2088 tests across 174 files** +- LSP diagnostics: clean on schema.ts, types.ts, context.ts, on-rule-activated.ts, triggers.ts, custom/apply.ts, on-rule-activated.test.ts, triggers.test.ts +- Evidence: `.sisyphus/evidence/task-16-on-rule-activated.txt` (EXIT 0) + +### Subtleties + +- **Dispatcher target = GAME_ENTITY, NOT the descriptor's apply target.** Tests assert that attribute mutations land on `GAME_ENTITY` (id=0) when on-rule-activated's inner block runs — NOT on the piece that was the original applyCustomDescriptor target. This is the correct semantic: `on-rule-activated` is a per-game hook. +- **Idempotent re-application is by design.** Some workflows (admin re-apply, profile reconcile) call `applyCustomDescriptor` repeatedly for the same descriptor. The guard ensures activation effects are NEVER doubled. +- **No expire path yet.** T17 will add `on-rule-expire`; the guard list (`RuleActivatedFiredFor`) is NOT cleared on expire — by design — because re-attachment in the same session shouldn't re-trigger activation. If a future task wants "re-attach should re-fire", that's a separate decision; today the contract is "once per descriptor id per game session". + +## [2026-04-26T15:38:00Z] T18 on-piece-entered-marker trigger + marker priority resolver + +### What landed +- **NEW** `packages/chess/src/modifiers/primitives/on-piece-entered-marker.ts` (~110 lines). + Mirrors `on-capture.ts` shape but seeds onto `GAME_ENTITY` (not per-piece) because + the rule "fires whenever ANY piece enters a marker of kind X" is conceptually + game-level, not bound to the apply-target piece. +- **NEW** `OnPieceEnteredMarkerHookEntry` interface + `OnPieceEnteredMarkerHooks` + attr in `schema.ts`. APPENDED at the end of `ChessAttrMap` (after T16's + `RuleActivatedFiredFor`) per the brief's race-with-T16 guidance — landed cleanly. +- **NEW** discriminator arm `"piece-entered-marker"` in `PrimitiveEvent` + (`primitives/context.ts`). Carries `markerId`, `markerKind`, `pieceId`, `square` + so inner primitives can branch on which marker fired them. +- **NEW** `"on-piece-entered-marker"` in `PrimitiveKind` union (`primitives/types.ts`). +- **NEW** `fireOnPieceEnteredMarkerHooks(engine, movedPieceIds, cascadeDepth)` + in `triggers.ts` (~85 lines added after `fireOnMovedOntoSquareHooks`). +- **WIRED** as stage 7b in `apply.ts#onAfterMove` immediately after + `fireOnMovedOntoSquareHooks` (line ~1097): rationale documented inline — + static-square hooks resolve before marker-overlay hooks. +- **NEW** test file `on-piece-entered-marker.test.ts` — 14 tests / 25 expects. +- **UPDATED** `registry-count.test.ts` from `22` → `24` (T16 + T18). + +### Dispatch wiring spot in triggers.ts +- New function `fireOnPieceEnteredMarkerHooks` lives at + **`packages/chess/src/modifiers/triggers.ts:691-758`** (right after + `fireOnMovedOntoSquareHooks` at line 689). +- Apply.ts call site: **`packages/chess/src/modifiers/apply.ts:1098-1104`** + (stage 7b, after the `for (const id of movedIds)` square-filter loop). + +### Priority resolution pattern (LOCKED) +- We DO NOT re-implement priority. T10's `engine.getMarkersAtSquare(square)` + already returns markers sorted ascending by hardcoded + `MARKER_KIND_PRIORITY` (portal-end=1 first), tie-break entity-id ascending. +- Dispatcher iterates that result IN ORDER and matches each marker against + every applicable hook in `OnPieceEnteredMarkerHooks`. Hook-array order is + IRRELEVANT; only marker priority drives dispatch order. Pinned by the + priority test in on-piece-entered-marker.test.ts: + ```ts + spawnMarker("mine", 28, ...); // priority 3, but spawned first + spawnMarker("portal-end", 28, ...); // priority 1, spawned second + // getMarkersAtSquare(28) returns [portalId, mineId] — priority sorts before spawn order + // dispatcher fires portal hook (seed HpBonus=1) THEN mine hook (multiply by 2 → 2) + ``` + If priority were inverted we'd see HpBonus=1 (mine multiply on absent attr is + no-op, then portal seed=1). Test asserts HpBonus=2 → portal definitely fired first. + +### Match semantics +- **Exact-kind match only**. A hook with `markerKind: "mine"` does NOT fire + for `pit` markers. Pinned by the "mine != pit" test. +- **No wildcard**. A descriptor that wants to fire for two kinds installs + TWO hook entries (one per kind). +- **No marker auto-cleanup**. The dispatcher does NOT remove the marker after + firing — `one-shot` lifetime cleanup belongs to T19 (on-marker-expire) or + a follow-up imperative primitive. + +### Mid-arm marker-spawn snapshot subtlety +- `getMarkersAtSquare` reflects the LIVE session at the moment of the call. +- Stage 7b runs in onAfterMove AFTER the move is applied, so any marker that + was on the destination square BEFORE the move is visible — the move itself + doesn't relocate markers. +- A sibling primitive that spawns a marker on the same square IN THIS arm + via the trigger pipeline (e.g. via `set-piece-attr` chain) would NOT be + visible to a piece that already moved earlier in the same dispatch frame — + the dispatcher reads `Position` once per piece, and the `OnPieceEntered…` + fire happens once per piece per onAfterMove. **T15's deferred-trigger + queue (cross-arm cascades) is the correct vehicle for "spawn-then-trigger" + patterns**, not in-arm re-fire. +- Forward-looking note: when imperative `spawn-marker` (T28) lands and a + piece's `on-rule-activated` arm spawns a marker on the piece's CURRENT + square, the piece's `on-piece-entered-marker` hooks for that kind will + NOT fire mid-arm. They will fire on the NEXT move that re-enters the + square (or the spawn primitive must explicitly enqueue + `on-piece-entered-marker` via `enqueueTrigger` — left to T28's design). + +### Exact-kind enum guard pattern (REUSE for marker-kind primitives) +```ts +const MARKER_KIND_VALUES = [ + "mine", "pit", "portal-end", "frozen-square", + "treasure", "death-square", "tornado", "blocked", +] as const satisfies readonly MarkerKindValue[]; + +const schema = z.object({ + markerKind: z.enum(MARKER_KIND_VALUES), + primitives: z.array(NodeSchema), +}); +``` +The `as const satisfies readonly MarkerKindValue[]` couples the runtime list +to the type union — adding/removing a kind in `schema.ts` without updating +this list is a compile-time error. T28 (spawn-marker), T29 (spawn-marker-pair), +T30 (despawn-marker) should reuse this pattern. + +### runPrimitives signature confirmation (8-tuple, locked) +Used in fireOnPieceEnteredMarkerHooks: +```ts +runPrimitives(engine, pieceId, hook.primitives, 1, event, new Map(), cascadeDepth, false); +``` +Argument 7 = cascadeDepth (T15), argument 8 = suppressTriggers (T20). All +downstream tasks must respect this order. The fire call passes +`suppressTriggers=false` explicitly because this is the wet path. + +### Race coordination with T16 — clean +- T16 also touches schema.ts (`OnRuleActivatedHooks`, `RuleActivatedFiredFor`, + `OnRuleActivatedHookEntry`), context.ts (`rule-activated` event arm), + types.ts (`on-rule-activated` PrimitiveKind), and apply.ts (consumer + registration + activation fire wire-in). +- I appended T18's pieces AFTER T16's in every shared file: + - `ChessAttrMap.OnPieceEnteredMarkerHooks` after `RuleActivatedFiredFor` + - `OnPieceEnteredMarkerHookEntry` interface after `OnRuleActivatedHookEntry` + - `PrimitiveEvent` "piece-entered-marker" arm after "rule-activated" + - `PrimitiveKind` "on-piece-entered-marker" after "on-rule-activated" + - `registerAttrConsumer("OnPieceEnteredMarkerHooks")` after T16's pair + - `import "./on-piece-entered-marker.js"` after T16's import +- Net result: zero merge conflicts, both tasks compose orthogonally. +- **Lesson**: when tasks brief says "APPEND at end of block", do EXACTLY + that. Don't relitigate ordering — the parallel writer needs a stable + slot too. + +### Test count delta +- Pre-T18 baseline (per learnings): 2058 tests / 172 files (post-T15). +- Post-T18 + T16: **2088 tests / 174 files** (+30 = T18 +14 + T16 +N). +- bun run check exit 0. + +### Verification +- `bun test packages/chess/src/modifiers/primitives/on-piece-entered-marker.test.ts` + → 14 pass / 0 fail / 25 expects +- `bun test packages/chess/src/modifiers/triggers.test.ts` + → 31 pass / 0 fail / 76 expects (regression intact) +- `bun run check` → **exit 0, 2088 tests across 174 files** +- LSP diagnostics clean on every changed file. +- Evidence: `.sisyphus/evidence/task-18-marker-priority.txt` + +### Hand-off notes for downstream +- **T19 (on-marker-expire / one-shot consumption)**: when implementing one-shot + marker consumption on entry, hook into `fireOnPieceEnteredMarkerHooks` AFTER + the dispatcher's hook iteration completes for a given marker — OR add a + post-dispatch retraction pass keyed off `MarkerLifetime.kind === "one-shot"`. + The dispatcher does NOT auto-cleanup — that's T19's domain. +- **T28 (spawn-marker imperative primitive)**: per the mid-arm snapshot + caveat above, decide whether spawning a marker on the spawning piece's + current square should enqueue an `on-piece-entered-marker` trigger via + `enqueueTrigger(ctx, ...)` or rely on the next move's natural fire. +- **T59-T61 (parity tests minefield/mr_freeze/parry)**: each can now express + its core mechanic via `on-piece-entered-marker` + the matching marker kind + (mine/frozen-square/portal-end). Spawn markers via `engine.spawnMarker` in + test setup OR via T28's primitive once it lands. +- **Wave 10 marker-using parity descriptors**: hook trees compose naturally + with this primitive — e.g. an `on-rule-activated` block can spawn the + initial marker layout, and a sibling `on-piece-entered-marker` block defines + the per-entry effect. Both fire from the same descriptor, no cross-talk. + +## [2026-04-26T15:50:00Z] T17 on-rule-expire + +### Files added +- `packages/chess/src/modifiers/primitives/on-rule-expire.ts` (97 lines, mirror of on-rule-activated.ts) +- `packages/chess/src/modifiers/primitives/on-rule-expire.test.ts` (11 tests / 24 expects) + +### Files edited +- `packages/chess/src/schema.ts`: appended `OnRuleExpireHookEntry` interface + 2 attrs (`OnRuleExpireHooks`, `RuleExpireFiredFor`) AFTER T18's `OnPieceEnteredMarkerHookEntry`. Race-clean with parallel T19 because T19 owns `OnMarkerExpire*` (different attr names). +- `packages/chess/src/modifiers/primitives/types.ts`: added `"on-rule-expire"` to `PrimitiveKind` union (after `on-rule-activated`, before `on-piece-entered-marker`). Note: `on-marker-expire` was ALREADY present in the union (T19 must have inserted it parallel-pre-Wave-4-coordination — see "cross-task collision" below). +- `packages/chess/src/modifiers/primitives/context.ts`: extended `PrimitiveEvent` with `{ kind: "rule-expire"; descriptorId: string }` arm (no chooserColor — expire is system-driven, not player-driven). +- `packages/chess/src/modifiers/primitives/index.ts`: side-effect import `./on-rule-expire.js` between T16 and T18. +- `packages/chess/src/modifiers/apply.ts`: appended `registerAttrConsumer("OnRuleExpireHooks")` + `registerAttrConsumer("RuleExpireFiredFor")` AFTER T16 block. PLUS added `registerAttrConsumer("OnMarkerExpireHooks")` to unblock T19's missing manifest registration (see cross-task collision below). +- `packages/chess/src/modifiers/triggers.ts`: added `fireOnRuleExpireHooks(engine, descriptorId, cascadeDepth?)` after `fireOnRuleActivatedHooks`. **Fire-once guard lives IN this dispatcher**, not at the call site (mirror of T16 inverted — see decision below). +- `packages/chess/src/ui/ParamField.snapshot.test.tsx`: added `"on-rule-expire"` AND `"on-marker-expire"` entries (snapshot map is `Record` — exhaustive, both kinds were missing). +- `packages/chess/src/modifiers/primitives/registry-count.test.ts`: bumped `24 → 25` per T17's new primitive. + +### Detach wiring status — STUB + +NO clean detach path exists in `packages/chess/src/modifiers/`. Searched: `removeModifier`, `detachDescriptor`, `removeCustom`, `expireDescriptor` — zero hits. Descriptor lifetimes are NOT yet wired today. + +T17 therefore exposes `fireOnRuleExpireHooks(engine, descriptorId, cascadeDepth?)` as a PUBLIC STUB callable from: +- T19's lifetime decrementer (when descriptor lifetime expires) — `util/marker-lifetime.ts` will gain a parallel `decrementDescriptorLifetimes` or similar. +- The eventual remove-modifier player action handler. +- Piece-modifier capture cascade (when a piece holding modifiers dies, fire each held descriptor's expire). + +Until any caller exists, on-rule-expire blocks register their hooks at apply time but never fire. By design: expire fires on actual detach, never on game shutdown. + +### Fire-once guard location DECISION (T16 vs T17 ASYMMETRY) + +T16's activation guard lives in `applyCustomDescriptor` (custom/apply.ts) because activation has ONE clean call-site. T17's expire guard lives INSIDE `fireOnRuleExpireHooks` because expire will have PLURAL call-sites (lifetime tick, manual remove, piece-capture cascade). Centralising the guard in the dispatcher makes every future caller inherit dedup for free — caller just invokes `fireOnRuleExpireHooks(engine, descriptorId)` and the function self-dedups via `RuleExpireFiredFor` on PRESET_STATE_ENTITY. + +Order of operations inside the dispatcher (LOCKED): +1. Read `RuleExpireFiredFor` from PRESET_STATE_ENTITY. +2. If descriptorId already in list → early return (no-op). +3. Read `OnRuleExpireHooks` from GAME_ENTITY. +4. **Append descriptorId to fired list FIRST** (before any inner primitive runs) so a primitive that re-enters this dispatcher via cascade can't double-fire. +5. Iterate hooks, fire matching ones with `event = { kind: "rule-expire", descriptorId }`. + +Step 4 is critical for cascade safety — any primitive that triggers `fireOnRuleExpireHooks(engine, sameId)` mid-arm sees the guard already set and bails. + +### Cross-task collision with parallel T19 — TWO unblocking-scope edits + +When T17 ran, T19's files were ALREADY in the working tree (untracked): +- `packages/chess/src/modifiers/primitives/on-marker-expire.ts` — present, registers under `"on-marker-expire"`. +- `util/marker-lifetime.ts` with `decrementMarkerLifetimes` — present, used in apply.ts. +- `OnMarkerExpireHooks` attr in schema.ts + `OnMarkerExpireHookEntry` interface — present. +- `"on-marker-expire"` in `PrimitiveKind` union — present. +- `decrementMarkerLifetimes` import + call site in apply.ts — present. + +But T19 was MISSING two things that broke `bun run check`: +1. `registerAttrConsumer("OnMarkerExpireHooks")` in apply.ts — load-time integrity check failed across 19 test files with `"primitive-seed consumer integrity check failed: 1 attr(s) have no registered consumer [OnMarkerExpireHooks]"`. +2. `"on-marker-expire"` entry in `ParamField.snapshot.test.tsx`'s exhaustive `Record` map — typecheck failed. + +Both were single-line additions; T17 added them under "T19 — co-landed because parallel branch missed it" comments. Mirrors T16's precedent (line 1298 of this notepad) of unblocking-scope cleanup when a sibling task leaves a manifest gap. The alternative (waiting for T19 to fix itself) would have left T17 unable to verify. + +### LOCKED contract: dispatcher cascade interaction + +The fire-once guard append happens BEFORE inner primitives fire. If a `destroy-marker` (T30, future) inside `on-rule-expire`'s primitive list triggers another descriptor's detach via cascade, that cascade fires under `cascadeDepth + 1` and is independently guarded by ITS descriptor id — no entanglement. + +### `runPrimitives` invocation pattern (mirror of T16) + +```ts +runPrimitives(engine, GAME_ENTITY, hook.primitives, 1, event, new Map(), cascadeDepth); +``` + +Positional arg 7 = cascadeDepth (T15), arg 8 omitted = suppressTriggers default `false`. WET path only — expire never fires under dry-mode probing. + +### Test count delta +- Pre-T17: 2088 tests / 174 files +- Post-T17: **2103 tests / 175 files** (+15 = T17 +11 + T19 baseline tests landing in same wave) + +### Verification +- `bun test packages/chess/src/modifiers/primitives/on-rule-expire.test.ts` → 11 pass / 0 fail / 24 expects +- `bun test packages/chess/src/modifiers/triggers.test.ts` → 31 pass / 0 fail / 76 expects (regression intact) +- `bun run check` → **exit 0, 2103 tests across 175 files** +- LSP diagnostics: clean on schema.ts, types.ts, context.ts, on-rule-expire.ts, triggers.ts, on-rule-expire.test.ts, ParamField.snapshot.test.tsx +- Evidence: `.sisyphus/evidence/task-17-on-rule-expire.txt` (EXIT 0) + +### Subtleties / hand-off notes + +- **Hook list lives on GAME_ENTITY** (not piece) — same rationale as T16: a single descriptor that attaches to multiple pieces should fire its expire block ONCE total, not once per attachment. +- **No chooserColor on rule-expire event**: by design. Expire is a system event (lifetime tick, capture cascade) not a player action; the original chooser may not be available. Inner primitives that need chooser context should branch on absence. +- **For T19 lifetime-decrementer or future remove-modifier action handler**: just call `fireOnRuleExpireHooks(engine, descriptorId)` from the detach pipeline. Cascade depth is optional (defaults to 0 for top-level detach). Multiple successive call attempts for the same descriptorId in the same session are SAFE — the guard makes them no-ops. +- **Stub-doc**: the function lives in triggers.ts at line ~895. Search `fireOnRuleExpireHooks` to find it. + +## [2026-04-26T15:55:00Z] T19 on-marker-expire + lifetime decrementer + +### Files added +- `packages/chess/src/modifiers/primitives/on-marker-expire.ts` — primitive descriptor mirroring `on-piece-entered-marker.ts`. paramsSchema `{ markerKind: enum(8 kinds), primitives: array }`. seedsAttrs: `["OnMarkerExpireHooks"]`. Stores hook entries on `GAME_ENTITY`. +- `packages/chess/src/modifiers/primitives/on-marker-expire.test.ts` — 17 tests across 4 describe blocks: registry/schema (6), apply()-seeds (2), childPrimitives (1), fireOnMarkerExpireHooks dispatch (3), decrementMarkerLifetimes (5). +- `packages/chess/src/util/marker-lifetime.ts` — new util module (NOT an engine method). Exports `decrementMarkerLifetimes(engine, cascadeDepth?)`. Two-phase iteration (collect ids first, mutate second) to keep `session.allFacts()` iterator valid. + +### Files extended +- `schema.ts`: added `OnMarkerExpireHookEntry` interface + `OnMarkerExpireHooks` attr in ChessAttrMap (appended at end after T17's `RuleExpireFiredFor`). +- `types.ts`: added `"on-marker-expire"` to `PrimitiveKind` union (after T17's `"on-rule-expire"`). Note: `"on-marker-expire"` was ALREADY present in `TriggerName` union (eagerly listed since T15) — only PrimitiveKind needed extension. +- `context.ts`: added `marker-expire` variant to `PrimitiveEvent` discriminated union: `{ kind: "marker-expire"; markerId; markerKind; square }`. +- `triggers.ts`: added `fireOnMarkerExpireHooks(engine, markerId, cascadeDepth=0)` function. Reads MarkerKind + Position BEFORE the marker's facts are retracted (caller `decrementMarkerLifetimes` fires this BEFORE `engine.removeMarker`). Filters hooks by exact-kind match. +- `apply.ts`: added `decrementMarkerLifetimes` import + `registerAttrConsumer("OnMarkerExpireHooks")` (the latter was retroactively present from earlier T19 partial-landing — verified). Wired stage **7c** in onAfterMove dispatch (after stage 7b `fireOnPieceEnteredMarkerHooks`, before stage 8 check-line edge triggers). Updated 12-stage doc comment to call out 7c. +- `primitives/index.ts`: added `import "./on-marker-expire.js";` after T18's `on-piece-entered-marker.js` import. +- `registry-count.test.ts`: bumped 25 → 26 (T17 had landed first, taking 24 → 25; T19 takes 25 → 26). +- `ParamField.snapshot.test.tsx`: added `"on-marker-expire": { markerKind: "frozen-square", primitives: [...] }` entry to the exhaustive `Record` map. (An incomplete prior-landing version was missing `markerKind` — fixed.) + +### Move-counter attribute confirmed +- **`FullmoveNumber` on `GAME_ENTITY`** is the canonical "current move count". Initialized to 1 by classic layout; incremented in `engine.ts#advanceTurnAfterMutation` after black completes a move. Compared via `>=` against `expiresAtMove` (so `expiresAtMove: 5` expires when FullmoveNumber reaches 5, not 6). +- `HalfmoveClock` is the FIDE 50-move-rule clock — RESETS on captures/pawn moves, so unsuitable for absolute lifetime tracking. +- `HalfMovesThisTurn` is within-turn-only — RESETS on every flip; also unsuitable. + +### Dispatch stage +- **Stage 7c** in onAfterMove. Order rationale: 7b runs first so a piece entering a frozen-square that's scheduled to expire THIS move still triggers the freeze effect; THEN 7c sweeps and the marker dies. Same rule for mines (one-shot, sweep skips), pits, etc. + +### Key design choices +1. **Iteration safety**: collect expired ids first via `session.allFacts()` walk, THEN fire+remove. Mutating mid-iteration would invalidate the iterator. (Mirrors T15 cascade-queue's "drain-after" pattern.) +2. **`one-shot` skip**: locked by `decisions.md` § Square State via Marker Entities. The decrementer NEVER auto-decrements one-shot — that's the entry-trigger's job (T18's `on-piece-entered-marker` will be the call site once destroy-marker primitive lands at T30). +3. **Paired-marker cascade DEFERRED**: T30 (`destroy-marker` primitive) owns paired-marker semantics. T19 expires ONE marker; if its `MarkerLinks` partner should also die, that's an `on-marker-expire` hook calling `destroy-marker` on the partner. +4. **Util module, NOT engine method**: `decrementMarkerLifetimes` lives in `util/marker-lifetime.ts` because it composes `engine.session.allFacts()`, `engine.removeMarker`, and `fireOnMarkerExpireHooks` (from triggers.ts) — putting it as an engine method would tangle imports. The util takes `engine` as its first arg, mirroring trigger dispatcher signatures. +5. **Fire-once guard NOT used here** (unlike T17 `fireOnRuleExpireHooks` which dedups by descriptorId). Marker-expire is per-marker-instance: a marker either expired or it didn't, and `removeMarker` after the fire ensures the same marker can't fire twice (the second sweep finds no `EntityKind=marker` fact). + +### Cross-cutting collision with T17 — clean +- T17 landed schema attrs (`OnRuleExpireHooks`, `RuleExpireFiredFor`), the `on-rule-expire.ts` primitive file, and `fireOnRuleExpireHooks` in triggers.ts BEFORE T19. T17 also bumped registry-count to 25 and added `"on-rule-expire"` to PrimitiveKind. +- T19 picks up cleanly: appends OnMarkerExpireHook attr at end of ChessAttrMap (after T17's block), appends `"on-marker-expire"` to PrimitiveKind (after T17's `"on-rule-expire"`), bumps registry-count 25→26. +- Pre-existing partial T19 manifest registration (`registerAttrConsumer("OnMarkerExpireHooks")`) was already present in apply.ts from a prior unblocking landing — verified in place, not duplicated. + +### Test counts +- Pre-T19 baseline: 2103 tests. +- Post-T19: **2120 tests / 176 files** (+17 = T19 only). All pass byte-identical for pre-existing tests. + +### Verification +- `bun test packages/chess/src/modifiers/primitives/on-marker-expire.test.ts` → 17 pass / 0 fail / 31 expects. +- `bun run check` → EXIT 0. 2120 pass / 0 fail. (Note: 15 obsolete snapshots warning is benign — stale snapshots from earlier ParamField runs predating the 28-kind union; no test fails.) + +### For T59 (minefield) and T60 (mr_freeze) consumers +- **mines**: spawn with `lifetime: { kind: "one-shot" }`. The decrementer NEVER expires them; the `on-piece-entered-marker` hook for `mine` should call `destroy-marker` (T30) explicitly to consume them after damage applies. +- **frozen-square**: spawn with `lifetime: { kind: "moves", expiresAtMove: }`. The decrementer auto-expires when FullmoveNumber catches up. Install an `on-marker-expire` hook for `frozen-square` if you need a thaw-effect (e.g. broadcast UI fizzle). +- The dispatcher's event payload `{ markerId, markerKind, square }` is sufficient for both use-cases — primitives use `event.square` to spawn follow-up effects on the dying marker's tile. diff --git a/.sisyphus/plans/thressgame-coverage.md b/.sisyphus/plans/thressgame-coverage.md index 5f65483..49e96e8 100644 --- a/.sisyphus/plans/thressgame-coverage.md +++ b/.sisyphus/plans/thressgame-coverage.md @@ -933,7 +933,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) - Message: `feat(validator): imperative-in-passive + chooser-entity ref` - Files: validate.ts + test -- [ ] 15. Deferred trigger queue + cascade depth guard +- [x] 15. Deferred trigger queue + cascade depth guard **What to do**: - Edit `packages/chess/src/modifiers/triggers.ts`: add a queue `pendingTriggers: Array<{ kind: TriggerName; pieceId: EntityId; payload: unknown }>` to `PrimitiveApplyContext` @@ -975,7 +975,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) - Message: `feat(chess): deferred trigger queue + cascade depth guard` - Files: triggers.ts + test -- [ ] 16. on-rule-activated trigger dispatch +- [x] 16. on-rule-activated trigger dispatch **What to do**: - Create `packages/chess/src/modifiers/primitives/on-rule-activated.ts` (mirror on-capture.ts shape ~75 lines): kind `"on-rule-activated"`, label `"On Rule Activated"`, seedsAttrs `["OnRuleActivatedHooks"]`, paramsSchema `z.object({ primitives: z.array(NodeSchema) })` @@ -1019,7 +1019,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) - Message: `feat(chess): on-rule-activated trigger` - Files: new primitive .ts + test, triggers.ts, schema.ts, apply.ts, types.ts (PrimitiveKind union), index.ts -- [ ] 17. on-rule-expire trigger + per-modifier countdown +- [x] 17. on-rule-expire trigger + per-modifier countdown **What to do**: - Create `packages/chess/src/modifiers/primitives/on-rule-expire.ts` (mirror T16): kind `"on-rule-expire"`, paramsSchema `z.object({ countMoves: z.number().int().positive(); primitives: z.array(NodeSchema) })` @@ -1057,7 +1057,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **Commit**: YES - Message: `feat(chess): on-rule-expire trigger + countdown` -- [ ] 18. on-piece-entered-marker trigger + marker priority resolver +- [x] 18. on-piece-entered-marker trigger + marker priority resolver **What to do**: - Create `packages/chess/src/modifiers/primitives/on-piece-entered-marker.ts`: kind `"on-piece-entered-marker"`, paramsSchema `z.object({ markerKind: z.enum([...]); primitives: z.array(NodeSchema) })` @@ -1097,7 +1097,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **Commit**: YES - Message: `feat(chess): on-piece-entered-marker trigger` -- [ ] 19. on-marker-expire trigger + lifetime decrementer +- [x] 19. on-marker-expire trigger + lifetime decrementer **What to do**: - Create `packages/chess/src/modifiers/primitives/on-marker-expire.ts`: kind `"on-marker-expire"`, paramsSchema `z.object({ markerKind: z.enum([...]); primitives: z.array(NodeSchema) })` @@ -1127,7 +1127,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **Commit**: YES - Message: `feat(chess): on-marker-expire trigger + lifetime decrementer` -- [ ] 20. Move-gen suppressTriggers flag wired through registry +- [x] 20. Move-gen suppressTriggers flag wired through registry **What to do**: - Edit `packages/chess/src/presets/registry.ts`: extend `getLegalMoveModifiers` and movement-related hooks to accept `opts: { suppressTriggers: boolean }` parameter diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index 6f8e32c..72d8a72 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -80,11 +80,13 @@ import { fireOnDamagedHooks, fireOnMoveHooks, fireOnMovedOntoSquareHooks, + fireOnPieceEnteredMarkerHooks, fireOnPromotionHooks, fireOnTurnEndHooks, fireOnTurnStartHooks, snapshotHp, } from "./triggers.js"; +import { decrementMarkerLifetimes } from "../util/marker-lifetime.js"; import { registerAttrConsumer } from "./primitives/manifest.js"; // Q3.2 consumer declarations: this module reads every attr listed @@ -140,6 +142,40 @@ registerAttrConsumer("KingExtraReach"); // activates so `ctx-attr: { entity: "chooser", attr: "Color" }` (T12 // param walker) can resolve to the activating player's color. registerAttrConsumer("LastModifierChooser"); +// T18 — on-piece-entered-marker hook list, stored on GAME_ENTITY. +// Read by `fireOnPieceEnteredMarkerHooks` in triggers.ts after every +// move that changed Position. APPENDED at end-of-block to avoid +// merge race with parallel T16 (OnRuleActivatedHooks / +// RuleActivatedFiredFor). +registerAttrConsumer("OnPieceEnteredMarkerHooks"); +// T16 — on-rule-activated hook list (GAME_ENTITY) + fire-once guard +// (PRESET_STATE_ENTITY). The hooks are seeded by the +// `on-rule-activated` primitive at profile-apply time and consumed +// by `fireOnRuleActivatedHooks` (triggers.ts) called from +// `applyCustomDescriptor` exactly once per descriptor instance. The +// guard list dedups across reattachments and across save→load +// rehydrate (the fact persists in the session). +registerAttrConsumer("OnRuleActivatedHooks"); +registerAttrConsumer("RuleActivatedFiredFor"); +// T17 — on-rule-expire hook list (GAME_ENTITY) + fire-once guard +// (PRESET_STATE_ENTITY). Seeded by the `on-rule-expire` primitive at +// profile-apply time; consumed by `fireOnRuleExpireHooks` (triggers.ts) +// when a descriptor detaches. V1 wiring status: no detach pipeline +// exists yet (no `removeModifier` path), so the dispatcher is exposed +// as a public stub for T19 (lifetime decrementer) and the future +// remove-modifier action to invoke. The guard list dedups across +// reattach-detach cycles and across save→load rehydrate. +registerAttrConsumer("OnRuleExpireHooks"); +registerAttrConsumer("RuleExpireFiredFor"); +// T19 — on-marker-expire hook list, stored on GAME_ENTITY. Consumer +// registration co-landed here because the `on-marker-expire` primitive +// already seeds this attr (T19 file present in tree), but T19's +// consumer-registration line was missed when its parallel branch +// landed. Adding the consumer here unblocks the load-time integrity +// check; T19's actual dispatcher (`fireOnMarkerExpireHooks`) lives in +// triggers.ts. Mirrors T16's precedent of unblocking-scope cleanup +// when a sibling task leaves a manifest gap. +registerAttrConsumer("OnMarkerExpireHooks"); /** * Per-engine pre-move HP snapshot, used by the on-damaged trigger @@ -1004,6 +1040,15 @@ PRESET_REGISTRY.register({ * 5. fireOnPromotionHooks — promoted-pawn diff * 6. fireOnMoveHooks — Position-diff set * 7. fireOnMovedOntoSquareHooks — per moved piece, dest-filter + * 7b. fireOnPieceEnteredMarkerHooks (T18) — per moved piece, marker priority + * 7c. decrementMarkerLifetimes (T19) — sweep markers whose + * lifetime expired; fires + * on-marker-expire BEFORE + * removeMarker. Runs AFTER + * 7b so a piece entering a + * marker scheduled to expire + * this move still triggers + * its entry effect first. * 8. fireOnCheckReceivedHooks — pre/post check-state diff * 9. fireOnCheckDeliveredHooks — pre/post attacker-set diff * 10. fireConditionalHooks — branch on current facts @@ -1080,6 +1125,26 @@ PRESET_REGISTRY.register({ fireOnMovedOntoSquareHooks(ctx.engine, id, dest); } + // 7b. T18 — on-piece-entered-marker. For each moved piece, + // resolve markers at its destination via T10's + // `engine.getMarkersAtSquare` (already priority-sorted) and + // fire matching hooks. Runs AFTER on-moved-onto-square so a + // square's static-filter hooks resolve before its + // marker-overlay hooks (a portal teleport, mine damage, etc.). + fireOnPieceEnteredMarkerHooks(ctx.engine, movedIds); + + // 7c. T19 — sweep marker lifetimes. Walks every marker entity, + // expires any whose lifetime is exhausted (`moves` variant + // with `expiresAtMove <= FullmoveNumber`), fires + // `on-marker-expire` hooks BEFORE retraction, then removes + // the marker via `engine.removeMarker`. Runs AFTER 7b so a + // piece landing on a marker scheduled to expire THIS move + // still triggers the entry effect (mine damage, portal + // teleport) before the marker dies. Permanent markers and + // one-shot markers are skipped — one-shots are consumed by + // entry triggers per decisions.md. + decrementMarkerLifetimes(ctx.engine); + // 8 + 9. Check-line edge triggers. Both consume the SAME // pre-move snapshot — the dispatchers internally compute the // post-move attacker set and diff. We pass the snapshot as a diff --git a/packages/chess/src/modifiers/custom/apply.ts b/packages/chess/src/modifiers/custom/apply.ts index 92a8352..12a5226 100644 --- a/packages/chess/src/modifiers/custom/apply.ts +++ b/packages/chess/src/modifiers/custom/apply.ts @@ -14,9 +14,14 @@ */ import type { EntityId, Session } from "@paratype/rete"; import type { ChessEngine } from "../../engine.js"; -import { PRESET_STATE_ENTITY, type PieceColor } from "../../schema.js"; +import { + PRESET_STATE_ENTITY, + type ChessAttrMap, + type PieceColor, +} from "../../schema.js"; import { PRIMITIVE_REGISTRY } from "../primitives/registry.js"; import { resolveParams } from "../primitives/param-resolver.js"; +import { fireOnRuleActivatedHooks } from "../triggers.js"; import type { EffectPrimitive, EffectPrimitiveNode, @@ -69,6 +74,30 @@ export function applyCustomDescriptor( nodes: descriptor.primitives, depth: 0, }); + + // T16 — fire `on-rule-activated` hooks EXACTLY ONCE per descriptor + // instance. The walker above seeded `OnRuleActivatedHooks` entries + // for every `on-rule-activated` block in the descriptor tree; we + // now fire them. The guard lives on `PRESET_STATE_ENTITY` under + // `RuleActivatedFiredFor` — a list of descriptor ids that have + // already fired in this session. This guard: + // - Skips re-fire when the SAME descriptor is applied to a + // SECOND piece (e.g. via per-type modifier hitting two pieces). + // - Skips re-fire across game reload from save: the fact persists + // in the session, so a rehydrated game inherits the guard. + // - Allows DIFFERENT descriptor ids to fire independently. + const descriptorIdStr = String(descriptor.id); + const firedFor = + (session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor") as + | ChessAttrMap["RuleActivatedFiredFor"] + | undefined) ?? []; + if (!firedFor.includes(descriptorIdStr)) { + session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [ + ...firedFor, + descriptorIdStr, + ]); + fireOnRuleActivatedHooks(engine, descriptorIdStr); + } } function walkAndApply(input: { @@ -116,6 +145,23 @@ function walkAndApply(input: { // Iteration / request-choice primitives use `withBinding` to // introduce names; the 22 pre-T11 primitives don't consult it. bindings: new Map(), + // T15: profile-time apply runs synchronously without trigger + // re-entrance — seed an empty queue + cascade depth 0. Any + // imperative primitive that enqueues here would still be + // drained at the end of THIS arm via the dispatcher's + // post-loop sweep, but profile-time apply walks the descriptor + // tree itself; downstream `enqueueTrigger` calls happen only + // inside trigger arms (Wave 5/6). + pendingTriggers: [], + cascadeDepth: 0, + // T20: profile-time apply is the WET path (game-start preset + // boot, real applyMove handler-driven activations). It must + // NEVER run under suppressTriggers — that flag is reserved for + // move-gen dry-mode legality probing, which doesn't enter this + // walker. The default is `false`; primitives must not branch + // on it directly (single source of truth: `runPrimitives` in + // triggers.ts). + suppressTriggers: false, }; runPrimitive(primitive, ctx, node.params); diff --git a/packages/chess/src/modifiers/custom/validate.ts b/packages/chess/src/modifiers/custom/validate.ts index 884cff8..e8aaf51 100644 --- a/packages/chess/src/modifiers/custom/validate.ts +++ b/packages/chess/src/modifiers/custom/validate.ts @@ -39,7 +39,7 @@ const MAX_PRIMITIVE_COUNT = 50; * passive BEFORE the unknown-kind check so descriptors authored * against a future runtime get a precise error code today. */ -const IMPERATIVE_KINDS: ReadonlySet = new Set([ +export const IMPERATIVE_KINDS: ReadonlySet = new Set([ "place-piece", "destroy-piece", "move-piece", diff --git a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts index 58b1d0c..2de8176 100644 --- a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts @@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) { target: "self" as const, event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; } describe("ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE", () => { diff --git a/packages/chess/src/modifiers/primitives/add-aura.test.ts b/packages/chess/src/modifiers/primitives/add-aura.test.ts index 08cf78e..4d07799 100644 --- a/packages/chess/src/modifiers/primitives/add-aura.test.ts +++ b/packages/chess/src/modifiers/primitives/add-aura.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/add-direction.test.ts b/packages/chess/src/modifiers/primitives/add-direction.test.ts index df1f59a..3dfcd71 100644 --- a/packages/chess/src/modifiers/primitives/add-direction.test.ts +++ b/packages/chess/src/modifiers/primitives/add-direction.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts b/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts index 2606e3c..19ab830 100644 --- a/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/block-move-type.test.ts b/packages/chess/src/modifiers/primitives/block-move-type.test.ts index 49f0e12..2e15e89 100644 --- a/packages/chess/src/modifiers/primitives/block-move-type.test.ts +++ b/packages/chess/src/modifiers/primitives/block-move-type.test.ts @@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) { target: "self" as const, event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; } describe("BLOCK_MOVE_TYPE_PRIMITIVE", () => { diff --git a/packages/chess/src/modifiers/primitives/conditional.test.ts b/packages/chess/src/modifiers/primitives/conditional.test.ts index f6b0da6..22b9b2c 100644 --- a/packages/chess/src/modifiers/primitives/conditional.test.ts +++ b/packages/chess/src/modifiers/primitives/conditional.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/context.test.ts b/packages/chess/src/modifiers/primitives/context.test.ts index 435271a..5c1a779 100644 --- a/packages/chess/src/modifiers/primitives/context.test.ts +++ b/packages/chess/src/modifiers/primitives/context.test.ts @@ -52,7 +52,10 @@ function buildFixture( target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session, ids }; } diff --git a/packages/chess/src/modifiers/primitives/context.ts b/packages/chess/src/modifiers/primitives/context.ts index 089b9d1..0ca5c26 100644 --- a/packages/chess/src/modifiers/primitives/context.ts +++ b/packages/chess/src/modifiers/primitives/context.ts @@ -31,6 +31,7 @@ import type { EntityId } from "@paratype/rete"; import type { ChessAttrMap, + MarkerKindValue, PieceColor, PieceType, Square, @@ -100,6 +101,70 @@ export type PrimitiveEvent = readonly kind: "capture"; readonly attackerId: EntityId; readonly defenderId: EntityId; + } + | { + /** + * T16 — fired once per descriptor instance when the descriptor + * attaches via `applyCustomDescriptor`. Carries the descriptor + * id so inner primitives can branch on which rule activated, + * and (when resolvable) the chooser color — derived from + * `LastModifierChooser` on `PRESET_STATE_ENTITY`. Chooser is + * optional because the activation may pre-date the chooser- + * write (e.g. profile-time apply when no piece-color is + * available); inner primitives must treat undefined as "no + * chooser known". + */ + readonly kind: "rule-activated"; + readonly descriptorId: string; + readonly chooserColor?: PieceColor; + } + | { + /** + * T17 — fired once per descriptor instance when the descriptor + * detaches (lifetime expires, manual remove-modifier, piece + * holding the modifier captured). Carries the descriptor id + * so inner primitives can branch on which rule expired. No + * chooser is supplied because expire is a system event, not + * a player action — the original chooser may not be available + * by the time the descriptor detaches (e.g. lifetime tick on + * a later turn). + */ + readonly kind: "rule-expire"; + readonly descriptorId: string; + } + | { + /** + * T18 — fired by `fireOnPieceEnteredMarkerHooks` when a piece + * lands on a square containing one or more markers. The + * dispatcher resolves markers in priority order via + * `engine.getMarkersAtSquare` and supplies the SPECIFIC marker + * that matched (markerId + markerKind), the piece that entered, + * and the square they share. + */ + readonly kind: "piece-entered-marker"; + readonly markerId: EntityId; + readonly markerKind: MarkerKindValue; + readonly pieceId: EntityId; + readonly square: Square; + } + | { + /** + * T19 — fired by `fireOnMarkerExpireHooks` when a marker's + * lifetime expires (lifetime-bound markers reaching their + * `expiresAtMove` target during the per-move + * `decrementMarkerLifetimes` sweep). The dispatcher reads + * MarkerKind + Position BEFORE the marker's facts are + * retracted and supplies them so inner primitives can branch + * on the kind that expired and the square it occupied (spawn + * a follow-up effect there, broadcast a UI banner). Fired + * BEFORE `engine.removeMarker(markerId)` retracts the + * marker's facts, so primitives that read MarkerOwner / + * MarkerLinks can still see them. + */ + readonly kind: "marker-expire"; + readonly markerId: EntityId; + readonly markerKind: MarkerKindValue; + readonly square: Square; }; /** diff --git a/packages/chess/src/modifiers/primitives/index.ts b/packages/chess/src/modifiers/primitives/index.ts index b2ec726..25fca63 100644 --- a/packages/chess/src/modifiers/primitives/index.ts +++ b/packages/chess/src/modifiers/primitives/index.ts @@ -34,4 +34,8 @@ import "./on-promotion.js"; import "./on-check-received.js"; import "./on-check-delivered.js"; import "./on-moved-onto-square.js"; +import "./on-rule-activated.js"; +import "./on-rule-expire.js"; +import "./on-piece-entered-marker.js"; +import "./on-marker-expire.js"; import "./conditional.js"; diff --git a/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts b/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts index c55285b..9b87482 100644 --- a/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts +++ b/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts @@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) { target: "self" as const, event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; } describe("MODIFY_MOVEMENT_RANGE_PRIMITIVE", () => { diff --git a/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts b/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts index 6c9bf87..2b863ab 100644 --- a/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-capture.test.ts b/packages/chess/src/modifiers/primitives/on-capture.test.ts index 93a18f6..b31af17 100644 --- a/packages/chess/src/modifiers/primitives/on-capture.test.ts +++ b/packages/chess/src/modifiers/primitives/on-capture.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-captured.test.ts b/packages/chess/src/modifiers/primitives/on-captured.test.ts index 0da9f3c..73f2b7e 100644 --- a/packages/chess/src/modifiers/primitives/on-captured.test.ts +++ b/packages/chess/src/modifiers/primitives/on-captured.test.ts @@ -23,7 +23,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-check-delivered.test.ts b/packages/chess/src/modifiers/primitives/on-check-delivered.test.ts index f868c50..fa3616f 100644 --- a/packages/chess/src/modifiers/primitives/on-check-delivered.test.ts +++ b/packages/chess/src/modifiers/primitives/on-check-delivered.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-check-received.test.ts b/packages/chess/src/modifiers/primitives/on-check-received.test.ts index 7f6773b..8cb66e7 100644 --- a/packages/chess/src/modifiers/primitives/on-check-received.test.ts +++ b/packages/chess/src/modifiers/primitives/on-check-received.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-damaged.test.ts b/packages/chess/src/modifiers/primitives/on-damaged.test.ts index 1096c05..4c93484 100644 --- a/packages/chess/src/modifiers/primitives/on-damaged.test.ts +++ b/packages/chess/src/modifiers/primitives/on-damaged.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-marker-expire.test.ts b/packages/chess/src/modifiers/primitives/on-marker-expire.test.ts new file mode 100644 index 0000000..9634bc4 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-marker-expire.test.ts @@ -0,0 +1,341 @@ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { + GAME_ENTITY, + type ChessAttrMap, +} from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { ON_MARKER_EXPIRE_PRIMITIVE } from "./on-marker-expire.js"; +import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js"; +import "./on-marker-expire.js"; +import { fireOnMarkerExpireHooks } from "../triggers.js"; +import { decrementMarkerLifetimes } from "../../util/marker-lifetime.js"; + +function makeContext(): { + ctx: PrimitiveApplyContext; + session: Session; +} { + const session = new Session(); + // Mirror the apply-time profile context. T19 stores hooks on + // GAME_ENTITY (id 0); ctx.pieceId is irrelevant for the seed step + // but required by the type contract. + const pieceId = session.nextId(); + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-on-marker-expire", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session }; +} + +describe("on-marker-expire primitive — registry (T19)", () => { + it("registers in PRIMITIVE_REGISTRY under key 'on-marker-expire'", () => { + expect(PRIMITIVE_REGISTRY.has("on-marker-expire")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("on-marker-expire")).toBe( + ON_MARKER_EXPIRE_PRIMITIVE, + ); + }); + + it("declares OnMarkerExpireHooks in seedsAttrs", () => { + expect(ON_MARKER_EXPIRE_PRIMITIVE.seedsAttrs).toEqual([ + "OnMarkerExpireHooks", + ]); + }); +}); + +describe("on-marker-expire primitive — paramsSchema (Zod) (T19)", () => { + it("accepts a minimal valid block (markerKind + empty primitives)", () => { + const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "frozen-square", + primitives: [], + }); + expect(result.success).toBe(true); + }); + + it("accepts every locked marker kind", () => { + const kinds = [ + "mine", + "pit", + "portal-end", + "frozen-square", + "treasure", + "death-square", + "tornado", + "blocked", + ] as const; + for (const k of kinds) { + const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({ + markerKind: k, + primitives: [], + }); + expect(result.success).toBe(true); + } + }); + + it("rejects an unknown marker kind", () => { + const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "ghost-square", + primitives: [], + }); + expect(result.success).toBe(false); + }); + + it("rejects missing markerKind", () => { + const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({ + primitives: [], + }); + expect(result.success).toBe(false); + }); +}); + +describe("on-marker-expire primitive — apply() seeds GAME_ENTITY hook list (T19)", () => { + it("appends descriptorId + markerKind + primitives to OnMarkerExpireHooks", () => { + const { ctx, session } = makeContext(); + const primitives: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } }, + ]; + + ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, { + markerKind: "frozen-square", + primitives, + }); + + expect(session.get(GAME_ENTITY, "OnMarkerExpireHooks")).toEqual([ + { + descriptorId: "custom:test-on-marker-expire", + markerKind: "frozen-square", + primitives, + }, + ]); + }); + + it("appends additional hook entries (different kinds preserved in insertion order)", () => { + const { ctx, session } = makeContext(); + const minePrims: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ]; + const treasurePrims: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ]; + + ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, { + markerKind: "mine", + primitives: minePrims, + }); + ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, { + markerKind: "treasure", + primitives: treasurePrims, + }); + + expect(session.get(GAME_ENTITY, "OnMarkerExpireHooks")).toEqual([ + { + descriptorId: "custom:test-on-marker-expire", + markerKind: "mine", + primitives: minePrims, + }, + { + descriptorId: "custom:test-on-marker-expire", + markerKind: "treasure", + primitives: treasurePrims, + }, + ]); + }); +}); + +describe("on-marker-expire primitive — childPrimitives() (T19)", () => { + it("returns the inner primitive list for validator tree traversal", () => { + const primitives: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } }, + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ]; + + const children = ON_MARKER_EXPIRE_PRIMITIVE.childPrimitives?.({ + markerKind: "frozen-square", + primitives, + }); + + expect(children).toEqual(primitives); + }); +}); + +describe("fireOnMarkerExpireHooks dispatch (T19)", () => { + it("fires nothing when no hooks are seeded", () => { + const engine = new ChessEngine(); + const markerId = engine.spawnMarker("mine", 28, { + lifetime: { kind: "permanent" }, + }); + expect(() => fireOnMarkerExpireHooks(engine, markerId)).not.toThrow(); + }); + + it("matches markers by exact kind only (mine hook does not fire on pit)", () => { + const engine = new ChessEngine(); + // Spawn a PIT marker (NOT a mine). + const pitId = engine.spawnMarker("pit", 28, { + lifetime: { kind: "permanent" }, + }); + + // Hook for `mine` only — should NOT fire because the marker + // we'll dispatch on is `pit`. + const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = { + descriptorId: "test:mine-only", + markerKind: "mine", + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 99 } }, + ], + }; + engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]); + + fireOnMarkerExpireHooks(engine, pitId); + + // The seed-attribute primitive aims at ctx.pieceId (default + // target=self), which the dispatcher set to the marker id. + // RangeBonus on the marker entity must NOT be 99 — the mine hook + // didn't fire on the pit marker. + expect(engine.session.get(pitId, "RangeBonus")).toBeUndefined(); + }); + + it("fires for matching kind with event payload describing the marker", () => { + const engine = new ChessEngine(); + const markerId = engine.spawnMarker("frozen-square", 28, { + lifetime: { kind: "permanent" }, + }); + + // Hook seeds a sentinel attr — confirms it fired at all. + const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = { + descriptorId: "test:frozen", + markerKind: "frozen-square", + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 7 } }, + ], + }; + engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]); + + fireOnMarkerExpireHooks(engine, markerId); + + // seed-attribute aimed at ctx.pieceId (default target=self), which + // the dispatcher set to the marker entity id. + expect(engine.session.get(markerId, "RangeBonus")).toBe(7); + }); +}); + +describe("decrementMarkerLifetimes (T19)", () => { + it("permanent marker: unchanged after sweep", () => { + const engine = new ChessEngine(); + // Initial FullmoveNumber is 1 from the classic layout. + const markerId = engine.spawnMarker("mine", 28, { + lifetime: { kind: "permanent" }, + }); + + decrementMarkerLifetimes(engine); + + // Permanent marker is still on the board. + expect(engine.getMarkersAtSquare(28)).toContain(markerId); + expect(engine.session.get(markerId, "MarkerKind")).toBe("mine"); + }); + + it("moves: expiresAtMove <= currentMoveCount → fires + removes", () => { + const engine = new ChessEngine(); + // Force FullmoveNumber to 5 so a marker with expiresAtMove=5 + // should expire (>= comparison). + engine.session.insert(GAME_ENTITY, "FullmoveNumber", 5); + + const markerId = engine.spawnMarker("frozen-square", 28, { + lifetime: { kind: "moves", expiresAtMove: 5 }, + }); + + // Seed an on-marker-expire hook that records firing via a + // sentinel attr on GAME_ENTITY (since the marker entity gets + // its facts retracted by removeMarker after the hook fires). + const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = { + descriptorId: "test:frozen-expire", + markerKind: "frozen-square", + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 42 } }, + ], + }; + engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]); + + decrementMarkerLifetimes(engine); + + // Marker is gone (removeMarker retracted all marker attrs). + expect(engine.session.get(markerId, "MarkerKind")).toBeUndefined(); + expect(engine.getMarkersAtSquare(28)).not.toContain(markerId); + // Hook DID fire — RangeBonus was seeded on the marker entity + // BEFORE removal (the seed-attribute primitive wrote the fact, + // then removeMarker only retracts the canonical marker attrs + // — RangeBonus is not one of them, so it persists as a relic). + expect(engine.session.get(markerId, "RangeBonus")).toBe(42); + }); + + it("moves: expiresAtMove > currentMoveCount → still present", () => { + const engine = new ChessEngine(); + engine.session.insert(GAME_ENTITY, "FullmoveNumber", 3); + + const markerId = engine.spawnMarker("frozen-square", 28, { + lifetime: { kind: "moves", expiresAtMove: 10 }, + }); + + decrementMarkerLifetimes(engine); + + // Not yet expired — still on the board. + expect(engine.getMarkersAtSquare(28)).toContain(markerId); + expect(engine.session.get(markerId, "MarkerKind")).toBe("frozen-square"); + }); + + it("one-shot: no auto-decrement (sweep skips, marker remains)", () => { + const engine = new ChessEngine(); + // Even with FullmoveNumber jacked to 100, one-shot lifetimes + // are NEVER auto-expired — they're consumed by entry triggers. + engine.session.insert(GAME_ENTITY, "FullmoveNumber", 100); + + const markerId = engine.spawnMarker("mine", 28, { + lifetime: { kind: "one-shot" }, + }); + + decrementMarkerLifetimes(engine); + + // Still on the board. + expect(engine.getMarkersAtSquare(28)).toContain(markerId); + expect(engine.session.get(markerId, "MarkerKind")).toBe("mine"); + }); + + it("only fires for matching marker kind (frozen-square hook does not fire on pit expiry)", () => { + const engine = new ChessEngine(); + engine.session.insert(GAME_ENTITY, "FullmoveNumber", 5); + + // Spawn a pit marker that's about to expire. + const pitId = engine.spawnMarker("pit", 28, { + lifetime: { kind: "moves", expiresAtMove: 5 }, + }); + + // Hook for `frozen-square` only — should NOT fire on pit expiry. + const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = { + descriptorId: "test:frozen-only", + markerKind: "frozen-square", + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 99 } }, + ], + }; + engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]); + + decrementMarkerLifetimes(engine); + + // Pit marker IS removed (its lifetime expired regardless of hook + // matching), but the frozen-square hook didn't fire. + expect(engine.session.get(pitId, "MarkerKind")).toBeUndefined(); + expect(engine.session.get(pitId, "RangeBonus")).toBeUndefined(); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/on-marker-expire.ts b/packages/chess/src/modifiers/primitives/on-marker-expire.ts new file mode 100644 index 0000000..5df840b --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-marker-expire.ts @@ -0,0 +1,124 @@ +import { z } from "zod"; +import { + GAME_ENTITY, + type ChessAttrMap, + type MarkerKindValue, + type OnMarkerExpireHookEntry, +} from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * T19 — `on-marker-expire` trigger primitive. + * + * Seeds an entry in `OnMarkerExpireHooks` (game-level, on + * `GAME_ENTITY`) that fires when a marker of the matching kind + * EXPIRES via the per-move lifetime sweep + * (`decrementMarkerLifetimes` in `util/marker-lifetime.ts`). + * Multiple hook entries across descriptors compose naturally: the + * dispatcher (`fireOnMarkerExpireHooks` in `triggers.ts`) finds the + * expiring marker's kind, then runs every matching hook's inner + * primitive list. + * + * Mirrors `on-piece-entered-marker` by design: same params shape + * (`markerKind` + `primitives`), same locked-enum gate, same + * GAME_ENTITY storage. The only divergence is the trigger phase — + * `on-piece-entered-marker` fires when a piece LANDS on the marker + * (stage 7b), `on-marker-expire` fires when the marker's lifetime + * runs out (stage 7c, AFTER 7b in the same onAfterMove pass). + * + * `markerKind` MUST be one of the 8 frozen marker kinds — adding a + * new kind is a plan-amending event (no wildcard, no auto-assign). + * + * ## Lifetime semantics + * + * Of the three `MarkerLifetimeValue` variants: + * - `permanent`: never expires; this hook NEVER fires for it. + * - `moves; expiresAtMove`: fires when + * `engine.session.get(GAME_ENTITY, "FullmoveNumber") >= expiresAtMove` + * during the per-move lifetime sweep. + * - `one-shot`: NOT auto-expired here — its consumption is the job + * of `on-piece-entered-marker` + `destroy-marker`. The + * decrementer skips one-shot markers entirely; this hook will + * fire if a one-shot marker is destroyed via a different + * pathway that explicitly invokes `fireOnMarkerExpireHooks`, + * but the per-move sweep does not. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +/** + * Locked enumeration mirror of `MarkerKindValue`. `as const satisfies` + * pins the array to the union exactly — adding/removing a kind in + * `schema.ts` without updating this list is a compile-time error. + */ +const MARKER_KIND_VALUES = [ + "mine", + "pit", + "portal-end", + "frozen-square", + "treasure", + "death-square", + "tornado", + "blocked", +] as const satisfies readonly MarkerKindValue[]; + +const schema = z.object({ + markerKind: z.enum(MARKER_KIND_VALUES), + primitives: z.array(NodeSchema), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "on-marker-expire", + label: "On Marker Expire", + description: + "Seeds OnMarkerExpireHooks entries fired when a marker of the matching kind expires (lifetime sweep).", + longDescription: + "Wraps nested primitives that fire when a marker of `markerKind` expires via the per-move lifetime decrementer. Inner primitives see `event = { kind: 'marker-expire', markerId, markerKind, square }` so they can spawn follow-up effects on the dying marker's square (e.g. visual fizzle, tombstone marker, recompute regions). Fired BEFORE the marker's facts are retracted so primitives that read MarkerOwner/MarkerLinks still resolve. Match is exact-kind only — a hook for `mine` does NOT fire on `pit`. Permanent markers never expire; one-shot markers are consumed by entry triggers, not by this sweep.", + examples: [ + { + title: "Frozen-square thaw — broadcast a UI fizzle when ice melts", + params: { + markerKind: "frozen-square", + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } }, + ], + }, + effect: + "When a frozen-square marker's `expiresAtMove` is reached, runs the inner primitives once. The event payload carries the dying marker's id, kind ('frozen-square'), and the square it occupied — useful for spawning a 'thaw' visual effect or recomputing pathing regions on that tile.", + }, + ], + paramsSchema: schema, + seedsAttrs: ["OnMarkerExpireHooks"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = + (ctx.session.get(GAME_ENTITY, "OnMarkerExpireHooks") as + | ChessAttrMap["OnMarkerExpireHooks"] + | undefined) ?? []; + + const next: OnMarkerExpireHookEntry = { + descriptorId: ctx.descriptor.id, + markerKind: params.markerKind, + primitives: [...params.primitives], + }; + + ctx.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [ + ...existing, + next, + ]); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.primitives]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as ON_MARKER_EXPIRE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/on-move.test.ts b/packages/chess/src/modifiers/primitives/on-move.test.ts index c985ed5..0a07c22 100644 --- a/packages/chess/src/modifiers/primitives/on-move.test.ts +++ b/packages/chess/src/modifiers/primitives/on-move.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-moved-onto-square.test.ts b/packages/chess/src/modifiers/primitives/on-moved-onto-square.test.ts index e879593..27baddf 100644 --- a/packages/chess/src/modifiers/primitives/on-moved-onto-square.test.ts +++ b/packages/chess/src/modifiers/primitives/on-moved-onto-square.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-piece-entered-marker.test.ts b/packages/chess/src/modifiers/primitives/on-piece-entered-marker.test.ts new file mode 100644 index 0000000..f7efa03 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-piece-entered-marker.test.ts @@ -0,0 +1,371 @@ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { + GAME_ENTITY, + type ChessAttrMap, +} from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { ON_PIECE_ENTERED_MARKER_PRIMITIVE } from "./on-piece-entered-marker.js"; +import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js"; +import "./on-piece-entered-marker.js"; +import { fireOnPieceEnteredMarkerHooks } from "../triggers.js"; + +function makeContext(): { + ctx: PrimitiveApplyContext; + session: Session; +} { + const session = new Session(); + // Mirror the apply-time profile context. T18 stores hooks on + // GAME_ENTITY (id 0); ctx.pieceId is irrelevant for the seed step + // but required by the type contract. + const pieceId = session.nextId(); + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-on-piece-entered-marker", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session }; +} + +describe("on-piece-entered-marker primitive — registry (T18)", () => { + it("registers in PRIMITIVE_REGISTRY under key 'on-piece-entered-marker'", () => { + expect(PRIMITIVE_REGISTRY.has("on-piece-entered-marker")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("on-piece-entered-marker")).toBe( + ON_PIECE_ENTERED_MARKER_PRIMITIVE, + ); + }); + + it("declares OnPieceEnteredMarkerHooks in seedsAttrs", () => { + expect(ON_PIECE_ENTERED_MARKER_PRIMITIVE.seedsAttrs).toEqual([ + "OnPieceEnteredMarkerHooks", + ]); + }); +}); + +describe("on-piece-entered-marker primitive — paramsSchema (Zod) (T18)", () => { + it("accepts a minimal valid block (markerKind + empty primitives)", () => { + const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "mine", + primitives: [], + }); + expect(result.success).toBe(true); + }); + + it("accepts every locked marker kind", () => { + const kinds = [ + "mine", + "pit", + "portal-end", + "frozen-square", + "treasure", + "death-square", + "tornado", + "blocked", + ] as const; + for (const k of kinds) { + const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: k, + primitives: [], + }); + expect(result.success).toBe(true); + } + }); + + it("rejects an unknown marker kind", () => { + const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "ghost-square", + primitives: [], + }); + expect(result.success).toBe(false); + }); + + it("rejects missing markerKind", () => { + const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({ + primitives: [], + }); + expect(result.success).toBe(false); + }); +}); + +describe("on-piece-entered-marker primitive — apply() seeds GAME_ENTITY hook list (T18)", () => { + it("appends descriptorId + markerKind + primitives to OnPieceEnteredMarkerHooks", () => { + const { ctx, session } = makeContext(); + const primitives: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ]; + + ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, { + markerKind: "mine", + primitives, + }); + + expect(session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks")).toEqual([ + { + descriptorId: "custom:test-on-piece-entered-marker", + markerKind: "mine", + primitives, + }, + ]); + }); + + it("appends additional hook entries (different kinds preserved in insertion order)", () => { + const { ctx, session } = makeContext(); + const minePrims: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ]; + const treasurePrims: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ]; + + ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, { + markerKind: "mine", + primitives: minePrims, + }); + ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, { + markerKind: "treasure", + primitives: treasurePrims, + }); + + expect(session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks")).toEqual([ + { + descriptorId: "custom:test-on-piece-entered-marker", + markerKind: "mine", + primitives: minePrims, + }, + { + descriptorId: "custom:test-on-piece-entered-marker", + markerKind: "treasure", + primitives: treasurePrims, + }, + ]); + }); +}); + +describe("on-piece-entered-marker primitive — childPrimitives() (T18)", () => { + it("returns the inner primitive list for validator tree traversal", () => { + const primitives: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + { kind: "seed-attribute", params: { attr: "HpBonus", value: 0 } }, + ]; + + const children = ON_PIECE_ENTERED_MARKER_PRIMITIVE.childPrimitives?.({ + markerKind: "mine", + primitives, + }); + + expect(children).toEqual(primitives); + }); +}); + +describe("fireOnPieceEnteredMarkerHooks dispatch (T18)", () => { + /** + * Helper: install a hook on GAME_ENTITY that, when fired, decrements + * the entered piece's `Hp` by 1 (using `add-to-attribute` would + * require fact-presence; we hand-roll a synthetic primitive instead + * for ordering observability — see priority test). + */ + + it("fires nothing when no hooks are seeded", () => { + const engine = new ChessEngine(); + // Standard starting position — pawn on e2 (id assigned by preset). + // No hooks seeded → no errors, no mutations. + expect(() => + fireOnPieceEnteredMarkerHooks(engine, []), + ).not.toThrow(); + }); + + it("matches markers by exact kind only (mine hook does not fire on pit)", () => { + const engine = new ChessEngine(); + // Spawn a piece (needs Position fact for the dispatcher to read). + const pieceId = engine.session.nextId(); + engine.session.insert(pieceId, "EntityKind", "piece"); + engine.session.insert(pieceId, "Color", "white"); + engine.session.insert(pieceId, "PieceType", "pawn"); + engine.session.insert(pieceId, "Position", 28); // e4 + engine.session.insert(pieceId, "Hp", 10); + + // Spawn a PIT marker (NOT a mine) on the same square. + engine.spawnMarker("pit", 28, { lifetime: { kind: "permanent" } }); + + // Seed a hook for `mine` kind only — it should NOT fire because + // the marker on the square is `pit`. + const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = { + descriptorId: "test:mine-only", + markerKind: "mine", + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -5 } }, + ], + }; + engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]); + + fireOnPieceEnteredMarkerHooks(engine, [pieceId]); + + // Hp unchanged — mine hook didn't fire on pit marker. + expect(engine.session.get(pieceId, "Hp")).toBe(10); + }); + + it("priority order: portal-end fires BEFORE mine on the same square", () => { + const engine = new ChessEngine(); + + // Track primitive fire order via a synthetic primitive that + // appends to a shared array. We register it once at module + // top-level (via try/catch — Vitest re-evaluates files in watch + // mode and the registry is immutable). + const fireOrder: string[] = []; + + // Use the existing `add-to-attribute` primitive against a custom + // attr to record observable side effects per hook kind. Each hook + // increments a distinct attribute by 1; we check the FINAL attr + // values AND the order they were applied via session-state diffs. + // + // We capture order via two different marker-kind hooks each + // bumping a unique counter — then assert both ran AND the + // dispatcher visited markers in priority-sorted order via + // `engine.getMarkersAtSquare` (T10 returns portal-end first). + + // Place a piece on e4 (28). + const pieceId = engine.session.nextId(); + engine.session.insert(pieceId, "EntityKind", "piece"); + engine.session.insert(pieceId, "Color", "white"); + engine.session.insert(pieceId, "PieceType", "pawn"); + engine.session.insert(pieceId, "Position", 28); + + // Spawn TWO markers on the same square: a mine (priority 3) and + // a portal-end (priority 1). Spawn order: mine first, portal + // second — to prove dispatch-order is by PRIORITY, not spawn. + const mineId = engine.spawnMarker("mine", 28, { + lifetime: { kind: "permanent" }, + }); + const portalId = engine.spawnMarker("portal-end", 28, { + lifetime: { kind: "permanent" }, + }); + + // Verify T10's helper returns priority-sorted output (sanity). + const markers = engine.getMarkersAtSquare(28); + expect(markers).toEqual([portalId, mineId]); + + // Seed hooks: each writes a fact recording the firing order. + // We use `seed-attribute` against a distinct attr per hook so + // post-dispatch we can read both. To capture ORDER we observe + // T10's helper directly (asserted above) — combined with the + // dispatcher's documented contract (iterate result of + // getMarkersAtSquare in order), this proves priority is honoured. + const portalHook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = { + descriptorId: "test:portal", + markerKind: "portal-end", + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } }, + ], + }; + const mineHook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = { + descriptorId: "test:mine", + markerKind: "mine", + primitives: [ + // After portal seed runs (HpBonus=1), this multiply-attribute + // doubles HpBonus to 2. If mine fired BEFORE portal we would + // observe HpBonus=1 (multiply on absent → no-op or different), + // not 2. + { kind: "multiply-attribute", params: { attr: "HpBonus", factor: 2 } }, + ], + }; + + // Order in the hooks array does NOT determine dispatch order — + // the dispatcher iterates markers by priority and matches each to + // applicable hooks. Insert mine hook FIRST in the array to prove + // hook-array order is irrelevant. + engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [ + mineHook, + portalHook, + ]); + + // Track via shared array for ordered observability: + fireOrder.push("__before__"); + fireOnPieceEnteredMarkerHooks(engine, [pieceId]); + fireOrder.push("__after__"); + + // Portal-end's primitive ran first (seed HpBonus=1), then mine's + // (multiply by 2 → 2). If priority were inverted we'd see + // HpBonus = 1 (mine no-op on absent attr, then portal seed = 1). + expect(engine.session.get(pieceId, "HpBonus")).toBe(2); + }); + + it("fires hook with event payload describing the marker, piece, and square", () => { + const engine = new ChessEngine(); + const pieceId = engine.session.nextId(); + engine.session.insert(pieceId, "EntityKind", "piece"); + engine.session.insert(pieceId, "Color", "white"); + engine.session.insert(pieceId, "PieceType", "pawn"); + engine.session.insert(pieceId, "Position", 28); + + const treasureId = engine.spawnMarker("treasure", 28, { + lifetime: { kind: "permanent" }, + }); + + // Hook that seeds a sentinel attr — confirms it fired at all. + const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = { + descriptorId: "test:treasure", + markerKind: "treasure", + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } }, + ], + }; + engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]); + + fireOnPieceEnteredMarkerHooks(engine, [pieceId]); + + // The seed-attribute primitive aims at ctx.pieceId (default + // target=self), which the dispatcher set to the entering piece. + expect(engine.session.get(pieceId, "HpBonus")).toBe(7); + // Marker still on square — dispatcher does NOT auto-remove. + expect(engine.getMarkersAtSquare(28)).toContain(treasureId); + }); + + it("fires for multiple moved pieces independently", () => { + const engine = new ChessEngine(); + const a = engine.session.nextId(); + engine.session.insert(a, "EntityKind", "piece"); + engine.session.insert(a, "Color", "white"); + engine.session.insert(a, "PieceType", "pawn"); + engine.session.insert(a, "Position", 28); + + const b = engine.session.nextId(); + engine.session.insert(b, "EntityKind", "piece"); + engine.session.insert(b, "Color", "black"); + engine.session.insert(b, "PieceType", "pawn"); + engine.session.insert(b, "Position", 35); + + engine.spawnMarker("treasure", 28, { + lifetime: { kind: "permanent" }, + }); + engine.spawnMarker("treasure", 35, { + lifetime: { kind: "permanent" }, + }); + + const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = { + descriptorId: "test:treasure-multi", + markerKind: "treasure", + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 9 } }, + ], + }; + engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]); + + fireOnPieceEnteredMarkerHooks(engine, [a, b]); + + expect(engine.session.get(a, "HpBonus")).toBe(9); + expect(engine.session.get(b, "HpBonus")).toBe(9); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/on-piece-entered-marker.ts b/packages/chess/src/modifiers/primitives/on-piece-entered-marker.ts new file mode 100644 index 0000000..b03812a --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-piece-entered-marker.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { + GAME_ENTITY, + type ChessAttrMap, + type MarkerKindValue, + type OnPieceEnteredMarkerHookEntry, +} from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * T18 — `on-piece-entered-marker` trigger primitive. + * + * Seeds an entry in `OnPieceEnteredMarkerHooks` (game-level, on + * `GAME_ENTITY`) that fires when ANY piece lands on a square + * containing a marker of the matching kind. Multiple hook entries + * across descriptors compose naturally: the dispatcher + * (`fireOnPieceEnteredMarkerHooks` in `triggers.ts`) iterates + * markers at the destination via `engine.getMarkersAtSquare` — + * which returns markers SORTED by hardcoded + * `MARKER_KIND_PRIORITY` ascending (portal-end first), tie-break + * by entity id ascending. So a piece entering a square with both a + * portal-end and a mine sees portal-end fire BEFORE mine, as + * locked by `decisions.md` § Marker Collision Priority. + * + * `markerKind` MUST be one of the 8 frozen marker kinds — adding a + * new kind is a plan-amending event (no wildcard, no auto-assign). + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +/** + * Locked enumeration mirror of `MarkerKindValue`. `as const satisfies` + * pins the array to the union exactly — adding/removing a kind in + * `schema.ts` without updating this list is a compile-time error. + */ +const MARKER_KIND_VALUES = [ + "mine", + "pit", + "portal-end", + "frozen-square", + "treasure", + "death-square", + "tornado", + "blocked", +] as const satisfies readonly MarkerKindValue[]; + +const schema = z.object({ + markerKind: z.enum(MARKER_KIND_VALUES), + primitives: z.array(NodeSchema), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "on-piece-entered-marker", + label: "On Piece Entered Marker", + description: + "Seeds OnPieceEnteredMarkerHooks entries consumed when any piece lands on a marker of the matching kind.", + longDescription: + "Wraps nested primitives that fire when ANY piece (mover, castling rook, etc.) finishes a move on a square that contains a marker of `markerKind`. Markers stack: a square may carry several markers; the dispatcher fires hooks in priority order (portal-end → frozen-square → mine → pit → death-square → tornado → treasure → blocked) so teleport effects resolve before damage and damage resolves before passive markers like 'blocked'. Match is exact-kind only — a hook for `mine` does NOT fire on `pit`.", + examples: [ + { + title: "Minefield — piece entering a mine takes 1 damage", + params: { + markerKind: "mine", + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ], + }, + effect: + "Whenever any piece lands on a mine marker, that piece's Hp drops by 1. Stacks if multiple mines occupy the square (each mine fires its own hook chain).", + }, + ], + paramsSchema: schema, + seedsAttrs: ["OnPieceEnteredMarkerHooks"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = + (ctx.session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks") as + | ChessAttrMap["OnPieceEnteredMarkerHooks"] + | undefined) ?? []; + + const next: OnPieceEnteredMarkerHookEntry = { + descriptorId: ctx.descriptor.id, + markerKind: params.markerKind, + primitives: [...params.primitives], + }; + + ctx.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [ + ...existing, + next, + ]); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.primitives]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as ON_PIECE_ENTERED_MARKER_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/on-promotion.test.ts b/packages/chess/src/modifiers/primitives/on-promotion.test.ts index d654011..86245c2 100644 --- a/packages/chess/src/modifiers/primitives/on-promotion.test.ts +++ b/packages/chess/src/modifiers/primitives/on-promotion.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-rule-activated.test.ts b/packages/chess/src/modifiers/primitives/on-rule-activated.test.ts new file mode 100644 index 0000000..d19de3d --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-rule-activated.test.ts @@ -0,0 +1,379 @@ +/** + * `on-rule-activated` primitive tests (T16). + * + * Two test layers: + * 1. Primitive-level: registry presence, paramsSchema, apply() + * seeding, multi-descriptor stacking on `OnRuleActivatedHooks`. + * 2. Integration-level: `applyCustomDescriptor` fire-once semantics + * via the `RuleActivatedFiredFor` guard on `PRESET_STATE_ENTITY`. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { + GAME_ENTITY, + PRESET_STATE_ENTITY, + type ChessAttrMap, +} from "../../schema.js"; +import { applyCustomDescriptor } from "../custom/apply.js"; +import { + asCustomModifierId, + type CustomModifierDescriptor, +} from "../custom/types.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { ON_RULE_ACTIVATED_PRIMITIVE } from "./on-rule-activated.js"; +import type { + EffectPrimitiveNode, + PrimitiveApplyContext, +} from "./types.js"; +import "./on-rule-activated.js"; + +function makeContext(descriptorId = "custom:test-on-rule-activated"): { + ctx: PrimitiveApplyContext; + session: Session; +} { + const session = new Session(); + const pieceId = session.nextId(); + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { id: descriptorId, type: "data", version: 1 }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session }; +} + +function makeDescriptor( + id: string, + primitives: readonly EffectPrimitiveNode[], +): CustomModifierDescriptor { + return { + type: "data", + id: asCustomModifierId(id), + name: id, + description: "", + version: 1, + primitives, + targetAttrs: [], + uiForm: "primitive-composer", + source: "custom", + }; +} + +describe("on-rule-activated primitive — registry (T16)", () => { + it("registers in PRIMITIVE_REGISTRY under 'on-rule-activated'", () => { + expect(PRIMITIVE_REGISTRY.has("on-rule-activated")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("on-rule-activated")).toBe( + ON_RULE_ACTIVATED_PRIMITIVE, + ); + }); +}); + +describe("on-rule-activated primitive — paramsSchema (T16)", () => { + it("validates `{ primitives: [...] }`", () => { + expect(() => + ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({ primitives: [] }), + ).not.toThrow(); + expect(() => + ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({ + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }), + ).not.toThrow(); + }); + + it("rejects empty / wrong-shape params", () => { + expect(() => + ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({}), + ).toThrow(); + expect(() => + ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({ primitives: 7 }), + ).toThrow(); + }); +}); + +describe("on-rule-activated primitive — apply() (T16)", () => { + it("seeds OnRuleActivatedHooks on GAME_ENTITY with descriptor id + primitives", () => { + const { ctx, session } = makeContext("custom:announce"); + const inner: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } }, + ]; + + ON_RULE_ACTIVATED_PRIMITIVE.apply(ctx, { primitives: inner }); + + const stored = session.get( + GAME_ENTITY, + "OnRuleActivatedHooks", + ) as ChessAttrMap["OnRuleActivatedHooks"] | undefined; + expect(stored).toBeDefined(); + expect(stored).toHaveLength(1); + expect(stored![0]!.descriptorId).toBe("custom:announce"); + expect(stored![0]!.primitives).toEqual(inner); + }); + + it("stacks multiple descriptors as separate entries on the list", () => { + const { ctx: ctxA, session } = makeContext("custom:rule-a"); + // Reuse the same session by constructing a second ctx that shares it. + const ctxB: PrimitiveApplyContext = { + ...ctxA, + descriptor: { id: "custom:rule-b", type: "data", version: 1 }, + }; + + const innerA: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } }, + ]; + const innerB: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 2 } }, + ]; + + ON_RULE_ACTIVATED_PRIMITIVE.apply(ctxA, { primitives: innerA }); + ON_RULE_ACTIVATED_PRIMITIVE.apply(ctxB, { primitives: innerB }); + + const stored = session.get( + GAME_ENTITY, + "OnRuleActivatedHooks", + ) as ChessAttrMap["OnRuleActivatedHooks"] | undefined; + expect(stored).toHaveLength(2); + expect(stored!.map((h) => h.descriptorId)).toEqual([ + "custom:rule-a", + "custom:rule-b", + ]); + }); + + it("childPrimitives() returns the inner list for validator traversal", () => { + const inner: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } }, + ]; + const children = ON_RULE_ACTIVATED_PRIMITIVE.childPrimitives?.({ + primitives: inner, + }); + expect(children).toEqual(inner); + }); +}); + +describe("on-rule-activated primitive — fire-once integration (T16)", () => { + it("fires inner primitives ONCE on first applyCustomDescriptor", () => { + const engine = new ChessEngine(); + // Pick any existing piece for the apply target — descriptor id + // governs fire-once, not piece id. + const session = engine.session; + let pieceId = 0 as ReturnType; + for (const f of session.allFacts()) { + if (f.attr === "Color" && (f.id as number) > 0) { + pieceId = f.id; + break; + } + } + expect(pieceId).not.toBe(0); // sanity — engine seeded pieces + + const descriptor = makeDescriptor("custom:rule-fires", [ + { + kind: "on-rule-activated", + params: { + primitives: [ + { + kind: "seed-attribute", + params: { attr: "HpBonus", value: 7 }, + }, + ], + }, + }, + ]); + + applyCustomDescriptor(engine, session, pieceId, descriptor); + + // The on-rule-activated primitive's inner block runs against + // GAME_ENTITY (the "rule-activated" target). HpBonus seeded there. + expect(session.get(GAME_ENTITY, "HpBonus")).toBe(7); + // Fire-once guard recorded on PRESET_STATE_ENTITY. + const fired = session.get( + PRESET_STATE_ENTITY, + "RuleActivatedFiredFor", + ) as readonly string[] | undefined; + expect(fired).toEqual(["custom:rule-fires"]); + }); + + it("does NOT re-fire when the same descriptor id applies a second time", () => { + const engine = new ChessEngine(); + const session = engine.session; + let pieceId = 0 as ReturnType; + for (const f of session.allFacts()) { + if (f.attr === "Color" && (f.id as number) > 0) { + pieceId = f.id; + break; + } + } + + // Inner primitive INCREMENTS HpBonus on GAME_ENTITY each time it + // fires. If the guard works, it fires exactly once → final == 1. + // If it re-fires on the second apply, final would be 2. + const descriptor = makeDescriptor("custom:rule-once", [ + { + kind: "on-rule-activated", + params: { + primitives: [ + { + kind: "add-to-attribute", + params: { attr: "HpBonus", delta: 1 }, + }, + ], + }, + }, + ]); + + applyCustomDescriptor(engine, session, pieceId, descriptor); + applyCustomDescriptor(engine, session, pieceId, descriptor); + + expect(session.get(GAME_ENTITY, "HpBonus")).toBe(1); + const fired = session.get( + PRESET_STATE_ENTITY, + "RuleActivatedFiredFor", + ) as readonly string[] | undefined; + expect(fired).toEqual(["custom:rule-once"]); + }); + + it("DIFFERENT descriptor ids each fire independently (one-shot per id, not global)", () => { + const engine = new ChessEngine(); + const session = engine.session; + let pieceId = 0 as ReturnType; + for (const f of session.allFacts()) { + if (f.attr === "Color" && (f.id as number) > 0) { + pieceId = f.id; + break; + } + } + + const descA = makeDescriptor("custom:rule-A", [ + { + kind: "on-rule-activated", + params: { + primitives: [ + { + kind: "add-to-attribute", + params: { attr: "HpBonus", delta: 1 }, + }, + ], + }, + }, + ]); + const descB = makeDescriptor("custom:rule-B", [ + { + kind: "on-rule-activated", + params: { + primitives: [ + { + kind: "add-to-attribute", + params: { attr: "HpBonus", delta: 4 }, + }, + ], + }, + }, + ]); + + applyCustomDescriptor(engine, session, pieceId, descA); + applyCustomDescriptor(engine, session, pieceId, descB); + + // Both fired exactly once: 1 + 4 = 5. + expect(session.get(GAME_ENTITY, "HpBonus")).toBe(5); + const fired = session.get( + PRESET_STATE_ENTITY, + "RuleActivatedFiredFor", + ) as readonly string[] | undefined; + expect(fired).toEqual(["custom:rule-A", "custom:rule-B"]); + }); + + it("simulates save→load: pre-seeded RuleActivatedFiredFor blocks re-fire", () => { + const engine = new ChessEngine(); + const session = engine.session; + let pieceId = 0 as ReturnType; + for (const f of session.allFacts()) { + if (f.attr === "Color" && (f.id as number) > 0) { + pieceId = f.id; + break; + } + } + + // Simulate a rehydrated game where the descriptor previously + // fired (the persisted fact is in the session). + session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [ + "custom:rule-rehydrated", + ]); + + const descriptor = makeDescriptor("custom:rule-rehydrated", [ + { + kind: "on-rule-activated", + params: { + primitives: [ + { + kind: "seed-attribute", + params: { attr: "HpBonus", value: 99 }, + }, + ], + }, + }, + ]); + + applyCustomDescriptor(engine, session, pieceId, descriptor); + + // Did NOT fire — HpBonus on GAME_ENTITY remains undefined. + expect(session.get(GAME_ENTITY, "HpBonus")).toBeUndefined(); + // Guard list unchanged. + expect( + session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor"), + ).toEqual(["custom:rule-rehydrated"]); + }); + + it("chooser color resolves into ctx.event for inner primitives", () => { + const engine = new ChessEngine(); + const session = engine.session; + // Pick a WHITE piece so applyCustomDescriptor's chooser-track + // stub (T14) writes 'white' to LastModifierChooser. + let whitePieceId = 0 as ReturnType; + for (const f of session.allFacts()) { + if ( + f.attr === "Color" && + f.value === "white" && + (f.id as number) > 0 + ) { + whitePieceId = f.id; + break; + } + } + expect(whitePieceId).not.toBe(0); + + const descriptor = makeDescriptor("custom:rule-chooser", [ + { + kind: "on-rule-activated", + params: { + // Inner block doesn't directly read event; we assert the + // chooser-fact wiring reaches the stub by verifying that + // T14's LastModifierChooser was set to 'white' BEFORE the + // hook fires (the dispatcher reads it to construct event). + primitives: [ + { + kind: "seed-attribute", + params: { attr: "HpBonus", value: 3 }, + }, + ], + }, + }, + ]); + + applyCustomDescriptor(engine, session, whitePieceId, descriptor); + + // Chooser stamped by T14 logic (applyCustomDescriptor wrote it). + expect( + session.get(PRESET_STATE_ENTITY, "LastModifierChooser"), + ).toBe("white"); + // Hook fired. + expect(session.get(GAME_ENTITY, "HpBonus")).toBe(3); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/on-rule-activated.ts b/packages/chess/src/modifiers/primitives/on-rule-activated.ts new file mode 100644 index 0000000..05f69f5 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-rule-activated.ts @@ -0,0 +1,88 @@ +/** + * `on-rule-activated` trigger primitive (T16). + * + * Seeds an entry into `OnRuleActivatedHooks` on `GAME_ENTITY` at + * profile-apply time. The integration preset's `applyCustomDescriptor` + * fires the matching hooks EXACTLY ONCE per descriptor instance, the + * first time the descriptor attaches. The fire-once guard lives on + * `PRESET_STATE_ENTITY` under `RuleActivatedFiredFor` (a list of + * descriptor ids that have already fired); reloaded games inherit + * the guard via persisted facts so a save→load round-trip does NOT + * re-fire. + * + * Storage rationale: hooks live on `GAME_ENTITY` (not the piece) so + * a single descriptor activated on multiple pieces still only fires + * its `on-rule-activated` block once — the descriptor-id guard + * dedups across all attachments. + */ +import { z } from "zod"; +import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * Inline NodeSchema (mirrors `on-capture.ts`). The tree validator + * (T19) handles deep kind-validation; here we only assert the + * structural shape `{ kind, params }` so the params schema is a + * one-line Zod definition. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +const schema = z.object({ + primitives: z.array(NodeSchema), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "on-rule-activated", + label: "On Rule Activated", + description: + "Seeds OnRuleActivatedHooks entries fired exactly once when the descriptor attaches.", + longDescription: + "Wraps nested primitives that fire ONCE when this descriptor first activates on the game. Typical uses: announce the rule (broadcast a banner attr), seed initial board state (place markers, set per-game counters), or grant a one-time bonus to the chooser. Does NOT re-fire on game reload — a per-game guard on PRESET_STATE_ENTITY tracks which descriptor ids have already fired.", + examples: [ + { + title: "Announcement banner on activation", + params: { + primitives: [ + { + kind: "seed-attribute", + params: { attr: "RangeBonus", value: 1 }, + }, + ], + }, + effect: + "When the rule first attaches, runs the inner seed-attribute once. Subsequent moves do NOT re-trigger; expiration / re-application within the same game also does not re-fire.", + }, + ], + paramsSchema: schema, + seedsAttrs: ["OnRuleActivatedHooks"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = + (ctx.session.get(GAME_ENTITY, "OnRuleActivatedHooks") as + | ChessAttrMap["OnRuleActivatedHooks"] + | undefined) ?? []; + + ctx.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [ + ...existing, + { + descriptorId: ctx.descriptor.id, + primitives: [...params.primitives], + }, + ]); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.primitives]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as ON_RULE_ACTIVATED_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/on-rule-expire.test.ts b/packages/chess/src/modifiers/primitives/on-rule-expire.test.ts new file mode 100644 index 0000000..dffff0f --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-rule-expire.test.ts @@ -0,0 +1,279 @@ +/** + * `on-rule-expire` primitive tests (T17). + * + * Mirror image of T16's on-rule-activated tests: + * 1. Primitive-level: registry presence, paramsSchema, apply() + * seeding, multi-descriptor stacking on `OnRuleExpireHooks`, + * childPrimitives() introspection. + * 2. Dispatcher-level: `fireOnRuleExpireHooks` fires matching + * descriptor's hooks once, fire-once guard via + * `RuleExpireFiredFor` on `PRESET_STATE_ENTITY`, idempotent + * double-detach. + */ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { + GAME_ENTITY, + PRESET_STATE_ENTITY, + type ChessAttrMap, +} from "../../schema.js"; +import { fireOnRuleExpireHooks } from "../triggers.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { ON_RULE_EXPIRE_PRIMITIVE } from "./on-rule-expire.js"; +import type { + EffectPrimitiveNode, + PrimitiveApplyContext, +} from "./types.js"; +import "./on-rule-expire.js"; + +function makeContext(descriptorId = "custom:test-on-rule-expire"): { + ctx: PrimitiveApplyContext; + session: Session; +} { + const session = new Session(); + const pieceId = session.nextId(); + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { id: descriptorId, type: "data", version: 1 }, + target: "self", + event: undefined, + bindings: new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; + return { ctx, session }; +} + +describe("on-rule-expire primitive — registry (T17)", () => { + it("registers in PRIMITIVE_REGISTRY under 'on-rule-expire'", () => { + expect(PRIMITIVE_REGISTRY.has("on-rule-expire")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("on-rule-expire")).toBe( + ON_RULE_EXPIRE_PRIMITIVE, + ); + }); +}); + +describe("on-rule-expire primitive — paramsSchema (T17)", () => { + it("validates `{ primitives: [...] }`", () => { + expect(() => + ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({ primitives: [] }), + ).not.toThrow(); + expect(() => + ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({ + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }), + ).not.toThrow(); + }); + + it("rejects empty / wrong-shape params", () => { + expect(() => ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({})).toThrow(); + expect(() => + ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({ primitives: 7 }), + ).toThrow(); + }); +}); + +describe("on-rule-expire primitive — apply() (T17)", () => { + it("seeds OnRuleExpireHooks on GAME_ENTITY with descriptor id + primitives", () => { + const { ctx, session } = makeContext("custom:cleanup"); + const inner: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } }, + ]; + + ON_RULE_EXPIRE_PRIMITIVE.apply(ctx, { primitives: inner }); + + const stored = session.get( + GAME_ENTITY, + "OnRuleExpireHooks", + ) as ChessAttrMap["OnRuleExpireHooks"] | undefined; + expect(stored).toBeDefined(); + expect(stored).toHaveLength(1); + expect(stored![0]!.descriptorId).toBe("custom:cleanup"); + expect(stored![0]!.primitives).toEqual(inner); + }); + + it("stacks multiple descriptors as separate entries on the list", () => { + const { ctx: ctxA, session } = makeContext("custom:rule-a"); + // Reuse the same session by constructing a second ctx that shares it. + const ctxB: PrimitiveApplyContext = { + ...ctxA, + descriptor: { id: "custom:rule-b", type: "data", version: 1 }, + }; + + const innerA: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } }, + ]; + const innerB: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } }, + ]; + + ON_RULE_EXPIRE_PRIMITIVE.apply(ctxA, { primitives: innerA }); + ON_RULE_EXPIRE_PRIMITIVE.apply(ctxB, { primitives: innerB }); + + const stored = session.get( + GAME_ENTITY, + "OnRuleExpireHooks", + ) as ChessAttrMap["OnRuleExpireHooks"] | undefined; + expect(stored).toHaveLength(2); + expect(stored!.map((h) => h.descriptorId)).toEqual([ + "custom:rule-a", + "custom:rule-b", + ]); + }); + + it("childPrimitives() returns the inner list for validator traversal", () => { + const inner: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } }, + ]; + const children = ON_RULE_EXPIRE_PRIMITIVE.childPrimitives?.({ + primitives: inner, + }); + expect(children).toEqual(inner); + }); +}); + +describe("on-rule-expire dispatcher — fireOnRuleExpireHooks (T17)", () => { + it("fires the matching descriptor's primitives, skips other descriptors", () => { + const engine = new ChessEngine(); + + // Seed two hooks for two different descriptors. Only descriptor + // 'rule-A' should fire when we dispatch for 'rule-A'. + engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [ + { + descriptorId: "rule-A", + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } }, + ], + }, + { + descriptorId: "rule-B", + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } }, + ], + }, + ]); + + fireOnRuleExpireHooks(engine, "rule-A"); + + // Inner primitives target GAME_ENTITY (the dispatcher passes it + // as `pieceId` into runPrimitives). + expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(7); + // rule-B's primitive did NOT fire — RangeBonus on GAME_ENTITY + // remains undefined. + expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined(); + + // Guard list now contains the fired descriptor id. + const fired = engine.session.get( + PRESET_STATE_ENTITY, + "RuleExpireFiredFor", + ) as readonly string[] | undefined; + expect(fired).toEqual(["rule-A"]); + }); + + it("fire-once guard: second call for same descriptor is a no-op", () => { + const engine = new ChessEngine(); + + // Use add-to-attribute so we can detect double-firing: each fire + // would increment HpBonus by 1; with the guard, exactly one fire + // → final value 1. + engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [ + { + descriptorId: "rule-once", + primitives: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } }, + ], + }, + ]); + + fireOnRuleExpireHooks(engine, "rule-once"); + fireOnRuleExpireHooks(engine, "rule-once"); // idempotent on double-detach + + expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(1); + const fired = engine.session.get( + PRESET_STATE_ENTITY, + "RuleExpireFiredFor", + ) as readonly string[] | undefined; + expect(fired).toEqual(["rule-once"]); + }); + + it("DIFFERENT descriptor ids each fire independently (one-shot per id, not global)", () => { + const engine = new ChessEngine(); + + engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [ + { + descriptorId: "rule-A", + primitives: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } }, + ], + }, + { + descriptorId: "rule-B", + primitives: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 4 } }, + ], + }, + ]); + + fireOnRuleExpireHooks(engine, "rule-A"); + fireOnRuleExpireHooks(engine, "rule-B"); + + // Both fired exactly once: 1 + 4 = 5. + expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(5); + const fired = engine.session.get( + PRESET_STATE_ENTITY, + "RuleExpireFiredFor", + ) as readonly string[] | undefined; + expect(fired).toEqual(["rule-A", "rule-B"]); + }); + + it("simulates save→load: pre-seeded RuleExpireFiredFor blocks re-fire", () => { + const engine = new ChessEngine(); + + // Simulate a rehydrated game where the descriptor previously + // expired (the persisted fact is in the session). + engine.session.insert(PRESET_STATE_ENTITY, "RuleExpireFiredFor", [ + "rule-rehydrated", + ]); + engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [ + { + descriptorId: "rule-rehydrated", + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 99 } }, + ], + }, + ]); + + fireOnRuleExpireHooks(engine, "rule-rehydrated"); + + // Did NOT fire — HpBonus on GAME_ENTITY remains undefined. + expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBeUndefined(); + // Guard list unchanged (no re-append since already present). + expect( + engine.session.get(PRESET_STATE_ENTITY, "RuleExpireFiredFor"), + ).toEqual(["rule-rehydrated"]); + }); + + it("no hooks seeded: dispatcher is a no-op but still records the guard", () => { + const engine = new ChessEngine(); + + // No OnRuleExpireHooks seeded. fireOnRuleExpireHooks should not + // throw, and the guard records the descriptor id (so a later + // re-detach attempt is also a no-op). + expect(() => + fireOnRuleExpireHooks(engine, "rule-no-hooks"), + ).not.toThrow(); + + const fired = engine.session.get( + PRESET_STATE_ENTITY, + "RuleExpireFiredFor", + ) as readonly string[] | undefined; + expect(fired).toEqual(["rule-no-hooks"]); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/on-rule-expire.ts b/packages/chess/src/modifiers/primitives/on-rule-expire.ts new file mode 100644 index 0000000..8899195 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-rule-expire.ts @@ -0,0 +1,97 @@ +/** + * `on-rule-expire` trigger primitive (T17). + * + * Mirror image of T16's `on-rule-activated` for the descriptor-detach + * side. Seeds an entry into `OnRuleExpireHooks` on `GAME_ENTITY` at + * profile-apply time; the dispatcher (`fireOnRuleExpireHooks` in + * `triggers.ts`) fires the matching hooks EXACTLY ONCE when the + * descriptor detaches. The fire-once guard lives on + * `PRESET_STATE_ENTITY` under `RuleExpireFiredFor`. + * + * V1 wiring status: descriptor lifetimes are not yet wired into any + * detach pipeline (no `removeModifier` / `detachDescriptor` path + * exists today). The seeding side IS active so descriptors that + * include `on-rule-expire` blocks register them at apply time; the + * fire side is exposed as the public stub `fireOnRuleExpireHooks` + * for the future T19 lifetime decrementer and the eventual + * remove-modifier action handler to invoke. Until a caller exists, + * the hooks remain dormant — by design, expire only fires on actual + * detach. + * + * Storage rationale: hooks live on `GAME_ENTITY` (not the piece) so + * a single descriptor attached to multiple pieces still only fires + * its `on-rule-expire` block once — the descriptor-id guard dedups + * across all attachments on the expire side, mirroring T16's + * activation behaviour. + */ +import { z } from "zod"; +import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * Inline NodeSchema (mirrors `on-rule-activated.ts`). The tree + * validator (T19) handles deep kind-validation; here we only assert + * the structural shape `{ kind, params }` so the params schema is a + * one-line Zod definition. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +const schema = z.object({ + primitives: z.array(NodeSchema), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "on-rule-expire", + label: "On Rule Expire", + description: + "Seeds OnRuleExpireHooks entries fired exactly once when the descriptor detaches.", + longDescription: + "Wraps nested primitives that fire ONCE when this descriptor detaches from the game (lifetime expires, manual remove-modifier action, or — for piece-bound modifiers — when the holding piece is captured). Typical uses: tear down banners or counters previously seeded by `on-rule-activated`, retract per-game state, or grant a parting bonus/penalty. A per-game guard on PRESET_STATE_ENTITY (`RuleExpireFiredFor`) tracks which descriptor ids have already fired their expire block, so a re-attach + re-detach cycle in the same session does NOT re-fire — consistent with the once-per-descriptor-id activation contract.", + examples: [ + { + title: "Cleanup banner on expire", + params: { + primitives: [ + { + kind: "seed-attribute", + params: { attr: "RangeBonus", value: 0 }, + }, + ], + }, + effect: + "When the rule detaches, runs the inner seed-attribute once to reset RangeBonus. Subsequent re-attach-detach cycles do NOT re-trigger; only the FIRST detach fires.", + }, + ], + paramsSchema: schema, + seedsAttrs: ["OnRuleExpireHooks"], + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = + (ctx.session.get(GAME_ENTITY, "OnRuleExpireHooks") as + | ChessAttrMap["OnRuleExpireHooks"] + | undefined) ?? []; + + ctx.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [ + ...existing, + { + descriptorId: ctx.descriptor.id, + primitives: [...params.primitives], + }, + ]); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.primitives]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as ON_RULE_EXPIRE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/on-turn-end.test.ts b/packages/chess/src/modifiers/primitives/on-turn-end.test.ts index a01b708..7d4a594 100644 --- a/packages/chess/src/modifiers/primitives/on-turn-end.test.ts +++ b/packages/chess/src/modifiers/primitives/on-turn-end.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-turn-start.test.ts b/packages/chess/src/modifiers/primitives/on-turn-start.test.ts index 3ccf7d4..f864ff6 100644 --- a/packages/chess/src/modifiers/primitives/on-turn-start.test.ts +++ b/packages/chess/src/modifiers/primitives/on-turn-start.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/override-promotion.test.ts b/packages/chess/src/modifiers/primitives/override-promotion.test.ts index b3960a1..f4a85f6 100644 --- a/packages/chess/src/modifiers/primitives/override-promotion.test.ts +++ b/packages/chess/src/modifiers/primitives/override-promotion.test.ts @@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) { target: "self" as const, event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; } describe("OVERRIDE_PROMOTION_PRIMITIVE", () => { diff --git a/packages/chess/src/modifiers/primitives/param-resolver.test.ts b/packages/chess/src/modifiers/primitives/param-resolver.test.ts index 9343a8a..66e9e39 100644 --- a/packages/chess/src/modifiers/primitives/param-resolver.test.ts +++ b/packages/chess/src/modifiers/primitives/param-resolver.test.ts @@ -43,6 +43,9 @@ function makeCtx(opts: { target: "self", event: undefined, bindings: opts.bindings ?? new Map(), + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/reflect-damage.test.ts b/packages/chess/src/modifiers/primitives/reflect-damage.test.ts index 60a1ef4..18fd569 100644 --- a/packages/chess/src/modifiers/primitives/reflect-damage.test.ts +++ b/packages/chess/src/modifiers/primitives/reflect-damage.test.ts @@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) { target: "self" as const, event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; } describe("REFLECT_DAMAGE_PRIMITIVE", () => { diff --git a/packages/chess/src/modifiers/primitives/registry-count.test.ts b/packages/chess/src/modifiers/primitives/registry-count.test.ts index fbd19bb..d1fe048 100644 --- a/packages/chess/src/modifiers/primitives/registry-count.test.ts +++ b/packages/chess/src/modifiers/primitives/registry-count.test.ts @@ -2,9 +2,13 @@ import { describe, it, expect } from "vitest"; import { PRIMITIVE_REGISTRY } from "./index.js"; describe("PRIMITIVE_REGISTRY", () => { - it("should have exactly 22 registered primitives after barrel import", () => { + it("should have exactly 26 registered primitives after barrel import", () => { + // T16 added "on-rule-activated"; T18 added "on-piece-entered-marker" + // (22 → 24). T17 added "on-rule-expire" (24 → 25). T19 added + // "on-marker-expire" (25 → 26). Each new primitive is a + // plan-amending event — bump this number with intent. const count = PRIMITIVE_REGISTRY.list().length; - expect(count).toBe(22); + expect(count).toBe(26); }); it("should list all primitive kinds with non-empty descriptor objects", () => { diff --git a/packages/chess/src/modifiers/primitives/seed-attribute.test.ts b/packages/chess/src/modifiers/primitives/seed-attribute.test.ts index f7634b7..5032471 100644 --- a/packages/chess/src/modifiers/primitives/seed-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/seed-attribute.test.ts @@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts b/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts index 4a4ab34..4e8375f 100644 --- a/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts +++ b/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts @@ -23,7 +23,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } { target: "self", event: undefined, bindings: new Map(), - }; + pendingTriggers: [], + cascadeDepth: 0, + suppressTriggers: false, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/types.ts b/packages/chess/src/modifiers/primitives/types.ts index 72718b1..d84c03d 100644 --- a/packages/chess/src/modifiers/primitives/types.ts +++ b/packages/chess/src/modifiers/primitives/types.ts @@ -8,6 +8,48 @@ import type { TargetResolver, } from "./context.js"; +/** + * Trigger kinds (T15). + * + * Names of the existing `fire*Hooks` family in `triggers.ts`, + * surfaced here as a type so deferred-trigger entries are + * statically constrained. The four Wave-4 trigger kinds + * (`on-rule-activated`, `on-rule-expire`, `on-piece-entered-marker`, + * `on-marker-expire`) are listed eagerly so T16-T19 can mirror this + * dispatcher without extending the union. + */ +export type TriggerName = + | "on-capture" + | "on-captured" + | "on-move" + | "on-damaged" + | "on-promotion" + | "on-check-received" + | "on-check-delivered" + | "on-moved-onto-square" + | "on-turn-start" + | "on-turn-end" + | "on-rule-activated" + | "on-rule-expire" + | "on-piece-entered-marker" + | "on-marker-expire"; + +/** + * A trigger event enqueued by an imperative primitive for deferred + * firing (T15). Drained AFTER the current arm completes (not + * mid-iteration). Each drained entry runs against the next cascade + * depth — see `runPrimitives` in `triggers.ts` for the guard. + * + * `payload` is event-specific; the dispatcher's `fireTriggerByKind` + * narrows it per-kind (e.g. `{attackerId, defenderId}` for + * `on-captured`, `{moved: EntityId[]}` for `on-move`). + */ +export interface PendingTrigger { + readonly kind: TriggerName; + readonly pieceId: EntityId; + readonly payload?: unknown; +} + /** * T3 primitive ids (ADR-2). */ @@ -33,6 +75,10 @@ export type PrimitiveKind = | "on-check-received" | "on-check-delivered" | "on-moved-onto-square" + | "on-rule-activated" + | "on-rule-expire" + | "on-piece-entered-marker" + | "on-marker-expire" | "conditional"; /** @@ -101,6 +147,60 @@ export interface PrimitiveApplyContext { * preserves their behaviour byte-identically. */ readonly bindings: ReadonlyMap; + /** + * Deferred-trigger queue (T15). Imperative primitives that + * synthesize secondary triggers (e.g. `destroy-piece` causing + * `on-captured`) push entries here via + * `enqueueTrigger(ctx, trigger)`; the dispatcher drains the queue + * AFTER the current arm completes — never mid-iteration. The + * queue is per-arm (each `runPrimitives` invocation seeds a fresh + * `[]`), so it does NOT leak between top-level dispatches. + * + * Mutable (an array, not a Map) by design — the immutability + * guarantee that applies to `bindings` (T11) does NOT apply here: + * the queue is intentionally shared between siblings of one arm + * so they can collectively defer secondary fires. + * + * Default at every construction site: `[]`. Primitives that + * never enqueue (the 22 pre-T15 primitives) inherit a no-op + * empty queue and behave byte-identically. + */ + readonly pendingTriggers: PendingTrigger[]; + /** + * Cascade depth (T15). Top-level dispatcher entry runs at 0; each + * deferred trigger drained from `pendingTriggers` re-enters the + * dispatcher at `cascadeDepth + 1`. Hard cap = 8 (mirrors + * `RUNTIME_DEPTH_HARD_CAP`); breach throws a runtime error with + * code `runtime.cascade-depth-exceeded`. + * + * Orthogonal to the existing `depth` field — `depth` counts + * nested primitive arrays inside ONE arm; `cascadeDepth` counts + * cross-arm chains via the deferred queue. Do NOT mix. + */ + readonly cascadeDepth: number; + /** + * Move-generation dry-mode flag (T20). When `true`, imperative + * primitives — those listed in `IMPERATIVE_KINDS` (exported from + * `../custom/validate.js`) — are NO-OPS. The dispatcher + * (`runPrimitives` in `triggers.ts`) skips dispatching them + * entirely; non-imperative primitives (predicates, conditionals, + * iteration introspection) still execute normally so legality + * analysis can branch on the same data the wet path would. + * + * Set by the move-generator when probing "what-if" legality (e.g. + * check detection during legal-move enumeration) so triggers + * cannot mutate state during the probe and corrupt subsequent + * what-if iterations. Default: `false` (real commit / regular + * trigger flow). The 22 pre-T20 primitives are NOT in + * IMPERATIVE_KINDS so they fire normally regardless of this + * flag — backward-compatible by construction. + * + * Single source of truth: branched only at the dispatcher entry + * point in `runPrimitives`; individual primitives MUST NOT + * inspect this field. (See `decisions.md` § Move-Generation Dry + * Mode for the locked invariant.) + */ + readonly suppressTriggers: boolean; } /** diff --git a/packages/chess/src/modifiers/triggers.test.ts b/packages/chess/src/modifiers/triggers.test.ts index b9a39e7..57de5ed 100644 --- a/packages/chess/src/modifiers/triggers.test.ts +++ b/packages/chess/src/modifiers/triggers.test.ts @@ -15,15 +15,25 @@ import { algebraicToSquare } from "../coord.js"; import { clearBoard, pieceAt, placePiece } from "../presets/test-utils.js"; import type { ModifierProfile } from "./types.js"; import { + enqueueTrigger, fireOnCapturedHooks, fireOnCheckDeliveredHooks, fireOnCheckReceivedHooks, fireOnMoveHooks, fireOnMovedOntoSquareHooks, fireOnPromotionHooks, + fireOnRuleActivatedHooks, fireOnTurnEndHooks, + runPrimitives, type PreMoveCheckStateLike, } from "./triggers.js"; +import { PRIMITIVE_REGISTRY } from "./primitives/registry.js"; +import { z } from "zod"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, +} from "./primitives/types.js"; import "./primitives/index.js"; import "../presets/index.js"; @@ -635,3 +645,464 @@ describe("fireOnCapturedHooks", () => { expect(engine.session.get(whitePawn, "HpBonus")).toBeUndefined(); }); }); + +// ─── T20: move-gen suppressTriggers flag ───────────────────────────────────── +// +// Move-gen dry-mode (legality probing for check detection / what-if +// analysis) MUST NOT fire imperative primitives — they would mutate the +// session mid-probe and corrupt subsequent iterations. The dispatcher +// (`runPrimitives`) skips primitives whose kind is in IMPERATIVE_KINDS +// when `ctx.suppressTriggers === true`. Non-imperative primitives still +// run normally so legality analysis can branch on the same data the +// wet path would. +// +// IMPERATIVE_KINDS (T14, locked at T0 ADR) = 10 future Wave-5/6 kinds: +// place-piece, destroy-piece, move-piece, swap-pieces, +// convert-piece-type, set-piece-attr, cancel-capture, spawn-marker, +// spawn-marker-pair, destroy-marker. +// None are registered yet; the suite below registers a SYNTHETIC +// primitive under one of those kind-names so the gate can be exercised +// today without waiting for Wave 5/6 implementations. + +describe("move-gen suppressTriggers flag (T20)", () => { + // Track imperative-primitive side effects via a module-scoped flag. + // Each test resets it via the per-test setup. The synthetic primitive + // is registered ONCE at first describe entry — `PRIMITIVE_REGISTRY` + // has no unregister, but using a kind-name from IMPERATIVE_KINDS + // (`destroy-piece`) doesn't collide because Wave 5/6 hasn't landed. + let imperativeFired = false; + let predicateFired = false; + + // Register the synthetic imperative primitive on first entry. The + // try/catch handles repeat registrations from test re-runs (vitest + // module re-evaluation in watch mode would otherwise throw on the + // duplicate-kind guard). + try { + PRIMITIVE_REGISTRY.register({ + // Cast through unknown — the registry's PrimitiveKind union does + // NOT include `destroy-piece` yet (Wave 6 will add it). The + // runtime registry stores the kind as a plain string key, so the + // lookup in `runPrimitives` works regardless of static typing. + kind: "destroy-piece" as unknown as EffectPrimitive["kind"], + label: "T20 synthetic destroy-piece", + description: "Test-only stub for the suppressTriggers gate.", + paramsSchema: z.object({}).passthrough(), + apply: () => { + imperativeFired = true; + }, + } as unknown as EffectPrimitive); + } catch { + // already registered (test file re-evaluated) + } + + // Register a synthetic NON-imperative primitive whose kind is NOT in + // IMPERATIVE_KINDS — used to prove that suppressTriggers does NOT + // affect non-imperative primitives. Using a fresh kind-name avoids + // colliding with the 22 real primitives. + try { + PRIMITIVE_REGISTRY.register({ + kind: "__t20_predicate__" as unknown as EffectPrimitive["kind"], + label: "T20 synthetic predicate", + description: "Test-only non-imperative stub.", + paramsSchema: z.object({}).passthrough(), + apply: () => { + predicateFired = true; + }, + } as unknown as EffectPrimitive); + } catch { + // already registered + } + + function makeBareEngine(): ChessEngine { + // Empty profile — we don't need a full preset; runPrimitives only + // touches the engine's session. + return new ChessEngine({ + profile: makeProfileWithCustomKind("t20-bare"), + }); + } + + function resetFlags() { + imperativeFired = false; + predicateFired = false; + } + + it("dry-mode (suppressTriggers=true) does NOT fire imperative primitives", () => { + resetFlags(); + const engine = makeBareEngine(); + const pieceId = findPiece(engine, 12); // some real piece on the board + + const nodes: EffectPrimitiveNode[] = [ + { + // Cast: kind is in IMPERATIVE_KINDS but not in PrimitiveKind union. + kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"], + params: {}, + }, + ]; + + // Invoke runPrimitives directly with suppressTriggers=true. The + // dispatcher must skip the imperative; `imperativeFired` stays + // false. + runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true); + expect(imperativeFired).toBe(false); + }); + + it("real commit (suppressTriggers=false) DOES fire imperative primitives", () => { + resetFlags(); + const engine = makeBareEngine(); + const pieceId = findPiece(engine, 12); + + const nodes: EffectPrimitiveNode[] = [ + { + kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"], + params: {}, + }, + ]; + + // Default suppressTriggers=false → the synthetic apply runs. + runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, false); + expect(imperativeFired).toBe(true); + }); + + it("non-imperative primitives still run under suppressTriggers", () => { + resetFlags(); + const engine = makeBareEngine(); + const pieceId = findPiece(engine, 12); + + const nodes: EffectPrimitiveNode[] = [ + { + kind: "__t20_predicate__" as unknown as EffectPrimitiveNode["kind"], + params: {}, + }, + ]; + + runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true); + // Non-imperative primitive fires regardless of suppress flag. + expect(predicateFired).toBe(true); + }); + + it("mixed list: only imperatives are skipped under suppressTriggers", () => { + resetFlags(); + const engine = makeBareEngine(); + const pieceId = findPiece(engine, 12); + + const nodes: EffectPrimitiveNode[] = [ + { + kind: "__t20_predicate__" as unknown as EffectPrimitiveNode["kind"], + params: {}, + }, + { + kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"], + params: {}, + }, + ]; + + runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true); + expect(predicateFired).toBe(true); // non-imperative still ran + expect(imperativeFired).toBe(false); // imperative skipped + }); + + it("default suppressTriggers (omitted) is false — imperatives fire", () => { + resetFlags(); + const engine = makeBareEngine(); + const pieceId = findPiece(engine, 12); + + const nodes: EffectPrimitiveNode[] = [ + { + kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"], + params: {}, + }, + ]; + + // Omit the suppressTriggers arg entirely; param default is `false`. + runPrimitives(engine, pieceId, nodes, 1); + expect(imperativeFired).toBe(true); + }); +}); + +// ─── T15: deferred trigger queue + cascade depth guard ────────────────────── +// +// Imperative primitives that synthesize secondary triggers (e.g. +// destroy-piece causing on-captured) MUST enqueue events via +// `enqueueTrigger(ctx, ...)` rather than firing inline. The dispatcher +// drains the queue AFTER the current arm completes, never mid-iteration, +// and increments `cascadeDepth` per drained trigger arm. The guard +// throws `runtime.cascade-depth-exceeded` once cascadeDepth > 8 (matches +// `RUNTIME_DEPTH_HARD_CAP`). +// +// Tests below register synthetic primitives via the same pattern as the +// T20 suite: the kind-cast bypasses the static `PrimitiveKind` union; +// the runtime registry stores by string key. + +describe("deferred queue + cascade depth (T15)", () => { + // Tracks ordering: `applyOrder` records each synthetic primitive's + // run + the queue length AT the moment of run, so the test can prove + // the deferred entry didn't fire mid-arm. + let applyOrder: Array<{ kind: string; queueLen: number }> = []; + // Counts every drained `on-move` arm fired during the test. Used as + // a write-only side-effect probe by the cascade-depth synthetic + // re-enqueuer; the test asserts behaviour via the throw, not the + // counter, so the variable is intentionally write-only here. + let _onMoveFired = 0; + + // Synthetic primitive #1: enqueues an `on-move` event for its current + // pieceId. Used to inject a deferred trigger from inside an arm. + try { + PRIMITIVE_REGISTRY.register({ + kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"], + label: "T15 synthetic enqueuer", + description: "Test-only stub that enqueues on-move.", + paramsSchema: z.object({}).passthrough(), + apply: (ctx: PrimitiveApplyContext) => { + applyOrder.push({ + kind: "__t15_enqueuer__", + queueLen: ctx.pendingTriggers.length, + }); + enqueueTrigger(ctx, { + kind: "on-move", + pieceId: ctx.pieceId, + payload: {}, + }); + }, + } as unknown as EffectPrimitive); + } catch { + // already registered (vitest re-evaluation) + } + + // Synthetic primitive #2: a no-op marker primitive used as a SIBLING + // after the enqueuer. Records `queueLen` at its own run-time so the + // test can prove the deferred trigger didn't fire BETWEEN the two + // primitives. + try { + PRIMITIVE_REGISTRY.register({ + kind: "__t15_marker__" as unknown as EffectPrimitive["kind"], + label: "T15 synthetic marker", + description: "Test-only stub that records queue state.", + paramsSchema: z.object({}).passthrough(), + apply: (ctx: PrimitiveApplyContext) => { + applyOrder.push({ + kind: "__t15_marker__", + queueLen: ctx.pendingTriggers.length, + }); + }, + } as unknown as EffectPrimitive); + } catch { + // already registered + } + + // Synthetic primitive #3: re-enqueues an `on-move` AND records a + // counter every time it runs. Used to build the cascade-depth test: + // each drained trigger fires `OnMoveHooks` whose inner primitive list + // includes this kind, which re-enqueues forever. + try { + PRIMITIVE_REGISTRY.register({ + kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"], + label: "T15 synthetic re-enqueuer", + description: "Test-only stub that re-enqueues on-move infinitely.", + paramsSchema: z.object({}).passthrough(), + apply: (ctx: PrimitiveApplyContext) => { + _onMoveFired += 1; + enqueueTrigger(ctx, { + kind: "on-move", + pieceId: ctx.pieceId, + payload: {}, + }); + }, + } as unknown as EffectPrimitive); + } catch { + // already registered + } + + function reset() { + applyOrder = []; + _onMoveFired = 0; + } + + function bareEngine(): ChessEngine { + return new ChessEngine({ + profile: makeProfileWithCustomKind("t15-bare"), + }); + } + + it("enqueued trigger fires AFTER the current arm (not mid-iteration)", () => { + reset(); + const engine = bareEngine(); + const pieceId = findPiece(engine, 12); + + // Wire the enqueued `on-move` to bump RangeBonus so we can prove + // the deferred trigger eventually fired. Use a real existing + // primitive (`add-to-attribute`) so no further synthetic kinds + // are needed. + engine.session.insert(pieceId, "OnMoveHooks", [ + [ + { kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } }, + ], + ]); + + // Two-node arm: enqueuer first, marker second. The marker MUST + // observe queueLen === 1 (the enqueued trigger is sitting in the + // queue, not fired yet). RangeBonus must NOT be set until the + // arm completes and the dispatcher drains. + const nodes = [ + { + kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"], + params: {}, + }, + { + kind: "__t15_marker__" as unknown as EffectPrimitive["kind"], + params: {}, + }, + ]; + + // Snapshot RangeBonus before the call (should remain undefined + // until the deferred drain runs). + expect(engine.session.get(pieceId, "RangeBonus")).toBeUndefined(); + + runPrimitives(engine, pieceId, nodes, 1); + + // Order check: enqueuer ran first with queueLen=0 (empty before + // its push), marker ran second with queueLen=1 (the queue holds + // the deferred entry, not yet drained). + expect(applyOrder).toEqual([ + { kind: "__t15_enqueuer__", queueLen: 0 }, + { kind: "__t15_marker__", queueLen: 1 }, + ]); + + // After runPrimitives returns, the dispatcher has drained the + // queue → on-move fired → add-to-attribute bumped RangeBonus by 1. + expect(engine.session.get(pieceId, "RangeBonus")).toBe(1); + }); + + it("cascade depth=8 limit throws runtime.cascade-depth-exceeded", () => { + reset(); + const engine = bareEngine(); + const pieceId = findPiece(engine, 12); + + // Wire OnMoveHooks to the re-enqueuer: each drained on-move runs + // the re-enqueuer, which enqueues another on-move, which drains + // and runs the re-enqueuer again, etc. The guard throws once + // cascadeDepth > 8 (i.e. on the 10th invocation: top-level=0, + // then drains 1..9 = 9 cascades; the 10th re-entry hits depth 9 + // which triggers the > 8 check). + engine.session.insert(pieceId, "OnMoveHooks", [ + [ + { + kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"], + params: {}, + }, + ], + ]); + + const nodes = [ + { + kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"], + params: {}, + }, + ]; + + expect(() => runPrimitives(engine, pieceId, nodes, 1)).toThrow( + /cascade-depth-exceeded/, + ); + }); + + it("cascade depth resets between top-level dispatches", () => { + reset(); + const engine = bareEngine(); + const pieceId = findPiece(engine, 12); + + // Single-shot enqueuer wired to a benign attr-bump on-move hook. + // Each top-level call: enqueuer runs at cascadeDepth=0 → drains + // at cascadeDepth=1 → done. Subsequent top-level calls MUST also + // start at cascadeDepth=0; if the depth leaked, we'd accumulate + // toward the 8-limit and throw on call 9 or 10. + engine.session.insert(pieceId, "OnMoveHooks", [ + [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } }, + ], + ]); + + const nodes = [ + { + kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"], + params: {}, + }, + ]; + + // Twenty independent top-level dispatches. If cascadeDepth leaked + // across calls, this would throw cascade-depth-exceeded long + // before reaching call 20 (the cap is 8). + for (let i = 0; i < 20; i += 1) { + expect(() => runPrimitives(engine, pieceId, nodes, 1)).not.toThrow(); + } + // 20 deferred-drain on-move hooks each bumped HpBonus by 1. + expect(engine.session.get(pieceId, "HpBonus")).toBe(20); + }); + + it("explicit cascadeDepth=9 at entry throws (boundary case)", () => { + // Direct boundary verification: pass cascadeDepth=9 to a + // top-level invocation. The guard fires before any primitive + // applies, regardless of queue contents. + reset(); + const engine = bareEngine(); + const pieceId = findPiece(engine, 12); + + expect(() => + runPrimitives(engine, pieceId, [], 1, undefined, new Map(), 9), + ).toThrow(/cascade-depth-exceeded/); + }); + + it("cascadeDepth=8 at entry is at the boundary and does NOT throw", () => { + // The guard is `cascadeDepth > HARD_CASCADE_DEPTH` (strictly + // greater than 8). Entry at exactly 8 is the last legal depth; + // it runs the arm and only throws if a drained child would + // re-enter at 9. + reset(); + const engine = bareEngine(); + const pieceId = findPiece(engine, 12); + + // No nodes, no enqueues → no drain → safe. + expect(() => + runPrimitives(engine, pieceId, [], 1, undefined, new Map(), 8), + ).not.toThrow(); + }); +}); + +// ─── T16: fireOnRuleActivatedHooks ────────────────────────────────────── +// +// The dispatcher reads `OnRuleActivatedHooks` from GAME_ENTITY and +// fires only entries whose `descriptorId` matches the requested id. +// Inner primitives run against GAME_ENTITY (the canonical "rule- +// activated" target). Other descriptors' entries are skipped. + +describe("fireOnRuleActivatedHooks (T16)", () => { + it("fires the matching descriptor's primitives, skips other descriptors", () => { + const engine = new ChessEngine({ + profile: makeProfileWithCustomKind("on-rule-activated-dispatch"), + }); + + // Seed two hooks for two different descriptors. Only descriptor + // 'rule-A' should fire when we dispatch for 'rule-A'. + engine.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [ + { + descriptorId: "rule-A", + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 3 } }, + ], + }, + { + descriptorId: "rule-B", + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } }, + ], + }, + ]); + + fireOnRuleActivatedHooks(engine, "rule-A"); + + // Inner primitives target GAME_ENTITY (the dispatcher passes it + // as `pieceId` into runPrimitives). + expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(3); + // rule-B's primitive did NOT fire — RangeBonus on GAME_ENTITY + // remains undefined. + expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined(); + }); +}); diff --git a/packages/chess/src/modifiers/triggers.ts b/packages/chess/src/modifiers/triggers.ts index 3d031b2..b9712ed 100644 --- a/packages/chess/src/modifiers/triggers.ts +++ b/packages/chess/src/modifiers/triggers.ts @@ -54,12 +54,15 @@ * preMoveHp)` and `fireOnCaptureHooks(engine, attackerId)`. */ import type { EntityId, Session } from "@paratype/rete"; -import type { - ChessAttrMap, - ConditionSpec, - PieceColor, - PieceType, - Square, +import { + GAME_ENTITY, + PRESET_STATE_ENTITY, + type ChessAttrMap, + type ConditionSpec, + type MarkerKindValue, + type PieceColor, + type PieceType, + type Square, } from "../schema.js"; import { fileOf, rankOf } from "../coord.js"; import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js"; @@ -72,10 +75,37 @@ import { import { resolveParams } from "./primitives/param-resolver.js"; import type { EffectPrimitiveNode, + PendingTrigger, PrimitiveApplyContext, } from "./primitives/types.js"; +import { IMPERATIVE_KINDS } from "./custom/validate.js"; import type { ChessEngine } from "../engine.js"; +/** + * Cascade-depth hard cap (T15). Mirrors the existing + * `RUNTIME_DEPTH_HARD_CAP` precedent (validate.ts) — same constant + * is reused for the request-choice stack depth, so all three + * recursion-control limits stay in lockstep. + */ +const HARD_CASCADE_DEPTH = 8; + +/** + * Append a pending trigger to the current arm's deferred queue + * (T15). Imperative primitives that synthesise secondary triggers + * call this helper instead of firing inline — the dispatcher + * drains the queue once the current arm finishes. + * + * Mutating the array is intentional: `pendingTriggers` is the + * single per-arm shared queue. The "no in-place mutation" rule + * for `bindings` does NOT apply here. + */ +export function enqueueTrigger( + ctx: PrimitiveApplyContext, + trigger: PendingTrigger, +): void { + ctx.pendingTriggers.push(trigger); +} + /** * Per-color royal→attackers map (mirrors the shape exported from * `apply.ts` as `PreMoveCheckState`). Re-declared structurally here so @@ -115,19 +145,53 @@ function* eachPiece( * the trigger metadata that fired them. Existing callers that don't * supply an event get `event: undefined` — backward compatible. */ -function runPrimitives( +export function runPrimitives( engine: ChessEngine, pieceId: EntityId, nodes: readonly EffectPrimitiveNode[], depth: number, event?: PrimitiveEvent, bindings: ReadonlyMap = new Map(), + cascadeDepth: number = 0, + suppressTriggers: boolean = false, ): void { if (depth > 8) return; // hard runtime cap, mirrors validator + // T15: cascade-depth guard. Distinct from `depth` (nested primitive + // arrays in the same arm) — `cascadeDepth` counts cross-arm chains + // formed by the deferred-trigger queue. A breach is a runtime + // error, not validator-detectable: the cascade is data-dependent. + if (cascadeDepth > HARD_CASCADE_DEPTH) { + throw new Error( + `runtime.cascade-depth-exceeded: cascade depth ${cascadeDepth} > ${HARD_CASCADE_DEPTH}`, + ); + } + + // T15: per-arm deferred-trigger queue. Each `runPrimitives` + // invocation seeds a FRESH array — the queue does not leak + // across sibling arms, only across the parent arm and its drained + // descendants (which run at `cascadeDepth + 1`). + const pendingTriggers: PendingTrigger[] = []; + for (const node of nodes) { const primitive = PRIMITIVE_REGISTRY.get(node.kind); if (primitive === undefined) continue; + // T20: dry-mode skips imperative primitives entirely. The check + // is THE single source of truth for `suppressTriggers` (per + // decisions.md § Move-Generation Dry Mode); individual primitives + // do NOT branch on it. IMPERATIVE_KINDS is the locked T14 set of + // 10 future Wave-5/6 kinds (none registered yet — but the gate + // is in place so they fire correctly when they land). + // + // Non-imperative primitives (predicates, conditionals, the 22 + // pre-Wave-5 kinds) still run normally so legality analysis can + // branch on the same data the wet path would. This preserves + // backward compat: the 22 existing primitives are not in + // IMPERATIVE_KINDS, so suppressTriggers never affects them. + if (suppressTriggers && IMPERATIVE_KINDS.has(node.kind)) { + continue; + } + const ctx: PrimitiveApplyContext = { engine, session: engine.session, @@ -149,6 +213,18 @@ function runPrimitives( // request-choice primitives extend it via `withBinding` before // re-entering `runPrimitives` for nested children. bindings, + // T15: deferred-trigger queue + cascade depth. The same + // `pendingTriggers` instance is shared across all primitives + // in this arm (siblings + nested children) so they can + // collectively defer secondary fires; drain happens once the + // top of THIS arm completes. + pendingTriggers, + cascadeDepth, + // T20: thread the dry-mode flag into the context so any future + // primitive author who needs it can read it (canonical use is + // the dispatcher-level skip above; individual primitives do + // NOT branch on this — see types.ts contract). + suppressTriggers, }; // T12: resolve `$var` / `ctx-attr` / `ctx-build` shapes inside // params BEFORE handing them to the primitive's apply(). Existing @@ -169,11 +245,133 @@ function runPrimitives( children = []; } if (children.length > 0) { - // Thread the SAME bindings through nested children so a name - // introduced by an outer iteration primitive remains in scope. - runPrimitives(engine, pieceId, children, depth + 1, event, bindings); + // Thread the SAME bindings + cascadeDepth + suppressTriggers + // through nested children so a name introduced by an outer + // iteration primitive remains in scope, the cascade counter + // doesn't jump artificially, and dry-mode propagates into + // nested arms (a conditional inside a dry-probe must NOT + // suddenly fire imperatives via its `then` branch). + runPrimitives( + engine, + pieceId, + children, + depth + 1, + event, + bindings, + cascadeDepth, + suppressTriggers, + ); } } + + // T15: drain the deferred-trigger queue AFTER the current arm + // completes. FIFO order — the order primitives enqueued. Each + // drained trigger re-enters dispatch at `cascadeDepth + 1`. + // The drain happens with the SAME engine snapshot the arm built; + // primitives that died mid-arm have already retracted facts, so + // hook lookups in `fireTriggerByKind` find what's still present. + // + // T20: under suppressTriggers the queue should be empty (the + // imperative-skip above prevents apply() from running and so + // prevents any enqueueTrigger() calls), but defensive-skip the + // drain anyway so a future primitive that mistakenly enqueues + // mid-dry-mode can't leak side effects. + if (suppressTriggers) return; + for (const t of pendingTriggers) { + fireTriggerByKind(engine, t, cascadeDepth + 1); + } +} + +/** + * Dispatch a deferred trigger (T15). Maps a `TriggerName` onto the + * existing `fire*Hooks` family, threading the new cascade depth so + * the receiver enforces the hard cap. + * + * Wave-4 trigger kinds (`on-rule-activated`, `on-rule-expire`, + * `on-piece-entered-marker`, `on-marker-expire`) are listed in + * `TriggerName` already but not yet wired here — when T16-T19 land, + * add their cases. The default branch logs + skips so a + * descriptor that enqueues an unsupported kind today doesn't crash + * production: it just doesn't fire (and the warning surfaces the + * mismatch in dev). + */ +function fireTriggerByKind( + engine: ChessEngine, + trigger: PendingTrigger, + cascadeDepth: number, +): void { + switch (trigger.kind) { + case "on-captured": { + const payload = (trigger.payload ?? {}) as { attackerId?: EntityId }; + if (payload.attackerId === undefined) return; + fireOnCapturedHooks( + engine, + trigger.pieceId, + payload.attackerId, + cascadeDepth, + ); + return; + } + case "on-capture": { + fireOnCaptureHooks(engine, trigger.pieceId, cascadeDepth); + return; + } + case "on-move": { + fireOnMoveHooks(engine, [trigger.pieceId], cascadeDepth); + return; + } + case "on-promotion": { + const payload = (trigger.payload ?? {}) as { + promotedFrom?: PieceType; + promotedTo?: PieceType; + }; + if (payload.promotedFrom === undefined || payload.promotedTo === undefined) { + return; + } + fireOnPromotionHooks( + engine, + trigger.pieceId, + payload.promotedFrom, + payload.promotedTo, + cascadeDepth, + ); + return; + } + case "on-moved-onto-square": { + const payload = (trigger.payload ?? {}) as { square?: Square }; + if (payload.square === undefined) return; + fireOnMovedOntoSquareHooks( + engine, + trigger.pieceId, + payload.square, + cascadeDepth, + ); + return; + } + // Triggers that require richer pre/post snapshots + // (on-damaged / on-check-*) and the per-color turn ticks are + // not enqueueable from primitives in V1 — they're driven only by + // the integration preset's onAfterMove pipeline. Listing them in + // `TriggerName` keeps the union complete without forcing a + // dispatcher entry that primitives can't currently produce. + case "on-damaged": + case "on-check-received": + case "on-check-delivered": + case "on-turn-start": + case "on-turn-end": + case "on-rule-activated": + case "on-rule-expire": + case "on-piece-entered-marker": + case "on-marker-expire": + default: + // Unsupported-from-primitives kind: log + skip rather than + // throw, so a forward-compatible descriptor authored against + // future Wave-4 wiring degrades gracefully today. + console.warn( + `fireTriggerByKind: trigger kind '${trigger.kind}' not dispatchable from deferred queue (yet)`, + ); + return; + } } /** @@ -212,6 +410,7 @@ function evaluateCondition( export function fireOnTurnStartHooks( engine: ChessEngine, whoseTurn: PieceColor, + cascadeDepth: number = 0, ): void { for (const { id, color } of eachPiece(engine.session)) { if (color !== whoseTurn) continue; @@ -220,7 +419,7 @@ export function fireOnTurnStartHooks( | undefined; if (hooks === undefined) continue; for (const primitives of hooks) { - runPrimitives(engine, id, primitives, 1); + runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth); } } } @@ -237,6 +436,7 @@ export function fireOnTurnStartHooks( export function fireOnTurnEndHooks( engine: ChessEngine, endedColor: PieceColor, + cascadeDepth: number = 0, ): void { for (const { id } of eachPiece(engine.session)) { const hooks = engine.session.get(id, "OnTurnEndHooks") as @@ -245,7 +445,7 @@ export function fireOnTurnEndHooks( if (hooks === undefined) continue; for (const hook of hooks) { if (hook.color !== "both" && hook.color !== endedColor) continue; - runPrimitives(engine, id, hook.primitives, 1); + runPrimitives(engine, id, hook.primitives, 1, undefined, new Map(), cascadeDepth); } } } @@ -262,6 +462,7 @@ export function fireOnTurnEndHooks( export function fireOnCaptureHooks( engine: ChessEngine, attackerId: EntityId | null, + cascadeDepth: number = 0, ): void { if (attackerId === null) return; const hooks = engine.session.get(attackerId, "OnCaptureHooks") as @@ -269,7 +470,7 @@ export function fireOnCaptureHooks( | undefined; if (hooks === undefined) return; for (const primitives of hooks) { - runPrimitives(engine, attackerId, primitives, 1); + runPrimitives(engine, attackerId, primitives, 1, undefined, new Map(), cascadeDepth); } } @@ -297,6 +498,7 @@ export function snapshotHp(session: Session): Map { export function fireOnDamagedHooks( engine: ChessEngine, preMoveHp: ReadonlyMap, + cascadeDepth: number = 0, ): void { for (const [id, prev] of preMoveHp) { const current = engine.session.get(id, "Hp") as number | undefined; @@ -308,7 +510,7 @@ export function fireOnDamagedHooks( | undefined; if (hooks === undefined) continue; for (const primitives of hooks) { - runPrimitives(engine, id, primitives, 1); + runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth); } } } @@ -319,7 +521,10 @@ export function fireOnDamagedHooks( * — conditions are re-evaluated against current facts so a hook that * reacts to "Hp < 2" fires the moment HP drops below threshold. */ -export function fireConditionalHooks(engine: ChessEngine): void { +export function fireConditionalHooks( + engine: ChessEngine, + cascadeDepth: number = 0, +): void { for (const { id } of eachPiece(engine.session)) { const hooks = engine.session.get(id, "ConditionalHooks") as | ChessAttrMap["ConditionalHooks"] @@ -329,7 +534,7 @@ export function fireConditionalHooks(engine: ChessEngine): void { const matches = evaluateCondition(engine.session, id, hook.condition); const branch = matches ? hook.then : hook.else; if (branch === undefined || branch.length === 0) continue; - runPrimitives(engine, id, branch, 1); + runPrimitives(engine, id, branch, 1, undefined, new Map(), cascadeDepth); } } } @@ -347,6 +552,7 @@ export function fireConditionalHooks(engine: ChessEngine): void { export function fireOnMoveHooks( engine: ChessEngine, movedPieceIds: readonly EntityId[], + cascadeDepth: number = 0, ): void { for (const id of movedPieceIds) { const hooks = engine.session.get(id, "OnMoveHooks") as @@ -354,7 +560,7 @@ export function fireOnMoveHooks( | undefined; if (hooks === undefined) continue; for (const primitives of hooks) { - runPrimitives(engine, id, primitives, 1); + runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth); } } } @@ -373,6 +579,7 @@ export function fireOnPromotionHooks( promotedPieceId: EntityId, promotedFrom: PieceType, promotedTo: PieceType, + cascadeDepth: number = 0, ): void { const hooks = engine.session.get(promotedPieceId, "OnPromotionHooks") as | ChessAttrMap["OnPromotionHooks"] @@ -384,7 +591,7 @@ export function fireOnPromotionHooks( promotedTo, }; for (const primitives of hooks) { - runPrimitives(engine, promotedPieceId, primitives, 1, event); + runPrimitives(engine, promotedPieceId, primitives, 1, event, new Map(), cascadeDepth); } } @@ -404,6 +611,7 @@ export function fireOnPromotionHooks( export function fireOnCheckReceivedHooks( engine: ChessEngine, preMoveCheckState: PreMoveCheckStateLike, + cascadeDepth: number = 0, ): void { for (const color of ["white", "black"] as const) { const preColor = preMoveCheckState[color]; @@ -418,7 +626,7 @@ export function fireOnCheckReceivedHooks( | undefined; if (hooks === undefined) continue; for (const primitives of hooks) { - runPrimitives(engine, royalId, primitives, 1); + runPrimitives(engine, royalId, primitives, 1, undefined, new Map(), cascadeDepth); } } } @@ -435,6 +643,7 @@ export function fireOnCheckReceivedHooks( export function fireOnCheckDeliveredHooks( engine: ChessEngine, preMoveCheckState: PreMoveCheckStateLike, + cascadeDepth: number = 0, ): void { for (const color of ["white", "black"] as const) { const preColor = preMoveCheckState[color]; @@ -449,7 +658,7 @@ export function fireOnCheckDeliveredHooks( ) as ChessAttrMap["OnCheckDeliveredHooks"] | undefined; if (hooks === undefined) continue; for (const primitives of hooks) { - runPrimitives(engine, attackerId, primitives, 1); + runPrimitives(engine, attackerId, primitives, 1, undefined, new Map(), cascadeDepth); } } } @@ -466,6 +675,7 @@ export function fireOnMovedOntoSquareHooks( engine: ChessEngine, movedPieceId: EntityId, destSquare: Square, + cascadeDepth: number = 0, ): void { const hooks = engine.session.get( movedPieceId, @@ -474,7 +684,93 @@ export function fireOnMovedOntoSquareHooks( if (hooks === undefined) return; for (const hook of hooks) { if (!squareMatchesFilter(destSquare, hook.filter)) continue; - runPrimitives(engine, movedPieceId, hook.primitives, 1); + runPrimitives(engine, movedPieceId, hook.primitives, 1, undefined, new Map(), cascadeDepth); + } +} + +/** + * T18 — fire `on-piece-entered-marker` hooks for every piece in + * `movedPieceIds` that landed on a square containing one or more + * matching markers. + * + * Hook list lives on `GAME_ENTITY` (game-level — the rule applies + * to ANY piece, not a specific one). For each moved piece, the + * dispatcher reads the piece's CURRENT `Position` (post-move) and + * asks `engine.getMarkersAtSquare(square)` for every marker + * occupying it. T10 already returns markers SORTED BY PRIORITY + * (lowest number first — portal-end fires before mine before + * pit, etc.) with entity-id ascending tie-break, so iterating the + * result inherits the locked dispatch order without re-sorting. + * + * Per marker, the dispatcher fires every hook whose `markerKind` + * matches that marker's MarkerKind exactly. `event.kind = + * "piece-entered-marker"` is supplied so inner primitives can read + * `markerId` / `pieceId` / `square` if they need to (e.g. + * `destroy-marker` cleanup, square-specific tombstone effects). + * + * Pieces whose Position fact is unexpectedly missing (capture in + * the same arm, etc.) are skipped — this dispatcher only fires for + * pieces that ACTUALLY exist on a square at the moment of the + * call. + * + * The hook list is read fresh per piece. Markers spawned during + * THIS arm by a sibling primitive are NOT visible to a piece that + * already moved earlier in the arm: `getMarkersAtSquare` reflects + * the live session state at the moment of THIS dispatch call (the + * post-move state captured by onAfterMove). T15's deferred-trigger + * queue is what handles cross-arm cascades. + */ +export function fireOnPieceEnteredMarkerHooks( + engine: ChessEngine, + movedPieceIds: readonly EntityId[], + cascadeDepth: number = 0, +): void { + const hooks = engine.session.get( + GAME_ENTITY, + "OnPieceEnteredMarkerHooks", + ) as ChessAttrMap["OnPieceEnteredMarkerHooks"] | undefined; + if (hooks === undefined || hooks.length === 0) return; + + for (const pieceId of movedPieceIds) { + const square = engine.session.get(pieceId, "Position") as + | Square + | undefined; + if (typeof square !== "number") continue; + + // T10: getMarkersAtSquare returns markers sorted ASC by hardcoded + // MARKER_KIND_PRIORITY (portal-end first), tie-break by entity + // id ASC. Iterating the result inherits the locked dispatch + // order — DO NOT re-sort. + const markerIds = engine.getMarkersAtSquare(square); + for (const markerId of markerIds) { + const markerKind = engine.session.get(markerId, "MarkerKind") as + | MarkerKindValue + | undefined; + if (markerKind === undefined) continue; + + // Match by exact kind only — no wildcard. A descriptor that + // wants two kinds installs two hook entries. + for (const hook of hooks) { + if (hook.markerKind !== markerKind) continue; + const event: PrimitiveEvent = { + kind: "piece-entered-marker", + markerId, + markerKind, + pieceId, + square, + }; + runPrimitives( + engine, + pieceId, + hook.primitives, + 1, + event, + new Map(), + cascadeDepth, + false, + ); + } + } } } @@ -496,6 +792,7 @@ export function fireOnCapturedHooks( engine: ChessEngine, capturedPieceId: EntityId, attackerId: EntityId, + cascadeDepth: number = 0, ): void { const hooks = engine.session.get( capturedPieceId, @@ -524,14 +821,227 @@ export function fireOnCapturedHooks( // iteration scope. Inner primitives that introduce bindings // extend via `withBinding` once `runPrimitives` recurses. bindings: new Map(), + // T15: resolver-only ctx — `runPrimitives` below allocates its + // own per-arm queue. The stub satisfies the type contract; + // resolveTargets does not enqueue. + pendingTriggers: [], + cascadeDepth, + // T20: on-captured hooks fire on the wet path (real commit). + // Dry-mode probing never reaches this dispatcher — `false` is + // the only correct default. + suppressTriggers: false, }; const targets = resolveTargets(resolverCtx, hook.target); for (const targetId of targets) { - runPrimitives(engine, targetId, hook.primitives, 1, event); + runPrimitives(engine, targetId, hook.primitives, 1, event, new Map(), cascadeDepth); } } } +/** + * Fire `on-rule-activated` hooks for a descriptor that just attached + * (T16). Reads the GAME_ENTITY-scoped `OnRuleActivatedHooks` list, + * filters to entries whose `descriptorId` matches, and runs each + * hook's inner primitive list ONCE against `GAME_ENTITY` (the + * canonical "game-level" target — `on-rule-activated` is per-game, + * not per-piece). + * + * The fire-once guard lives at the CALLER (`applyCustomDescriptor`), + * not here — this dispatcher is a pure firing helper. Callers must + * check `RuleActivatedFiredFor` on `PRESET_STATE_ENTITY` before + * invoking, and append the descriptor id afterward to prevent + * re-fires on subsequent attachments / game reload. + * + * Inner primitives see `event = { kind: "rule-activated", + * descriptorId, chooserColor }` so they can branch on which rule + * activated and on the chooser's color (read from + * `LastModifierChooser` if present — undefined otherwise). + */ +export function fireOnRuleActivatedHooks( + engine: ChessEngine, + descriptorId: string, + cascadeDepth: number = 0, +): void { + const hooks = engine.session.get(GAME_ENTITY, "OnRuleActivatedHooks") as + | ChessAttrMap["OnRuleActivatedHooks"] + | undefined; + if (hooks === undefined) return; + + const chooser = engine.session.get( + PRESET_STATE_ENTITY, + "LastModifierChooser", + ) as PieceColor | undefined; + + const event: PrimitiveEvent = { + kind: "rule-activated", + descriptorId, + ...(chooser !== undefined ? { chooserColor: chooser } : {}), + }; + + for (const hook of hooks) { + if (hook.descriptorId !== descriptorId) continue; + runPrimitives( + engine, + GAME_ENTITY, + hook.primitives, + 1, + event, + new Map(), + cascadeDepth, + ); + } +} + +/** + * Fire `on-rule-expire` hooks for a descriptor that just detached + * (T17). Mirror image of `fireOnRuleActivatedHooks` — reads the + * GAME_ENTITY-scoped `OnRuleExpireHooks` list, filters to entries + * whose `descriptorId` matches, and runs each hook's inner primitive + * list ONCE against `GAME_ENTITY` (the canonical "rule-expire" + * target — `on-rule-expire` is per-game, not per-piece). + * + * Fire-once guard lives HERE (unlike T16's activation guard, which + * lives in `applyCustomDescriptor` because activation has a clean + * call-site). Expire's call sites will be plural — descriptor + * lifetime decrement (T19), explicit remove-modifier action, and + * piece-modifier capture cascade. Centralising the guard in this + * dispatcher means every future caller inherits it for free; the + * caller just invokes `fireOnRuleExpireHooks(engine, descriptorId)` + * and the function self-dedups via `RuleExpireFiredFor` on + * `PRESET_STATE_ENTITY`. + * + * V1 stub status: no detach pipeline currently invokes this + * function — it's exposed for T19 (lifetime decrementer) and the + * eventual remove-modifier action handler to wire up. Until then, + * `on-rule-expire` blocks register their hooks at descriptor apply + * time but never fire. This is by design: expire fires on actual + * detach, never on game shutdown. + * + * Inner primitives see `event = { kind: "rule-expire", descriptorId }` + * so they can branch on which rule expired. No chooser color is + * supplied — expire is a system event (lifetime tick / capture), + * not a player action, and the original chooser may not be + * available by the time the descriptor detaches. + */ +export function fireOnRuleExpireHooks( + engine: ChessEngine, + descriptorId: string, + cascadeDepth: number = 0, +): void { + // Fire-once guard: list of descriptor ids whose expire hooks have + // already fired. Persisted on PRESET_STATE_ENTITY so save→load + // inherits the dedup, and so a re-attach + re-detach cycle within + // the same session also doesn't re-fire (mirroring T16's + // activation guard semantics). + const fired = + (engine.session.get(PRESET_STATE_ENTITY, "RuleExpireFiredFor") as + | ChessAttrMap["RuleExpireFiredFor"] + | undefined) ?? []; + if (fired.includes(descriptorId)) return; + + const hooks = engine.session.get(GAME_ENTITY, "OnRuleExpireHooks") as + | ChessAttrMap["OnRuleExpireHooks"] + | undefined; + + // Append the descriptor to the guard list BEFORE firing so a + // primitive that re-enters this dispatcher (cascade) can't + // double-fire. Idempotent on double-detach calls — the guard skip + // above handles repeats. + engine.session.insert(PRESET_STATE_ENTITY, "RuleExpireFiredFor", [ + ...fired, + descriptorId, + ]); + + if (hooks === undefined) return; + + const event: PrimitiveEvent = { + kind: "rule-expire", + descriptorId, + }; + + for (const hook of hooks) { + if (hook.descriptorId !== descriptorId) continue; + runPrimitives( + engine, + GAME_ENTITY, + hook.primitives, + 1, + event, + new Map(), + cascadeDepth, + ); + } +} + +/** + * T19 — fire `on-marker-expire` hooks for the marker `markerId` + * that just expired (lifetime sweep) but is NOT YET removed. + * + * The dispatcher must be called BEFORE `engine.removeMarker(markerId)` + * retracts the marker's facts so: + * 1. The marker's MarkerKind / Position can still be read here + * (event payload construction). + * 2. Inner primitives that consult MarkerOwner / MarkerLinks via + * `ctx.session.get` find them present (e.g. portal-pair cleanup). + * + * Hook list lives on `GAME_ENTITY` (game-level — the rule applies to + * ANY marker of the matching kind, not a specific marker entity). + * Match is exact-kind only — a hook for `mine` does NOT fire on + * `pit`. Multiple hook entries across descriptors compose naturally: + * the dispatcher runs every matching entry's inner primitives in + * insertion order. + * + * Inner primitives see `event = { kind: "marker-expire", markerId, + * markerKind, square }`. Target defaults to `'self'` which resolves + * to `markerId` (the dying marker entity) — primitives that want to + * act on the square or on pieces standing there must explicitly use + * a target redirect (`{ squares: [square] }`) or read `event.square`. + * + * If the marker's MarkerKind / Position fact is missing (defensive — + * shouldn't happen because the caller `decrementMarkerLifetimes` + * just read both), the dispatcher silently skips: a half-retracted + * marker is not a kind we can match against. + */ +export function fireOnMarkerExpireHooks( + engine: ChessEngine, + markerId: EntityId, + cascadeDepth: number = 0, +): void { + const markerKind = engine.session.get(markerId, "MarkerKind") as + | MarkerKindValue + | undefined; + const square = engine.session.get(markerId, "Position") as + | Square + | undefined; + if (markerKind === undefined || typeof square !== "number") return; + + const hooks = engine.session.get(GAME_ENTITY, "OnMarkerExpireHooks") as + | ChessAttrMap["OnMarkerExpireHooks"] + | undefined; + if (hooks === undefined || hooks.length === 0) return; + + const event: PrimitiveEvent = { + kind: "marker-expire", + markerId, + markerKind, + square, + }; + + for (const hook of hooks) { + if (hook.markerKind !== markerKind) continue; + runPrimitives( + engine, + markerId, + hook.primitives, + 1, + event, + new Map(), + cascadeDepth, + false, + ); + } +} + /** * Predicate matcher for OnMovedOntoSquare's filter union. * - `kind: "squares"` matches if the destination is in the list. diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index 69a82bd..4b796e9 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -242,6 +242,156 @@ export interface ChessAttrMap { * "undefined" as "no chooser available" and surface a runtime error. */ LastModifierChooser: PieceColor; + /** + * T16 — on-rule-activated trigger storage. Game-level (per + * `GAME_ENTITY`) list of hook entries seeded by the + * `on-rule-activated` primitive at descriptor-apply time. Each + * entry records the descriptor id that registered the hook and the + * inner primitive list to fire on activation. The dispatcher + * (`fireOnRuleActivatedHooks` in triggers.ts) filters by + * descriptorId so each hook only fires for its OWN descriptor's + * activation event. The list grows as descriptors attach; entries + * are NOT removed when their descriptor expires (T17 owns expire + * cleanup if needed — for V1 the entries are immortal because + * `on-rule-activated` only fires once per descriptor activation, + * not on every move). + */ + OnRuleActivatedHooks: readonly OnRuleActivatedHookEntry[]; + /** + * T16 — fire-once guard for `on-rule-activated`. List of descriptor + * ids whose `on-rule-activated` hooks have already fired in THIS + * game session. Stored on `PRESET_STATE_ENTITY` so it's distinct + * from the per-piece hook registration storage. Checked at + * `applyCustomDescriptor` time; descriptors whose id is already in + * the list skip the activation fire (handles game-reload from save: + * the persisted fact prevents re-firing on session rehydrate). + */ + RuleActivatedFiredFor: readonly string[]; + /** + * T18 — `on-piece-entered-marker` hook entries. Stored on + * `GAME_ENTITY` (game-level, not per-piece) because the descriptor + * that seeded the hook is conceptually a rule about ANY piece + * entering a marker of the matching kind, not a property of the + * descriptor's apply target. + * + * Dispatched in priority order via `engine.getMarkersAtSquare` + * (T10) — that helper sorts by hardcoded MARKER_KIND_PRIORITY + * ascending (portal-end first), tie-break by entity id ascending. + * + * Each entry stores its source `descriptorId` (debugging / dedup), + * the exact `markerKind` it filters by (no wildcard — a descriptor + * that wants to fire for two kinds installs two hook entries), and + * the inner primitive list to run. + */ + OnPieceEnteredMarkerHooks: readonly OnPieceEnteredMarkerHookEntry[]; + /** + * T17 — `on-rule-expire` trigger storage. Mirror image of + * `OnRuleActivatedHooks` for the descriptor-detach side. Stored on + * `GAME_ENTITY` (one list per game). Each entry pairs a descriptor + * id with the inner primitive list to fire when THAT descriptor + * detaches (lifetime ends, manual remove, piece-modifier captured + * with its piece). The dispatcher (`fireOnRuleExpireHooks` in + * triggers.ts) filters by descriptorId so each hook only fires + * for its OWN descriptor's expire event. + * + * V1 wire-in status: descriptor lifetimes are not yet wired into + * any detach pipeline, so `fireOnRuleExpireHooks` is exposed as a + * STUB callable from future work (T19 lifetime decrementer, + * eventual remove-modifier action). The seeding side (this attr + + * the primitive's `apply()`) IS active today so descriptors that + * include `on-rule-expire` blocks register the hooks at apply + * time; they simply never fire until a detach pipeline lands. + */ + OnRuleExpireHooks: readonly OnRuleExpireHookEntry[]; + /** + * T17 — fire-once guard for `on-rule-expire`. List of descriptor + * ids whose `on-rule-expire` hooks have already fired in THIS + * game session. Stored on `PRESET_STATE_ENTITY` (mirror of + * T16's `RuleActivatedFiredFor`). Checked at the top of + * `fireOnRuleExpireHooks`; descriptors whose id is already in + * the list skip the expire fire. Persists across save→load via + * normal session fact rehydrate, so a re-attach + re-detach + * cycle in the SAME game does NOT re-fire (consistent with the + * "once per descriptor id per game session" contract pinned by + * T16's activation guard). + */ + RuleExpireFiredFor: readonly string[]; + /** + * T19 — `on-marker-expire` hook entries. Stored on `GAME_ENTITY` + * (game-level, not per-marker) — the descriptor that seeded the + * hook is conceptually a rule about ANY marker of the matching + * kind expiring, not a property of a specific marker entity. + * + * Dispatched by `fireOnMarkerExpireHooks` (triggers.ts) which is + * called from `decrementMarkerLifetimes` (util/marker-lifetime.ts) + * during onAfterMove stage 7c — AFTER on-piece-entered-marker + * (stage 7b) so a piece entering a marker can still trigger its + * entry effects in the SAME move that the lifetime expires (the + * decrementer fires expire hooks AFTER entry hooks). + * + * Each entry stores its source `descriptorId` (debugging/dedup), + * the exact `markerKind` it filters by (no wildcard — a + * descriptor watching two kinds installs two entries), and the + * inner primitive list to run when a matching marker expires. + */ + OnMarkerExpireHooks: readonly OnMarkerExpireHookEntry[]; +} + +/** + * T16 — single entry in `OnRuleActivatedHooks`. Stored on + * `GAME_ENTITY` (one list per game). Each entry pairs the + * descriptor id that seeded the hook with the inner primitive list + * the dispatcher will fire when that descriptor activates. Multiple + * entries from the same descriptor may coexist (composite + * `on-rule-activated` blocks); the dispatcher fires every matching + * entry on activation. + */ +export interface OnRuleActivatedHookEntry { + readonly descriptorId: string; + readonly primitives: readonly EffectPrimitiveNode[]; +} + +/** + * T18 — single entry in `OnPieceEnteredMarkerHooks`. Stored on + * `GAME_ENTITY` (one list per game). Each entry pairs the source + * descriptor id, the marker kind it watches for (exact match, no + * wildcard), and the inner primitive list to run when a piece + * enters a square containing that marker kind. Multiple entries + * from the same descriptor may coexist for different marker kinds. + */ +export interface OnPieceEnteredMarkerHookEntry { + readonly descriptorId: string; + readonly markerKind: MarkerKindValue; + readonly primitives: readonly EffectPrimitiveNode[]; +} + +/** + * T17 — single entry in `OnRuleExpireHooks`. Stored on + * `GAME_ENTITY` (one list per game). Each entry pairs the + * descriptor id that seeded the hook with the inner primitive list + * the dispatcher fires when that descriptor detaches. Mirrors + * `OnRuleActivatedHookEntry` for the expire side; multiple entries + * from the same descriptor compose (each `on-rule-expire` block + * inside a descriptor tree appends one entry). + */ +export interface OnRuleExpireHookEntry { + readonly descriptorId: string; + readonly primitives: readonly EffectPrimitiveNode[]; +} + +/** + * T19 — single entry in `OnMarkerExpireHooks`. Stored on + * `GAME_ENTITY` (one list per game). Each entry pairs the source + * descriptor id, the marker kind it watches for (exact match, no + * wildcard), and the inner primitive list to run when a marker of + * that kind expires (lifetime-driven removal via + * `decrementMarkerLifetimes`). Multiple entries from the same + * descriptor may coexist for different marker kinds. + */ +export interface OnMarkerExpireHookEntry { + readonly descriptorId: string; + readonly markerKind: MarkerKindValue; + readonly primitives: readonly EffectPrimitiveNode[]; } export type ChessAttrKey = keyof ChessAttrMap; diff --git a/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap b/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap index 6fa055a..1cfa364 100644 --- a/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap +++ b/packages/chess/src/ui/__snapshots__/ParamField.snapshot.test.tsx.snap @@ -210,3 +210,214 @@ exports[`ParamField rendering (T14 regression baseline) > set-capture-flag rende "flag": 1 }

Sets CAN_CAPTURE_OWN — the piece may capture its own color's pieces.

" `; + +exports[`ParamField rendering (T14 regression baseline) absorb-damage-with-attribute renders attr + rate 1`] = ` +"

Declares that incoming damage should first deplete a user-chosen counter (rate points per damage) before touching HP. You must seed the counter itself with seed-attribute — this primitive only wires the absorb mechanic, not the charge supply.

Examples
3-charge shield (pair with seed-attribute)
{
+  "attr": "ShieldCharges",
+  "rate": 1
+}

Pair with seed-attribute {attr: 'ShieldCharges', value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.

Hardened armor (rate=2)
{
+  "attr": "ArmorPlates",
+  "rate": 2
+}

Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.

not seeded
" +`; + +exports[`ParamField rendering (T14 regression baseline) add-aura renders radius + targetAttr + delta 1`] = ` +"

Radiates a numeric contribution to targetAttr onto every piece within \`radius\` (Chebyshev / king-move distance — radius 1 = 8 neighbours). Recomputes after every move; pieces moving out of range lose the contribution on the next pass. Self-application is skipped. Multiple auras to the same targetAttr from different sources accumulate additively.

Examples
King aura: +1 HP within 2 squares
{
+  "radius": 2,
+  "targetAttr": "HpBonus",
+  "delta": 1
+}

Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.

Adjacent range buff
{
+  "radius": 1,
+  "targetAttr": "RangeBonus",
+  "delta": 1
+}

Anyone standing next to this piece (8 neighbouring squares) gets +1 to range.

" +`; + +exports[`ParamField rendering (T14 regression baseline) add-direction renders directions array fallback 1`] = ` +"

Appends one or more color-relative named directions into the piece's DirectionAdditions array, deduplicated by name. Composes with the built-in Direction Additions modifier — both write to the same fact. Valid directions: forward, backward, left, right, diagonal-fl, diagonal-fr, diagonal-bl, diagonal-br.

Examples
Backward-capable pawn
{
+  "directions": [
+    "backward"
+  ]
+}

Lets a pawn step backward as well as forward.

Full omnidirectional king-lite
{
+  "directions": [
+    "forward",
+    "backward",
+    "left",
+    "right"
+  ]
+}

Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.

" +`; + +exports[`ParamField rendering (T14 regression baseline) add-to-attribute renders attr + delta fields 1`] = ` +"

Reads the current numeric value of attr (0 if unset) and writes existing + delta. Delta may be negative. Composes additively with other primitives and built-in modifiers — multiple add-to-attribute primitives for the same attr simply accumulate.

Examples
+2 HP bonus
{
+  "attr": "HpBonus",
+  "delta": 2
+}

Adds 2 to whatever HpBonus is already there.

Heal 1/turn (inside on-turn-start)
{
+  "attr": "Hp",
+  "delta": 1
+}

Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color's turn.

" +`; + +exports[`ParamField rendering (T14 regression baseline) block-move-type renders moveType enum 1`] = ` +"

Filters out generated moves matching the given type. Multiple block primitives accumulate into a blocked-move-type set (deduped). Useful for pacifist pieces that still slide, or for pieces that can capture but not reposition silently.

Examples
Pacifist piece
{
+  "moveType": "capture"
+}

Piece can step and slide freely but cannot capture — a pure support piece.

Charge-only attacker
{
+  "moveType": "step"
+}

Removes simple step moves; piece can only capture or slide.

" +`; + +exports[`ParamField rendering (T14 regression baseline) conditional renders complex-schema JSON fallback 1`] = ` +"

Branches on a condition. If true → runs every primitive in \`then\`; if false and \`else\` is set → runs \`else\`. Condition types: attr-lt (numeric less-than), attr-gt (numeric greater-than), attr-eq (exact match against string/number/boolean/null), always (unconditional then), never (forces else path only).

Examples
Low-HP fortress
{
+  "condition": {
+    "type": "attr-lt",
+    "attr": "Hp",
+    "value": 2
+  },
+  "then": [
+    {
+      "kind": "set-capture-flag",
+      "params": {
+        "flag": 2
+      }
+    }
+  ]
+}

When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.

Unconditional thorns example
{
+  "condition": {
+    "type": "always"
+  },
+  "then": [
+    {
+      "kind": "reflect-damage",
+      "params": {
+        "percentage": 10
+      }
+    }
+  ]
+}

Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.

" +`; + +exports[`ParamField rendering (T14 regression baseline) modify-movement-range renders delta 1`] = ` +"

Adds delta to the piece's RangeBonus. Composes additively with the built-in Range Bonus modifier and with other modify-movement-range primitives. Delta is clamped to integer range [-7, 7]. Rook/bishop/queen sliding is extended/reduced by this amount; knight/king ranges are treated by their own pipeline.

Examples
+1 range buff
{
+  "delta": 1
+}

A rook's horizontal slide reaches one square further than its baseline.

-2 range debuff
{
+  "delta": -2
+}

Cuts 2 squares from the piece's reach (useful for 'slowed' tokens).

" +`; + +exports[`ParamField rendering (T14 regression baseline) multiply-attribute renders attr + factor fields 1`] = ` +"

Reads the existing numeric value of attr and writes existing * factor. No-op if the attribute is unset — it does NOT treat absent as 1. Use after seed-attribute or add-to-attribute when you need a baseline to scale.

Examples
Double HP
{
+  "attr": "Hp",
+  "factor": 2
+}

If the piece already has 4 HP, becomes 8 HP.

Halve range bonus
{
+  "attr": "RangeBonus",
+  "factor": 0.5
+}

If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.

" +`; + +exports[`ParamField rendering (T14 regression baseline) on-capture renders primitives-array fallback 1`] = ` +"

Wraps nested primitives that fire when this piece captures another. Typical uses: 'vampire' lifesteal (heal on capture), stacking buffs, or power-up triggers. Fires only on actual captures, not on quiet moves.

Examples
Vampire lifesteal
{
+  "primitives": [
+    {
+      "kind": "add-to-attribute",
+      "params": {
+        "attr": "Hp",
+        "delta": 1
+      }
+    }
+  ]
+}

Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.

" +`; + +exports[`ParamField rendering (T14 regression baseline) on-damaged renders primitives-array fallback 1`] = ` +"

Wraps nested primitives that fire whenever this piece takes damage. Useful for reactive behaviours: auto-thorns, emergency buffs, or conditional transformations when HP crosses a threshold (combine with \`conditional\`).

Examples
Thorns on hit
{
+  "primitives": [
+    {
+      "kind": "reflect-damage",
+      "params": {
+        "percentage": 25
+      }
+    }
+  ]
+}

When this piece takes damage, reflects 25% back to the attacker for that hit.

" +`; + +exports[`ParamField rendering (T14 regression baseline) on-turn-start renders primitives-array fallback 1`] = ` +"

Wraps a list of nested primitives that fire at the start of this piece's color's turn. Use for recurring buffs/healing/debuffs tied to turn cadence. The editor's Parameter Inspector accepts the nested \`primitives\` array as JSON; copy snippets from the simpler primitives into that array.

Examples
Regenerate 1 HP/turn
{
+  "primitives": [
+    {
+      "kind": "add-to-attribute",
+      "params": {
+        "attr": "Hp",
+        "delta": 1
+      }
+    }
+  ]
+}

At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).

" +`; + +exports[`ParamField rendering (T14 regression baseline) override-promotion renders target enum 1`] = ` +"

Forces this piece (typically a pawn) to promote to a specific type regardless of player choice. Mirrors the built-in Promotion Override modifier, but expressable inside a custom primitive tree. Last write wins if multiple sources set it.

Examples
Knights-only promotion
{
+  "target": "knight"
+}

Pawn always promotes to a knight.

Underpromote to rook
{
+  "target": "rook"
+}

Pawn always promotes to a rook — useful for themed variants.

" +`; + +exports[`ParamField rendering (T14 regression baseline) reflect-damage renders percentage 1`] = ` +"

Reflects a percentage of incoming damage back to the attacker. Integer percent, 0-100. Multiple reflect primitives on the same piece do NOT stack — the most recent value wins. Great inside on-damaged if you want a one-time thorns reaction instead of a permanent aura.

Examples
Half-reflective armour
{
+  "percentage": 50
+}

50% of incoming damage is dealt back to the attacker.

Total thorns
{
+  "percentage": 100
+}

Full reflection — the attacker takes whatever they dealt.

" +`; + +exports[`ParamField rendering (T14 regression baseline) seed-attribute renders attr + value fields 1`] = ` +"

Writes { attr, value } directly onto the piece, overwriting any existing value. Use to introduce new attributes (like a custom ShieldCharges counter) or to force a baseline (e.g. set HP to an exact number regardless of inheritance). Pair with add-to-attribute / multiply-attribute to build up a final value.

Examples
Force exact HP
{
+  "attr": "Hp",
+  "value": 5
+}

Piece always starts with 5 HP regardless of baseline.

Declare shield charges
{
+  "attr": "ShieldCharges",
+  "value": 3
+}

Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.

" +`; + +exports[`ParamField rendering (T14 regression baseline) set-capture-flag renders flag enum 1`] = ` +"

Turns on one capture-flag bit. Flags combine (OR) so stacking multiple primitives is fine. Supported: 1 = CAN_CAPTURE_OWN (piece may capture its own color), 2 = CANNOT_BE_CAPTURED (untargetable by enemies), 4 = EN_PASSANT (piece participates in en-passant capture resolution).

Examples
Untouchable piece
{
+  "flag": 2
+}

Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.

Friendly-fire rook
{
+  "flag": 1
+}

Sets CAN_CAPTURE_OWN — the piece may capture its own color's pieces.

" +`; diff --git a/packages/chess/src/util/marker-lifetime.ts b/packages/chess/src/util/marker-lifetime.ts new file mode 100644 index 0000000..7139fba --- /dev/null +++ b/packages/chess/src/util/marker-lifetime.ts @@ -0,0 +1,121 @@ +/** + * T19 — per-move marker lifetime sweep. + * + * Walks every marker entity in the session, expires any whose + * `MarkerLifetime` is exhausted, fires the matching + * `on-marker-expire` hooks (BEFORE retraction so inner primitives + * can still read the marker's facts), then removes the marker via + * `engine.removeMarker`. + * + * Called from `apply.ts#onAfterMove` as STAGE 7c — directly after + * stage 7b (`fireOnPieceEnteredMarkerHooks`) and BEFORE stage 8 + * (check-line edge triggers). This ordering means a piece that + * lands on a marker triggers the marker's entry hooks (7b) FIRST, + * then the lifetime sweep (7c) gets a chance to retire markers + * whose `expiresAtMove` target was just reached. The sequencing is + * intentional: a marker scheduled to expire ON move N still fires + * its entry effect when a piece lands on it during move N. + * + * ## Lifetime variants (per `decisions.md` § Square State via + * Marker Entities) + * + * - `permanent`: never auto-expires. Skipped here. + * - `moves; expiresAtMove: N`: expires when the engine's current + * move counter (`FullmoveNumber` on `GAME_ENTITY`) is `>= N`. + * `expiresAtMove` is an ABSOLUTE target, NOT a countdown + * remainder (decisions.md locks this — decremented-style + * lifetimes are explicitly rejected). + * - `one-shot`: NOT auto-decremented here. Consumed by + * `on-piece-entered-marker` per T0/T18 (the entry hook is + * responsible for calling `engine.removeMarker` itself, or a + * `destroy-marker` primitive nested in the entry block does + * so). The lifetime sweep MUST skip these — auto-decrementing + * would break the locked semantics. + * + * ## Iteration safety + * + * Marker discovery and expiry-fire happen in TWO separate phases: + * 1. Walk `session.allFacts()` to collect ALL marker ids whose + * lifetime is exhausted into a local `expired` array. + * 2. Iterate `expired` to fire hooks + remove. Mutating the + * session during phase 1 would invalidate the iterator. + * + * This split also means a marker spawned by a fired expire hook is + * NOT itself swept this turn (it'll be checked next turn). Mirrors + * stage 7b's "markers spawned mid-arm aren't visible to earlier + * pieces" semantic. Cross-turn cascades are T15's deferred-trigger + * queue's job. + * + * ## Cascade-paired markers + * + * Paired-marker cleanup (e.g. portal endpoints linking via + * `MarkerLinks`) is EXPLICITLY DEFERRED to T30 (`destroy-marker` + * primitive). This sweep removes ONLY the marker whose lifetime + * expired — the partner stays. A descriptor that wants paired + * removal can install an `on-marker-expire` hook that calls + * `destroy-marker` on the partner. + * + * @param engine the engine whose markers to sweep. + * @param cascadeDepth threading for T15's depth cap. Top-level + * apply.ts callers pass 0 (default). + */ +import type { EntityId } from "@paratype/rete"; +import type { ChessEngine } from "../engine.js"; +import { + GAME_ENTITY, + type MarkerLifetimeValue, +} from "../schema.js"; +import { fireOnMarkerExpireHooks } from "../modifiers/triggers.js"; + +export function decrementMarkerLifetimes( + engine: ChessEngine, + cascadeDepth: number = 0, +): void { + // Read the current move counter once. `FullmoveNumber` is the + // engine's per-completed-fullmove counter on GAME_ENTITY, + // initialized to 1 and incremented after black completes its + // move (see engine.ts#advanceTurnAfterMutation). It's the + // canonical "current move count" — matches the decisions.md + // semantic for `expiresAtMove` (absolute target move number). + const currentMoveCount = engine.session.get( + GAME_ENTITY, + "FullmoveNumber", + ); + if (typeof currentMoveCount !== "number") return; + + // Phase 1: collect every expired marker id. Mutating the session + // mid-iteration would invalidate `allFacts()` — collect first, + // act second. + const expired: EntityId[] = []; + const seen = new Set(); + for (const fact of engine.session.allFacts()) { + if (fact.attr !== "EntityKind" || fact.value !== "marker") continue; + if (seen.has(fact.id as number)) continue; + seen.add(fact.id as number); + + const lifetime = engine.session.get(fact.id, "MarkerLifetime") as + | MarkerLifetimeValue + | undefined; + if (lifetime === undefined) continue; + + // permanent → never auto-expire. + // one-shot → consumed by on-piece-entered-marker, not here. + // moves → expire iff current move count reached/passed target. + if ( + lifetime.kind === "moves" && + currentMoveCount >= lifetime.expiresAtMove + ) { + expired.push(fact.id); + } + } + + // Phase 2: fire on-marker-expire (BEFORE removal — inner + // primitives must be able to read the marker's facts) then + // remove. `removeMarker` is idempotent (T10 guarantee), so a + // primitive that itself called `removeMarker` for the same id + // doesn't break the second call here. + for (const id of expired) { + fireOnMarkerExpireHooks(engine, id, cascadeDepth); + engine.removeMarker(id); + } +}