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