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
|
||||
|
|
|
|||
|
|
@ -80,11 +80,13 @@ import {
|
|||
fireOnDamagedHooks,
|
||||
fireOnMoveHooks,
|
||||
fireOnMovedOntoSquareHooks,
|
||||
fireOnPieceEnteredMarkerHooks,
|
||||
fireOnPromotionHooks,
|
||||
fireOnTurnEndHooks,
|
||||
fireOnTurnStartHooks,
|
||||
snapshotHp,
|
||||
} from "./triggers.js";
|
||||
import { decrementMarkerLifetimes } from "../util/marker-lifetime.js";
|
||||
import { registerAttrConsumer } from "./primitives/manifest.js";
|
||||
|
||||
// Q3.2 consumer declarations: this module reads every attr listed
|
||||
|
|
@ -140,6 +142,40 @@ registerAttrConsumer("KingExtraReach");
|
|||
// activates so `ctx-attr: { entity: "chooser", attr: "Color" }` (T12
|
||||
// param walker) can resolve to the activating player's color.
|
||||
registerAttrConsumer("LastModifierChooser");
|
||||
// T18 — on-piece-entered-marker hook list, stored on GAME_ENTITY.
|
||||
// Read by `fireOnPieceEnteredMarkerHooks` in triggers.ts after every
|
||||
// move that changed Position. APPENDED at end-of-block to avoid
|
||||
// merge race with parallel T16 (OnRuleActivatedHooks /
|
||||
// RuleActivatedFiredFor).
|
||||
registerAttrConsumer("OnPieceEnteredMarkerHooks");
|
||||
// T16 — on-rule-activated hook list (GAME_ENTITY) + fire-once guard
|
||||
// (PRESET_STATE_ENTITY). The hooks are seeded by the
|
||||
// `on-rule-activated` primitive at profile-apply time and consumed
|
||||
// by `fireOnRuleActivatedHooks` (triggers.ts) called from
|
||||
// `applyCustomDescriptor` exactly once per descriptor instance. The
|
||||
// guard list dedups across reattachments and across save→load
|
||||
// rehydrate (the fact persists in the session).
|
||||
registerAttrConsumer("OnRuleActivatedHooks");
|
||||
registerAttrConsumer("RuleActivatedFiredFor");
|
||||
// T17 — on-rule-expire hook list (GAME_ENTITY) + fire-once guard
|
||||
// (PRESET_STATE_ENTITY). Seeded by the `on-rule-expire` primitive at
|
||||
// profile-apply time; consumed by `fireOnRuleExpireHooks` (triggers.ts)
|
||||
// when a descriptor detaches. V1 wiring status: no detach pipeline
|
||||
// exists yet (no `removeModifier` path), so the dispatcher is exposed
|
||||
// as a public stub for T19 (lifetime decrementer) and the future
|
||||
// remove-modifier action to invoke. The guard list dedups across
|
||||
// reattach-detach cycles and across save→load rehydrate.
|
||||
registerAttrConsumer("OnRuleExpireHooks");
|
||||
registerAttrConsumer("RuleExpireFiredFor");
|
||||
// T19 — on-marker-expire hook list, stored on GAME_ENTITY. Consumer
|
||||
// registration co-landed here because the `on-marker-expire` primitive
|
||||
// already seeds this attr (T19 file present in tree), but T19's
|
||||
// consumer-registration line was missed when its parallel branch
|
||||
// landed. Adding the consumer here unblocks the load-time integrity
|
||||
// check; T19's actual dispatcher (`fireOnMarkerExpireHooks`) lives in
|
||||
// triggers.ts. Mirrors T16's precedent of unblocking-scope cleanup
|
||||
// when a sibling task leaves a manifest gap.
|
||||
registerAttrConsumer("OnMarkerExpireHooks");
|
||||
|
||||
/**
|
||||
* Per-engine pre-move HP snapshot, used by the on-damaged trigger
|
||||
|
|
@ -1004,6 +1040,15 @@ PRESET_REGISTRY.register({
|
|||
* 5. fireOnPromotionHooks — promoted-pawn diff
|
||||
* 6. fireOnMoveHooks — Position-diff set
|
||||
* 7. fireOnMovedOntoSquareHooks — per moved piece, dest-filter
|
||||
* 7b. fireOnPieceEnteredMarkerHooks (T18) — per moved piece, marker priority
|
||||
* 7c. decrementMarkerLifetimes (T19) — sweep markers whose
|
||||
* lifetime expired; fires
|
||||
* on-marker-expire BEFORE
|
||||
* removeMarker. Runs AFTER
|
||||
* 7b so a piece entering a
|
||||
* marker scheduled to expire
|
||||
* this move still triggers
|
||||
* its entry effect first.
|
||||
* 8. fireOnCheckReceivedHooks — pre/post check-state diff
|
||||
* 9. fireOnCheckDeliveredHooks — pre/post attacker-set diff
|
||||
* 10. fireConditionalHooks — branch on current facts
|
||||
|
|
@ -1080,6 +1125,26 @@ PRESET_REGISTRY.register({
|
|||
fireOnMovedOntoSquareHooks(ctx.engine, id, dest);
|
||||
}
|
||||
|
||||
// 7b. T18 — on-piece-entered-marker. For each moved piece,
|
||||
// resolve markers at its destination via T10's
|
||||
// `engine.getMarkersAtSquare` (already priority-sorted) and
|
||||
// fire matching hooks. Runs AFTER on-moved-onto-square so a
|
||||
// square's static-filter hooks resolve before its
|
||||
// marker-overlay hooks (a portal teleport, mine damage, etc.).
|
||||
fireOnPieceEnteredMarkerHooks(ctx.engine, movedIds);
|
||||
|
||||
// 7c. T19 — sweep marker lifetimes. Walks every marker entity,
|
||||
// expires any whose lifetime is exhausted (`moves` variant
|
||||
// with `expiresAtMove <= FullmoveNumber`), fires
|
||||
// `on-marker-expire` hooks BEFORE retraction, then removes
|
||||
// the marker via `engine.removeMarker`. Runs AFTER 7b so a
|
||||
// piece landing on a marker scheduled to expire THIS move
|
||||
// still triggers the entry effect (mine damage, portal
|
||||
// teleport) before the marker dies. Permanent markers and
|
||||
// one-shot markers are skipped — one-shots are consumed by
|
||||
// entry triggers per decisions.md.
|
||||
decrementMarkerLifetimes(ctx.engine);
|
||||
|
||||
// 8 + 9. Check-line edge triggers. Both consume the SAME
|
||||
// pre-move snapshot — the dispatchers internally compute the
|
||||
// post-move attacker set and diff. We pass the snapshot as a
|
||||
|
|
|
|||
|
|
@ -14,9 +14,14 @@
|
|||
*/
|
||||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type { ChessEngine } from "../../engine.js";
|
||||
import { PRESET_STATE_ENTITY, type PieceColor } from "../../schema.js";
|
||||
import {
|
||||
PRESET_STATE_ENTITY,
|
||||
type ChessAttrMap,
|
||||
type PieceColor,
|
||||
} from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "../primitives/registry.js";
|
||||
import { resolveParams } from "../primitives/param-resolver.js";
|
||||
import { fireOnRuleActivatedHooks } from "../triggers.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
|
|
@ -69,6 +74,30 @@ export function applyCustomDescriptor(
|
|||
nodes: descriptor.primitives,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
// T16 — fire `on-rule-activated` hooks EXACTLY ONCE per descriptor
|
||||
// instance. The walker above seeded `OnRuleActivatedHooks` entries
|
||||
// for every `on-rule-activated` block in the descriptor tree; we
|
||||
// now fire them. The guard lives on `PRESET_STATE_ENTITY` under
|
||||
// `RuleActivatedFiredFor` — a list of descriptor ids that have
|
||||
// already fired in this session. This guard:
|
||||
// - Skips re-fire when the SAME descriptor is applied to a
|
||||
// SECOND piece (e.g. via per-type modifier hitting two pieces).
|
||||
// - Skips re-fire across game reload from save: the fact persists
|
||||
// in the session, so a rehydrated game inherits the guard.
|
||||
// - Allows DIFFERENT descriptor ids to fire independently.
|
||||
const descriptorIdStr = String(descriptor.id);
|
||||
const firedFor =
|
||||
(session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor") as
|
||||
| ChessAttrMap["RuleActivatedFiredFor"]
|
||||
| undefined) ?? [];
|
||||
if (!firedFor.includes(descriptorIdStr)) {
|
||||
session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [
|
||||
...firedFor,
|
||||
descriptorIdStr,
|
||||
]);
|
||||
fireOnRuleActivatedHooks(engine, descriptorIdStr);
|
||||
}
|
||||
}
|
||||
|
||||
function walkAndApply(input: {
|
||||
|
|
@ -116,6 +145,23 @@ function walkAndApply(input: {
|
|||
// Iteration / request-choice primitives use `withBinding` to
|
||||
// introduce names; the 22 pre-T11 primitives don't consult it.
|
||||
bindings: new Map(),
|
||||
// T15: profile-time apply runs synchronously without trigger
|
||||
// re-entrance — seed an empty queue + cascade depth 0. Any
|
||||
// imperative primitive that enqueues here would still be
|
||||
// drained at the end of THIS arm via the dispatcher's
|
||||
// post-loop sweep, but profile-time apply walks the descriptor
|
||||
// tree itself; downstream `enqueueTrigger` calls happen only
|
||||
// inside trigger arms (Wave 5/6).
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
// T20: profile-time apply is the WET path (game-start preset
|
||||
// boot, real applyMove handler-driven activations). It must
|
||||
// NEVER run under suppressTriggers — that flag is reserved for
|
||||
// move-gen dry-mode legality probing, which doesn't enter this
|
||||
// walker. The default is `false`; primitives must not branch
|
||||
// on it directly (single source of truth: `runPrimitives` in
|
||||
// triggers.ts).
|
||||
suppressTriggers: false,
|
||||
};
|
||||
|
||||
runPrimitive(primitive, ctx, node.params);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const MAX_PRIMITIVE_COUNT = 50;
|
|||
* passive BEFORE the unknown-kind check so descriptors authored
|
||||
* against a future runtime get a precise error code today.
|
||||
*/
|
||||
const IMPERATIVE_KINDS: ReadonlySet<string> = new Set<string>([
|
||||
export const IMPERATIVE_KINDS: ReadonlySet<string> = new Set<string>([
|
||||
"place-piece",
|
||||
"destroy-piece",
|
||||
"move-piece",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
|
|||
target: "self" as const,
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE", () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
|
|||
target: "self" as const,
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BLOCK_MOVE_TYPE_PRIMITIVE", () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,10 @@ function buildFixture(
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session, ids };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
import type { EntityId } from "@paratype/rete";
|
||||
import type {
|
||||
ChessAttrMap,
|
||||
MarkerKindValue,
|
||||
PieceColor,
|
||||
PieceType,
|
||||
Square,
|
||||
|
|
@ -100,6 +101,70 @@ export type PrimitiveEvent =
|
|||
readonly kind: "capture";
|
||||
readonly attackerId: EntityId;
|
||||
readonly defenderId: EntityId;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* T16 — fired once per descriptor instance when the descriptor
|
||||
* attaches via `applyCustomDescriptor`. Carries the descriptor
|
||||
* id so inner primitives can branch on which rule activated,
|
||||
* and (when resolvable) the chooser color — derived from
|
||||
* `LastModifierChooser` on `PRESET_STATE_ENTITY`. Chooser is
|
||||
* optional because the activation may pre-date the chooser-
|
||||
* write (e.g. profile-time apply when no piece-color is
|
||||
* available); inner primitives must treat undefined as "no
|
||||
* chooser known".
|
||||
*/
|
||||
readonly kind: "rule-activated";
|
||||
readonly descriptorId: string;
|
||||
readonly chooserColor?: PieceColor;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* T17 — fired once per descriptor instance when the descriptor
|
||||
* detaches (lifetime expires, manual remove-modifier, piece
|
||||
* holding the modifier captured). Carries the descriptor id
|
||||
* so inner primitives can branch on which rule expired. No
|
||||
* chooser is supplied because expire is a system event, not
|
||||
* a player action — the original chooser may not be available
|
||||
* by the time the descriptor detaches (e.g. lifetime tick on
|
||||
* a later turn).
|
||||
*/
|
||||
readonly kind: "rule-expire";
|
||||
readonly descriptorId: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* T18 — fired by `fireOnPieceEnteredMarkerHooks` when a piece
|
||||
* lands on a square containing one or more markers. The
|
||||
* dispatcher resolves markers in priority order via
|
||||
* `engine.getMarkersAtSquare` and supplies the SPECIFIC marker
|
||||
* that matched (markerId + markerKind), the piece that entered,
|
||||
* and the square they share.
|
||||
*/
|
||||
readonly kind: "piece-entered-marker";
|
||||
readonly markerId: EntityId;
|
||||
readonly markerKind: MarkerKindValue;
|
||||
readonly pieceId: EntityId;
|
||||
readonly square: Square;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* T19 — fired by `fireOnMarkerExpireHooks` when a marker's
|
||||
* lifetime expires (lifetime-bound markers reaching their
|
||||
* `expiresAtMove` target during the per-move
|
||||
* `decrementMarkerLifetimes` sweep). The dispatcher reads
|
||||
* MarkerKind + Position BEFORE the marker's facts are
|
||||
* retracted and supplies them so inner primitives can branch
|
||||
* on the kind that expired and the square it occupied (spawn
|
||||
* a follow-up effect there, broadcast a UI banner). Fired
|
||||
* BEFORE `engine.removeMarker(markerId)` retracts the
|
||||
* marker's facts, so primitives that read MarkerOwner /
|
||||
* MarkerLinks can still see them.
|
||||
*/
|
||||
readonly kind: "marker-expire";
|
||||
readonly markerId: EntityId;
|
||||
readonly markerKind: MarkerKindValue;
|
||||
readonly square: Square;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,4 +34,8 @@ import "./on-promotion.js";
|
|||
import "./on-check-received.js";
|
||||
import "./on-check-delivered.js";
|
||||
import "./on-moved-onto-square.js";
|
||||
import "./on-rule-activated.js";
|
||||
import "./on-rule-expire.js";
|
||||
import "./on-piece-entered-marker.js";
|
||||
import "./on-marker-expire.js";
|
||||
import "./conditional.js";
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
|
|||
target: "self" as const,
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MODIFY_MOVEMENT_RANGE_PRIMITIVE", () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
341
packages/chess/src/modifiers/primitives/on-marker-expire.test.ts
Normal file
341
packages/chess/src/modifiers/primitives/on-marker-expire.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type ChessAttrMap,
|
||||
} from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ON_MARKER_EXPIRE_PRIMITIVE } from "./on-marker-expire.js";
|
||||
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
|
||||
import "./on-marker-expire.js";
|
||||
import { fireOnMarkerExpireHooks } from "../triggers.js";
|
||||
import { decrementMarkerLifetimes } from "../../util/marker-lifetime.js";
|
||||
|
||||
function makeContext(): {
|
||||
ctx: PrimitiveApplyContext;
|
||||
session: Session;
|
||||
} {
|
||||
const session = new Session();
|
||||
// Mirror the apply-time profile context. T19 stores hooks on
|
||||
// GAME_ENTITY (id 0); ctx.pieceId is irrelevant for the seed step
|
||||
// but required by the type contract.
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-on-marker-expire",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("on-marker-expire primitive — registry (T19)", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'on-marker-expire'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("on-marker-expire")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("on-marker-expire")).toBe(
|
||||
ON_MARKER_EXPIRE_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("declares OnMarkerExpireHooks in seedsAttrs", () => {
|
||||
expect(ON_MARKER_EXPIRE_PRIMITIVE.seedsAttrs).toEqual([
|
||||
"OnMarkerExpireHooks",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-marker-expire primitive — paramsSchema (Zod) (T19)", () => {
|
||||
it("accepts a minimal valid block (markerKind + empty primitives)", () => {
|
||||
const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
|
||||
markerKind: "frozen-square",
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts every locked marker kind", () => {
|
||||
const kinds = [
|
||||
"mine",
|
||||
"pit",
|
||||
"portal-end",
|
||||
"frozen-square",
|
||||
"treasure",
|
||||
"death-square",
|
||||
"tornado",
|
||||
"blocked",
|
||||
] as const;
|
||||
for (const k of kinds) {
|
||||
const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
|
||||
markerKind: k,
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown marker kind", () => {
|
||||
const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
|
||||
markerKind: "ghost-square",
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing markerKind", () => {
|
||||
const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-marker-expire primitive — apply() seeds GAME_ENTITY hook list (T19)", () => {
|
||||
it("appends descriptorId + markerKind + primitives to OnMarkerExpireHooks", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
];
|
||||
|
||||
ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, {
|
||||
markerKind: "frozen-square",
|
||||
primitives,
|
||||
});
|
||||
|
||||
expect(session.get(GAME_ENTITY, "OnMarkerExpireHooks")).toEqual([
|
||||
{
|
||||
descriptorId: "custom:test-on-marker-expire",
|
||||
markerKind: "frozen-square",
|
||||
primitives,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends additional hook entries (different kinds preserved in insertion order)", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const minePrims: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
];
|
||||
const treasurePrims: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
];
|
||||
|
||||
ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, {
|
||||
markerKind: "mine",
|
||||
primitives: minePrims,
|
||||
});
|
||||
ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, {
|
||||
markerKind: "treasure",
|
||||
primitives: treasurePrims,
|
||||
});
|
||||
|
||||
expect(session.get(GAME_ENTITY, "OnMarkerExpireHooks")).toEqual([
|
||||
{
|
||||
descriptorId: "custom:test-on-marker-expire",
|
||||
markerKind: "mine",
|
||||
primitives: minePrims,
|
||||
},
|
||||
{
|
||||
descriptorId: "custom:test-on-marker-expire",
|
||||
markerKind: "treasure",
|
||||
primitives: treasurePrims,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-marker-expire primitive — childPrimitives() (T19)", () => {
|
||||
it("returns the inner primitive list for validator tree traversal", () => {
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
];
|
||||
|
||||
const children = ON_MARKER_EXPIRE_PRIMITIVE.childPrimitives?.({
|
||||
markerKind: "frozen-square",
|
||||
primitives,
|
||||
});
|
||||
|
||||
expect(children).toEqual(primitives);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fireOnMarkerExpireHooks dispatch (T19)", () => {
|
||||
it("fires nothing when no hooks are seeded", () => {
|
||||
const engine = new ChessEngine();
|
||||
const markerId = engine.spawnMarker("mine", 28, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
expect(() => fireOnMarkerExpireHooks(engine, markerId)).not.toThrow();
|
||||
});
|
||||
|
||||
it("matches markers by exact kind only (mine hook does not fire on pit)", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Spawn a PIT marker (NOT a mine).
|
||||
const pitId = engine.spawnMarker("pit", 28, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
|
||||
// Hook for `mine` only — should NOT fire because the marker
|
||||
// we'll dispatch on is `pit`.
|
||||
const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = {
|
||||
descriptorId: "test:mine-only",
|
||||
markerKind: "mine",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 99 } },
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]);
|
||||
|
||||
fireOnMarkerExpireHooks(engine, pitId);
|
||||
|
||||
// The seed-attribute primitive aims at ctx.pieceId (default
|
||||
// target=self), which the dispatcher set to the marker id.
|
||||
// RangeBonus on the marker entity must NOT be 99 — the mine hook
|
||||
// didn't fire on the pit marker.
|
||||
expect(engine.session.get(pitId, "RangeBonus")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fires for matching kind with event payload describing the marker", () => {
|
||||
const engine = new ChessEngine();
|
||||
const markerId = engine.spawnMarker("frozen-square", 28, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
|
||||
// Hook seeds a sentinel attr — confirms it fired at all.
|
||||
const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = {
|
||||
descriptorId: "test:frozen",
|
||||
markerKind: "frozen-square",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 7 } },
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]);
|
||||
|
||||
fireOnMarkerExpireHooks(engine, markerId);
|
||||
|
||||
// seed-attribute aimed at ctx.pieceId (default target=self), which
|
||||
// the dispatcher set to the marker entity id.
|
||||
expect(engine.session.get(markerId, "RangeBonus")).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrementMarkerLifetimes (T19)", () => {
|
||||
it("permanent marker: unchanged after sweep", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Initial FullmoveNumber is 1 from the classic layout.
|
||||
const markerId = engine.spawnMarker("mine", 28, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
|
||||
decrementMarkerLifetimes(engine);
|
||||
|
||||
// Permanent marker is still on the board.
|
||||
expect(engine.getMarkersAtSquare(28)).toContain(markerId);
|
||||
expect(engine.session.get(markerId, "MarkerKind")).toBe("mine");
|
||||
});
|
||||
|
||||
it("moves: expiresAtMove <= currentMoveCount → fires + removes", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Force FullmoveNumber to 5 so a marker with expiresAtMove=5
|
||||
// should expire (>= comparison).
|
||||
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 5);
|
||||
|
||||
const markerId = engine.spawnMarker("frozen-square", 28, {
|
||||
lifetime: { kind: "moves", expiresAtMove: 5 },
|
||||
});
|
||||
|
||||
// Seed an on-marker-expire hook that records firing via a
|
||||
// sentinel attr on GAME_ENTITY (since the marker entity gets
|
||||
// its facts retracted by removeMarker after the hook fires).
|
||||
const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = {
|
||||
descriptorId: "test:frozen-expire",
|
||||
markerKind: "frozen-square",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 42 } },
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]);
|
||||
|
||||
decrementMarkerLifetimes(engine);
|
||||
|
||||
// Marker is gone (removeMarker retracted all marker attrs).
|
||||
expect(engine.session.get(markerId, "MarkerKind")).toBeUndefined();
|
||||
expect(engine.getMarkersAtSquare(28)).not.toContain(markerId);
|
||||
// Hook DID fire — RangeBonus was seeded on the marker entity
|
||||
// BEFORE removal (the seed-attribute primitive wrote the fact,
|
||||
// then removeMarker only retracts the canonical marker attrs
|
||||
// — RangeBonus is not one of them, so it persists as a relic).
|
||||
expect(engine.session.get(markerId, "RangeBonus")).toBe(42);
|
||||
});
|
||||
|
||||
it("moves: expiresAtMove > currentMoveCount → still present", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 3);
|
||||
|
||||
const markerId = engine.spawnMarker("frozen-square", 28, {
|
||||
lifetime: { kind: "moves", expiresAtMove: 10 },
|
||||
});
|
||||
|
||||
decrementMarkerLifetimes(engine);
|
||||
|
||||
// Not yet expired — still on the board.
|
||||
expect(engine.getMarkersAtSquare(28)).toContain(markerId);
|
||||
expect(engine.session.get(markerId, "MarkerKind")).toBe("frozen-square");
|
||||
});
|
||||
|
||||
it("one-shot: no auto-decrement (sweep skips, marker remains)", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Even with FullmoveNumber jacked to 100, one-shot lifetimes
|
||||
// are NEVER auto-expired — they're consumed by entry triggers.
|
||||
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 100);
|
||||
|
||||
const markerId = engine.spawnMarker("mine", 28, {
|
||||
lifetime: { kind: "one-shot" },
|
||||
});
|
||||
|
||||
decrementMarkerLifetimes(engine);
|
||||
|
||||
// Still on the board.
|
||||
expect(engine.getMarkersAtSquare(28)).toContain(markerId);
|
||||
expect(engine.session.get(markerId, "MarkerKind")).toBe("mine");
|
||||
});
|
||||
|
||||
it("only fires for matching marker kind (frozen-square hook does not fire on pit expiry)", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 5);
|
||||
|
||||
// Spawn a pit marker that's about to expire.
|
||||
const pitId = engine.spawnMarker("pit", 28, {
|
||||
lifetime: { kind: "moves", expiresAtMove: 5 },
|
||||
});
|
||||
|
||||
// Hook for `frozen-square` only — should NOT fire on pit expiry.
|
||||
const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = {
|
||||
descriptorId: "test:frozen-only",
|
||||
markerKind: "frozen-square",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 99 } },
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]);
|
||||
|
||||
decrementMarkerLifetimes(engine);
|
||||
|
||||
// Pit marker IS removed (its lifetime expired regardless of hook
|
||||
// matching), but the frozen-square hook didn't fire.
|
||||
expect(engine.session.get(pitId, "MarkerKind")).toBeUndefined();
|
||||
expect(engine.session.get(pitId, "RangeBonus")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
124
packages/chess/src/modifiers/primitives/on-marker-expire.ts
Normal file
124
packages/chess/src/modifiers/primitives/on-marker-expire.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type ChessAttrMap,
|
||||
type MarkerKindValue,
|
||||
type OnMarkerExpireHookEntry,
|
||||
} from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* T19 — `on-marker-expire` trigger primitive.
|
||||
*
|
||||
* Seeds an entry in `OnMarkerExpireHooks` (game-level, on
|
||||
* `GAME_ENTITY`) that fires when a marker of the matching kind
|
||||
* EXPIRES via the per-move lifetime sweep
|
||||
* (`decrementMarkerLifetimes` in `util/marker-lifetime.ts`).
|
||||
* Multiple hook entries across descriptors compose naturally: the
|
||||
* dispatcher (`fireOnMarkerExpireHooks` in `triggers.ts`) finds the
|
||||
* expiring marker's kind, then runs every matching hook's inner
|
||||
* primitive list.
|
||||
*
|
||||
* Mirrors `on-piece-entered-marker` by design: same params shape
|
||||
* (`markerKind` + `primitives`), same locked-enum gate, same
|
||||
* GAME_ENTITY storage. The only divergence is the trigger phase —
|
||||
* `on-piece-entered-marker` fires when a piece LANDS on the marker
|
||||
* (stage 7b), `on-marker-expire` fires when the marker's lifetime
|
||||
* runs out (stage 7c, AFTER 7b in the same onAfterMove pass).
|
||||
*
|
||||
* `markerKind` MUST be one of the 8 frozen marker kinds — adding a
|
||||
* new kind is a plan-amending event (no wildcard, no auto-assign).
|
||||
*
|
||||
* ## Lifetime semantics
|
||||
*
|
||||
* Of the three `MarkerLifetimeValue` variants:
|
||||
* - `permanent`: never expires; this hook NEVER fires for it.
|
||||
* - `moves; expiresAtMove`: fires when
|
||||
* `engine.session.get(GAME_ENTITY, "FullmoveNumber") >= expiresAtMove`
|
||||
* during the per-move lifetime sweep.
|
||||
* - `one-shot`: NOT auto-expired here — its consumption is the job
|
||||
* of `on-piece-entered-marker` + `destroy-marker`. The
|
||||
* decrementer skips one-shot markers entirely; this hook will
|
||||
* fire if a one-shot marker is destroyed via a different
|
||||
* pathway that explicitly invokes `fireOnMarkerExpireHooks`,
|
||||
* but the per-move sweep does not.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Locked enumeration mirror of `MarkerKindValue`. `as const satisfies`
|
||||
* pins the array to the union exactly — adding/removing a kind in
|
||||
* `schema.ts` without updating this list is a compile-time error.
|
||||
*/
|
||||
const MARKER_KIND_VALUES = [
|
||||
"mine",
|
||||
"pit",
|
||||
"portal-end",
|
||||
"frozen-square",
|
||||
"treasure",
|
||||
"death-square",
|
||||
"tornado",
|
||||
"blocked",
|
||||
] as const satisfies readonly MarkerKindValue[];
|
||||
|
||||
const schema = z.object({
|
||||
markerKind: z.enum(MARKER_KIND_VALUES),
|
||||
primitives: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "on-marker-expire",
|
||||
label: "On Marker Expire",
|
||||
description:
|
||||
"Seeds OnMarkerExpireHooks entries fired when a marker of the matching kind expires (lifetime sweep).",
|
||||
longDescription:
|
||||
"Wraps nested primitives that fire when a marker of `markerKind` expires via the per-move lifetime decrementer. Inner primitives see `event = { kind: 'marker-expire', markerId, markerKind, square }` so they can spawn follow-up effects on the dying marker's square (e.g. visual fizzle, tombstone marker, recompute regions). Fired BEFORE the marker's facts are retracted so primitives that read MarkerOwner/MarkerLinks still resolve. Match is exact-kind only — a hook for `mine` does NOT fire on `pit`. Permanent markers never expire; one-shot markers are consumed by entry triggers, not by this sweep.",
|
||||
examples: [
|
||||
{
|
||||
title: "Frozen-square thaw — broadcast a UI fizzle when ice melts",
|
||||
params: {
|
||||
markerKind: "frozen-square",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
],
|
||||
},
|
||||
effect:
|
||||
"When a frozen-square marker's `expiresAtMove` is reached, runs the inner primitives once. The event payload carries the dying marker's id, kind ('frozen-square'), and the square it occupied — useful for spawning a 'thaw' visual effect or recomputing pathing regions on that tile.",
|
||||
},
|
||||
],
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["OnMarkerExpireHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(GAME_ENTITY, "OnMarkerExpireHooks") as
|
||||
| ChessAttrMap["OnMarkerExpireHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
const next: OnMarkerExpireHookEntry = {
|
||||
descriptorId: ctx.descriptor.id,
|
||||
markerKind: params.markerKind,
|
||||
primitives: [...params.primitives],
|
||||
};
|
||||
|
||||
ctx.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [
|
||||
...existing,
|
||||
next,
|
||||
]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.primitives];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ON_MARKER_EXPIRE_PRIMITIVE };
|
||||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type ChessAttrMap,
|
||||
} from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ON_PIECE_ENTERED_MARKER_PRIMITIVE } from "./on-piece-entered-marker.js";
|
||||
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
|
||||
import "./on-piece-entered-marker.js";
|
||||
import { fireOnPieceEnteredMarkerHooks } from "../triggers.js";
|
||||
|
||||
function makeContext(): {
|
||||
ctx: PrimitiveApplyContext;
|
||||
session: Session;
|
||||
} {
|
||||
const session = new Session();
|
||||
// Mirror the apply-time profile context. T18 stores hooks on
|
||||
// GAME_ENTITY (id 0); ctx.pieceId is irrelevant for the seed step
|
||||
// but required by the type contract.
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-on-piece-entered-marker",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("on-piece-entered-marker primitive — registry (T18)", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'on-piece-entered-marker'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("on-piece-entered-marker")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("on-piece-entered-marker")).toBe(
|
||||
ON_PIECE_ENTERED_MARKER_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("declares OnPieceEnteredMarkerHooks in seedsAttrs", () => {
|
||||
expect(ON_PIECE_ENTERED_MARKER_PRIMITIVE.seedsAttrs).toEqual([
|
||||
"OnPieceEnteredMarkerHooks",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-piece-entered-marker primitive — paramsSchema (Zod) (T18)", () => {
|
||||
it("accepts a minimal valid block (markerKind + empty primitives)", () => {
|
||||
const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({
|
||||
markerKind: "mine",
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts every locked marker kind", () => {
|
||||
const kinds = [
|
||||
"mine",
|
||||
"pit",
|
||||
"portal-end",
|
||||
"frozen-square",
|
||||
"treasure",
|
||||
"death-square",
|
||||
"tornado",
|
||||
"blocked",
|
||||
] as const;
|
||||
for (const k of kinds) {
|
||||
const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({
|
||||
markerKind: k,
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown marker kind", () => {
|
||||
const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({
|
||||
markerKind: "ghost-square",
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing markerKind", () => {
|
||||
const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({
|
||||
primitives: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-piece-entered-marker primitive — apply() seeds GAME_ENTITY hook list (T18)", () => {
|
||||
it("appends descriptorId + markerKind + primitives to OnPieceEnteredMarkerHooks", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
];
|
||||
|
||||
ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, {
|
||||
markerKind: "mine",
|
||||
primitives,
|
||||
});
|
||||
|
||||
expect(session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks")).toEqual([
|
||||
{
|
||||
descriptorId: "custom:test-on-piece-entered-marker",
|
||||
markerKind: "mine",
|
||||
primitives,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends additional hook entries (different kinds preserved in insertion order)", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const minePrims: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
];
|
||||
const treasurePrims: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
];
|
||||
|
||||
ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, {
|
||||
markerKind: "mine",
|
||||
primitives: minePrims,
|
||||
});
|
||||
ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, {
|
||||
markerKind: "treasure",
|
||||
primitives: treasurePrims,
|
||||
});
|
||||
|
||||
expect(session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks")).toEqual([
|
||||
{
|
||||
descriptorId: "custom:test-on-piece-entered-marker",
|
||||
markerKind: "mine",
|
||||
primitives: minePrims,
|
||||
},
|
||||
{
|
||||
descriptorId: "custom:test-on-piece-entered-marker",
|
||||
markerKind: "treasure",
|
||||
primitives: treasurePrims,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-piece-entered-marker primitive — childPrimitives() (T18)", () => {
|
||||
it("returns the inner primitive list for validator tree traversal", () => {
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 0 } },
|
||||
];
|
||||
|
||||
const children = ON_PIECE_ENTERED_MARKER_PRIMITIVE.childPrimitives?.({
|
||||
markerKind: "mine",
|
||||
primitives,
|
||||
});
|
||||
|
||||
expect(children).toEqual(primitives);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fireOnPieceEnteredMarkerHooks dispatch (T18)", () => {
|
||||
/**
|
||||
* Helper: install a hook on GAME_ENTITY that, when fired, decrements
|
||||
* the entered piece's `Hp` by 1 (using `add-to-attribute` would
|
||||
* require fact-presence; we hand-roll a synthetic primitive instead
|
||||
* for ordering observability — see priority test).
|
||||
*/
|
||||
|
||||
it("fires nothing when no hooks are seeded", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Standard starting position — pawn on e2 (id assigned by preset).
|
||||
// No hooks seeded → no errors, no mutations.
|
||||
expect(() =>
|
||||
fireOnPieceEnteredMarkerHooks(engine, []),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("matches markers by exact kind only (mine hook does not fire on pit)", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Spawn a piece (needs Position fact for the dispatcher to read).
|
||||
const pieceId = engine.session.nextId();
|
||||
engine.session.insert(pieceId, "EntityKind", "piece");
|
||||
engine.session.insert(pieceId, "Color", "white");
|
||||
engine.session.insert(pieceId, "PieceType", "pawn");
|
||||
engine.session.insert(pieceId, "Position", 28); // e4
|
||||
engine.session.insert(pieceId, "Hp", 10);
|
||||
|
||||
// Spawn a PIT marker (NOT a mine) on the same square.
|
||||
engine.spawnMarker("pit", 28, { lifetime: { kind: "permanent" } });
|
||||
|
||||
// Seed a hook for `mine` kind only — it should NOT fire because
|
||||
// the marker on the square is `pit`.
|
||||
const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
|
||||
descriptorId: "test:mine-only",
|
||||
markerKind: "mine",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -5 } },
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]);
|
||||
|
||||
fireOnPieceEnteredMarkerHooks(engine, [pieceId]);
|
||||
|
||||
// Hp unchanged — mine hook didn't fire on pit marker.
|
||||
expect(engine.session.get(pieceId, "Hp")).toBe(10);
|
||||
});
|
||||
|
||||
it("priority order: portal-end fires BEFORE mine on the same square", () => {
|
||||
const engine = new ChessEngine();
|
||||
|
||||
// Track primitive fire order via a synthetic primitive that
|
||||
// appends to a shared array. We register it once at module
|
||||
// top-level (via try/catch — Vitest re-evaluates files in watch
|
||||
// mode and the registry is immutable).
|
||||
const fireOrder: string[] = [];
|
||||
|
||||
// Use the existing `add-to-attribute` primitive against a custom
|
||||
// attr to record observable side effects per hook kind. Each hook
|
||||
// increments a distinct attribute by 1; we check the FINAL attr
|
||||
// values AND the order they were applied via session-state diffs.
|
||||
//
|
||||
// We capture order via two different marker-kind hooks each
|
||||
// bumping a unique counter — then assert both ran AND the
|
||||
// dispatcher visited markers in priority-sorted order via
|
||||
// `engine.getMarkersAtSquare` (T10 returns portal-end first).
|
||||
|
||||
// Place a piece on e4 (28).
|
||||
const pieceId = engine.session.nextId();
|
||||
engine.session.insert(pieceId, "EntityKind", "piece");
|
||||
engine.session.insert(pieceId, "Color", "white");
|
||||
engine.session.insert(pieceId, "PieceType", "pawn");
|
||||
engine.session.insert(pieceId, "Position", 28);
|
||||
|
||||
// Spawn TWO markers on the same square: a mine (priority 3) and
|
||||
// a portal-end (priority 1). Spawn order: mine first, portal
|
||||
// second — to prove dispatch-order is by PRIORITY, not spawn.
|
||||
const mineId = engine.spawnMarker("mine", 28, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
const portalId = engine.spawnMarker("portal-end", 28, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
|
||||
// Verify T10's helper returns priority-sorted output (sanity).
|
||||
const markers = engine.getMarkersAtSquare(28);
|
||||
expect(markers).toEqual([portalId, mineId]);
|
||||
|
||||
// Seed hooks: each writes a fact recording the firing order.
|
||||
// We use `seed-attribute` against a distinct attr per hook so
|
||||
// post-dispatch we can read both. To capture ORDER we observe
|
||||
// T10's helper directly (asserted above) — combined with the
|
||||
// dispatcher's documented contract (iterate result of
|
||||
// getMarkersAtSquare in order), this proves priority is honoured.
|
||||
const portalHook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
|
||||
descriptorId: "test:portal",
|
||||
markerKind: "portal-end",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
|
||||
],
|
||||
};
|
||||
const mineHook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
|
||||
descriptorId: "test:mine",
|
||||
markerKind: "mine",
|
||||
primitives: [
|
||||
// After portal seed runs (HpBonus=1), this multiply-attribute
|
||||
// doubles HpBonus to 2. If mine fired BEFORE portal we would
|
||||
// observe HpBonus=1 (multiply on absent → no-op or different),
|
||||
// not 2.
|
||||
{ kind: "multiply-attribute", params: { attr: "HpBonus", factor: 2 } },
|
||||
],
|
||||
};
|
||||
|
||||
// Order in the hooks array does NOT determine dispatch order —
|
||||
// the dispatcher iterates markers by priority and matches each to
|
||||
// applicable hooks. Insert mine hook FIRST in the array to prove
|
||||
// hook-array order is irrelevant.
|
||||
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [
|
||||
mineHook,
|
||||
portalHook,
|
||||
]);
|
||||
|
||||
// Track via shared array for ordered observability:
|
||||
fireOrder.push("__before__");
|
||||
fireOnPieceEnteredMarkerHooks(engine, [pieceId]);
|
||||
fireOrder.push("__after__");
|
||||
|
||||
// Portal-end's primitive ran first (seed HpBonus=1), then mine's
|
||||
// (multiply by 2 → 2). If priority were inverted we'd see
|
||||
// HpBonus = 1 (mine no-op on absent attr, then portal seed = 1).
|
||||
expect(engine.session.get(pieceId, "HpBonus")).toBe(2);
|
||||
});
|
||||
|
||||
it("fires hook with event payload describing the marker, piece, and square", () => {
|
||||
const engine = new ChessEngine();
|
||||
const pieceId = engine.session.nextId();
|
||||
engine.session.insert(pieceId, "EntityKind", "piece");
|
||||
engine.session.insert(pieceId, "Color", "white");
|
||||
engine.session.insert(pieceId, "PieceType", "pawn");
|
||||
engine.session.insert(pieceId, "Position", 28);
|
||||
|
||||
const treasureId = engine.spawnMarker("treasure", 28, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
|
||||
// Hook that seeds a sentinel attr — confirms it fired at all.
|
||||
const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
|
||||
descriptorId: "test:treasure",
|
||||
markerKind: "treasure",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } },
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]);
|
||||
|
||||
fireOnPieceEnteredMarkerHooks(engine, [pieceId]);
|
||||
|
||||
// The seed-attribute primitive aims at ctx.pieceId (default
|
||||
// target=self), which the dispatcher set to the entering piece.
|
||||
expect(engine.session.get(pieceId, "HpBonus")).toBe(7);
|
||||
// Marker still on square — dispatcher does NOT auto-remove.
|
||||
expect(engine.getMarkersAtSquare(28)).toContain(treasureId);
|
||||
});
|
||||
|
||||
it("fires for multiple moved pieces independently", () => {
|
||||
const engine = new ChessEngine();
|
||||
const a = engine.session.nextId();
|
||||
engine.session.insert(a, "EntityKind", "piece");
|
||||
engine.session.insert(a, "Color", "white");
|
||||
engine.session.insert(a, "PieceType", "pawn");
|
||||
engine.session.insert(a, "Position", 28);
|
||||
|
||||
const b = engine.session.nextId();
|
||||
engine.session.insert(b, "EntityKind", "piece");
|
||||
engine.session.insert(b, "Color", "black");
|
||||
engine.session.insert(b, "PieceType", "pawn");
|
||||
engine.session.insert(b, "Position", 35);
|
||||
|
||||
engine.spawnMarker("treasure", 28, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
engine.spawnMarker("treasure", 35, {
|
||||
lifetime: { kind: "permanent" },
|
||||
});
|
||||
|
||||
const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
|
||||
descriptorId: "test:treasure-multi",
|
||||
markerKind: "treasure",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 9 } },
|
||||
],
|
||||
};
|
||||
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]);
|
||||
|
||||
fireOnPieceEnteredMarkerHooks(engine, [a, b]);
|
||||
|
||||
expect(engine.session.get(a, "HpBonus")).toBe(9);
|
||||
expect(engine.session.get(b, "HpBonus")).toBe(9);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type ChessAttrMap,
|
||||
type MarkerKindValue,
|
||||
type OnPieceEnteredMarkerHookEntry,
|
||||
} from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* T18 — `on-piece-entered-marker` trigger primitive.
|
||||
*
|
||||
* Seeds an entry in `OnPieceEnteredMarkerHooks` (game-level, on
|
||||
* `GAME_ENTITY`) that fires when ANY piece lands on a square
|
||||
* containing a marker of the matching kind. Multiple hook entries
|
||||
* across descriptors compose naturally: the dispatcher
|
||||
* (`fireOnPieceEnteredMarkerHooks` in `triggers.ts`) iterates
|
||||
* markers at the destination via `engine.getMarkersAtSquare` —
|
||||
* which returns markers SORTED by hardcoded
|
||||
* `MARKER_KIND_PRIORITY` ascending (portal-end first), tie-break
|
||||
* by entity id ascending. So a piece entering a square with both a
|
||||
* portal-end and a mine sees portal-end fire BEFORE mine, as
|
||||
* locked by `decisions.md` § Marker Collision Priority.
|
||||
*
|
||||
* `markerKind` MUST be one of the 8 frozen marker kinds — adding a
|
||||
* new kind is a plan-amending event (no wildcard, no auto-assign).
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Locked enumeration mirror of `MarkerKindValue`. `as const satisfies`
|
||||
* pins the array to the union exactly — adding/removing a kind in
|
||||
* `schema.ts` without updating this list is a compile-time error.
|
||||
*/
|
||||
const MARKER_KIND_VALUES = [
|
||||
"mine",
|
||||
"pit",
|
||||
"portal-end",
|
||||
"frozen-square",
|
||||
"treasure",
|
||||
"death-square",
|
||||
"tornado",
|
||||
"blocked",
|
||||
] as const satisfies readonly MarkerKindValue[];
|
||||
|
||||
const schema = z.object({
|
||||
markerKind: z.enum(MARKER_KIND_VALUES),
|
||||
primitives: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "on-piece-entered-marker",
|
||||
label: "On Piece Entered Marker",
|
||||
description:
|
||||
"Seeds OnPieceEnteredMarkerHooks entries consumed when any piece lands on a marker of the matching kind.",
|
||||
longDescription:
|
||||
"Wraps nested primitives that fire when ANY piece (mover, castling rook, etc.) finishes a move on a square that contains a marker of `markerKind`. Markers stack: a square may carry several markers; the dispatcher fires hooks in priority order (portal-end → frozen-square → mine → pit → death-square → tornado → treasure → blocked) so teleport effects resolve before damage and damage resolves before passive markers like 'blocked'. Match is exact-kind only — a hook for `mine` does NOT fire on `pit`.",
|
||||
examples: [
|
||||
{
|
||||
title: "Minefield — piece entering a mine takes 1 damage",
|
||||
params: {
|
||||
markerKind: "mine",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
},
|
||||
effect:
|
||||
"Whenever any piece lands on a mine marker, that piece's Hp drops by 1. Stacks if multiple mines occupy the square (each mine fires its own hook chain).",
|
||||
},
|
||||
],
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["OnPieceEnteredMarkerHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks") as
|
||||
| ChessAttrMap["OnPieceEnteredMarkerHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
const next: OnPieceEnteredMarkerHookEntry = {
|
||||
descriptorId: ctx.descriptor.id,
|
||||
markerKind: params.markerKind,
|
||||
primitives: [...params.primitives],
|
||||
};
|
||||
|
||||
ctx.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [
|
||||
...existing,
|
||||
next,
|
||||
]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.primitives];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ON_PIECE_ENTERED_MARKER_PRIMITIVE };
|
||||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* `on-rule-activated` primitive tests (T16).
|
||||
*
|
||||
* Two test layers:
|
||||
* 1. Primitive-level: registry presence, paramsSchema, apply()
|
||||
* seeding, multi-descriptor stacking on `OnRuleActivatedHooks`.
|
||||
* 2. Integration-level: `applyCustomDescriptor` fire-once semantics
|
||||
* via the `RuleActivatedFiredFor` guard on `PRESET_STATE_ENTITY`.
|
||||
*/
|
||||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
PRESET_STATE_ENTITY,
|
||||
type ChessAttrMap,
|
||||
} from "../../schema.js";
|
||||
import { applyCustomDescriptor } from "../custom/apply.js";
|
||||
import {
|
||||
asCustomModifierId,
|
||||
type CustomModifierDescriptor,
|
||||
} from "../custom/types.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ON_RULE_ACTIVATED_PRIMITIVE } from "./on-rule-activated.js";
|
||||
import type {
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
} from "./types.js";
|
||||
import "./on-rule-activated.js";
|
||||
|
||||
function makeContext(descriptorId = "custom:test-on-rule-activated"): {
|
||||
ctx: PrimitiveApplyContext;
|
||||
session: Session;
|
||||
} {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: { id: descriptorId, type: "data", version: 1 },
|
||||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
function makeDescriptor(
|
||||
id: string,
|
||||
primitives: readonly EffectPrimitiveNode[],
|
||||
): CustomModifierDescriptor {
|
||||
return {
|
||||
type: "data",
|
||||
id: asCustomModifierId(id),
|
||||
name: id,
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives,
|
||||
targetAttrs: [],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
};
|
||||
}
|
||||
|
||||
describe("on-rule-activated primitive — registry (T16)", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under 'on-rule-activated'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("on-rule-activated")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("on-rule-activated")).toBe(
|
||||
ON_RULE_ACTIVATED_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-rule-activated primitive — paramsSchema (T16)", () => {
|
||||
it("validates `{ primitives: [...] }`", () => {
|
||||
expect(() =>
|
||||
ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({ primitives: [] }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty / wrong-shape params", () => {
|
||||
expect(() =>
|
||||
ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({}),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({ primitives: 7 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-rule-activated primitive — apply() (T16)", () => {
|
||||
it("seeds OnRuleActivatedHooks on GAME_ENTITY with descriptor id + primitives", () => {
|
||||
const { ctx, session } = makeContext("custom:announce");
|
||||
const inner: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
|
||||
];
|
||||
|
||||
ON_RULE_ACTIVATED_PRIMITIVE.apply(ctx, { primitives: inner });
|
||||
|
||||
const stored = session.get(
|
||||
GAME_ENTITY,
|
||||
"OnRuleActivatedHooks",
|
||||
) as ChessAttrMap["OnRuleActivatedHooks"] | undefined;
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored![0]!.descriptorId).toBe("custom:announce");
|
||||
expect(stored![0]!.primitives).toEqual(inner);
|
||||
});
|
||||
|
||||
it("stacks multiple descriptors as separate entries on the list", () => {
|
||||
const { ctx: ctxA, session } = makeContext("custom:rule-a");
|
||||
// Reuse the same session by constructing a second ctx that shares it.
|
||||
const ctxB: PrimitiveApplyContext = {
|
||||
...ctxA,
|
||||
descriptor: { id: "custom:rule-b", type: "data", version: 1 },
|
||||
};
|
||||
|
||||
const innerA: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
|
||||
];
|
||||
const innerB: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 2 } },
|
||||
];
|
||||
|
||||
ON_RULE_ACTIVATED_PRIMITIVE.apply(ctxA, { primitives: innerA });
|
||||
ON_RULE_ACTIVATED_PRIMITIVE.apply(ctxB, { primitives: innerB });
|
||||
|
||||
const stored = session.get(
|
||||
GAME_ENTITY,
|
||||
"OnRuleActivatedHooks",
|
||||
) as ChessAttrMap["OnRuleActivatedHooks"] | undefined;
|
||||
expect(stored).toHaveLength(2);
|
||||
expect(stored!.map((h) => h.descriptorId)).toEqual([
|
||||
"custom:rule-a",
|
||||
"custom:rule-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("childPrimitives() returns the inner list for validator traversal", () => {
|
||||
const inner: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
|
||||
];
|
||||
const children = ON_RULE_ACTIVATED_PRIMITIVE.childPrimitives?.({
|
||||
primitives: inner,
|
||||
});
|
||||
expect(children).toEqual(inner);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-rule-activated primitive — fire-once integration (T16)", () => {
|
||||
it("fires inner primitives ONCE on first applyCustomDescriptor", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Pick any existing piece for the apply target — descriptor id
|
||||
// governs fire-once, not piece id.
|
||||
const session = engine.session;
|
||||
let pieceId = 0 as ReturnType<typeof session.nextId>;
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr === "Color" && (f.id as number) > 0) {
|
||||
pieceId = f.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(pieceId).not.toBe(0); // sanity — engine seeded pieces
|
||||
|
||||
const descriptor = makeDescriptor("custom:rule-fires", [
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 7 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
applyCustomDescriptor(engine, session, pieceId, descriptor);
|
||||
|
||||
// The on-rule-activated primitive's inner block runs against
|
||||
// GAME_ENTITY (the "rule-activated" target). HpBonus seeded there.
|
||||
expect(session.get(GAME_ENTITY, "HpBonus")).toBe(7);
|
||||
// Fire-once guard recorded on PRESET_STATE_ENTITY.
|
||||
const fired = session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"RuleActivatedFiredFor",
|
||||
) as readonly string[] | undefined;
|
||||
expect(fired).toEqual(["custom:rule-fires"]);
|
||||
});
|
||||
|
||||
it("does NOT re-fire when the same descriptor id applies a second time", () => {
|
||||
const engine = new ChessEngine();
|
||||
const session = engine.session;
|
||||
let pieceId = 0 as ReturnType<typeof session.nextId>;
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr === "Color" && (f.id as number) > 0) {
|
||||
pieceId = f.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Inner primitive INCREMENTS HpBonus on GAME_ENTITY each time it
|
||||
// fires. If the guard works, it fires exactly once → final == 1.
|
||||
// If it re-fires on the second apply, final would be 2.
|
||||
const descriptor = makeDescriptor("custom:rule-once", [
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "add-to-attribute",
|
||||
params: { attr: "HpBonus", delta: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
applyCustomDescriptor(engine, session, pieceId, descriptor);
|
||||
applyCustomDescriptor(engine, session, pieceId, descriptor);
|
||||
|
||||
expect(session.get(GAME_ENTITY, "HpBonus")).toBe(1);
|
||||
const fired = session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"RuleActivatedFiredFor",
|
||||
) as readonly string[] | undefined;
|
||||
expect(fired).toEqual(["custom:rule-once"]);
|
||||
});
|
||||
|
||||
it("DIFFERENT descriptor ids each fire independently (one-shot per id, not global)", () => {
|
||||
const engine = new ChessEngine();
|
||||
const session = engine.session;
|
||||
let pieceId = 0 as ReturnType<typeof session.nextId>;
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr === "Color" && (f.id as number) > 0) {
|
||||
pieceId = f.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const descA = makeDescriptor("custom:rule-A", [
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "add-to-attribute",
|
||||
params: { attr: "HpBonus", delta: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const descB = makeDescriptor("custom:rule-B", [
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "add-to-attribute",
|
||||
params: { attr: "HpBonus", delta: 4 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
applyCustomDescriptor(engine, session, pieceId, descA);
|
||||
applyCustomDescriptor(engine, session, pieceId, descB);
|
||||
|
||||
// Both fired exactly once: 1 + 4 = 5.
|
||||
expect(session.get(GAME_ENTITY, "HpBonus")).toBe(5);
|
||||
const fired = session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"RuleActivatedFiredFor",
|
||||
) as readonly string[] | undefined;
|
||||
expect(fired).toEqual(["custom:rule-A", "custom:rule-B"]);
|
||||
});
|
||||
|
||||
it("simulates save→load: pre-seeded RuleActivatedFiredFor blocks re-fire", () => {
|
||||
const engine = new ChessEngine();
|
||||
const session = engine.session;
|
||||
let pieceId = 0 as ReturnType<typeof session.nextId>;
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr === "Color" && (f.id as number) > 0) {
|
||||
pieceId = f.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate a rehydrated game where the descriptor previously
|
||||
// fired (the persisted fact is in the session).
|
||||
session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [
|
||||
"custom:rule-rehydrated",
|
||||
]);
|
||||
|
||||
const descriptor = makeDescriptor("custom:rule-rehydrated", [
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 99 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
applyCustomDescriptor(engine, session, pieceId, descriptor);
|
||||
|
||||
// Did NOT fire — HpBonus on GAME_ENTITY remains undefined.
|
||||
expect(session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
|
||||
// Guard list unchanged.
|
||||
expect(
|
||||
session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor"),
|
||||
).toEqual(["custom:rule-rehydrated"]);
|
||||
});
|
||||
|
||||
it("chooser color resolves into ctx.event for inner primitives", () => {
|
||||
const engine = new ChessEngine();
|
||||
const session = engine.session;
|
||||
// Pick a WHITE piece so applyCustomDescriptor's chooser-track
|
||||
// stub (T14) writes 'white' to LastModifierChooser.
|
||||
let whitePieceId = 0 as ReturnType<typeof session.nextId>;
|
||||
for (const f of session.allFacts()) {
|
||||
if (
|
||||
f.attr === "Color" &&
|
||||
f.value === "white" &&
|
||||
(f.id as number) > 0
|
||||
) {
|
||||
whitePieceId = f.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(whitePieceId).not.toBe(0);
|
||||
|
||||
const descriptor = makeDescriptor("custom:rule-chooser", [
|
||||
{
|
||||
kind: "on-rule-activated",
|
||||
params: {
|
||||
// Inner block doesn't directly read event; we assert the
|
||||
// chooser-fact wiring reaches the stub by verifying that
|
||||
// T14's LastModifierChooser was set to 'white' BEFORE the
|
||||
// hook fires (the dispatcher reads it to construct event).
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
applyCustomDescriptor(engine, session, whitePieceId, descriptor);
|
||||
|
||||
// Chooser stamped by T14 logic (applyCustomDescriptor wrote it).
|
||||
expect(
|
||||
session.get(PRESET_STATE_ENTITY, "LastModifierChooser"),
|
||||
).toBe("white");
|
||||
// Hook fired.
|
||||
expect(session.get(GAME_ENTITY, "HpBonus")).toBe(3);
|
||||
});
|
||||
});
|
||||
88
packages/chess/src/modifiers/primitives/on-rule-activated.ts
Normal file
88
packages/chess/src/modifiers/primitives/on-rule-activated.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* `on-rule-activated` trigger primitive (T16).
|
||||
*
|
||||
* Seeds an entry into `OnRuleActivatedHooks` on `GAME_ENTITY` at
|
||||
* profile-apply time. The integration preset's `applyCustomDescriptor`
|
||||
* fires the matching hooks EXACTLY ONCE per descriptor instance, the
|
||||
* first time the descriptor attaches. The fire-once guard lives on
|
||||
* `PRESET_STATE_ENTITY` under `RuleActivatedFiredFor` (a list of
|
||||
* descriptor ids that have already fired); reloaded games inherit
|
||||
* the guard via persisted facts so a save→load round-trip does NOT
|
||||
* re-fire.
|
||||
*
|
||||
* Storage rationale: hooks live on `GAME_ENTITY` (not the piece) so
|
||||
* a single descriptor activated on multiple pieces still only fires
|
||||
* its `on-rule-activated` block once — the descriptor-id guard
|
||||
* dedups across all attachments.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Inline NodeSchema (mirrors `on-capture.ts`). The tree validator
|
||||
* (T19) handles deep kind-validation; here we only assert the
|
||||
* structural shape `{ kind, params }` so the params schema is a
|
||||
* one-line Zod definition.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
primitives: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "on-rule-activated",
|
||||
label: "On Rule Activated",
|
||||
description:
|
||||
"Seeds OnRuleActivatedHooks entries fired exactly once when the descriptor attaches.",
|
||||
longDescription:
|
||||
"Wraps nested primitives that fire ONCE when this descriptor first activates on the game. Typical uses: announce the rule (broadcast a banner attr), seed initial board state (place markers, set per-game counters), or grant a one-time bonus to the chooser. Does NOT re-fire on game reload — a per-game guard on PRESET_STATE_ENTITY tracks which descriptor ids have already fired.",
|
||||
examples: [
|
||||
{
|
||||
title: "Announcement banner on activation",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "RangeBonus", value: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
effect:
|
||||
"When the rule first attaches, runs the inner seed-attribute once. Subsequent moves do NOT re-trigger; expiration / re-application within the same game also does not re-fire.",
|
||||
},
|
||||
],
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["OnRuleActivatedHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(GAME_ENTITY, "OnRuleActivatedHooks") as
|
||||
| ChessAttrMap["OnRuleActivatedHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
ctx.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [
|
||||
...existing,
|
||||
{
|
||||
descriptorId: ctx.descriptor.id,
|
||||
primitives: [...params.primitives],
|
||||
},
|
||||
]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.primitives];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ON_RULE_ACTIVATED_PRIMITIVE };
|
||||
279
packages/chess/src/modifiers/primitives/on-rule-expire.test.ts
Normal file
279
packages/chess/src/modifiers/primitives/on-rule-expire.test.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* `on-rule-expire` primitive tests (T17).
|
||||
*
|
||||
* Mirror image of T16's on-rule-activated tests:
|
||||
* 1. Primitive-level: registry presence, paramsSchema, apply()
|
||||
* seeding, multi-descriptor stacking on `OnRuleExpireHooks`,
|
||||
* childPrimitives() introspection.
|
||||
* 2. Dispatcher-level: `fireOnRuleExpireHooks` fires matching
|
||||
* descriptor's hooks once, fire-once guard via
|
||||
* `RuleExpireFiredFor` on `PRESET_STATE_ENTITY`, idempotent
|
||||
* double-detach.
|
||||
*/
|
||||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
PRESET_STATE_ENTITY,
|
||||
type ChessAttrMap,
|
||||
} from "../../schema.js";
|
||||
import { fireOnRuleExpireHooks } from "../triggers.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ON_RULE_EXPIRE_PRIMITIVE } from "./on-rule-expire.js";
|
||||
import type {
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
} from "./types.js";
|
||||
import "./on-rule-expire.js";
|
||||
|
||||
function makeContext(descriptorId = "custom:test-on-rule-expire"): {
|
||||
ctx: PrimitiveApplyContext;
|
||||
session: Session;
|
||||
} {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: { id: descriptorId, type: "data", version: 1 },
|
||||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("on-rule-expire primitive — registry (T17)", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under 'on-rule-expire'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("on-rule-expire")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("on-rule-expire")).toBe(
|
||||
ON_RULE_EXPIRE_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-rule-expire primitive — paramsSchema (T17)", () => {
|
||||
it("validates `{ primitives: [...] }`", () => {
|
||||
expect(() =>
|
||||
ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({ primitives: [] }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty / wrong-shape params", () => {
|
||||
expect(() => ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({})).toThrow();
|
||||
expect(() =>
|
||||
ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({ primitives: 7 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-rule-expire primitive — apply() (T17)", () => {
|
||||
it("seeds OnRuleExpireHooks on GAME_ENTITY with descriptor id + primitives", () => {
|
||||
const { ctx, session } = makeContext("custom:cleanup");
|
||||
const inner: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
];
|
||||
|
||||
ON_RULE_EXPIRE_PRIMITIVE.apply(ctx, { primitives: inner });
|
||||
|
||||
const stored = session.get(
|
||||
GAME_ENTITY,
|
||||
"OnRuleExpireHooks",
|
||||
) as ChessAttrMap["OnRuleExpireHooks"] | undefined;
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored![0]!.descriptorId).toBe("custom:cleanup");
|
||||
expect(stored![0]!.primitives).toEqual(inner);
|
||||
});
|
||||
|
||||
it("stacks multiple descriptors as separate entries on the list", () => {
|
||||
const { ctx: ctxA, session } = makeContext("custom:rule-a");
|
||||
// Reuse the same session by constructing a second ctx that shares it.
|
||||
const ctxB: PrimitiveApplyContext = {
|
||||
...ctxA,
|
||||
descriptor: { id: "custom:rule-b", type: "data", version: 1 },
|
||||
};
|
||||
|
||||
const innerA: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
];
|
||||
const innerB: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
|
||||
];
|
||||
|
||||
ON_RULE_EXPIRE_PRIMITIVE.apply(ctxA, { primitives: innerA });
|
||||
ON_RULE_EXPIRE_PRIMITIVE.apply(ctxB, { primitives: innerB });
|
||||
|
||||
const stored = session.get(
|
||||
GAME_ENTITY,
|
||||
"OnRuleExpireHooks",
|
||||
) as ChessAttrMap["OnRuleExpireHooks"] | undefined;
|
||||
expect(stored).toHaveLength(2);
|
||||
expect(stored!.map((h) => h.descriptorId)).toEqual([
|
||||
"custom:rule-a",
|
||||
"custom:rule-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("childPrimitives() returns the inner list for validator traversal", () => {
|
||||
const inner: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
|
||||
];
|
||||
const children = ON_RULE_EXPIRE_PRIMITIVE.childPrimitives?.({
|
||||
primitives: inner,
|
||||
});
|
||||
expect(children).toEqual(inner);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-rule-expire dispatcher — fireOnRuleExpireHooks (T17)", () => {
|
||||
it("fires the matching descriptor's primitives, skips other descriptors", () => {
|
||||
const engine = new ChessEngine();
|
||||
|
||||
// Seed two hooks for two different descriptors. Only descriptor
|
||||
// 'rule-A' should fire when we dispatch for 'rule-A'.
|
||||
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
|
||||
{
|
||||
descriptorId: "rule-A",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
descriptorId: "rule-B",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
fireOnRuleExpireHooks(engine, "rule-A");
|
||||
|
||||
// Inner primitives target GAME_ENTITY (the dispatcher passes it
|
||||
// as `pieceId` into runPrimitives).
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(7);
|
||||
// rule-B's primitive did NOT fire — RangeBonus on GAME_ENTITY
|
||||
// remains undefined.
|
||||
expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined();
|
||||
|
||||
// Guard list now contains the fired descriptor id.
|
||||
const fired = engine.session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"RuleExpireFiredFor",
|
||||
) as readonly string[] | undefined;
|
||||
expect(fired).toEqual(["rule-A"]);
|
||||
});
|
||||
|
||||
it("fire-once guard: second call for same descriptor is a no-op", () => {
|
||||
const engine = new ChessEngine();
|
||||
|
||||
// Use add-to-attribute so we can detect double-firing: each fire
|
||||
// would increment HpBonus by 1; with the guard, exactly one fire
|
||||
// → final value 1.
|
||||
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
|
||||
{
|
||||
descriptorId: "rule-once",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
fireOnRuleExpireHooks(engine, "rule-once");
|
||||
fireOnRuleExpireHooks(engine, "rule-once"); // idempotent on double-detach
|
||||
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(1);
|
||||
const fired = engine.session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"RuleExpireFiredFor",
|
||||
) as readonly string[] | undefined;
|
||||
expect(fired).toEqual(["rule-once"]);
|
||||
});
|
||||
|
||||
it("DIFFERENT descriptor ids each fire independently (one-shot per id, not global)", () => {
|
||||
const engine = new ChessEngine();
|
||||
|
||||
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
|
||||
{
|
||||
descriptorId: "rule-A",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
descriptorId: "rule-B",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 4 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
fireOnRuleExpireHooks(engine, "rule-A");
|
||||
fireOnRuleExpireHooks(engine, "rule-B");
|
||||
|
||||
// Both fired exactly once: 1 + 4 = 5.
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(5);
|
||||
const fired = engine.session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"RuleExpireFiredFor",
|
||||
) as readonly string[] | undefined;
|
||||
expect(fired).toEqual(["rule-A", "rule-B"]);
|
||||
});
|
||||
|
||||
it("simulates save→load: pre-seeded RuleExpireFiredFor blocks re-fire", () => {
|
||||
const engine = new ChessEngine();
|
||||
|
||||
// Simulate a rehydrated game where the descriptor previously
|
||||
// expired (the persisted fact is in the session).
|
||||
engine.session.insert(PRESET_STATE_ENTITY, "RuleExpireFiredFor", [
|
||||
"rule-rehydrated",
|
||||
]);
|
||||
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
|
||||
{
|
||||
descriptorId: "rule-rehydrated",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 99 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
fireOnRuleExpireHooks(engine, "rule-rehydrated");
|
||||
|
||||
// Did NOT fire — HpBonus on GAME_ENTITY remains undefined.
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
|
||||
// Guard list unchanged (no re-append since already present).
|
||||
expect(
|
||||
engine.session.get(PRESET_STATE_ENTITY, "RuleExpireFiredFor"),
|
||||
).toEqual(["rule-rehydrated"]);
|
||||
});
|
||||
|
||||
it("no hooks seeded: dispatcher is a no-op but still records the guard", () => {
|
||||
const engine = new ChessEngine();
|
||||
|
||||
// No OnRuleExpireHooks seeded. fireOnRuleExpireHooks should not
|
||||
// throw, and the guard records the descriptor id (so a later
|
||||
// re-detach attempt is also a no-op).
|
||||
expect(() =>
|
||||
fireOnRuleExpireHooks(engine, "rule-no-hooks"),
|
||||
).not.toThrow();
|
||||
|
||||
const fired = engine.session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"RuleExpireFiredFor",
|
||||
) as readonly string[] | undefined;
|
||||
expect(fired).toEqual(["rule-no-hooks"]);
|
||||
});
|
||||
});
|
||||
97
packages/chess/src/modifiers/primitives/on-rule-expire.ts
Normal file
97
packages/chess/src/modifiers/primitives/on-rule-expire.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* `on-rule-expire` trigger primitive (T17).
|
||||
*
|
||||
* Mirror image of T16's `on-rule-activated` for the descriptor-detach
|
||||
* side. Seeds an entry into `OnRuleExpireHooks` on `GAME_ENTITY` at
|
||||
* profile-apply time; the dispatcher (`fireOnRuleExpireHooks` in
|
||||
* `triggers.ts`) fires the matching hooks EXACTLY ONCE when the
|
||||
* descriptor detaches. The fire-once guard lives on
|
||||
* `PRESET_STATE_ENTITY` under `RuleExpireFiredFor`.
|
||||
*
|
||||
* V1 wiring status: descriptor lifetimes are not yet wired into any
|
||||
* detach pipeline (no `removeModifier` / `detachDescriptor` path
|
||||
* exists today). The seeding side IS active so descriptors that
|
||||
* include `on-rule-expire` blocks register them at apply time; the
|
||||
* fire side is exposed as the public stub `fireOnRuleExpireHooks`
|
||||
* for the future T19 lifetime decrementer and the eventual
|
||||
* remove-modifier action handler to invoke. Until a caller exists,
|
||||
* the hooks remain dormant — by design, expire only fires on actual
|
||||
* detach.
|
||||
*
|
||||
* Storage rationale: hooks live on `GAME_ENTITY` (not the piece) so
|
||||
* a single descriptor attached to multiple pieces still only fires
|
||||
* its `on-rule-expire` block once — the descriptor-id guard dedups
|
||||
* across all attachments on the expire side, mirroring T16's
|
||||
* activation behaviour.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Inline NodeSchema (mirrors `on-rule-activated.ts`). The tree
|
||||
* validator (T19) handles deep kind-validation; here we only assert
|
||||
* the structural shape `{ kind, params }` so the params schema is a
|
||||
* one-line Zod definition.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
primitives: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "on-rule-expire",
|
||||
label: "On Rule Expire",
|
||||
description:
|
||||
"Seeds OnRuleExpireHooks entries fired exactly once when the descriptor detaches.",
|
||||
longDescription:
|
||||
"Wraps nested primitives that fire ONCE when this descriptor detaches from the game (lifetime expires, manual remove-modifier action, or — for piece-bound modifiers — when the holding piece is captured). Typical uses: tear down banners or counters previously seeded by `on-rule-activated`, retract per-game state, or grant a parting bonus/penalty. A per-game guard on PRESET_STATE_ENTITY (`RuleExpireFiredFor`) tracks which descriptor ids have already fired their expire block, so a re-attach + re-detach cycle in the same session does NOT re-fire — consistent with the once-per-descriptor-id activation contract.",
|
||||
examples: [
|
||||
{
|
||||
title: "Cleanup banner on expire",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "RangeBonus", value: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
effect:
|
||||
"When the rule detaches, runs the inner seed-attribute once to reset RangeBonus. Subsequent re-attach-detach cycles do NOT re-trigger; only the FIRST detach fires.",
|
||||
},
|
||||
],
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["OnRuleExpireHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(GAME_ENTITY, "OnRuleExpireHooks") as
|
||||
| ChessAttrMap["OnRuleExpireHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
ctx.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
|
||||
...existing,
|
||||
{
|
||||
descriptorId: ctx.descriptor.id,
|
||||
primitives: [...params.primitives],
|
||||
},
|
||||
]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.primitives];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ON_RULE_EXPIRE_PRIMITIVE };
|
||||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
|
|||
target: "self" as const,
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("OVERRIDE_PROMOTION_PRIMITIVE", () => {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ function makeCtx(opts: {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: opts.bindings ?? new Map(),
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
|
|||
target: "self" as const,
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("REFLECT_DAMAGE_PRIMITIVE", () => {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ import { describe, it, expect } from "vitest";
|
|||
import { PRIMITIVE_REGISTRY } from "./index.js";
|
||||
|
||||
describe("PRIMITIVE_REGISTRY", () => {
|
||||
it("should have exactly 22 registered primitives after barrel import", () => {
|
||||
it("should have exactly 26 registered primitives after barrel import", () => {
|
||||
// T16 added "on-rule-activated"; T18 added "on-piece-entered-marker"
|
||||
// (22 → 24). T17 added "on-rule-expire" (24 → 25). T19 added
|
||||
// "on-marker-expire" (25 → 26). Each new primitive is a
|
||||
// plan-amending event — bump this number with intent.
|
||||
const count = PRIMITIVE_REGISTRY.list().length;
|
||||
expect(count).toBe(22);
|
||||
expect(count).toBe(26);
|
||||
});
|
||||
|
||||
it("should list all primitive kinds with non-empty descriptor objects", () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
|||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
};
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,48 @@ import type {
|
|||
TargetResolver,
|
||||
} from "./context.js";
|
||||
|
||||
/**
|
||||
* Trigger kinds (T15).
|
||||
*
|
||||
* Names of the existing `fire*Hooks` family in `triggers.ts`,
|
||||
* surfaced here as a type so deferred-trigger entries are
|
||||
* statically constrained. The four Wave-4 trigger kinds
|
||||
* (`on-rule-activated`, `on-rule-expire`, `on-piece-entered-marker`,
|
||||
* `on-marker-expire`) are listed eagerly so T16-T19 can mirror this
|
||||
* dispatcher without extending the union.
|
||||
*/
|
||||
export type TriggerName =
|
||||
| "on-capture"
|
||||
| "on-captured"
|
||||
| "on-move"
|
||||
| "on-damaged"
|
||||
| "on-promotion"
|
||||
| "on-check-received"
|
||||
| "on-check-delivered"
|
||||
| "on-moved-onto-square"
|
||||
| "on-turn-start"
|
||||
| "on-turn-end"
|
||||
| "on-rule-activated"
|
||||
| "on-rule-expire"
|
||||
| "on-piece-entered-marker"
|
||||
| "on-marker-expire";
|
||||
|
||||
/**
|
||||
* A trigger event enqueued by an imperative primitive for deferred
|
||||
* firing (T15). Drained AFTER the current arm completes (not
|
||||
* mid-iteration). Each drained entry runs against the next cascade
|
||||
* depth — see `runPrimitives` in `triggers.ts` for the guard.
|
||||
*
|
||||
* `payload` is event-specific; the dispatcher's `fireTriggerByKind`
|
||||
* narrows it per-kind (e.g. `{attackerId, defenderId}` for
|
||||
* `on-captured`, `{moved: EntityId[]}` for `on-move`).
|
||||
*/
|
||||
export interface PendingTrigger {
|
||||
readonly kind: TriggerName;
|
||||
readonly pieceId: EntityId;
|
||||
readonly payload?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* T3 primitive ids (ADR-2).
|
||||
*/
|
||||
|
|
@ -33,6 +75,10 @@ export type PrimitiveKind =
|
|||
| "on-check-received"
|
||||
| "on-check-delivered"
|
||||
| "on-moved-onto-square"
|
||||
| "on-rule-activated"
|
||||
| "on-rule-expire"
|
||||
| "on-piece-entered-marker"
|
||||
| "on-marker-expire"
|
||||
| "conditional";
|
||||
|
||||
/**
|
||||
|
|
@ -101,6 +147,60 @@ export interface PrimitiveApplyContext {
|
|||
* preserves their behaviour byte-identically.
|
||||
*/
|
||||
readonly bindings: ReadonlyMap<string, BindingValue>;
|
||||
/**
|
||||
* Deferred-trigger queue (T15). Imperative primitives that
|
||||
* synthesize secondary triggers (e.g. `destroy-piece` causing
|
||||
* `on-captured`) push entries here via
|
||||
* `enqueueTrigger(ctx, trigger)`; the dispatcher drains the queue
|
||||
* AFTER the current arm completes — never mid-iteration. The
|
||||
* queue is per-arm (each `runPrimitives` invocation seeds a fresh
|
||||
* `[]`), so it does NOT leak between top-level dispatches.
|
||||
*
|
||||
* Mutable (an array, not a Map) by design — the immutability
|
||||
* guarantee that applies to `bindings` (T11) does NOT apply here:
|
||||
* the queue is intentionally shared between siblings of one arm
|
||||
* so they can collectively defer secondary fires.
|
||||
*
|
||||
* Default at every construction site: `[]`. Primitives that
|
||||
* never enqueue (the 22 pre-T15 primitives) inherit a no-op
|
||||
* empty queue and behave byte-identically.
|
||||
*/
|
||||
readonly pendingTriggers: PendingTrigger[];
|
||||
/**
|
||||
* Cascade depth (T15). Top-level dispatcher entry runs at 0; each
|
||||
* deferred trigger drained from `pendingTriggers` re-enters the
|
||||
* dispatcher at `cascadeDepth + 1`. Hard cap = 8 (mirrors
|
||||
* `RUNTIME_DEPTH_HARD_CAP`); breach throws a runtime error with
|
||||
* code `runtime.cascade-depth-exceeded`.
|
||||
*
|
||||
* Orthogonal to the existing `depth` field — `depth` counts
|
||||
* nested primitive arrays inside ONE arm; `cascadeDepth` counts
|
||||
* cross-arm chains via the deferred queue. Do NOT mix.
|
||||
*/
|
||||
readonly cascadeDepth: number;
|
||||
/**
|
||||
* Move-generation dry-mode flag (T20). When `true`, imperative
|
||||
* primitives — those listed in `IMPERATIVE_KINDS` (exported from
|
||||
* `../custom/validate.js`) — are NO-OPS. The dispatcher
|
||||
* (`runPrimitives` in `triggers.ts`) skips dispatching them
|
||||
* entirely; non-imperative primitives (predicates, conditionals,
|
||||
* iteration introspection) still execute normally so legality
|
||||
* analysis can branch on the same data the wet path would.
|
||||
*
|
||||
* Set by the move-generator when probing "what-if" legality (e.g.
|
||||
* check detection during legal-move enumeration) so triggers
|
||||
* cannot mutate state during the probe and corrupt subsequent
|
||||
* what-if iterations. Default: `false` (real commit / regular
|
||||
* trigger flow). The 22 pre-T20 primitives are NOT in
|
||||
* IMPERATIVE_KINDS so they fire normally regardless of this
|
||||
* flag — backward-compatible by construction.
|
||||
*
|
||||
* Single source of truth: branched only at the dispatcher entry
|
||||
* point in `runPrimitives`; individual primitives MUST NOT
|
||||
* inspect this field. (See `decisions.md` § Move-Generation Dry
|
||||
* Mode for the locked invariant.)
|
||||
*/
|
||||
readonly suppressTriggers: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,15 +15,25 @@ import { algebraicToSquare } from "../coord.js";
|
|||
import { clearBoard, pieceAt, placePiece } from "../presets/test-utils.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
import {
|
||||
enqueueTrigger,
|
||||
fireOnCapturedHooks,
|
||||
fireOnCheckDeliveredHooks,
|
||||
fireOnCheckReceivedHooks,
|
||||
fireOnMoveHooks,
|
||||
fireOnMovedOntoSquareHooks,
|
||||
fireOnPromotionHooks,
|
||||
fireOnRuleActivatedHooks,
|
||||
fireOnTurnEndHooks,
|
||||
runPrimitives,
|
||||
type PreMoveCheckStateLike,
|
||||
} from "./triggers.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./primitives/registry.js";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
} from "./primitives/types.js";
|
||||
import "./primitives/index.js";
|
||||
import "../presets/index.js";
|
||||
|
||||
|
|
@ -635,3 +645,464 @@ describe("fireOnCapturedHooks", () => {
|
|||
expect(engine.session.get(whitePawn, "HpBonus")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── T20: move-gen suppressTriggers flag ─────────────────────────────────────
|
||||
//
|
||||
// Move-gen dry-mode (legality probing for check detection / what-if
|
||||
// analysis) MUST NOT fire imperative primitives — they would mutate the
|
||||
// session mid-probe and corrupt subsequent iterations. The dispatcher
|
||||
// (`runPrimitives`) skips primitives whose kind is in IMPERATIVE_KINDS
|
||||
// when `ctx.suppressTriggers === true`. Non-imperative primitives still
|
||||
// run normally so legality analysis can branch on the same data the
|
||||
// wet path would.
|
||||
//
|
||||
// IMPERATIVE_KINDS (T14, locked at T0 ADR) = 10 future Wave-5/6 kinds:
|
||||
// place-piece, destroy-piece, move-piece, swap-pieces,
|
||||
// convert-piece-type, set-piece-attr, cancel-capture, spawn-marker,
|
||||
// spawn-marker-pair, destroy-marker.
|
||||
// None are registered yet; the suite below registers a SYNTHETIC
|
||||
// primitive under one of those kind-names so the gate can be exercised
|
||||
// today without waiting for Wave 5/6 implementations.
|
||||
|
||||
describe("move-gen suppressTriggers flag (T20)", () => {
|
||||
// Track imperative-primitive side effects via a module-scoped flag.
|
||||
// Each test resets it via the per-test setup. The synthetic primitive
|
||||
// is registered ONCE at first describe entry — `PRIMITIVE_REGISTRY`
|
||||
// has no unregister, but using a kind-name from IMPERATIVE_KINDS
|
||||
// (`destroy-piece`) doesn't collide because Wave 5/6 hasn't landed.
|
||||
let imperativeFired = false;
|
||||
let predicateFired = false;
|
||||
|
||||
// Register the synthetic imperative primitive on first entry. The
|
||||
// try/catch handles repeat registrations from test re-runs (vitest
|
||||
// module re-evaluation in watch mode would otherwise throw on the
|
||||
// duplicate-kind guard).
|
||||
try {
|
||||
PRIMITIVE_REGISTRY.register({
|
||||
// Cast through unknown — the registry's PrimitiveKind union does
|
||||
// NOT include `destroy-piece` yet (Wave 6 will add it). The
|
||||
// runtime registry stores the kind as a plain string key, so the
|
||||
// lookup in `runPrimitives` works regardless of static typing.
|
||||
kind: "destroy-piece" as unknown as EffectPrimitive["kind"],
|
||||
label: "T20 synthetic destroy-piece",
|
||||
description: "Test-only stub for the suppressTriggers gate.",
|
||||
paramsSchema: z.object({}).passthrough(),
|
||||
apply: () => {
|
||||
imperativeFired = true;
|
||||
},
|
||||
} as unknown as EffectPrimitive);
|
||||
} catch {
|
||||
// already registered (test file re-evaluated)
|
||||
}
|
||||
|
||||
// Register a synthetic NON-imperative primitive whose kind is NOT in
|
||||
// IMPERATIVE_KINDS — used to prove that suppressTriggers does NOT
|
||||
// affect non-imperative primitives. Using a fresh kind-name avoids
|
||||
// colliding with the 22 real primitives.
|
||||
try {
|
||||
PRIMITIVE_REGISTRY.register({
|
||||
kind: "__t20_predicate__" as unknown as EffectPrimitive["kind"],
|
||||
label: "T20 synthetic predicate",
|
||||
description: "Test-only non-imperative stub.",
|
||||
paramsSchema: z.object({}).passthrough(),
|
||||
apply: () => {
|
||||
predicateFired = true;
|
||||
},
|
||||
} as unknown as EffectPrimitive);
|
||||
} catch {
|
||||
// already registered
|
||||
}
|
||||
|
||||
function makeBareEngine(): ChessEngine {
|
||||
// Empty profile — we don't need a full preset; runPrimitives only
|
||||
// touches the engine's session.
|
||||
return new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("t20-bare"),
|
||||
});
|
||||
}
|
||||
|
||||
function resetFlags() {
|
||||
imperativeFired = false;
|
||||
predicateFired = false;
|
||||
}
|
||||
|
||||
it("dry-mode (suppressTriggers=true) does NOT fire imperative primitives", () => {
|
||||
resetFlags();
|
||||
const engine = makeBareEngine();
|
||||
const pieceId = findPiece(engine, 12); // some real piece on the board
|
||||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
// Cast: kind is in IMPERATIVE_KINDS but not in PrimitiveKind union.
|
||||
kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Invoke runPrimitives directly with suppressTriggers=true. The
|
||||
// dispatcher must skip the imperative; `imperativeFired` stays
|
||||
// false.
|
||||
runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true);
|
||||
expect(imperativeFired).toBe(false);
|
||||
});
|
||||
|
||||
it("real commit (suppressTriggers=false) DOES fire imperative primitives", () => {
|
||||
resetFlags();
|
||||
const engine = makeBareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Default suppressTriggers=false → the synthetic apply runs.
|
||||
runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, false);
|
||||
expect(imperativeFired).toBe(true);
|
||||
});
|
||||
|
||||
it("non-imperative primitives still run under suppressTriggers", () => {
|
||||
resetFlags();
|
||||
const engine = makeBareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
kind: "__t20_predicate__" as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
||||
runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true);
|
||||
// Non-imperative primitive fires regardless of suppress flag.
|
||||
expect(predicateFired).toBe(true);
|
||||
});
|
||||
|
||||
it("mixed list: only imperatives are skipped under suppressTriggers", () => {
|
||||
resetFlags();
|
||||
const engine = makeBareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
kind: "__t20_predicate__" as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
||||
runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true);
|
||||
expect(predicateFired).toBe(true); // non-imperative still ran
|
||||
expect(imperativeFired).toBe(false); // imperative skipped
|
||||
});
|
||||
|
||||
it("default suppressTriggers (omitted) is false — imperatives fire", () => {
|
||||
resetFlags();
|
||||
const engine = makeBareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Omit the suppressTriggers arg entirely; param default is `false`.
|
||||
runPrimitives(engine, pieceId, nodes, 1);
|
||||
expect(imperativeFired).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── T15: deferred trigger queue + cascade depth guard ──────────────────────
|
||||
//
|
||||
// Imperative primitives that synthesize secondary triggers (e.g.
|
||||
// destroy-piece causing on-captured) MUST enqueue events via
|
||||
// `enqueueTrigger(ctx, ...)` rather than firing inline. The dispatcher
|
||||
// drains the queue AFTER the current arm completes, never mid-iteration,
|
||||
// and increments `cascadeDepth` per drained trigger arm. The guard
|
||||
// throws `runtime.cascade-depth-exceeded` once cascadeDepth > 8 (matches
|
||||
// `RUNTIME_DEPTH_HARD_CAP`).
|
||||
//
|
||||
// Tests below register synthetic primitives via the same pattern as the
|
||||
// T20 suite: the kind-cast bypasses the static `PrimitiveKind` union;
|
||||
// the runtime registry stores by string key.
|
||||
|
||||
describe("deferred queue + cascade depth (T15)", () => {
|
||||
// Tracks ordering: `applyOrder` records each synthetic primitive's
|
||||
// run + the queue length AT the moment of run, so the test can prove
|
||||
// the deferred entry didn't fire mid-arm.
|
||||
let applyOrder: Array<{ kind: string; queueLen: number }> = [];
|
||||
// Counts every drained `on-move` arm fired during the test. Used as
|
||||
// a write-only side-effect probe by the cascade-depth synthetic
|
||||
// re-enqueuer; the test asserts behaviour via the throw, not the
|
||||
// counter, so the variable is intentionally write-only here.
|
||||
let _onMoveFired = 0;
|
||||
|
||||
// Synthetic primitive #1: enqueues an `on-move` event for its current
|
||||
// pieceId. Used to inject a deferred trigger from inside an arm.
|
||||
try {
|
||||
PRIMITIVE_REGISTRY.register({
|
||||
kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"],
|
||||
label: "T15 synthetic enqueuer",
|
||||
description: "Test-only stub that enqueues on-move.",
|
||||
paramsSchema: z.object({}).passthrough(),
|
||||
apply: (ctx: PrimitiveApplyContext) => {
|
||||
applyOrder.push({
|
||||
kind: "__t15_enqueuer__",
|
||||
queueLen: ctx.pendingTriggers.length,
|
||||
});
|
||||
enqueueTrigger(ctx, {
|
||||
kind: "on-move",
|
||||
pieceId: ctx.pieceId,
|
||||
payload: {},
|
||||
});
|
||||
},
|
||||
} as unknown as EffectPrimitive);
|
||||
} catch {
|
||||
// already registered (vitest re-evaluation)
|
||||
}
|
||||
|
||||
// Synthetic primitive #2: a no-op marker primitive used as a SIBLING
|
||||
// after the enqueuer. Records `queueLen` at its own run-time so the
|
||||
// test can prove the deferred trigger didn't fire BETWEEN the two
|
||||
// primitives.
|
||||
try {
|
||||
PRIMITIVE_REGISTRY.register({
|
||||
kind: "__t15_marker__" as unknown as EffectPrimitive["kind"],
|
||||
label: "T15 synthetic marker",
|
||||
description: "Test-only stub that records queue state.",
|
||||
paramsSchema: z.object({}).passthrough(),
|
||||
apply: (ctx: PrimitiveApplyContext) => {
|
||||
applyOrder.push({
|
||||
kind: "__t15_marker__",
|
||||
queueLen: ctx.pendingTriggers.length,
|
||||
});
|
||||
},
|
||||
} as unknown as EffectPrimitive);
|
||||
} catch {
|
||||
// already registered
|
||||
}
|
||||
|
||||
// Synthetic primitive #3: re-enqueues an `on-move` AND records a
|
||||
// counter every time it runs. Used to build the cascade-depth test:
|
||||
// each drained trigger fires `OnMoveHooks` whose inner primitive list
|
||||
// includes this kind, which re-enqueues forever.
|
||||
try {
|
||||
PRIMITIVE_REGISTRY.register({
|
||||
kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"],
|
||||
label: "T15 synthetic re-enqueuer",
|
||||
description: "Test-only stub that re-enqueues on-move infinitely.",
|
||||
paramsSchema: z.object({}).passthrough(),
|
||||
apply: (ctx: PrimitiveApplyContext) => {
|
||||
_onMoveFired += 1;
|
||||
enqueueTrigger(ctx, {
|
||||
kind: "on-move",
|
||||
pieceId: ctx.pieceId,
|
||||
payload: {},
|
||||
});
|
||||
},
|
||||
} as unknown as EffectPrimitive);
|
||||
} catch {
|
||||
// already registered
|
||||
}
|
||||
|
||||
function reset() {
|
||||
applyOrder = [];
|
||||
_onMoveFired = 0;
|
||||
}
|
||||
|
||||
function bareEngine(): ChessEngine {
|
||||
return new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("t15-bare"),
|
||||
});
|
||||
}
|
||||
|
||||
it("enqueued trigger fires AFTER the current arm (not mid-iteration)", () => {
|
||||
reset();
|
||||
const engine = bareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
// Wire the enqueued `on-move` to bump RangeBonus so we can prove
|
||||
// the deferred trigger eventually fired. Use a real existing
|
||||
// primitive (`add-to-attribute`) so no further synthetic kinds
|
||||
// are needed.
|
||||
engine.session.insert(pieceId, "OnMoveHooks", [
|
||||
[
|
||||
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
|
||||
],
|
||||
]);
|
||||
|
||||
// Two-node arm: enqueuer first, marker second. The marker MUST
|
||||
// observe queueLen === 1 (the enqueued trigger is sitting in the
|
||||
// queue, not fired yet). RangeBonus must NOT be set until the
|
||||
// arm completes and the dispatcher drains.
|
||||
const nodes = [
|
||||
{
|
||||
kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"],
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
kind: "__t15_marker__" as unknown as EffectPrimitive["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Snapshot RangeBonus before the call (should remain undefined
|
||||
// until the deferred drain runs).
|
||||
expect(engine.session.get(pieceId, "RangeBonus")).toBeUndefined();
|
||||
|
||||
runPrimitives(engine, pieceId, nodes, 1);
|
||||
|
||||
// Order check: enqueuer ran first with queueLen=0 (empty before
|
||||
// its push), marker ran second with queueLen=1 (the queue holds
|
||||
// the deferred entry, not yet drained).
|
||||
expect(applyOrder).toEqual([
|
||||
{ kind: "__t15_enqueuer__", queueLen: 0 },
|
||||
{ kind: "__t15_marker__", queueLen: 1 },
|
||||
]);
|
||||
|
||||
// After runPrimitives returns, the dispatcher has drained the
|
||||
// queue → on-move fired → add-to-attribute bumped RangeBonus by 1.
|
||||
expect(engine.session.get(pieceId, "RangeBonus")).toBe(1);
|
||||
});
|
||||
|
||||
it("cascade depth=8 limit throws runtime.cascade-depth-exceeded", () => {
|
||||
reset();
|
||||
const engine = bareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
// Wire OnMoveHooks to the re-enqueuer: each drained on-move runs
|
||||
// the re-enqueuer, which enqueues another on-move, which drains
|
||||
// and runs the re-enqueuer again, etc. The guard throws once
|
||||
// cascadeDepth > 8 (i.e. on the 10th invocation: top-level=0,
|
||||
// then drains 1..9 = 9 cascades; the 10th re-entry hits depth 9
|
||||
// which triggers the > 8 check).
|
||||
engine.session.insert(pieceId, "OnMoveHooks", [
|
||||
[
|
||||
{
|
||||
kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"],
|
||||
params: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const nodes = [
|
||||
{
|
||||
kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => runPrimitives(engine, pieceId, nodes, 1)).toThrow(
|
||||
/cascade-depth-exceeded/,
|
||||
);
|
||||
});
|
||||
|
||||
it("cascade depth resets between top-level dispatches", () => {
|
||||
reset();
|
||||
const engine = bareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
// Single-shot enqueuer wired to a benign attr-bump on-move hook.
|
||||
// Each top-level call: enqueuer runs at cascadeDepth=0 → drains
|
||||
// at cascadeDepth=1 → done. Subsequent top-level calls MUST also
|
||||
// start at cascadeDepth=0; if the depth leaked, we'd accumulate
|
||||
// toward the 8-limit and throw on call 9 or 10.
|
||||
engine.session.insert(pieceId, "OnMoveHooks", [
|
||||
[
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
|
||||
],
|
||||
]);
|
||||
|
||||
const nodes = [
|
||||
{
|
||||
kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Twenty independent top-level dispatches. If cascadeDepth leaked
|
||||
// across calls, this would throw cascade-depth-exceeded long
|
||||
// before reaching call 20 (the cap is 8).
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
expect(() => runPrimitives(engine, pieceId, nodes, 1)).not.toThrow();
|
||||
}
|
||||
// 20 deferred-drain on-move hooks each bumped HpBonus by 1.
|
||||
expect(engine.session.get(pieceId, "HpBonus")).toBe(20);
|
||||
});
|
||||
|
||||
it("explicit cascadeDepth=9 at entry throws (boundary case)", () => {
|
||||
// Direct boundary verification: pass cascadeDepth=9 to a
|
||||
// top-level invocation. The guard fires before any primitive
|
||||
// applies, regardless of queue contents.
|
||||
reset();
|
||||
const engine = bareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
expect(() =>
|
||||
runPrimitives(engine, pieceId, [], 1, undefined, new Map(), 9),
|
||||
).toThrow(/cascade-depth-exceeded/);
|
||||
});
|
||||
|
||||
it("cascadeDepth=8 at entry is at the boundary and does NOT throw", () => {
|
||||
// The guard is `cascadeDepth > HARD_CASCADE_DEPTH` (strictly
|
||||
// greater than 8). Entry at exactly 8 is the last legal depth;
|
||||
// it runs the arm and only throws if a drained child would
|
||||
// re-enter at 9.
|
||||
reset();
|
||||
const engine = bareEngine();
|
||||
const pieceId = findPiece(engine, 12);
|
||||
|
||||
// No nodes, no enqueues → no drain → safe.
|
||||
expect(() =>
|
||||
runPrimitives(engine, pieceId, [], 1, undefined, new Map(), 8),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── T16: fireOnRuleActivatedHooks ──────────────────────────────────────
|
||||
//
|
||||
// The dispatcher reads `OnRuleActivatedHooks` from GAME_ENTITY and
|
||||
// fires only entries whose `descriptorId` matches the requested id.
|
||||
// Inner primitives run against GAME_ENTITY (the canonical "rule-
|
||||
// activated" target). Other descriptors' entries are skipped.
|
||||
|
||||
describe("fireOnRuleActivatedHooks (T16)", () => {
|
||||
it("fires the matching descriptor's primitives, skips other descriptors", () => {
|
||||
const engine = new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("on-rule-activated-dispatch"),
|
||||
});
|
||||
|
||||
// Seed two hooks for two different descriptors. Only descriptor
|
||||
// 'rule-A' should fire when we dispatch for 'rule-A'.
|
||||
engine.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [
|
||||
{
|
||||
descriptorId: "rule-A",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 3 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
descriptorId: "rule-B",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
fireOnRuleActivatedHooks(engine, "rule-A");
|
||||
|
||||
// Inner primitives target GAME_ENTITY (the dispatcher passes it
|
||||
// as `pieceId` into runPrimitives).
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(3);
|
||||
// rule-B's primitive did NOT fire — RangeBonus on GAME_ENTITY
|
||||
// remains undefined.
|
||||
expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,12 +54,15 @@
|
|||
* preMoveHp)` and `fireOnCaptureHooks(engine, attackerId)`.
|
||||
*/
|
||||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type {
|
||||
ChessAttrMap,
|
||||
ConditionSpec,
|
||||
PieceColor,
|
||||
PieceType,
|
||||
Square,
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
PRESET_STATE_ENTITY,
|
||||
type ChessAttrMap,
|
||||
type ConditionSpec,
|
||||
type MarkerKindValue,
|
||||
type PieceColor,
|
||||
type PieceType,
|
||||
type Square,
|
||||
} from "../schema.js";
|
||||
import { fileOf, rankOf } from "../coord.js";
|
||||
import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js";
|
||||
|
|
@ -72,10 +75,37 @@ import {
|
|||
import { resolveParams } from "./primitives/param-resolver.js";
|
||||
import type {
|
||||
EffectPrimitiveNode,
|
||||
PendingTrigger,
|
||||
PrimitiveApplyContext,
|
||||
} from "./primitives/types.js";
|
||||
import { IMPERATIVE_KINDS } from "./custom/validate.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
|
||||
/**
|
||||
* Cascade-depth hard cap (T15). Mirrors the existing
|
||||
* `RUNTIME_DEPTH_HARD_CAP` precedent (validate.ts) — same constant
|
||||
* is reused for the request-choice stack depth, so all three
|
||||
* recursion-control limits stay in lockstep.
|
||||
*/
|
||||
const HARD_CASCADE_DEPTH = 8;
|
||||
|
||||
/**
|
||||
* Append a pending trigger to the current arm's deferred queue
|
||||
* (T15). Imperative primitives that synthesise secondary triggers
|
||||
* call this helper instead of firing inline — the dispatcher
|
||||
* drains the queue once the current arm finishes.
|
||||
*
|
||||
* Mutating the array is intentional: `pendingTriggers` is the
|
||||
* single per-arm shared queue. The "no in-place mutation" rule
|
||||
* for `bindings` does NOT apply here.
|
||||
*/
|
||||
export function enqueueTrigger(
|
||||
ctx: PrimitiveApplyContext,
|
||||
trigger: PendingTrigger,
|
||||
): void {
|
||||
ctx.pendingTriggers.push(trigger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-color royal→attackers map (mirrors the shape exported from
|
||||
* `apply.ts` as `PreMoveCheckState`). Re-declared structurally here so
|
||||
|
|
@ -115,19 +145,53 @@ function* eachPiece(
|
|||
* the trigger metadata that fired them. Existing callers that don't
|
||||
* supply an event get `event: undefined` — backward compatible.
|
||||
*/
|
||||
function runPrimitives(
|
||||
export function runPrimitives(
|
||||
engine: ChessEngine,
|
||||
pieceId: EntityId,
|
||||
nodes: readonly EffectPrimitiveNode[],
|
||||
depth: number,
|
||||
event?: PrimitiveEvent,
|
||||
bindings: ReadonlyMap<string, BindingValue> = new Map(),
|
||||
cascadeDepth: number = 0,
|
||||
suppressTriggers: boolean = false,
|
||||
): void {
|
||||
if (depth > 8) return; // hard runtime cap, mirrors validator
|
||||
// T15: cascade-depth guard. Distinct from `depth` (nested primitive
|
||||
// arrays in the same arm) — `cascadeDepth` counts cross-arm chains
|
||||
// formed by the deferred-trigger queue. A breach is a runtime
|
||||
// error, not validator-detectable: the cascade is data-dependent.
|
||||
if (cascadeDepth > HARD_CASCADE_DEPTH) {
|
||||
throw new Error(
|
||||
`runtime.cascade-depth-exceeded: cascade depth ${cascadeDepth} > ${HARD_CASCADE_DEPTH}`,
|
||||
);
|
||||
}
|
||||
|
||||
// T15: per-arm deferred-trigger queue. Each `runPrimitives`
|
||||
// invocation seeds a FRESH array — the queue does not leak
|
||||
// across sibling arms, only across the parent arm and its drained
|
||||
// descendants (which run at `cascadeDepth + 1`).
|
||||
const pendingTriggers: PendingTrigger[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
if (primitive === undefined) continue;
|
||||
|
||||
// T20: dry-mode skips imperative primitives entirely. The check
|
||||
// is THE single source of truth for `suppressTriggers` (per
|
||||
// decisions.md § Move-Generation Dry Mode); individual primitives
|
||||
// do NOT branch on it. IMPERATIVE_KINDS is the locked T14 set of
|
||||
// 10 future Wave-5/6 kinds (none registered yet — but the gate
|
||||
// is in place so they fire correctly when they land).
|
||||
//
|
||||
// Non-imperative primitives (predicates, conditionals, the 22
|
||||
// pre-Wave-5 kinds) still run normally so legality analysis can
|
||||
// branch on the same data the wet path would. This preserves
|
||||
// backward compat: the 22 existing primitives are not in
|
||||
// IMPERATIVE_KINDS, so suppressTriggers never affects them.
|
||||
if (suppressTriggers && IMPERATIVE_KINDS.has(node.kind)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine,
|
||||
session: engine.session,
|
||||
|
|
@ -149,6 +213,18 @@ function runPrimitives(
|
|||
// request-choice primitives extend it via `withBinding` before
|
||||
// re-entering `runPrimitives` for nested children.
|
||||
bindings,
|
||||
// T15: deferred-trigger queue + cascade depth. The same
|
||||
// `pendingTriggers` instance is shared across all primitives
|
||||
// in this arm (siblings + nested children) so they can
|
||||
// collectively defer secondary fires; drain happens once the
|
||||
// top of THIS arm completes.
|
||||
pendingTriggers,
|
||||
cascadeDepth,
|
||||
// T20: thread the dry-mode flag into the context so any future
|
||||
// primitive author who needs it can read it (canonical use is
|
||||
// the dispatcher-level skip above; individual primitives do
|
||||
// NOT branch on this — see types.ts contract).
|
||||
suppressTriggers,
|
||||
};
|
||||
// T12: resolve `$var` / `ctx-attr` / `ctx-build` shapes inside
|
||||
// params BEFORE handing them to the primitive's apply(). Existing
|
||||
|
|
@ -169,11 +245,133 @@ function runPrimitives(
|
|||
children = [];
|
||||
}
|
||||
if (children.length > 0) {
|
||||
// Thread the SAME bindings through nested children so a name
|
||||
// introduced by an outer iteration primitive remains in scope.
|
||||
runPrimitives(engine, pieceId, children, depth + 1, event, bindings);
|
||||
// Thread the SAME bindings + cascadeDepth + suppressTriggers
|
||||
// through nested children so a name introduced by an outer
|
||||
// iteration primitive remains in scope, the cascade counter
|
||||
// doesn't jump artificially, and dry-mode propagates into
|
||||
// nested arms (a conditional inside a dry-probe must NOT
|
||||
// suddenly fire imperatives via its `then` branch).
|
||||
runPrimitives(
|
||||
engine,
|
||||
pieceId,
|
||||
children,
|
||||
depth + 1,
|
||||
event,
|
||||
bindings,
|
||||
cascadeDepth,
|
||||
suppressTriggers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// T15: drain the deferred-trigger queue AFTER the current arm
|
||||
// completes. FIFO order — the order primitives enqueued. Each
|
||||
// drained trigger re-enters dispatch at `cascadeDepth + 1`.
|
||||
// The drain happens with the SAME engine snapshot the arm built;
|
||||
// primitives that died mid-arm have already retracted facts, so
|
||||
// hook lookups in `fireTriggerByKind` find what's still present.
|
||||
//
|
||||
// T20: under suppressTriggers the queue should be empty (the
|
||||
// imperative-skip above prevents apply() from running and so
|
||||
// prevents any enqueueTrigger() calls), but defensive-skip the
|
||||
// drain anyway so a future primitive that mistakenly enqueues
|
||||
// mid-dry-mode can't leak side effects.
|
||||
if (suppressTriggers) return;
|
||||
for (const t of pendingTriggers) {
|
||||
fireTriggerByKind(engine, t, cascadeDepth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a deferred trigger (T15). Maps a `TriggerName` onto the
|
||||
* existing `fire*Hooks` family, threading the new cascade depth so
|
||||
* the receiver enforces the hard cap.
|
||||
*
|
||||
* Wave-4 trigger kinds (`on-rule-activated`, `on-rule-expire`,
|
||||
* `on-piece-entered-marker`, `on-marker-expire`) are listed in
|
||||
* `TriggerName` already but not yet wired here — when T16-T19 land,
|
||||
* add their cases. The default branch logs + skips so a
|
||||
* descriptor that enqueues an unsupported kind today doesn't crash
|
||||
* production: it just doesn't fire (and the warning surfaces the
|
||||
* mismatch in dev).
|
||||
*/
|
||||
function fireTriggerByKind(
|
||||
engine: ChessEngine,
|
||||
trigger: PendingTrigger,
|
||||
cascadeDepth: number,
|
||||
): void {
|
||||
switch (trigger.kind) {
|
||||
case "on-captured": {
|
||||
const payload = (trigger.payload ?? {}) as { attackerId?: EntityId };
|
||||
if (payload.attackerId === undefined) return;
|
||||
fireOnCapturedHooks(
|
||||
engine,
|
||||
trigger.pieceId,
|
||||
payload.attackerId,
|
||||
cascadeDepth,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "on-capture": {
|
||||
fireOnCaptureHooks(engine, trigger.pieceId, cascadeDepth);
|
||||
return;
|
||||
}
|
||||
case "on-move": {
|
||||
fireOnMoveHooks(engine, [trigger.pieceId], cascadeDepth);
|
||||
return;
|
||||
}
|
||||
case "on-promotion": {
|
||||
const payload = (trigger.payload ?? {}) as {
|
||||
promotedFrom?: PieceType;
|
||||
promotedTo?: PieceType;
|
||||
};
|
||||
if (payload.promotedFrom === undefined || payload.promotedTo === undefined) {
|
||||
return;
|
||||
}
|
||||
fireOnPromotionHooks(
|
||||
engine,
|
||||
trigger.pieceId,
|
||||
payload.promotedFrom,
|
||||
payload.promotedTo,
|
||||
cascadeDepth,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "on-moved-onto-square": {
|
||||
const payload = (trigger.payload ?? {}) as { square?: Square };
|
||||
if (payload.square === undefined) return;
|
||||
fireOnMovedOntoSquareHooks(
|
||||
engine,
|
||||
trigger.pieceId,
|
||||
payload.square,
|
||||
cascadeDepth,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Triggers that require richer pre/post snapshots
|
||||
// (on-damaged / on-check-*) and the per-color turn ticks are
|
||||
// not enqueueable from primitives in V1 — they're driven only by
|
||||
// the integration preset's onAfterMove pipeline. Listing them in
|
||||
// `TriggerName` keeps the union complete without forcing a
|
||||
// dispatcher entry that primitives can't currently produce.
|
||||
case "on-damaged":
|
||||
case "on-check-received":
|
||||
case "on-check-delivered":
|
||||
case "on-turn-start":
|
||||
case "on-turn-end":
|
||||
case "on-rule-activated":
|
||||
case "on-rule-expire":
|
||||
case "on-piece-entered-marker":
|
||||
case "on-marker-expire":
|
||||
default:
|
||||
// Unsupported-from-primitives kind: log + skip rather than
|
||||
// throw, so a forward-compatible descriptor authored against
|
||||
// future Wave-4 wiring degrades gracefully today.
|
||||
console.warn(
|
||||
`fireTriggerByKind: trigger kind '${trigger.kind}' not dispatchable from deferred queue (yet)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -212,6 +410,7 @@ function evaluateCondition(
|
|||
export function fireOnTurnStartHooks(
|
||||
engine: ChessEngine,
|
||||
whoseTurn: PieceColor,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
for (const { id, color } of eachPiece(engine.session)) {
|
||||
if (color !== whoseTurn) continue;
|
||||
|
|
@ -220,7 +419,7 @@ export function fireOnTurnStartHooks(
|
|||
| undefined;
|
||||
if (hooks === undefined) continue;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, id, primitives, 1);
|
||||
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -237,6 +436,7 @@ export function fireOnTurnStartHooks(
|
|||
export function fireOnTurnEndHooks(
|
||||
engine: ChessEngine,
|
||||
endedColor: PieceColor,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
for (const { id } of eachPiece(engine.session)) {
|
||||
const hooks = engine.session.get(id, "OnTurnEndHooks") as
|
||||
|
|
@ -245,7 +445,7 @@ export function fireOnTurnEndHooks(
|
|||
if (hooks === undefined) continue;
|
||||
for (const hook of hooks) {
|
||||
if (hook.color !== "both" && hook.color !== endedColor) continue;
|
||||
runPrimitives(engine, id, hook.primitives, 1);
|
||||
runPrimitives(engine, id, hook.primitives, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -262,6 +462,7 @@ export function fireOnTurnEndHooks(
|
|||
export function fireOnCaptureHooks(
|
||||
engine: ChessEngine,
|
||||
attackerId: EntityId | null,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
if (attackerId === null) return;
|
||||
const hooks = engine.session.get(attackerId, "OnCaptureHooks") as
|
||||
|
|
@ -269,7 +470,7 @@ export function fireOnCaptureHooks(
|
|||
| undefined;
|
||||
if (hooks === undefined) return;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, attackerId, primitives, 1);
|
||||
runPrimitives(engine, attackerId, primitives, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,6 +498,7 @@ export function snapshotHp(session: Session): Map<EntityId, number> {
|
|||
export function fireOnDamagedHooks(
|
||||
engine: ChessEngine,
|
||||
preMoveHp: ReadonlyMap<EntityId, number>,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
for (const [id, prev] of preMoveHp) {
|
||||
const current = engine.session.get(id, "Hp") as number | undefined;
|
||||
|
|
@ -308,7 +510,7 @@ export function fireOnDamagedHooks(
|
|||
| undefined;
|
||||
if (hooks === undefined) continue;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, id, primitives, 1);
|
||||
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -319,7 +521,10 @@ export function fireOnDamagedHooks(
|
|||
* — conditions are re-evaluated against current facts so a hook that
|
||||
* reacts to "Hp < 2" fires the moment HP drops below threshold.
|
||||
*/
|
||||
export function fireConditionalHooks(engine: ChessEngine): void {
|
||||
export function fireConditionalHooks(
|
||||
engine: ChessEngine,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
for (const { id } of eachPiece(engine.session)) {
|
||||
const hooks = engine.session.get(id, "ConditionalHooks") as
|
||||
| ChessAttrMap["ConditionalHooks"]
|
||||
|
|
@ -329,7 +534,7 @@ export function fireConditionalHooks(engine: ChessEngine): void {
|
|||
const matches = evaluateCondition(engine.session, id, hook.condition);
|
||||
const branch = matches ? hook.then : hook.else;
|
||||
if (branch === undefined || branch.length === 0) continue;
|
||||
runPrimitives(engine, id, branch, 1);
|
||||
runPrimitives(engine, id, branch, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -347,6 +552,7 @@ export function fireConditionalHooks(engine: ChessEngine): void {
|
|||
export function fireOnMoveHooks(
|
||||
engine: ChessEngine,
|
||||
movedPieceIds: readonly EntityId[],
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
for (const id of movedPieceIds) {
|
||||
const hooks = engine.session.get(id, "OnMoveHooks") as
|
||||
|
|
@ -354,7 +560,7 @@ export function fireOnMoveHooks(
|
|||
| undefined;
|
||||
if (hooks === undefined) continue;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, id, primitives, 1);
|
||||
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -373,6 +579,7 @@ export function fireOnPromotionHooks(
|
|||
promotedPieceId: EntityId,
|
||||
promotedFrom: PieceType,
|
||||
promotedTo: PieceType,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const hooks = engine.session.get(promotedPieceId, "OnPromotionHooks") as
|
||||
| ChessAttrMap["OnPromotionHooks"]
|
||||
|
|
@ -384,7 +591,7 @@ export function fireOnPromotionHooks(
|
|||
promotedTo,
|
||||
};
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, promotedPieceId, primitives, 1, event);
|
||||
runPrimitives(engine, promotedPieceId, primitives, 1, event, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -404,6 +611,7 @@ export function fireOnPromotionHooks(
|
|||
export function fireOnCheckReceivedHooks(
|
||||
engine: ChessEngine,
|
||||
preMoveCheckState: PreMoveCheckStateLike,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
for (const color of ["white", "black"] as const) {
|
||||
const preColor = preMoveCheckState[color];
|
||||
|
|
@ -418,7 +626,7 @@ export function fireOnCheckReceivedHooks(
|
|||
| undefined;
|
||||
if (hooks === undefined) continue;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, royalId, primitives, 1);
|
||||
runPrimitives(engine, royalId, primitives, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -435,6 +643,7 @@ export function fireOnCheckReceivedHooks(
|
|||
export function fireOnCheckDeliveredHooks(
|
||||
engine: ChessEngine,
|
||||
preMoveCheckState: PreMoveCheckStateLike,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
for (const color of ["white", "black"] as const) {
|
||||
const preColor = preMoveCheckState[color];
|
||||
|
|
@ -449,7 +658,7 @@ export function fireOnCheckDeliveredHooks(
|
|||
) as ChessAttrMap["OnCheckDeliveredHooks"] | undefined;
|
||||
if (hooks === undefined) continue;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, attackerId, primitives, 1);
|
||||
runPrimitives(engine, attackerId, primitives, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -466,6 +675,7 @@ export function fireOnMovedOntoSquareHooks(
|
|||
engine: ChessEngine,
|
||||
movedPieceId: EntityId,
|
||||
destSquare: Square,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const hooks = engine.session.get(
|
||||
movedPieceId,
|
||||
|
|
@ -474,7 +684,93 @@ export function fireOnMovedOntoSquareHooks(
|
|||
if (hooks === undefined) return;
|
||||
for (const hook of hooks) {
|
||||
if (!squareMatchesFilter(destSquare, hook.filter)) continue;
|
||||
runPrimitives(engine, movedPieceId, hook.primitives, 1);
|
||||
runPrimitives(engine, movedPieceId, hook.primitives, 1, undefined, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T18 — fire `on-piece-entered-marker` hooks for every piece in
|
||||
* `movedPieceIds` that landed on a square containing one or more
|
||||
* matching markers.
|
||||
*
|
||||
* Hook list lives on `GAME_ENTITY` (game-level — the rule applies
|
||||
* to ANY piece, not a specific one). For each moved piece, the
|
||||
* dispatcher reads the piece's CURRENT `Position` (post-move) and
|
||||
* asks `engine.getMarkersAtSquare(square)` for every marker
|
||||
* occupying it. T10 already returns markers SORTED BY PRIORITY
|
||||
* (lowest number first — portal-end fires before mine before
|
||||
* pit, etc.) with entity-id ascending tie-break, so iterating the
|
||||
* result inherits the locked dispatch order without re-sorting.
|
||||
*
|
||||
* Per marker, the dispatcher fires every hook whose `markerKind`
|
||||
* matches that marker's MarkerKind exactly. `event.kind =
|
||||
* "piece-entered-marker"` is supplied so inner primitives can read
|
||||
* `markerId` / `pieceId` / `square` if they need to (e.g.
|
||||
* `destroy-marker` cleanup, square-specific tombstone effects).
|
||||
*
|
||||
* Pieces whose Position fact is unexpectedly missing (capture in
|
||||
* the same arm, etc.) are skipped — this dispatcher only fires for
|
||||
* pieces that ACTUALLY exist on a square at the moment of the
|
||||
* call.
|
||||
*
|
||||
* The hook list is read fresh per piece. Markers spawned during
|
||||
* THIS arm by a sibling primitive are NOT visible to a piece that
|
||||
* already moved earlier in the arm: `getMarkersAtSquare` reflects
|
||||
* the live session state at the moment of THIS dispatch call (the
|
||||
* post-move state captured by onAfterMove). T15's deferred-trigger
|
||||
* queue is what handles cross-arm cascades.
|
||||
*/
|
||||
export function fireOnPieceEnteredMarkerHooks(
|
||||
engine: ChessEngine,
|
||||
movedPieceIds: readonly EntityId[],
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const hooks = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"OnPieceEnteredMarkerHooks",
|
||||
) as ChessAttrMap["OnPieceEnteredMarkerHooks"] | undefined;
|
||||
if (hooks === undefined || hooks.length === 0) return;
|
||||
|
||||
for (const pieceId of movedPieceIds) {
|
||||
const square = engine.session.get(pieceId, "Position") as
|
||||
| Square
|
||||
| undefined;
|
||||
if (typeof square !== "number") continue;
|
||||
|
||||
// T10: getMarkersAtSquare returns markers sorted ASC by hardcoded
|
||||
// MARKER_KIND_PRIORITY (portal-end first), tie-break by entity
|
||||
// id ASC. Iterating the result inherits the locked dispatch
|
||||
// order — DO NOT re-sort.
|
||||
const markerIds = engine.getMarkersAtSquare(square);
|
||||
for (const markerId of markerIds) {
|
||||
const markerKind = engine.session.get(markerId, "MarkerKind") as
|
||||
| MarkerKindValue
|
||||
| undefined;
|
||||
if (markerKind === undefined) continue;
|
||||
|
||||
// Match by exact kind only — no wildcard. A descriptor that
|
||||
// wants two kinds installs two hook entries.
|
||||
for (const hook of hooks) {
|
||||
if (hook.markerKind !== markerKind) continue;
|
||||
const event: PrimitiveEvent = {
|
||||
kind: "piece-entered-marker",
|
||||
markerId,
|
||||
markerKind,
|
||||
pieceId,
|
||||
square,
|
||||
};
|
||||
runPrimitives(
|
||||
engine,
|
||||
pieceId,
|
||||
hook.primitives,
|
||||
1,
|
||||
event,
|
||||
new Map(),
|
||||
cascadeDepth,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -496,6 +792,7 @@ export function fireOnCapturedHooks(
|
|||
engine: ChessEngine,
|
||||
capturedPieceId: EntityId,
|
||||
attackerId: EntityId,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const hooks = engine.session.get(
|
||||
capturedPieceId,
|
||||
|
|
@ -524,14 +821,227 @@ export function fireOnCapturedHooks(
|
|||
// iteration scope. Inner primitives that introduce bindings
|
||||
// extend via `withBinding` once `runPrimitives` recurses.
|
||||
bindings: new Map(),
|
||||
// T15: resolver-only ctx — `runPrimitives` below allocates its
|
||||
// own per-arm queue. The stub satisfies the type contract;
|
||||
// resolveTargets does not enqueue.
|
||||
pendingTriggers: [],
|
||||
cascadeDepth,
|
||||
// T20: on-captured hooks fire on the wet path (real commit).
|
||||
// Dry-mode probing never reaches this dispatcher — `false` is
|
||||
// the only correct default.
|
||||
suppressTriggers: false,
|
||||
};
|
||||
const targets = resolveTargets(resolverCtx, hook.target);
|
||||
for (const targetId of targets) {
|
||||
runPrimitives(engine, targetId, hook.primitives, 1, event);
|
||||
runPrimitives(engine, targetId, hook.primitives, 1, event, new Map(), cascadeDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `on-rule-activated` hooks for a descriptor that just attached
|
||||
* (T16). Reads the GAME_ENTITY-scoped `OnRuleActivatedHooks` list,
|
||||
* filters to entries whose `descriptorId` matches, and runs each
|
||||
* hook's inner primitive list ONCE against `GAME_ENTITY` (the
|
||||
* canonical "game-level" target — `on-rule-activated` is per-game,
|
||||
* not per-piece).
|
||||
*
|
||||
* The fire-once guard lives at the CALLER (`applyCustomDescriptor`),
|
||||
* not here — this dispatcher is a pure firing helper. Callers must
|
||||
* check `RuleActivatedFiredFor` on `PRESET_STATE_ENTITY` before
|
||||
* invoking, and append the descriptor id afterward to prevent
|
||||
* re-fires on subsequent attachments / game reload.
|
||||
*
|
||||
* Inner primitives see `event = { kind: "rule-activated",
|
||||
* descriptorId, chooserColor }` so they can branch on which rule
|
||||
* activated and on the chooser's color (read from
|
||||
* `LastModifierChooser` if present — undefined otherwise).
|
||||
*/
|
||||
export function fireOnRuleActivatedHooks(
|
||||
engine: ChessEngine,
|
||||
descriptorId: string,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const hooks = engine.session.get(GAME_ENTITY, "OnRuleActivatedHooks") as
|
||||
| ChessAttrMap["OnRuleActivatedHooks"]
|
||||
| undefined;
|
||||
if (hooks === undefined) return;
|
||||
|
||||
const chooser = engine.session.get(
|
||||
PRESET_STATE_ENTITY,
|
||||
"LastModifierChooser",
|
||||
) as PieceColor | undefined;
|
||||
|
||||
const event: PrimitiveEvent = {
|
||||
kind: "rule-activated",
|
||||
descriptorId,
|
||||
...(chooser !== undefined ? { chooserColor: chooser } : {}),
|
||||
};
|
||||
|
||||
for (const hook of hooks) {
|
||||
if (hook.descriptorId !== descriptorId) continue;
|
||||
runPrimitives(
|
||||
engine,
|
||||
GAME_ENTITY,
|
||||
hook.primitives,
|
||||
1,
|
||||
event,
|
||||
new Map(),
|
||||
cascadeDepth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `on-rule-expire` hooks for a descriptor that just detached
|
||||
* (T17). Mirror image of `fireOnRuleActivatedHooks` — reads the
|
||||
* GAME_ENTITY-scoped `OnRuleExpireHooks` list, filters to entries
|
||||
* whose `descriptorId` matches, and runs each hook's inner primitive
|
||||
* list ONCE against `GAME_ENTITY` (the canonical "rule-expire"
|
||||
* target — `on-rule-expire` is per-game, not per-piece).
|
||||
*
|
||||
* Fire-once guard lives HERE (unlike T16's activation guard, which
|
||||
* lives in `applyCustomDescriptor` because activation has a clean
|
||||
* call-site). Expire's call sites will be plural — descriptor
|
||||
* lifetime decrement (T19), explicit remove-modifier action, and
|
||||
* piece-modifier capture cascade. Centralising the guard in this
|
||||
* dispatcher means every future caller inherits it for free; the
|
||||
* caller just invokes `fireOnRuleExpireHooks(engine, descriptorId)`
|
||||
* and the function self-dedups via `RuleExpireFiredFor` on
|
||||
* `PRESET_STATE_ENTITY`.
|
||||
*
|
||||
* V1 stub status: no detach pipeline currently invokes this
|
||||
* function — it's exposed for T19 (lifetime decrementer) and the
|
||||
* eventual remove-modifier action handler to wire up. Until then,
|
||||
* `on-rule-expire` blocks register their hooks at descriptor apply
|
||||
* time but never fire. This is by design: expire fires on actual
|
||||
* detach, never on game shutdown.
|
||||
*
|
||||
* Inner primitives see `event = { kind: "rule-expire", descriptorId }`
|
||||
* so they can branch on which rule expired. No chooser color is
|
||||
* supplied — expire is a system event (lifetime tick / capture),
|
||||
* not a player action, and the original chooser may not be
|
||||
* available by the time the descriptor detaches.
|
||||
*/
|
||||
export function fireOnRuleExpireHooks(
|
||||
engine: ChessEngine,
|
||||
descriptorId: string,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
// Fire-once guard: list of descriptor ids whose expire hooks have
|
||||
// already fired. Persisted on PRESET_STATE_ENTITY so save→load
|
||||
// inherits the dedup, and so a re-attach + re-detach cycle within
|
||||
// the same session also doesn't re-fire (mirroring T16's
|
||||
// activation guard semantics).
|
||||
const fired =
|
||||
(engine.session.get(PRESET_STATE_ENTITY, "RuleExpireFiredFor") as
|
||||
| ChessAttrMap["RuleExpireFiredFor"]
|
||||
| undefined) ?? [];
|
||||
if (fired.includes(descriptorId)) return;
|
||||
|
||||
const hooks = engine.session.get(GAME_ENTITY, "OnRuleExpireHooks") as
|
||||
| ChessAttrMap["OnRuleExpireHooks"]
|
||||
| undefined;
|
||||
|
||||
// Append the descriptor to the guard list BEFORE firing so a
|
||||
// primitive that re-enters this dispatcher (cascade) can't
|
||||
// double-fire. Idempotent on double-detach calls — the guard skip
|
||||
// above handles repeats.
|
||||
engine.session.insert(PRESET_STATE_ENTITY, "RuleExpireFiredFor", [
|
||||
...fired,
|
||||
descriptorId,
|
||||
]);
|
||||
|
||||
if (hooks === undefined) return;
|
||||
|
||||
const event: PrimitiveEvent = {
|
||||
kind: "rule-expire",
|
||||
descriptorId,
|
||||
};
|
||||
|
||||
for (const hook of hooks) {
|
||||
if (hook.descriptorId !== descriptorId) continue;
|
||||
runPrimitives(
|
||||
engine,
|
||||
GAME_ENTITY,
|
||||
hook.primitives,
|
||||
1,
|
||||
event,
|
||||
new Map(),
|
||||
cascadeDepth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T19 — fire `on-marker-expire` hooks for the marker `markerId`
|
||||
* that just expired (lifetime sweep) but is NOT YET removed.
|
||||
*
|
||||
* The dispatcher must be called BEFORE `engine.removeMarker(markerId)`
|
||||
* retracts the marker's facts so:
|
||||
* 1. The marker's MarkerKind / Position can still be read here
|
||||
* (event payload construction).
|
||||
* 2. Inner primitives that consult MarkerOwner / MarkerLinks via
|
||||
* `ctx.session.get` find them present (e.g. portal-pair cleanup).
|
||||
*
|
||||
* Hook list lives on `GAME_ENTITY` (game-level — the rule applies to
|
||||
* ANY marker of the matching kind, not a specific marker entity).
|
||||
* Match is exact-kind only — a hook for `mine` does NOT fire on
|
||||
* `pit`. Multiple hook entries across descriptors compose naturally:
|
||||
* the dispatcher runs every matching entry's inner primitives in
|
||||
* insertion order.
|
||||
*
|
||||
* Inner primitives see `event = { kind: "marker-expire", markerId,
|
||||
* markerKind, square }`. Target defaults to `'self'` which resolves
|
||||
* to `markerId` (the dying marker entity) — primitives that want to
|
||||
* act on the square or on pieces standing there must explicitly use
|
||||
* a target redirect (`{ squares: [square] }`) or read `event.square`.
|
||||
*
|
||||
* If the marker's MarkerKind / Position fact is missing (defensive —
|
||||
* shouldn't happen because the caller `decrementMarkerLifetimes`
|
||||
* just read both), the dispatcher silently skips: a half-retracted
|
||||
* marker is not a kind we can match against.
|
||||
*/
|
||||
export function fireOnMarkerExpireHooks(
|
||||
engine: ChessEngine,
|
||||
markerId: EntityId,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
const markerKind = engine.session.get(markerId, "MarkerKind") as
|
||||
| MarkerKindValue
|
||||
| undefined;
|
||||
const square = engine.session.get(markerId, "Position") as
|
||||
| Square
|
||||
| undefined;
|
||||
if (markerKind === undefined || typeof square !== "number") return;
|
||||
|
||||
const hooks = engine.session.get(GAME_ENTITY, "OnMarkerExpireHooks") as
|
||||
| ChessAttrMap["OnMarkerExpireHooks"]
|
||||
| undefined;
|
||||
if (hooks === undefined || hooks.length === 0) return;
|
||||
|
||||
const event: PrimitiveEvent = {
|
||||
kind: "marker-expire",
|
||||
markerId,
|
||||
markerKind,
|
||||
square,
|
||||
};
|
||||
|
||||
for (const hook of hooks) {
|
||||
if (hook.markerKind !== markerKind) continue;
|
||||
runPrimitives(
|
||||
engine,
|
||||
markerId,
|
||||
hook.primitives,
|
||||
1,
|
||||
event,
|
||||
new Map(),
|
||||
cascadeDepth,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate matcher for OnMovedOntoSquare's filter union.
|
||||
* - `kind: "squares"` matches if the destination is in the list.
|
||||
|
|
|
|||
|
|
@ -242,6 +242,156 @@ export interface ChessAttrMap {
|
|||
* "undefined" as "no chooser available" and surface a runtime error.
|
||||
*/
|
||||
LastModifierChooser: PieceColor;
|
||||
/**
|
||||
* T16 — on-rule-activated trigger storage. Game-level (per
|
||||
* `GAME_ENTITY`) list of hook entries seeded by the
|
||||
* `on-rule-activated` primitive at descriptor-apply time. Each
|
||||
* entry records the descriptor id that registered the hook and the
|
||||
* inner primitive list to fire on activation. The dispatcher
|
||||
* (`fireOnRuleActivatedHooks` in triggers.ts) filters by
|
||||
* descriptorId so each hook only fires for its OWN descriptor's
|
||||
* activation event. The list grows as descriptors attach; entries
|
||||
* are NOT removed when their descriptor expires (T17 owns expire
|
||||
* cleanup if needed — for V1 the entries are immortal because
|
||||
* `on-rule-activated` only fires once per descriptor activation,
|
||||
* not on every move).
|
||||
*/
|
||||
OnRuleActivatedHooks: readonly OnRuleActivatedHookEntry[];
|
||||
/**
|
||||
* T16 — fire-once guard for `on-rule-activated`. List of descriptor
|
||||
* ids whose `on-rule-activated` hooks have already fired in THIS
|
||||
* game session. Stored on `PRESET_STATE_ENTITY` so it's distinct
|
||||
* from the per-piece hook registration storage. Checked at
|
||||
* `applyCustomDescriptor` time; descriptors whose id is already in
|
||||
* the list skip the activation fire (handles game-reload from save:
|
||||
* the persisted fact prevents re-firing on session rehydrate).
|
||||
*/
|
||||
RuleActivatedFiredFor: readonly string[];
|
||||
/**
|
||||
* T18 — `on-piece-entered-marker` hook entries. Stored on
|
||||
* `GAME_ENTITY` (game-level, not per-piece) because the descriptor
|
||||
* that seeded the hook is conceptually a rule about ANY piece
|
||||
* entering a marker of the matching kind, not a property of the
|
||||
* descriptor's apply target.
|
||||
*
|
||||
* Dispatched in priority order via `engine.getMarkersAtSquare`
|
||||
* (T10) — that helper sorts by hardcoded MARKER_KIND_PRIORITY
|
||||
* ascending (portal-end first), tie-break by entity id ascending.
|
||||
*
|
||||
* Each entry stores its source `descriptorId` (debugging / dedup),
|
||||
* the exact `markerKind` it filters by (no wildcard — a descriptor
|
||||
* that wants to fire for two kinds installs two hook entries), and
|
||||
* the inner primitive list to run.
|
||||
*/
|
||||
OnPieceEnteredMarkerHooks: readonly OnPieceEnteredMarkerHookEntry[];
|
||||
/**
|
||||
* T17 — `on-rule-expire` trigger storage. Mirror image of
|
||||
* `OnRuleActivatedHooks` for the descriptor-detach side. Stored on
|
||||
* `GAME_ENTITY` (one list per game). Each entry pairs a descriptor
|
||||
* id with the inner primitive list to fire when THAT descriptor
|
||||
* detaches (lifetime ends, manual remove, piece-modifier captured
|
||||
* with its piece). The dispatcher (`fireOnRuleExpireHooks` in
|
||||
* triggers.ts) filters by descriptorId so each hook only fires
|
||||
* for its OWN descriptor's expire event.
|
||||
*
|
||||
* V1 wire-in status: descriptor lifetimes are not yet wired into
|
||||
* any detach pipeline, so `fireOnRuleExpireHooks` is exposed as a
|
||||
* STUB callable from future work (T19 lifetime decrementer,
|
||||
* eventual remove-modifier action). The seeding side (this attr +
|
||||
* the primitive's `apply()`) IS active today so descriptors that
|
||||
* include `on-rule-expire` blocks register the hooks at apply
|
||||
* time; they simply never fire until a detach pipeline lands.
|
||||
*/
|
||||
OnRuleExpireHooks: readonly OnRuleExpireHookEntry[];
|
||||
/**
|
||||
* T17 — fire-once guard for `on-rule-expire`. List of descriptor
|
||||
* ids whose `on-rule-expire` hooks have already fired in THIS
|
||||
* game session. Stored on `PRESET_STATE_ENTITY` (mirror of
|
||||
* T16's `RuleActivatedFiredFor`). Checked at the top of
|
||||
* `fireOnRuleExpireHooks`; descriptors whose id is already in
|
||||
* the list skip the expire fire. Persists across save→load via
|
||||
* normal session fact rehydrate, so a re-attach + re-detach
|
||||
* cycle in the SAME game does NOT re-fire (consistent with the
|
||||
* "once per descriptor id per game session" contract pinned by
|
||||
* T16's activation guard).
|
||||
*/
|
||||
RuleExpireFiredFor: readonly string[];
|
||||
/**
|
||||
* T19 — `on-marker-expire` hook entries. Stored on `GAME_ENTITY`
|
||||
* (game-level, not per-marker) — the descriptor that seeded the
|
||||
* hook is conceptually a rule about ANY marker of the matching
|
||||
* kind expiring, not a property of a specific marker entity.
|
||||
*
|
||||
* Dispatched by `fireOnMarkerExpireHooks` (triggers.ts) which is
|
||||
* called from `decrementMarkerLifetimes` (util/marker-lifetime.ts)
|
||||
* during onAfterMove stage 7c — AFTER on-piece-entered-marker
|
||||
* (stage 7b) so a piece entering a marker can still trigger its
|
||||
* entry effects in the SAME move that the lifetime expires (the
|
||||
* decrementer fires expire hooks AFTER entry hooks).
|
||||
*
|
||||
* Each entry stores its source `descriptorId` (debugging/dedup),
|
||||
* the exact `markerKind` it filters by (no wildcard — a
|
||||
* descriptor watching two kinds installs two entries), and the
|
||||
* inner primitive list to run when a matching marker expires.
|
||||
*/
|
||||
OnMarkerExpireHooks: readonly OnMarkerExpireHookEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* T16 — single entry in `OnRuleActivatedHooks`. Stored on
|
||||
* `GAME_ENTITY` (one list per game). Each entry pairs the
|
||||
* descriptor id that seeded the hook with the inner primitive list
|
||||
* the dispatcher will fire when that descriptor activates. Multiple
|
||||
* entries from the same descriptor may coexist (composite
|
||||
* `on-rule-activated` blocks); the dispatcher fires every matching
|
||||
* entry on activation.
|
||||
*/
|
||||
export interface OnRuleActivatedHookEntry {
|
||||
readonly descriptorId: string;
|
||||
readonly primitives: readonly EffectPrimitiveNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* T18 — single entry in `OnPieceEnteredMarkerHooks`. Stored on
|
||||
* `GAME_ENTITY` (one list per game). Each entry pairs the source
|
||||
* descriptor id, the marker kind it watches for (exact match, no
|
||||
* wildcard), and the inner primitive list to run when a piece
|
||||
* enters a square containing that marker kind. Multiple entries
|
||||
* from the same descriptor may coexist for different marker kinds.
|
||||
*/
|
||||
export interface OnPieceEnteredMarkerHookEntry {
|
||||
readonly descriptorId: string;
|
||||
readonly markerKind: MarkerKindValue;
|
||||
readonly primitives: readonly EffectPrimitiveNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* T17 — single entry in `OnRuleExpireHooks`. Stored on
|
||||
* `GAME_ENTITY` (one list per game). Each entry pairs the
|
||||
* descriptor id that seeded the hook with the inner primitive list
|
||||
* the dispatcher fires when that descriptor detaches. Mirrors
|
||||
* `OnRuleActivatedHookEntry` for the expire side; multiple entries
|
||||
* from the same descriptor compose (each `on-rule-expire` block
|
||||
* inside a descriptor tree appends one entry).
|
||||
*/
|
||||
export interface OnRuleExpireHookEntry {
|
||||
readonly descriptorId: string;
|
||||
readonly primitives: readonly EffectPrimitiveNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* T19 — single entry in `OnMarkerExpireHooks`. Stored on
|
||||
* `GAME_ENTITY` (one list per game). Each entry pairs the source
|
||||
* descriptor id, the marker kind it watches for (exact match, no
|
||||
* wildcard), and the inner primitive list to run when a marker of
|
||||
* that kind expires (lifetime-driven removal via
|
||||
* `decrementMarkerLifetimes`). Multiple entries from the same
|
||||
* descriptor may coexist for different marker kinds.
|
||||
*/
|
||||
export interface OnMarkerExpireHookEntry {
|
||||
readonly descriptorId: string;
|
||||
readonly markerKind: MarkerKindValue;
|
||||
readonly primitives: readonly EffectPrimitiveNode[];
|
||||
}
|
||||
|
||||
export type ChessAttrKey = keyof ChessAttrMap;
|
||||
|
|
|
|||
|
|
@ -210,3 +210,214 @@ exports[`ParamField rendering (T14 regression baseline) > set-capture-flag rende
|
|||
"flag": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CAN_CAPTURE_OWN — the piece may capture its own color's pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">flag</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) absorb-damage-with-attribute renders attr + rate 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Absorb Damage with Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">absorb-damage-with-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Declares that incoming damage should first deplete a user-chosen counter (rate points per damage) before touching HP. You must seed the counter itself with seed-attribute — this primitive only wires the absorb mechanic, not the charge supply.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">3-charge shield (pair with seed-attribute)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "ShieldCharges",
|
||||
"rate": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pair with seed-attribute {attr: 'ShieldCharges', value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Hardened armor (rate=2)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "ArmorPlates",
|
||||
"rate": 2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-absorb-damage-with-attribute-attr" data-recognized="false" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-absorb-damage-with-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/><span class="text-[10px] font-semibold px-1.5 py-0.5 rounded text-red-800 bg-red-50 border border-red-200" title="This attribute isn't a built-in and isn't seeded by any primitive in this descriptor. Reading it will be a no-op unless seeded elsewhere." data-testid="primitive-absorb-damage-with-attribute-attr-badge">not seeded</span></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">rate</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) add-aura renders radius + targetAttr + delta 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Aura</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-aura</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Radiates a numeric contribution to targetAttr onto every piece within \`radius\` (Chebyshev / king-move distance — radius 1 = 8 neighbours). Recomputes after every move; pieces moving out of range lose the contribution on the next pass. Self-application is skipped. Multiple auras to the same targetAttr from different sources accumulate additively.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">King aura: +1 HP within 2 squares</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"radius": 2,
|
||||
"targetAttr": "HpBonus",
|
||||
"delta": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Adjacent range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"radius": 1,
|
||||
"targetAttr": "RangeBonus",
|
||||
"delta": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Anyone standing next to this piece (8 neighbouring squares) gets +1 to range.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">radius</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">targetAttr</label><div class="relative" data-testid="primitive-add-aura-targetAttr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-aura-targetAttr-input" aria-autocomplete="list" aria-expanded="false" value="HpBonus"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) add-direction renders directions array fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Direction</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-direction</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Appends one or more color-relative named directions into the piece's DirectionAdditions array, deduplicated by name. Composes with the built-in Direction Additions modifier — both write to the same fact. Valid directions: forward, backward, left, right, diagonal-fl, diagonal-fr, diagonal-bl, diagonal-br.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Backward-capable pawn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"directions": [
|
||||
"backward"
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Lets a pawn step backward as well as forward.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Full omnidirectional king-lite</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"directions": [
|
||||
"forward",
|
||||
"backward",
|
||||
"left",
|
||||
"right"
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">directions</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
"forward",
|
||||
"backward"
|
||||
]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) add-to-attribute renders attr + delta fields 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add To Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-to-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reads the current numeric value of attr (0 if unset) and writes existing + delta. Delta may be negative. Composes additively with other primitives and built-in modifiers — multiple add-to-attribute primitives for the same attr simply accumulate.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+2 HP bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "HpBonus",
|
||||
"delta": 2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds 2 to whatever HpBonus is already there.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Heal 1/turn (inside on-turn-start)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color's turn.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-add-to-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-to-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) block-move-type renders moveType enum 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Block Move Type</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">block-move-type</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Filters out generated moves matching the given type. Multiple block primitives accumulate into a blocked-move-type set (deduped). Useful for pacifist pieces that still slide, or for pieces that can capture but not reposition silently.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Pacifist piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"moveType": "capture"
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece can step and slide freely but cannot capture — a pure support piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Charge-only attacker</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"moveType": "step"
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Removes simple step moves; piece can only capture or slide.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">moveType</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="capture" selected="">capture</option><option value="step">step</option><option value="slide">slide</option></select></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) conditional renders complex-schema JSON fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Conditional</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">conditional</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Branches on a condition. If true → runs every primitive in \`then\`; if false and \`else\` is set → runs \`else\`. Condition types: attr-lt (numeric less-than), attr-gt (numeric greater-than), attr-eq (exact match against string/number/boolean/null), always (unconditional then), never (forces else path only).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Low-HP fortress</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"condition": {
|
||||
"type": "attr-lt",
|
||||
"attr": "Hp",
|
||||
"value": 2
|
||||
},
|
||||
"then": [
|
||||
{
|
||||
"kind": "set-capture-flag",
|
||||
"params": {
|
||||
"flag": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Unconditional thorns example</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"condition": {
|
||||
"type": "always"
|
||||
},
|
||||
"then": [
|
||||
{
|
||||
"kind": "reflect-damage",
|
||||
"params": {
|
||||
"percentage": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">condition</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="[object Object]"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">then</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
{
|
||||
"kind": "set-capture-flag",
|
||||
"params": {
|
||||
"flag": 2
|
||||
}
|
||||
}
|
||||
]</textarea></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">else</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) modify-movement-range renders delta 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Modify Movement Range</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">modify-movement-range</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Adds delta to the piece's RangeBonus. Composes additively with the built-in Range Bonus modifier and with other modify-movement-range primitives. Delta is clamped to integer range [-7, 7]. Rook/bishop/queen sliding is extended/reduced by this amount; knight/king ranges are treated by their own pipeline.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+1 range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"delta": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A rook's horizontal slide reaches one square further than its baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">-2 range debuff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"delta": -2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Cuts 2 squares from the piece's reach (useful for 'slowed' tokens).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) multiply-attribute renders attr + factor fields 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Multiply Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">multiply-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reads the existing numeric value of attr and writes existing * factor. No-op if the attribute is unset — it does NOT treat absent as 1. Use after seed-attribute or add-to-attribute when you need a baseline to scale.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Double HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "Hp",
|
||||
"factor": 2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If the piece already has 4 HP, becomes 8 HP.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Halve range bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "RangeBonus",
|
||||
"factor": 0.5
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-multiply-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-multiply-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">factor</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) on-capture renders primitives-array fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Capture</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-capture</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps nested primitives that fire when this piece captures another. Typical uses: 'vampire' lifesteal (heal on capture), stacking buffs, or power-up triggers. Fires only on actual captures, not on quiet moves.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Vampire lifesteal</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"primitives": [
|
||||
{
|
||||
"kind": "add-to-attribute",
|
||||
"params": {
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
{
|
||||
"kind": "add-to-attribute",
|
||||
"params": {
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}
|
||||
}
|
||||
]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) on-damaged renders primitives-array fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Damaged</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-damaged</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps nested primitives that fire whenever this piece takes damage. Useful for reactive behaviours: auto-thorns, emergency buffs, or conditional transformations when HP crosses a threshold (combine with \`conditional\`).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Thorns on hit</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"primitives": [
|
||||
{
|
||||
"kind": "reflect-damage",
|
||||
"params": {
|
||||
"percentage": 25
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When this piece takes damage, reflects 25% back to the attacker for that hit.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
{
|
||||
"kind": "reflect-damage",
|
||||
"params": {
|
||||
"percentage": 25
|
||||
}
|
||||
}
|
||||
]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) on-turn-start renders primitives-array fallback 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Turn Start</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-turn-start</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps a list of nested primitives that fire at the start of this piece's color's turn. Use for recurring buffs/healing/debuffs tied to turn cadence. The editor's Parameter Inspector accepts the nested \`primitives\` array as JSON; copy snippets from the simpler primitives into that array.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Regenerate 1 HP/turn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"primitives": [
|
||||
{
|
||||
"kind": "add-to-attribute",
|
||||
"params": {
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
|
||||
{
|
||||
"kind": "add-to-attribute",
|
||||
"params": {
|
||||
"attr": "Hp",
|
||||
"delta": 1
|
||||
}
|
||||
}
|
||||
]</textarea></div></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) override-promotion renders target enum 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Override Promotion</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">override-promotion</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Forces this piece (typically a pawn) to promote to a specific type regardless of player choice. Mirrors the built-in Promotion Override modifier, but expressable inside a custom primitive tree. Last write wins if multiple sources set it.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Knights-only promotion</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"target": "knight"
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pawn always promotes to a knight.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Underpromote to rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"target": "rook"
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pawn always promotes to a rook — useful for themed variants.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">target</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="pawn">pawn</option><option value="knight" selected="">knight</option><option value="bishop">bishop</option><option value="rook">rook</option><option value="queen">queen</option><option value="king">king</option></select></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) reflect-damage renders percentage 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Reflect Damage</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">reflect-damage</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reflects a percentage of incoming damage back to the attacker. Integer percent, 0-100. Multiple reflect primitives on the same piece do NOT stack — the most recent value wins. Great inside on-damaged if you want a one-time thorns reaction instead of a permanent aura.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Half-reflective armour</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"percentage": 50
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">50% of incoming damage is dealt back to the attacker.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Total thorns</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"percentage": 100
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Full reflection — the attacker takes whatever they dealt.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">percentage</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="25"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) seed-attribute renders attr + value fields 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Seed Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">seed-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Writes { attr, value } directly onto the piece, overwriting any existing value. Use to introduce new attributes (like a custom ShieldCharges counter) or to force a baseline (e.g. set HP to an exact number regardless of inheritance). Pair with add-to-attribute / multiply-attribute to build up a final value.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Force exact HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "Hp",
|
||||
"value": 5
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece always starts with 5 HP regardless of baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Declare shield charges</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"attr": "ShieldCharges",
|
||||
"value": 3
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-seed-attribute-attr" data-recognized="true" data-mode="declare"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-seed-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">value</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="3"/></div></div>"
|
||||
`;
|
||||
|
||||
exports[`ParamField rendering (T14 regression baseline) set-capture-flag renders flag enum 1`] = `
|
||||
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Set Capture Flag</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">set-capture-flag</span></div><span class="text-xs text-blue-600 font-medium">Hide docs & examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Turns on one capture-flag bit. Flags combine (OR) so stacking multiple primitives is fine. Supported: 1 = CAN_CAPTURE_OWN (piece may capture its own color), 2 = CANNOT_BE_CAPTURED (untargetable by enemies), 4 = EN_PASSANT (piece participates in en-passant capture resolution).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Untouchable piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"flag": 2
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Friendly-fire rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
|
||||
"flag": 1
|
||||
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CAN_CAPTURE_OWN — the piece may capture its own color's pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">flag</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
|
||||
`;
|
||||
|
|
|
|||
121
packages/chess/src/util/marker-lifetime.ts
Normal file
121
packages/chess/src/util/marker-lifetime.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* T19 — per-move marker lifetime sweep.
|
||||
*
|
||||
* Walks every marker entity in the session, expires any whose
|
||||
* `MarkerLifetime` is exhausted, fires the matching
|
||||
* `on-marker-expire` hooks (BEFORE retraction so inner primitives
|
||||
* can still read the marker's facts), then removes the marker via
|
||||
* `engine.removeMarker`.
|
||||
*
|
||||
* Called from `apply.ts#onAfterMove` as STAGE 7c — directly after
|
||||
* stage 7b (`fireOnPieceEnteredMarkerHooks`) and BEFORE stage 8
|
||||
* (check-line edge triggers). This ordering means a piece that
|
||||
* lands on a marker triggers the marker's entry hooks (7b) FIRST,
|
||||
* then the lifetime sweep (7c) gets a chance to retire markers
|
||||
* whose `expiresAtMove` target was just reached. The sequencing is
|
||||
* intentional: a marker scheduled to expire ON move N still fires
|
||||
* its entry effect when a piece lands on it during move N.
|
||||
*
|
||||
* ## Lifetime variants (per `decisions.md` § Square State via
|
||||
* Marker Entities)
|
||||
*
|
||||
* - `permanent`: never auto-expires. Skipped here.
|
||||
* - `moves; expiresAtMove: N`: expires when the engine's current
|
||||
* move counter (`FullmoveNumber` on `GAME_ENTITY`) is `>= N`.
|
||||
* `expiresAtMove` is an ABSOLUTE target, NOT a countdown
|
||||
* remainder (decisions.md locks this — decremented-style
|
||||
* lifetimes are explicitly rejected).
|
||||
* - `one-shot`: NOT auto-decremented here. Consumed by
|
||||
* `on-piece-entered-marker` per T0/T18 (the entry hook is
|
||||
* responsible for calling `engine.removeMarker` itself, or a
|
||||
* `destroy-marker` primitive nested in the entry block does
|
||||
* so). The lifetime sweep MUST skip these — auto-decrementing
|
||||
* would break the locked semantics.
|
||||
*
|
||||
* ## Iteration safety
|
||||
*
|
||||
* Marker discovery and expiry-fire happen in TWO separate phases:
|
||||
* 1. Walk `session.allFacts()` to collect ALL marker ids whose
|
||||
* lifetime is exhausted into a local `expired` array.
|
||||
* 2. Iterate `expired` to fire hooks + remove. Mutating the
|
||||
* session during phase 1 would invalidate the iterator.
|
||||
*
|
||||
* This split also means a marker spawned by a fired expire hook is
|
||||
* NOT itself swept this turn (it'll be checked next turn). Mirrors
|
||||
* stage 7b's "markers spawned mid-arm aren't visible to earlier
|
||||
* pieces" semantic. Cross-turn cascades are T15's deferred-trigger
|
||||
* queue's job.
|
||||
*
|
||||
* ## Cascade-paired markers
|
||||
*
|
||||
* Paired-marker cleanup (e.g. portal endpoints linking via
|
||||
* `MarkerLinks`) is EXPLICITLY DEFERRED to T30 (`destroy-marker`
|
||||
* primitive). This sweep removes ONLY the marker whose lifetime
|
||||
* expired — the partner stays. A descriptor that wants paired
|
||||
* removal can install an `on-marker-expire` hook that calls
|
||||
* `destroy-marker` on the partner.
|
||||
*
|
||||
* @param engine the engine whose markers to sweep.
|
||||
* @param cascadeDepth threading for T15's depth cap. Top-level
|
||||
* apply.ts callers pass 0 (default).
|
||||
*/
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type MarkerLifetimeValue,
|
||||
} from "../schema.js";
|
||||
import { fireOnMarkerExpireHooks } from "../modifiers/triggers.js";
|
||||
|
||||
export function decrementMarkerLifetimes(
|
||||
engine: ChessEngine,
|
||||
cascadeDepth: number = 0,
|
||||
): void {
|
||||
// Read the current move counter once. `FullmoveNumber` is the
|
||||
// engine's per-completed-fullmove counter on GAME_ENTITY,
|
||||
// initialized to 1 and incremented after black completes its
|
||||
// move (see engine.ts#advanceTurnAfterMutation). It's the
|
||||
// canonical "current move count" — matches the decisions.md
|
||||
// semantic for `expiresAtMove` (absolute target move number).
|
||||
const currentMoveCount = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"FullmoveNumber",
|
||||
);
|
||||
if (typeof currentMoveCount !== "number") return;
|
||||
|
||||
// Phase 1: collect every expired marker id. Mutating the session
|
||||
// mid-iteration would invalidate `allFacts()` — collect first,
|
||||
// act second.
|
||||
const expired: EntityId[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const fact of engine.session.allFacts()) {
|
||||
if (fact.attr !== "EntityKind" || fact.value !== "marker") continue;
|
||||
if (seen.has(fact.id as number)) continue;
|
||||
seen.add(fact.id as number);
|
||||
|
||||
const lifetime = engine.session.get(fact.id, "MarkerLifetime") as
|
||||
| MarkerLifetimeValue
|
||||
| undefined;
|
||||
if (lifetime === undefined) continue;
|
||||
|
||||
// permanent → never auto-expire.
|
||||
// one-shot → consumed by on-piece-entered-marker, not here.
|
||||
// moves → expire iff current move count reached/passed target.
|
||||
if (
|
||||
lifetime.kind === "moves" &&
|
||||
currentMoveCount >= lifetime.expiresAtMove
|
||||
) {
|
||||
expired.push(fact.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: fire on-marker-expire (BEFORE removal — inner
|
||||
// primitives must be able to read the marker's facts) then
|
||||
// remove. `removeMarker` is idempotent (T10 guarantee), so a
|
||||
// primitive that itself called `removeMarker` for the same id
|
||||
// doesn't break the second call here.
|
||||
for (const id of expired) {
|
||||
fireOnMarkerExpireHooks(engine, id, cascadeDepth);
|
||||
engine.removeMarker(id);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue