feat(thressgame-coverage): Wave 4 (deferred dispatch + 4 new triggers + suppressTriggers)

- T15: deferred trigger queue (PendingTrigger[] + cascadeDepth on PrimitiveApplyContext); HARD_CASCADE_DEPTH=8; runtime.cascade-depth-exceeded; FIFO drain after arm; enqueueTrigger helper
- T16: on-rule-activated trigger primitive + fireOnRuleActivatedHooks; OnRuleActivatedHooks attr on GAME_ENTITY; RuleActivatedFiredFor guard on PRESET_STATE_ENTITY; chooser color in event
- T17: on-rule-expire trigger primitive + fireOnRuleExpireHooks; OnRuleExpireHooks attr; RuleExpireFiredFor guard
- T18: on-piece-entered-marker trigger + fireOnPieceEnteredMarkerHooks; OnPieceEnteredMarkerHooks attr; wired stage 7b in onAfterMove (uses T10 getMarkersAtSquare priority order)
- T19: on-marker-expire trigger + decrementMarkerLifetimes (util/marker-lifetime.ts); OnMarkerExpireHooks attr; wired stage 7c after T18
- T20: suppressTriggers flag on PrimitiveApplyContext; runPrimitives skips IMPERATIVE_KINDS under suppress; IMPERATIVE_KINDS exported from validate.ts; runPrimitives now public

Registry: 22 -> 26 primitives. Tests: 2058 -> 2120 (+62). bun run check exit 0.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 09:52:50 -06:00
commit 70a7c50613
No known key found for this signature in database
46 changed files with 4371 additions and 55 deletions

View file

@ -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<string, BindingValue> = 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<PrimitiveKind, unknown>` 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<PrimitiveKind, unknown>` 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<PrimitiveKind, ...>` 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<PrimitiveKind, unknown>` — 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<PrimitiveKind, unknown>` 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<PrimitiveKind, unknown>` 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: <currentFullmove + N> }`. 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.

View file

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

View file

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

View file

@ -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);

View file

@ -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<string> = new Set<string>([
export const IMPERATIVE_KINDS: ReadonlySet<string> = new Set<string>([
"place-piece",
"destroy-piece",
"move-piece",

View file

@ -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", () => {

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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", () => {

View file

@ -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 };
}

View file

@ -52,7 +52,10 @@ function buildFixture(
target: "self",
event: undefined,
bindings: new Map(),
};
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
return { ctx, session, ids };
}

View file

@ -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;
};
/**

View file

@ -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";

View file

@ -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", () => {

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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();
});
});

View file

@ -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<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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);
});
});

View file

@ -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<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

@ -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 };
}

View file

@ -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<typeof session.nextId>;
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<typeof session.nextId>;
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<typeof session.nextId>;
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<typeof session.nextId>;
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<typeof session.nextId>;
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);
});
});

View file

@ -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<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

@ -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"]);
});
});

View file

@ -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<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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", () => {

View file

@ -43,6 +43,9 @@ function makeCtx(opts: {
target: "self",
event: undefined,
bindings: opts.bindings ?? new Map(),
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
return { ctx, session };
}

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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<string, BindingValue>;
/**
* 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;
}
/**

View file

@ -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();
});
});

View file

@ -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<string, BindingValue> = 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<EntityId, number> {
export function fireOnDamagedHooks(
engine: ChessEngine,
preMoveHp: ReadonlyMap<EntityId, number>,
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.

View file

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

View file

@ -210,3 +210,214 @@ exports[`ParamField rendering (T14 regression baseline) > set-capture-flag rende
&quot;flag&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CAN_CAPTURE_OWN — the piece may capture its own color&#x27;s pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">flag</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) absorb-damage-with-attribute renders attr + rate 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Absorb Damage with Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">absorb-damage-with-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">3-charge shield (pair with seed-attribute)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;rate&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pair with seed-attribute {attr: &#x27;ShieldCharges&#x27;, value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Hardened armor (rate=2)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ArmorPlates&quot;,
&quot;rate&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-absorb-damage-with-attribute-attr" data-recognized="false" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-absorb-damage-with-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/><span class="text-[10px] font-semibold px-1.5 py-0.5 rounded text-red-800 bg-red-50 border border-red-200" title="This attribute isn&#x27;t a built-in and isn&#x27;t seeded by any primitive in this descriptor. Reading it will be a no-op unless seeded elsewhere." data-testid="primitive-absorb-damage-with-attribute-attr-badge">not seeded</span></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">rate</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) add-aura renders radius + targetAttr + delta 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Aura</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-aura</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">King aura: +1 HP within 2 squares</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;radius&quot;: 2,
&quot;targetAttr&quot;: &quot;HpBonus&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Adjacent range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;radius&quot;: 1,
&quot;targetAttr&quot;: &quot;RangeBonus&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Anyone standing next to this piece (8 neighbouring squares) gets +1 to range.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">radius</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">targetAttr</label><div class="relative" data-testid="primitive-add-aura-targetAttr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-aura-targetAttr-input" aria-autocomplete="list" aria-expanded="false" value="HpBonus"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) add-direction renders directions array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Direction</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-direction</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Appends one or more color-relative named directions into the piece&#x27;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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Backward-capable pawn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;directions&quot;: [
&quot;backward&quot;
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Lets a pawn step backward as well as forward.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Full omnidirectional king-lite</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;directions&quot;: [
&quot;forward&quot;,
&quot;backward&quot;,
&quot;left&quot;,
&quot;right&quot;
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">directions</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
&quot;forward&quot;,
&quot;backward&quot;
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) add-to-attribute renders attr + delta fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add To Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-to-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+2 HP bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;HpBonus&quot;,
&quot;delta&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds 2 to whatever HpBonus is already there.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Heal 1/turn (inside on-turn-start)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color&#x27;s turn.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-add-to-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-to-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) block-move-type renders moveType enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Block Move Type</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">block-move-type</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Pacifist piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;moveType&quot;: &quot;capture&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece can step and slide freely but cannot capture — a pure support piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Charge-only attacker</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;moveType&quot;: &quot;step&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Removes simple step moves; piece can only capture or slide.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">moveType</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="capture" selected="">capture</option><option value="step">step</option><option value="slide">slide</option></select></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) conditional renders complex-schema JSON fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Conditional</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">conditional</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Low-HP fortress</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;condition&quot;: {
&quot;type&quot;: &quot;attr-lt&quot;,
&quot;attr&quot;: &quot;Hp&quot;,
&quot;value&quot;: 2
},
&quot;then&quot;: [
{
&quot;kind&quot;: &quot;set-capture-flag&quot;,
&quot;params&quot;: {
&quot;flag&quot;: 2
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Unconditional thorns example</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;condition&quot;: {
&quot;type&quot;: &quot;always&quot;
},
&quot;then&quot;: [
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 10
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">condition</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="[object Object]"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">then</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;set-capture-flag&quot;,
&quot;params&quot;: {
&quot;flag&quot;: 2
}
}
]</textarea></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">else</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) modify-movement-range renders delta 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Modify Movement Range</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">modify-movement-range</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Adds delta to the piece&#x27;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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+1 range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A rook&#x27;s horizontal slide reaches one square further than its baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">-2 range debuff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;delta&quot;: -2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Cuts 2 squares from the piece&#x27;s reach (useful for &#x27;slowed&#x27; tokens).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) multiply-attribute renders attr + factor fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Multiply Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">multiply-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Double HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;factor&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If the piece already has 4 HP, becomes 8 HP.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Halve range bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;RangeBonus&quot;,
&quot;factor&quot;: 0.5
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-multiply-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-multiply-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">factor</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) on-capture renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Capture</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-capture</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps nested primitives that fire when this piece captures another. Typical uses: &#x27;vampire&#x27; lifesteal (heal on capture), stacking buffs, or power-up triggers. Fires only on actual captures, not on quiet moves.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Vampire lifesteal</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) on-damaged renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Damaged</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-damaged</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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\`).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Thorns on hit</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 25
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When this piece takes damage, reflects 25% back to the attacker for that hit.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 25
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) on-turn-start renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Turn Start</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-turn-start</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps a list of nested primitives that fire at the start of this piece&#x27;s color&#x27;s turn. Use for recurring buffs/healing/debuffs tied to turn cadence. The editor&#x27;s Parameter Inspector accepts the nested \`primitives\` array as JSON; copy snippets from the simpler primitives into that array.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Regenerate 1 HP/turn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) override-promotion renders target enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Override Promotion</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">override-promotion</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Knights-only promotion</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;target&quot;: &quot;knight&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pawn always promotes to a knight.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Underpromote to rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;target&quot;: &quot;rook&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pawn always promotes to a rook — useful for themed variants.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">target</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="pawn">pawn</option><option value="knight" selected="">knight</option><option value="bishop">bishop</option><option value="rook">rook</option><option value="queen">queen</option><option value="king">king</option></select></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) reflect-damage renders percentage 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Reflect Damage</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">reflect-damage</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Half-reflective armour</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;percentage&quot;: 50
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">50% of incoming damage is dealt back to the attacker.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Total thorns</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;percentage&quot;: 100
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Full reflection — the attacker takes whatever they dealt.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">percentage</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="25"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) seed-attribute renders attr + value fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Seed Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">seed-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Force exact HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;value&quot;: 5
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece always starts with 5 HP regardless of baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Declare shield charges</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;value&quot;: 3
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-seed-attribute-attr" data-recognized="true" data-mode="declare"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-seed-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">value</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="3"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) set-capture-flag renders flag enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Set Capture Flag</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">set-capture-flag</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">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).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Untouchable piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;flag&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Friendly-fire rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;flag&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CAN_CAPTURE_OWN — the piece may capture its own color&#x27;s pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">flag</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;

View file

@ -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<number>();
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);
}
}