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:
parent
defe56feb9
commit
70a7c50613
46 changed files with 4371 additions and 55 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue