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

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

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

View file

@ -80,11 +80,13 @@ import {
fireOnDamagedHooks,
fireOnMoveHooks,
fireOnMovedOntoSquareHooks,
fireOnPieceEnteredMarkerHooks,
fireOnPromotionHooks,
fireOnTurnEndHooks,
fireOnTurnStartHooks,
snapshotHp,
} from "./triggers.js";
import { decrementMarkerLifetimes } from "../util/marker-lifetime.js";
import { registerAttrConsumer } from "./primitives/manifest.js";
// Q3.2 consumer declarations: this module reads every attr listed
@ -140,6 +142,40 @@ registerAttrConsumer("KingExtraReach");
// activates so `ctx-attr: { entity: "chooser", attr: "Color" }` (T12
// param walker) can resolve to the activating player's color.
registerAttrConsumer("LastModifierChooser");
// T18 — on-piece-entered-marker hook list, stored on GAME_ENTITY.
// Read by `fireOnPieceEnteredMarkerHooks` in triggers.ts after every
// move that changed Position. APPENDED at end-of-block to avoid
// merge race with parallel T16 (OnRuleActivatedHooks /
// RuleActivatedFiredFor).
registerAttrConsumer("OnPieceEnteredMarkerHooks");
// T16 — on-rule-activated hook list (GAME_ENTITY) + fire-once guard
// (PRESET_STATE_ENTITY). The hooks are seeded by the
// `on-rule-activated` primitive at profile-apply time and consumed
// by `fireOnRuleActivatedHooks` (triggers.ts) called from
// `applyCustomDescriptor` exactly once per descriptor instance. The
// guard list dedups across reattachments and across save→load
// rehydrate (the fact persists in the session).
registerAttrConsumer("OnRuleActivatedHooks");
registerAttrConsumer("RuleActivatedFiredFor");
// T17 — on-rule-expire hook list (GAME_ENTITY) + fire-once guard
// (PRESET_STATE_ENTITY). Seeded by the `on-rule-expire` primitive at
// profile-apply time; consumed by `fireOnRuleExpireHooks` (triggers.ts)
// when a descriptor detaches. V1 wiring status: no detach pipeline
// exists yet (no `removeModifier` path), so the dispatcher is exposed
// as a public stub for T19 (lifetime decrementer) and the future
// remove-modifier action to invoke. The guard list dedups across
// reattach-detach cycles and across save→load rehydrate.
registerAttrConsumer("OnRuleExpireHooks");
registerAttrConsumer("RuleExpireFiredFor");
// T19 — on-marker-expire hook list, stored on GAME_ENTITY. Consumer
// registration co-landed here because the `on-marker-expire` primitive
// already seeds this attr (T19 file present in tree), but T19's
// consumer-registration line was missed when its parallel branch
// landed. Adding the consumer here unblocks the load-time integrity
// check; T19's actual dispatcher (`fireOnMarkerExpireHooks`) lives in
// triggers.ts. Mirrors T16's precedent of unblocking-scope cleanup
// when a sibling task leaves a manifest gap.
registerAttrConsumer("OnMarkerExpireHooks");
/**
* Per-engine pre-move HP snapshot, used by the on-damaged trigger
@ -1004,6 +1040,15 @@ PRESET_REGISTRY.register({
* 5. fireOnPromotionHooks — promoted-pawn diff
* 6. fireOnMoveHooks — Position-diff set
* 7. fireOnMovedOntoSquareHooks — per moved piece, dest-filter
* 7b. fireOnPieceEnteredMarkerHooks (T18) — per moved piece, marker priority
* 7c. decrementMarkerLifetimes (T19) — sweep markers whose
* lifetime expired; fires
* on-marker-expire BEFORE
* removeMarker. Runs AFTER
* 7b so a piece entering a
* marker scheduled to expire
* this move still triggers
* its entry effect first.
* 8. fireOnCheckReceivedHooks — pre/post check-state diff
* 9. fireOnCheckDeliveredHooks — pre/post attacker-set diff
* 10. fireConditionalHooks — branch on current facts
@ -1080,6 +1125,26 @@ PRESET_REGISTRY.register({
fireOnMovedOntoSquareHooks(ctx.engine, id, dest);
}
// 7b. T18 — on-piece-entered-marker. For each moved piece,
// resolve markers at its destination via T10's
// `engine.getMarkersAtSquare` (already priority-sorted) and
// fire matching hooks. Runs AFTER on-moved-onto-square so a
// square's static-filter hooks resolve before its
// marker-overlay hooks (a portal teleport, mine damage, etc.).
fireOnPieceEnteredMarkerHooks(ctx.engine, movedIds);
// 7c. T19 — sweep marker lifetimes. Walks every marker entity,
// expires any whose lifetime is exhausted (`moves` variant
// with `expiresAtMove <= FullmoveNumber`), fires
// `on-marker-expire` hooks BEFORE retraction, then removes
// the marker via `engine.removeMarker`. Runs AFTER 7b so a
// piece landing on a marker scheduled to expire THIS move
// still triggers the entry effect (mine damage, portal
// teleport) before the marker dies. Permanent markers and
// one-shot markers are skipped — one-shots are consumed by
// entry triggers per decisions.md.
decrementMarkerLifetimes(ctx.engine);
// 8 + 9. Check-line edge triggers. Both consume the SAME
// pre-move snapshot — the dispatchers internally compute the
// post-move attacker set and diff. We pass the snapshot as a

View file

@ -14,9 +14,14 @@
*/
import type { EntityId, Session } from "@paratype/rete";
import type { ChessEngine } from "../../engine.js";
import { PRESET_STATE_ENTITY, type PieceColor } from "../../schema.js";
import {
PRESET_STATE_ENTITY,
type ChessAttrMap,
type PieceColor,
} from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "../primitives/registry.js";
import { resolveParams } from "../primitives/param-resolver.js";
import { fireOnRuleActivatedHooks } from "../triggers.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
@ -69,6 +74,30 @@ export function applyCustomDescriptor(
nodes: descriptor.primitives,
depth: 0,
});
// T16 — fire `on-rule-activated` hooks EXACTLY ONCE per descriptor
// instance. The walker above seeded `OnRuleActivatedHooks` entries
// for every `on-rule-activated` block in the descriptor tree; we
// now fire them. The guard lives on `PRESET_STATE_ENTITY` under
// `RuleActivatedFiredFor` — a list of descriptor ids that have
// already fired in this session. This guard:
// - Skips re-fire when the SAME descriptor is applied to a
// SECOND piece (e.g. via per-type modifier hitting two pieces).
// - Skips re-fire across game reload from save: the fact persists
// in the session, so a rehydrated game inherits the guard.
// - Allows DIFFERENT descriptor ids to fire independently.
const descriptorIdStr = String(descriptor.id);
const firedFor =
(session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor") as
| ChessAttrMap["RuleActivatedFiredFor"]
| undefined) ?? [];
if (!firedFor.includes(descriptorIdStr)) {
session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [
...firedFor,
descriptorIdStr,
]);
fireOnRuleActivatedHooks(engine, descriptorIdStr);
}
}
function walkAndApply(input: {
@ -116,6 +145,23 @@ function walkAndApply(input: {
// Iteration / request-choice primitives use `withBinding` to
// introduce names; the 22 pre-T11 primitives don't consult it.
bindings: new Map(),
// T15: profile-time apply runs synchronously without trigger
// re-entrance — seed an empty queue + cascade depth 0. Any
// imperative primitive that enqueues here would still be
// drained at the end of THIS arm via the dispatcher's
// post-loop sweep, but profile-time apply walks the descriptor
// tree itself; downstream `enqueueTrigger` calls happen only
// inside trigger arms (Wave 5/6).
pendingTriggers: [],
cascadeDepth: 0,
// T20: profile-time apply is the WET path (game-start preset
// boot, real applyMove handler-driven activations). It must
// NEVER run under suppressTriggers — that flag is reserved for
// move-gen dry-mode legality probing, which doesn't enter this
// walker. The default is `false`; primitives must not branch
// on it directly (single source of truth: `runPrimitives` in
// triggers.ts).
suppressTriggers: false,
};
runPrimitive(primitive, ctx, node.params);

View file

@ -39,7 +39,7 @@ const MAX_PRIMITIVE_COUNT = 50;
* passive BEFORE the unknown-kind check so descriptors authored
* against a future runtime get a precise error code today.
*/
const IMPERATIVE_KINDS: ReadonlySet<string> = new Set<string>([
export const IMPERATIVE_KINDS: ReadonlySet<string> = new Set<string>([
"place-piece",
"destroy-piece",
"move-piece",

View file

@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
target: "self" as const,
event: undefined,
bindings: new Map(),
};
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
}
describe("ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE", () => {

View file

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

View file

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

View file

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

View file

@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
target: "self" as const,
event: undefined,
bindings: new Map(),
};
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
}
describe("BLOCK_MOVE_TYPE_PRIMITIVE", () => {

View file

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

View file

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

View file

@ -31,6 +31,7 @@
import type { EntityId } from "@paratype/rete";
import type {
ChessAttrMap,
MarkerKindValue,
PieceColor,
PieceType,
Square,
@ -100,6 +101,70 @@ export type PrimitiveEvent =
readonly kind: "capture";
readonly attackerId: EntityId;
readonly defenderId: EntityId;
}
| {
/**
* T16 — fired once per descriptor instance when the descriptor
* attaches via `applyCustomDescriptor`. Carries the descriptor
* id so inner primitives can branch on which rule activated,
* and (when resolvable) the chooser color — derived from
* `LastModifierChooser` on `PRESET_STATE_ENTITY`. Chooser is
* optional because the activation may pre-date the chooser-
* write (e.g. profile-time apply when no piece-color is
* available); inner primitives must treat undefined as "no
* chooser known".
*/
readonly kind: "rule-activated";
readonly descriptorId: string;
readonly chooserColor?: PieceColor;
}
| {
/**
* T17 — fired once per descriptor instance when the descriptor
* detaches (lifetime expires, manual remove-modifier, piece
* holding the modifier captured). Carries the descriptor id
* so inner primitives can branch on which rule expired. No
* chooser is supplied because expire is a system event, not
* a player action — the original chooser may not be available
* by the time the descriptor detaches (e.g. lifetime tick on
* a later turn).
*/
readonly kind: "rule-expire";
readonly descriptorId: string;
}
| {
/**
* T18 — fired by `fireOnPieceEnteredMarkerHooks` when a piece
* lands on a square containing one or more markers. The
* dispatcher resolves markers in priority order via
* `engine.getMarkersAtSquare` and supplies the SPECIFIC marker
* that matched (markerId + markerKind), the piece that entered,
* and the square they share.
*/
readonly kind: "piece-entered-marker";
readonly markerId: EntityId;
readonly markerKind: MarkerKindValue;
readonly pieceId: EntityId;
readonly square: Square;
}
| {
/**
* T19 — fired by `fireOnMarkerExpireHooks` when a marker's
* lifetime expires (lifetime-bound markers reaching their
* `expiresAtMove` target during the per-move
* `decrementMarkerLifetimes` sweep). The dispatcher reads
* MarkerKind + Position BEFORE the marker's facts are
* retracted and supplies them so inner primitives can branch
* on the kind that expired and the square it occupied (spawn
* a follow-up effect there, broadcast a UI banner). Fired
* BEFORE `engine.removeMarker(markerId)` retracts the
* marker's facts, so primitives that read MarkerOwner /
* MarkerLinks can still see them.
*/
readonly kind: "marker-expire";
readonly markerId: EntityId;
readonly markerKind: MarkerKindValue;
readonly square: Square;
};
/**

View file

@ -34,4 +34,8 @@ import "./on-promotion.js";
import "./on-check-received.js";
import "./on-check-delivered.js";
import "./on-moved-onto-square.js";
import "./on-rule-activated.js";
import "./on-rule-expire.js";
import "./on-piece-entered-marker.js";
import "./on-marker-expire.js";
import "./conditional.js";

View file

@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
target: "self" as const,
event: undefined,
bindings: new Map(),
};
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
}
describe("MODIFY_MOVEMENT_RANGE_PRIMITIVE", () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,341 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import {
GAME_ENTITY,
type ChessAttrMap,
} from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_MARKER_EXPIRE_PRIMITIVE } from "./on-marker-expire.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-marker-expire.js";
import { fireOnMarkerExpireHooks } from "../triggers.js";
import { decrementMarkerLifetimes } from "../../util/marker-lifetime.js";
function makeContext(): {
ctx: PrimitiveApplyContext;
session: Session;
} {
const session = new Session();
// Mirror the apply-time profile context. T19 stores hooks on
// GAME_ENTITY (id 0); ctx.pieceId is irrelevant for the seed step
// but required by the type contract.
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-on-marker-expire",
type: "data",
version: 1,
},
target: "self",
event: undefined,
bindings: new Map(),
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
return { ctx, session };
}
describe("on-marker-expire primitive — registry (T19)", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-marker-expire'", () => {
expect(PRIMITIVE_REGISTRY.has("on-marker-expire")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-marker-expire")).toBe(
ON_MARKER_EXPIRE_PRIMITIVE,
);
});
it("declares OnMarkerExpireHooks in seedsAttrs", () => {
expect(ON_MARKER_EXPIRE_PRIMITIVE.seedsAttrs).toEqual([
"OnMarkerExpireHooks",
]);
});
});
describe("on-marker-expire primitive — paramsSchema (Zod) (T19)", () => {
it("accepts a minimal valid block (markerKind + empty primitives)", () => {
const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
markerKind: "frozen-square",
primitives: [],
});
expect(result.success).toBe(true);
});
it("accepts every locked marker kind", () => {
const kinds = [
"mine",
"pit",
"portal-end",
"frozen-square",
"treasure",
"death-square",
"tornado",
"blocked",
] as const;
for (const k of kinds) {
const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
markerKind: k,
primitives: [],
});
expect(result.success).toBe(true);
}
});
it("rejects an unknown marker kind", () => {
const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
markerKind: "ghost-square",
primitives: [],
});
expect(result.success).toBe(false);
});
it("rejects missing markerKind", () => {
const result = ON_MARKER_EXPIRE_PRIMITIVE.paramsSchema.safeParse({
primitives: [],
});
expect(result.success).toBe(false);
});
});
describe("on-marker-expire primitive — apply() seeds GAME_ENTITY hook list (T19)", () => {
it("appends descriptorId + markerKind + primitives to OnMarkerExpireHooks", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
];
ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, {
markerKind: "frozen-square",
primitives,
});
expect(session.get(GAME_ENTITY, "OnMarkerExpireHooks")).toEqual([
{
descriptorId: "custom:test-on-marker-expire",
markerKind: "frozen-square",
primitives,
},
]);
});
it("appends additional hook entries (different kinds preserved in insertion order)", () => {
const { ctx, session } = makeContext();
const minePrims: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
];
const treasurePrims: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, {
markerKind: "mine",
primitives: minePrims,
});
ON_MARKER_EXPIRE_PRIMITIVE.apply(ctx, {
markerKind: "treasure",
primitives: treasurePrims,
});
expect(session.get(GAME_ENTITY, "OnMarkerExpireHooks")).toEqual([
{
descriptorId: "custom:test-on-marker-expire",
markerKind: "mine",
primitives: minePrims,
},
{
descriptorId: "custom:test-on-marker-expire",
markerKind: "treasure",
primitives: treasurePrims,
},
]);
});
});
describe("on-marker-expire primitive — childPrimitives() (T19)", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
const children = ON_MARKER_EXPIRE_PRIMITIVE.childPrimitives?.({
markerKind: "frozen-square",
primitives,
});
expect(children).toEqual(primitives);
});
});
describe("fireOnMarkerExpireHooks dispatch (T19)", () => {
it("fires nothing when no hooks are seeded", () => {
const engine = new ChessEngine();
const markerId = engine.spawnMarker("mine", 28, {
lifetime: { kind: "permanent" },
});
expect(() => fireOnMarkerExpireHooks(engine, markerId)).not.toThrow();
});
it("matches markers by exact kind only (mine hook does not fire on pit)", () => {
const engine = new ChessEngine();
// Spawn a PIT marker (NOT a mine).
const pitId = engine.spawnMarker("pit", 28, {
lifetime: { kind: "permanent" },
});
// Hook for `mine` only — should NOT fire because the marker
// we'll dispatch on is `pit`.
const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = {
descriptorId: "test:mine-only",
markerKind: "mine",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 99 } },
],
};
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]);
fireOnMarkerExpireHooks(engine, pitId);
// The seed-attribute primitive aims at ctx.pieceId (default
// target=self), which the dispatcher set to the marker id.
// RangeBonus on the marker entity must NOT be 99 — the mine hook
// didn't fire on the pit marker.
expect(engine.session.get(pitId, "RangeBonus")).toBeUndefined();
});
it("fires for matching kind with event payload describing the marker", () => {
const engine = new ChessEngine();
const markerId = engine.spawnMarker("frozen-square", 28, {
lifetime: { kind: "permanent" },
});
// Hook seeds a sentinel attr — confirms it fired at all.
const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = {
descriptorId: "test:frozen",
markerKind: "frozen-square",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 7 } },
],
};
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]);
fireOnMarkerExpireHooks(engine, markerId);
// seed-attribute aimed at ctx.pieceId (default target=self), which
// the dispatcher set to the marker entity id.
expect(engine.session.get(markerId, "RangeBonus")).toBe(7);
});
});
describe("decrementMarkerLifetimes (T19)", () => {
it("permanent marker: unchanged after sweep", () => {
const engine = new ChessEngine();
// Initial FullmoveNumber is 1 from the classic layout.
const markerId = engine.spawnMarker("mine", 28, {
lifetime: { kind: "permanent" },
});
decrementMarkerLifetimes(engine);
// Permanent marker is still on the board.
expect(engine.getMarkersAtSquare(28)).toContain(markerId);
expect(engine.session.get(markerId, "MarkerKind")).toBe("mine");
});
it("moves: expiresAtMove <= currentMoveCount → fires + removes", () => {
const engine = new ChessEngine();
// Force FullmoveNumber to 5 so a marker with expiresAtMove=5
// should expire (>= comparison).
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 5);
const markerId = engine.spawnMarker("frozen-square", 28, {
lifetime: { kind: "moves", expiresAtMove: 5 },
});
// Seed an on-marker-expire hook that records firing via a
// sentinel attr on GAME_ENTITY (since the marker entity gets
// its facts retracted by removeMarker after the hook fires).
const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = {
descriptorId: "test:frozen-expire",
markerKind: "frozen-square",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 42 } },
],
};
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]);
decrementMarkerLifetimes(engine);
// Marker is gone (removeMarker retracted all marker attrs).
expect(engine.session.get(markerId, "MarkerKind")).toBeUndefined();
expect(engine.getMarkersAtSquare(28)).not.toContain(markerId);
// Hook DID fire — RangeBonus was seeded on the marker entity
// BEFORE removal (the seed-attribute primitive wrote the fact,
// then removeMarker only retracts the canonical marker attrs
// — RangeBonus is not one of them, so it persists as a relic).
expect(engine.session.get(markerId, "RangeBonus")).toBe(42);
});
it("moves: expiresAtMove > currentMoveCount → still present", () => {
const engine = new ChessEngine();
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 3);
const markerId = engine.spawnMarker("frozen-square", 28, {
lifetime: { kind: "moves", expiresAtMove: 10 },
});
decrementMarkerLifetimes(engine);
// Not yet expired — still on the board.
expect(engine.getMarkersAtSquare(28)).toContain(markerId);
expect(engine.session.get(markerId, "MarkerKind")).toBe("frozen-square");
});
it("one-shot: no auto-decrement (sweep skips, marker remains)", () => {
const engine = new ChessEngine();
// Even with FullmoveNumber jacked to 100, one-shot lifetimes
// are NEVER auto-expired — they're consumed by entry triggers.
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 100);
const markerId = engine.spawnMarker("mine", 28, {
lifetime: { kind: "one-shot" },
});
decrementMarkerLifetimes(engine);
// Still on the board.
expect(engine.getMarkersAtSquare(28)).toContain(markerId);
expect(engine.session.get(markerId, "MarkerKind")).toBe("mine");
});
it("only fires for matching marker kind (frozen-square hook does not fire on pit expiry)", () => {
const engine = new ChessEngine();
engine.session.insert(GAME_ENTITY, "FullmoveNumber", 5);
// Spawn a pit marker that's about to expire.
const pitId = engine.spawnMarker("pit", 28, {
lifetime: { kind: "moves", expiresAtMove: 5 },
});
// Hook for `frozen-square` only — should NOT fire on pit expiry.
const hook: ChessAttrMap["OnMarkerExpireHooks"][number] = {
descriptorId: "test:frozen-only",
markerKind: "frozen-square",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 99 } },
],
};
engine.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [hook]);
decrementMarkerLifetimes(engine);
// Pit marker IS removed (its lifetime expired regardless of hook
// matching), but the frozen-square hook didn't fire.
expect(engine.session.get(pitId, "MarkerKind")).toBeUndefined();
expect(engine.session.get(pitId, "RangeBonus")).toBeUndefined();
});
});

View file

@ -0,0 +1,124 @@
import { z } from "zod";
import {
GAME_ENTITY,
type ChessAttrMap,
type MarkerKindValue,
type OnMarkerExpireHookEntry,
} from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* T19 — `on-marker-expire` trigger primitive.
*
* Seeds an entry in `OnMarkerExpireHooks` (game-level, on
* `GAME_ENTITY`) that fires when a marker of the matching kind
* EXPIRES via the per-move lifetime sweep
* (`decrementMarkerLifetimes` in `util/marker-lifetime.ts`).
* Multiple hook entries across descriptors compose naturally: the
* dispatcher (`fireOnMarkerExpireHooks` in `triggers.ts`) finds the
* expiring marker's kind, then runs every matching hook's inner
* primitive list.
*
* Mirrors `on-piece-entered-marker` by design: same params shape
* (`markerKind` + `primitives`), same locked-enum gate, same
* GAME_ENTITY storage. The only divergence is the trigger phase —
* `on-piece-entered-marker` fires when a piece LANDS on the marker
* (stage 7b), `on-marker-expire` fires when the marker's lifetime
* runs out (stage 7c, AFTER 7b in the same onAfterMove pass).
*
* `markerKind` MUST be one of the 8 frozen marker kinds — adding a
* new kind is a plan-amending event (no wildcard, no auto-assign).
*
* ## Lifetime semantics
*
* Of the three `MarkerLifetimeValue` variants:
* - `permanent`: never expires; this hook NEVER fires for it.
* - `moves; expiresAtMove`: fires when
* `engine.session.get(GAME_ENTITY, "FullmoveNumber") >= expiresAtMove`
* during the per-move lifetime sweep.
* - `one-shot`: NOT auto-expired here — its consumption is the job
* of `on-piece-entered-marker` + `destroy-marker`. The
* decrementer skips one-shot markers entirely; this hook will
* fire if a one-shot marker is destroyed via a different
* pathway that explicitly invokes `fireOnMarkerExpireHooks`,
* but the per-move sweep does not.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
/**
* Locked enumeration mirror of `MarkerKindValue`. `as const satisfies`
* pins the array to the union exactly — adding/removing a kind in
* `schema.ts` without updating this list is a compile-time error.
*/
const MARKER_KIND_VALUES = [
"mine",
"pit",
"portal-end",
"frozen-square",
"treasure",
"death-square",
"tornado",
"blocked",
] as const satisfies readonly MarkerKindValue[];
const schema = z.object({
markerKind: z.enum(MARKER_KIND_VALUES),
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-marker-expire",
label: "On Marker Expire",
description:
"Seeds OnMarkerExpireHooks entries fired when a marker of the matching kind expires (lifetime sweep).",
longDescription:
"Wraps nested primitives that fire when a marker of `markerKind` expires via the per-move lifetime decrementer. Inner primitives see `event = { kind: 'marker-expire', markerId, markerKind, square }` so they can spawn follow-up effects on the dying marker's square (e.g. visual fizzle, tombstone marker, recompute regions). Fired BEFORE the marker's facts are retracted so primitives that read MarkerOwner/MarkerLinks still resolve. Match is exact-kind only — a hook for `mine` does NOT fire on `pit`. Permanent markers never expire; one-shot markers are consumed by entry triggers, not by this sweep.",
examples: [
{
title: "Frozen-square thaw — broadcast a UI fizzle when ice melts",
params: {
markerKind: "frozen-square",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
],
},
effect:
"When a frozen-square marker's `expiresAtMove` is reached, runs the inner primitives once. The event payload carries the dying marker's id, kind ('frozen-square'), and the square it occupied — useful for spawning a 'thaw' visual effect or recomputing pathing regions on that tile.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnMarkerExpireHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(GAME_ENTITY, "OnMarkerExpireHooks") as
| ChessAttrMap["OnMarkerExpireHooks"]
| undefined) ?? [];
const next: OnMarkerExpireHookEntry = {
descriptorId: ctx.descriptor.id,
markerKind: params.markerKind,
primitives: [...params.primitives],
};
ctx.session.insert(GAME_ENTITY, "OnMarkerExpireHooks", [
...existing,
next,
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_MARKER_EXPIRE_PRIMITIVE };

View file

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

View file

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

View file

@ -0,0 +1,371 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import {
GAME_ENTITY,
type ChessAttrMap,
} from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_PIECE_ENTERED_MARKER_PRIMITIVE } from "./on-piece-entered-marker.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-piece-entered-marker.js";
import { fireOnPieceEnteredMarkerHooks } from "../triggers.js";
function makeContext(): {
ctx: PrimitiveApplyContext;
session: Session;
} {
const session = new Session();
// Mirror the apply-time profile context. T18 stores hooks on
// GAME_ENTITY (id 0); ctx.pieceId is irrelevant for the seed step
// but required by the type contract.
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-on-piece-entered-marker",
type: "data",
version: 1,
},
target: "self",
event: undefined,
bindings: new Map(),
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
return { ctx, session };
}
describe("on-piece-entered-marker primitive — registry (T18)", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-piece-entered-marker'", () => {
expect(PRIMITIVE_REGISTRY.has("on-piece-entered-marker")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-piece-entered-marker")).toBe(
ON_PIECE_ENTERED_MARKER_PRIMITIVE,
);
});
it("declares OnPieceEnteredMarkerHooks in seedsAttrs", () => {
expect(ON_PIECE_ENTERED_MARKER_PRIMITIVE.seedsAttrs).toEqual([
"OnPieceEnteredMarkerHooks",
]);
});
});
describe("on-piece-entered-marker primitive — paramsSchema (Zod) (T18)", () => {
it("accepts a minimal valid block (markerKind + empty primitives)", () => {
const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({
markerKind: "mine",
primitives: [],
});
expect(result.success).toBe(true);
});
it("accepts every locked marker kind", () => {
const kinds = [
"mine",
"pit",
"portal-end",
"frozen-square",
"treasure",
"death-square",
"tornado",
"blocked",
] as const;
for (const k of kinds) {
const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({
markerKind: k,
primitives: [],
});
expect(result.success).toBe(true);
}
});
it("rejects an unknown marker kind", () => {
const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({
markerKind: "ghost-square",
primitives: [],
});
expect(result.success).toBe(false);
});
it("rejects missing markerKind", () => {
const result = ON_PIECE_ENTERED_MARKER_PRIMITIVE.paramsSchema.safeParse({
primitives: [],
});
expect(result.success).toBe(false);
});
});
describe("on-piece-entered-marker primitive — apply() seeds GAME_ENTITY hook list (T18)", () => {
it("appends descriptorId + markerKind + primitives to OnPieceEnteredMarkerHooks", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
];
ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, {
markerKind: "mine",
primitives,
});
expect(session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks")).toEqual([
{
descriptorId: "custom:test-on-piece-entered-marker",
markerKind: "mine",
primitives,
},
]);
});
it("appends additional hook entries (different kinds preserved in insertion order)", () => {
const { ctx, session } = makeContext();
const minePrims: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
];
const treasurePrims: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, {
markerKind: "mine",
primitives: minePrims,
});
ON_PIECE_ENTERED_MARKER_PRIMITIVE.apply(ctx, {
markerKind: "treasure",
primitives: treasurePrims,
});
expect(session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks")).toEqual([
{
descriptorId: "custom:test-on-piece-entered-marker",
markerKind: "mine",
primitives: minePrims,
},
{
descriptorId: "custom:test-on-piece-entered-marker",
markerKind: "treasure",
primitives: treasurePrims,
},
]);
});
});
describe("on-piece-entered-marker primitive — childPrimitives() (T18)", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 0 } },
];
const children = ON_PIECE_ENTERED_MARKER_PRIMITIVE.childPrimitives?.({
markerKind: "mine",
primitives,
});
expect(children).toEqual(primitives);
});
});
describe("fireOnPieceEnteredMarkerHooks dispatch (T18)", () => {
/**
* Helper: install a hook on GAME_ENTITY that, when fired, decrements
* the entered piece's `Hp` by 1 (using `add-to-attribute` would
* require fact-presence; we hand-roll a synthetic primitive instead
* for ordering observability — see priority test).
*/
it("fires nothing when no hooks are seeded", () => {
const engine = new ChessEngine();
// Standard starting position — pawn on e2 (id assigned by preset).
// No hooks seeded → no errors, no mutations.
expect(() =>
fireOnPieceEnteredMarkerHooks(engine, []),
).not.toThrow();
});
it("matches markers by exact kind only (mine hook does not fire on pit)", () => {
const engine = new ChessEngine();
// Spawn a piece (needs Position fact for the dispatcher to read).
const pieceId = engine.session.nextId();
engine.session.insert(pieceId, "EntityKind", "piece");
engine.session.insert(pieceId, "Color", "white");
engine.session.insert(pieceId, "PieceType", "pawn");
engine.session.insert(pieceId, "Position", 28); // e4
engine.session.insert(pieceId, "Hp", 10);
// Spawn a PIT marker (NOT a mine) on the same square.
engine.spawnMarker("pit", 28, { lifetime: { kind: "permanent" } });
// Seed a hook for `mine` kind only — it should NOT fire because
// the marker on the square is `pit`.
const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
descriptorId: "test:mine-only",
markerKind: "mine",
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -5 } },
],
};
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]);
fireOnPieceEnteredMarkerHooks(engine, [pieceId]);
// Hp unchanged — mine hook didn't fire on pit marker.
expect(engine.session.get(pieceId, "Hp")).toBe(10);
});
it("priority order: portal-end fires BEFORE mine on the same square", () => {
const engine = new ChessEngine();
// Track primitive fire order via a synthetic primitive that
// appends to a shared array. We register it once at module
// top-level (via try/catch — Vitest re-evaluates files in watch
// mode and the registry is immutable).
const fireOrder: string[] = [];
// Use the existing `add-to-attribute` primitive against a custom
// attr to record observable side effects per hook kind. Each hook
// increments a distinct attribute by 1; we check the FINAL attr
// values AND the order they were applied via session-state diffs.
//
// We capture order via two different marker-kind hooks each
// bumping a unique counter — then assert both ran AND the
// dispatcher visited markers in priority-sorted order via
// `engine.getMarkersAtSquare` (T10 returns portal-end first).
// Place a piece on e4 (28).
const pieceId = engine.session.nextId();
engine.session.insert(pieceId, "EntityKind", "piece");
engine.session.insert(pieceId, "Color", "white");
engine.session.insert(pieceId, "PieceType", "pawn");
engine.session.insert(pieceId, "Position", 28);
// Spawn TWO markers on the same square: a mine (priority 3) and
// a portal-end (priority 1). Spawn order: mine first, portal
// second — to prove dispatch-order is by PRIORITY, not spawn.
const mineId = engine.spawnMarker("mine", 28, {
lifetime: { kind: "permanent" },
});
const portalId = engine.spawnMarker("portal-end", 28, {
lifetime: { kind: "permanent" },
});
// Verify T10's helper returns priority-sorted output (sanity).
const markers = engine.getMarkersAtSquare(28);
expect(markers).toEqual([portalId, mineId]);
// Seed hooks: each writes a fact recording the firing order.
// We use `seed-attribute` against a distinct attr per hook so
// post-dispatch we can read both. To capture ORDER we observe
// T10's helper directly (asserted above) — combined with the
// dispatcher's documented contract (iterate result of
// getMarkersAtSquare in order), this proves priority is honoured.
const portalHook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
descriptorId: "test:portal",
markerKind: "portal-end",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
],
};
const mineHook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
descriptorId: "test:mine",
markerKind: "mine",
primitives: [
// After portal seed runs (HpBonus=1), this multiply-attribute
// doubles HpBonus to 2. If mine fired BEFORE portal we would
// observe HpBonus=1 (multiply on absent → no-op or different),
// not 2.
{ kind: "multiply-attribute", params: { attr: "HpBonus", factor: 2 } },
],
};
// Order in the hooks array does NOT determine dispatch order —
// the dispatcher iterates markers by priority and matches each to
// applicable hooks. Insert mine hook FIRST in the array to prove
// hook-array order is irrelevant.
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [
mineHook,
portalHook,
]);
// Track via shared array for ordered observability:
fireOrder.push("__before__");
fireOnPieceEnteredMarkerHooks(engine, [pieceId]);
fireOrder.push("__after__");
// Portal-end's primitive ran first (seed HpBonus=1), then mine's
// (multiply by 2 → 2). If priority were inverted we'd see
// HpBonus = 1 (mine no-op on absent attr, then portal seed = 1).
expect(engine.session.get(pieceId, "HpBonus")).toBe(2);
});
it("fires hook with event payload describing the marker, piece, and square", () => {
const engine = new ChessEngine();
const pieceId = engine.session.nextId();
engine.session.insert(pieceId, "EntityKind", "piece");
engine.session.insert(pieceId, "Color", "white");
engine.session.insert(pieceId, "PieceType", "pawn");
engine.session.insert(pieceId, "Position", 28);
const treasureId = engine.spawnMarker("treasure", 28, {
lifetime: { kind: "permanent" },
});
// Hook that seeds a sentinel attr — confirms it fired at all.
const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
descriptorId: "test:treasure",
markerKind: "treasure",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } },
],
};
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]);
fireOnPieceEnteredMarkerHooks(engine, [pieceId]);
// The seed-attribute primitive aims at ctx.pieceId (default
// target=self), which the dispatcher set to the entering piece.
expect(engine.session.get(pieceId, "HpBonus")).toBe(7);
// Marker still on square — dispatcher does NOT auto-remove.
expect(engine.getMarkersAtSquare(28)).toContain(treasureId);
});
it("fires for multiple moved pieces independently", () => {
const engine = new ChessEngine();
const a = engine.session.nextId();
engine.session.insert(a, "EntityKind", "piece");
engine.session.insert(a, "Color", "white");
engine.session.insert(a, "PieceType", "pawn");
engine.session.insert(a, "Position", 28);
const b = engine.session.nextId();
engine.session.insert(b, "EntityKind", "piece");
engine.session.insert(b, "Color", "black");
engine.session.insert(b, "PieceType", "pawn");
engine.session.insert(b, "Position", 35);
engine.spawnMarker("treasure", 28, {
lifetime: { kind: "permanent" },
});
engine.spawnMarker("treasure", 35, {
lifetime: { kind: "permanent" },
});
const hook: ChessAttrMap["OnPieceEnteredMarkerHooks"][number] = {
descriptorId: "test:treasure-multi",
markerKind: "treasure",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 9 } },
],
};
engine.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [hook]);
fireOnPieceEnteredMarkerHooks(engine, [a, b]);
expect(engine.session.get(a, "HpBonus")).toBe(9);
expect(engine.session.get(b, "HpBonus")).toBe(9);
});
});

View file

@ -0,0 +1,106 @@
import { z } from "zod";
import {
GAME_ENTITY,
type ChessAttrMap,
type MarkerKindValue,
type OnPieceEnteredMarkerHookEntry,
} from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* T18 — `on-piece-entered-marker` trigger primitive.
*
* Seeds an entry in `OnPieceEnteredMarkerHooks` (game-level, on
* `GAME_ENTITY`) that fires when ANY piece lands on a square
* containing a marker of the matching kind. Multiple hook entries
* across descriptors compose naturally: the dispatcher
* (`fireOnPieceEnteredMarkerHooks` in `triggers.ts`) iterates
* markers at the destination via `engine.getMarkersAtSquare` —
* which returns markers SORTED by hardcoded
* `MARKER_KIND_PRIORITY` ascending (portal-end first), tie-break
* by entity id ascending. So a piece entering a square with both a
* portal-end and a mine sees portal-end fire BEFORE mine, as
* locked by `decisions.md` § Marker Collision Priority.
*
* `markerKind` MUST be one of the 8 frozen marker kinds — adding a
* new kind is a plan-amending event (no wildcard, no auto-assign).
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
/**
* Locked enumeration mirror of `MarkerKindValue`. `as const satisfies`
* pins the array to the union exactly — adding/removing a kind in
* `schema.ts` without updating this list is a compile-time error.
*/
const MARKER_KIND_VALUES = [
"mine",
"pit",
"portal-end",
"frozen-square",
"treasure",
"death-square",
"tornado",
"blocked",
] as const satisfies readonly MarkerKindValue[];
const schema = z.object({
markerKind: z.enum(MARKER_KIND_VALUES),
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-piece-entered-marker",
label: "On Piece Entered Marker",
description:
"Seeds OnPieceEnteredMarkerHooks entries consumed when any piece lands on a marker of the matching kind.",
longDescription:
"Wraps nested primitives that fire when ANY piece (mover, castling rook, etc.) finishes a move on a square that contains a marker of `markerKind`. Markers stack: a square may carry several markers; the dispatcher fires hooks in priority order (portal-end → frozen-square → mine → pit → death-square → tornado → treasure → blocked) so teleport effects resolve before damage and damage resolves before passive markers like 'blocked'. Match is exact-kind only — a hook for `mine` does NOT fire on `pit`.",
examples: [
{
title: "Minefield — piece entering a mine takes 1 damage",
params: {
markerKind: "mine",
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
],
},
effect:
"Whenever any piece lands on a mine marker, that piece's Hp drops by 1. Stacks if multiple mines occupy the square (each mine fires its own hook chain).",
},
],
paramsSchema: schema,
seedsAttrs: ["OnPieceEnteredMarkerHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(GAME_ENTITY, "OnPieceEnteredMarkerHooks") as
| ChessAttrMap["OnPieceEnteredMarkerHooks"]
| undefined) ?? [];
const next: OnPieceEnteredMarkerHookEntry = {
descriptorId: ctx.descriptor.id,
markerKind: params.markerKind,
primitives: [...params.primitives],
};
ctx.session.insert(GAME_ENTITY, "OnPieceEnteredMarkerHooks", [
...existing,
next,
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_PIECE_ENTERED_MARKER_PRIMITIVE };

View file

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

View file

@ -0,0 +1,379 @@
/**
* `on-rule-activated` primitive tests (T16).
*
* Two test layers:
* 1. Primitive-level: registry presence, paramsSchema, apply()
* seeding, multi-descriptor stacking on `OnRuleActivatedHooks`.
* 2. Integration-level: `applyCustomDescriptor` fire-once semantics
* via the `RuleActivatedFiredFor` guard on `PRESET_STATE_ENTITY`.
*/
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import {
GAME_ENTITY,
PRESET_STATE_ENTITY,
type ChessAttrMap,
} from "../../schema.js";
import { applyCustomDescriptor } from "../custom/apply.js";
import {
asCustomModifierId,
type CustomModifierDescriptor,
} from "../custom/types.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_RULE_ACTIVATED_PRIMITIVE } from "./on-rule-activated.js";
import type {
EffectPrimitiveNode,
PrimitiveApplyContext,
} from "./types.js";
import "./on-rule-activated.js";
function makeContext(descriptorId = "custom:test-on-rule-activated"): {
ctx: PrimitiveApplyContext;
session: Session;
} {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: { id: descriptorId, type: "data", version: 1 },
target: "self",
event: undefined,
bindings: new Map(),
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
return { ctx, session };
}
function makeDescriptor(
id: string,
primitives: readonly EffectPrimitiveNode[],
): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId(id),
name: id,
description: "",
version: 1,
primitives,
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
}
describe("on-rule-activated primitive — registry (T16)", () => {
it("registers in PRIMITIVE_REGISTRY under 'on-rule-activated'", () => {
expect(PRIMITIVE_REGISTRY.has("on-rule-activated")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-rule-activated")).toBe(
ON_RULE_ACTIVATED_PRIMITIVE,
);
});
});
describe("on-rule-activated primitive — paramsSchema (T16)", () => {
it("validates `{ primitives: [...] }`", () => {
expect(() =>
ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({ primitives: [] }),
).not.toThrow();
expect(() =>
ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
}),
).not.toThrow();
});
it("rejects empty / wrong-shape params", () => {
expect(() =>
ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({}),
).toThrow();
expect(() =>
ON_RULE_ACTIVATED_PRIMITIVE.paramsSchema.parse({ primitives: 7 }),
).toThrow();
});
});
describe("on-rule-activated primitive — apply() (T16)", () => {
it("seeds OnRuleActivatedHooks on GAME_ENTITY with descriptor id + primitives", () => {
const { ctx, session } = makeContext("custom:announce");
const inner: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
];
ON_RULE_ACTIVATED_PRIMITIVE.apply(ctx, { primitives: inner });
const stored = session.get(
GAME_ENTITY,
"OnRuleActivatedHooks",
) as ChessAttrMap["OnRuleActivatedHooks"] | undefined;
expect(stored).toBeDefined();
expect(stored).toHaveLength(1);
expect(stored![0]!.descriptorId).toBe("custom:announce");
expect(stored![0]!.primitives).toEqual(inner);
});
it("stacks multiple descriptors as separate entries on the list", () => {
const { ctx: ctxA, session } = makeContext("custom:rule-a");
// Reuse the same session by constructing a second ctx that shares it.
const ctxB: PrimitiveApplyContext = {
...ctxA,
descriptor: { id: "custom:rule-b", type: "data", version: 1 },
};
const innerA: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
];
const innerB: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 2 } },
];
ON_RULE_ACTIVATED_PRIMITIVE.apply(ctxA, { primitives: innerA });
ON_RULE_ACTIVATED_PRIMITIVE.apply(ctxB, { primitives: innerB });
const stored = session.get(
GAME_ENTITY,
"OnRuleActivatedHooks",
) as ChessAttrMap["OnRuleActivatedHooks"] | undefined;
expect(stored).toHaveLength(2);
expect(stored!.map((h) => h.descriptorId)).toEqual([
"custom:rule-a",
"custom:rule-b",
]);
});
it("childPrimitives() returns the inner list for validator traversal", () => {
const inner: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
];
const children = ON_RULE_ACTIVATED_PRIMITIVE.childPrimitives?.({
primitives: inner,
});
expect(children).toEqual(inner);
});
});
describe("on-rule-activated primitive — fire-once integration (T16)", () => {
it("fires inner primitives ONCE on first applyCustomDescriptor", () => {
const engine = new ChessEngine();
// Pick any existing piece for the apply target — descriptor id
// governs fire-once, not piece id.
const session = engine.session;
let pieceId = 0 as ReturnType<typeof session.nextId>;
for (const f of session.allFacts()) {
if (f.attr === "Color" && (f.id as number) > 0) {
pieceId = f.id;
break;
}
}
expect(pieceId).not.toBe(0); // sanity — engine seeded pieces
const descriptor = makeDescriptor("custom:rule-fires", [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "seed-attribute",
params: { attr: "HpBonus", value: 7 },
},
],
},
},
]);
applyCustomDescriptor(engine, session, pieceId, descriptor);
// The on-rule-activated primitive's inner block runs against
// GAME_ENTITY (the "rule-activated" target). HpBonus seeded there.
expect(session.get(GAME_ENTITY, "HpBonus")).toBe(7);
// Fire-once guard recorded on PRESET_STATE_ENTITY.
const fired = session.get(
PRESET_STATE_ENTITY,
"RuleActivatedFiredFor",
) as readonly string[] | undefined;
expect(fired).toEqual(["custom:rule-fires"]);
});
it("does NOT re-fire when the same descriptor id applies a second time", () => {
const engine = new ChessEngine();
const session = engine.session;
let pieceId = 0 as ReturnType<typeof session.nextId>;
for (const f of session.allFacts()) {
if (f.attr === "Color" && (f.id as number) > 0) {
pieceId = f.id;
break;
}
}
// Inner primitive INCREMENTS HpBonus on GAME_ENTITY each time it
// fires. If the guard works, it fires exactly once → final == 1.
// If it re-fires on the second apply, final would be 2.
const descriptor = makeDescriptor("custom:rule-once", [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "add-to-attribute",
params: { attr: "HpBonus", delta: 1 },
},
],
},
},
]);
applyCustomDescriptor(engine, session, pieceId, descriptor);
applyCustomDescriptor(engine, session, pieceId, descriptor);
expect(session.get(GAME_ENTITY, "HpBonus")).toBe(1);
const fired = session.get(
PRESET_STATE_ENTITY,
"RuleActivatedFiredFor",
) as readonly string[] | undefined;
expect(fired).toEqual(["custom:rule-once"]);
});
it("DIFFERENT descriptor ids each fire independently (one-shot per id, not global)", () => {
const engine = new ChessEngine();
const session = engine.session;
let pieceId = 0 as ReturnType<typeof session.nextId>;
for (const f of session.allFacts()) {
if (f.attr === "Color" && (f.id as number) > 0) {
pieceId = f.id;
break;
}
}
const descA = makeDescriptor("custom:rule-A", [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "add-to-attribute",
params: { attr: "HpBonus", delta: 1 },
},
],
},
},
]);
const descB = makeDescriptor("custom:rule-B", [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "add-to-attribute",
params: { attr: "HpBonus", delta: 4 },
},
],
},
},
]);
applyCustomDescriptor(engine, session, pieceId, descA);
applyCustomDescriptor(engine, session, pieceId, descB);
// Both fired exactly once: 1 + 4 = 5.
expect(session.get(GAME_ENTITY, "HpBonus")).toBe(5);
const fired = session.get(
PRESET_STATE_ENTITY,
"RuleActivatedFiredFor",
) as readonly string[] | undefined;
expect(fired).toEqual(["custom:rule-A", "custom:rule-B"]);
});
it("simulates save→load: pre-seeded RuleActivatedFiredFor blocks re-fire", () => {
const engine = new ChessEngine();
const session = engine.session;
let pieceId = 0 as ReturnType<typeof session.nextId>;
for (const f of session.allFacts()) {
if (f.attr === "Color" && (f.id as number) > 0) {
pieceId = f.id;
break;
}
}
// Simulate a rehydrated game where the descriptor previously
// fired (the persisted fact is in the session).
session.insert(PRESET_STATE_ENTITY, "RuleActivatedFiredFor", [
"custom:rule-rehydrated",
]);
const descriptor = makeDescriptor("custom:rule-rehydrated", [
{
kind: "on-rule-activated",
params: {
primitives: [
{
kind: "seed-attribute",
params: { attr: "HpBonus", value: 99 },
},
],
},
},
]);
applyCustomDescriptor(engine, session, pieceId, descriptor);
// Did NOT fire — HpBonus on GAME_ENTITY remains undefined.
expect(session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
// Guard list unchanged.
expect(
session.get(PRESET_STATE_ENTITY, "RuleActivatedFiredFor"),
).toEqual(["custom:rule-rehydrated"]);
});
it("chooser color resolves into ctx.event for inner primitives", () => {
const engine = new ChessEngine();
const session = engine.session;
// Pick a WHITE piece so applyCustomDescriptor's chooser-track
// stub (T14) writes 'white' to LastModifierChooser.
let whitePieceId = 0 as ReturnType<typeof session.nextId>;
for (const f of session.allFacts()) {
if (
f.attr === "Color" &&
f.value === "white" &&
(f.id as number) > 0
) {
whitePieceId = f.id;
break;
}
}
expect(whitePieceId).not.toBe(0);
const descriptor = makeDescriptor("custom:rule-chooser", [
{
kind: "on-rule-activated",
params: {
// Inner block doesn't directly read event; we assert the
// chooser-fact wiring reaches the stub by verifying that
// T14's LastModifierChooser was set to 'white' BEFORE the
// hook fires (the dispatcher reads it to construct event).
primitives: [
{
kind: "seed-attribute",
params: { attr: "HpBonus", value: 3 },
},
],
},
},
]);
applyCustomDescriptor(engine, session, whitePieceId, descriptor);
// Chooser stamped by T14 logic (applyCustomDescriptor wrote it).
expect(
session.get(PRESET_STATE_ENTITY, "LastModifierChooser"),
).toBe("white");
// Hook fired.
expect(session.get(GAME_ENTITY, "HpBonus")).toBe(3);
});
});

View file

@ -0,0 +1,88 @@
/**
* `on-rule-activated` trigger primitive (T16).
*
* Seeds an entry into `OnRuleActivatedHooks` on `GAME_ENTITY` at
* profile-apply time. The integration preset's `applyCustomDescriptor`
* fires the matching hooks EXACTLY ONCE per descriptor instance, the
* first time the descriptor attaches. The fire-once guard lives on
* `PRESET_STATE_ENTITY` under `RuleActivatedFiredFor` (a list of
* descriptor ids that have already fired); reloaded games inherit
* the guard via persisted facts so a save→load round-trip does NOT
* re-fire.
*
* Storage rationale: hooks live on `GAME_ENTITY` (not the piece) so
* a single descriptor activated on multiple pieces still only fires
* its `on-rule-activated` block once — the descriptor-id guard
* dedups across all attachments.
*/
import { z } from "zod";
import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Inline NodeSchema (mirrors `on-capture.ts`). The tree validator
* (T19) handles deep kind-validation; here we only assert the
* structural shape `{ kind, params }` so the params schema is a
* one-line Zod definition.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-rule-activated",
label: "On Rule Activated",
description:
"Seeds OnRuleActivatedHooks entries fired exactly once when the descriptor attaches.",
longDescription:
"Wraps nested primitives that fire ONCE when this descriptor first activates on the game. Typical uses: announce the rule (broadcast a banner attr), seed initial board state (place markers, set per-game counters), or grant a one-time bonus to the chooser. Does NOT re-fire on game reload — a per-game guard on PRESET_STATE_ENTITY tracks which descriptor ids have already fired.",
examples: [
{
title: "Announcement banner on activation",
params: {
primitives: [
{
kind: "seed-attribute",
params: { attr: "RangeBonus", value: 1 },
},
],
},
effect:
"When the rule first attaches, runs the inner seed-attribute once. Subsequent moves do NOT re-trigger; expiration / re-application within the same game also does not re-fire.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnRuleActivatedHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(GAME_ENTITY, "OnRuleActivatedHooks") as
| ChessAttrMap["OnRuleActivatedHooks"]
| undefined) ?? [];
ctx.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [
...existing,
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_RULE_ACTIVATED_PRIMITIVE };

View file

@ -0,0 +1,279 @@
/**
* `on-rule-expire` primitive tests (T17).
*
* Mirror image of T16's on-rule-activated tests:
* 1. Primitive-level: registry presence, paramsSchema, apply()
* seeding, multi-descriptor stacking on `OnRuleExpireHooks`,
* childPrimitives() introspection.
* 2. Dispatcher-level: `fireOnRuleExpireHooks` fires matching
* descriptor's hooks once, fire-once guard via
* `RuleExpireFiredFor` on `PRESET_STATE_ENTITY`, idempotent
* double-detach.
*/
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import {
GAME_ENTITY,
PRESET_STATE_ENTITY,
type ChessAttrMap,
} from "../../schema.js";
import { fireOnRuleExpireHooks } from "../triggers.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_RULE_EXPIRE_PRIMITIVE } from "./on-rule-expire.js";
import type {
EffectPrimitiveNode,
PrimitiveApplyContext,
} from "./types.js";
import "./on-rule-expire.js";
function makeContext(descriptorId = "custom:test-on-rule-expire"): {
ctx: PrimitiveApplyContext;
session: Session;
} {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: { id: descriptorId, type: "data", version: 1 },
target: "self",
event: undefined,
bindings: new Map(),
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
return { ctx, session };
}
describe("on-rule-expire primitive — registry (T17)", () => {
it("registers in PRIMITIVE_REGISTRY under 'on-rule-expire'", () => {
expect(PRIMITIVE_REGISTRY.has("on-rule-expire")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-rule-expire")).toBe(
ON_RULE_EXPIRE_PRIMITIVE,
);
});
});
describe("on-rule-expire primitive — paramsSchema (T17)", () => {
it("validates `{ primitives: [...] }`", () => {
expect(() =>
ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({ primitives: [] }),
).not.toThrow();
expect(() =>
ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
}),
).not.toThrow();
});
it("rejects empty / wrong-shape params", () => {
expect(() => ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({})).toThrow();
expect(() =>
ON_RULE_EXPIRE_PRIMITIVE.paramsSchema.parse({ primitives: 7 }),
).toThrow();
});
});
describe("on-rule-expire primitive — apply() (T17)", () => {
it("seeds OnRuleExpireHooks on GAME_ENTITY with descriptor id + primitives", () => {
const { ctx, session } = makeContext("custom:cleanup");
const inner: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
];
ON_RULE_EXPIRE_PRIMITIVE.apply(ctx, { primitives: inner });
const stored = session.get(
GAME_ENTITY,
"OnRuleExpireHooks",
) as ChessAttrMap["OnRuleExpireHooks"] | undefined;
expect(stored).toBeDefined();
expect(stored).toHaveLength(1);
expect(stored![0]!.descriptorId).toBe("custom:cleanup");
expect(stored![0]!.primitives).toEqual(inner);
});
it("stacks multiple descriptors as separate entries on the list", () => {
const { ctx: ctxA, session } = makeContext("custom:rule-a");
// Reuse the same session by constructing a second ctx that shares it.
const ctxB: PrimitiveApplyContext = {
...ctxA,
descriptor: { id: "custom:rule-b", type: "data", version: 1 },
};
const innerA: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
];
const innerB: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
];
ON_RULE_EXPIRE_PRIMITIVE.apply(ctxA, { primitives: innerA });
ON_RULE_EXPIRE_PRIMITIVE.apply(ctxB, { primitives: innerB });
const stored = session.get(
GAME_ENTITY,
"OnRuleExpireHooks",
) as ChessAttrMap["OnRuleExpireHooks"] | undefined;
expect(stored).toHaveLength(2);
expect(stored!.map((h) => h.descriptorId)).toEqual([
"custom:rule-a",
"custom:rule-b",
]);
});
it("childPrimitives() returns the inner list for validator traversal", () => {
const inner: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
];
const children = ON_RULE_EXPIRE_PRIMITIVE.childPrimitives?.({
primitives: inner,
});
expect(children).toEqual(inner);
});
});
describe("on-rule-expire dispatcher — fireOnRuleExpireHooks (T17)", () => {
it("fires the matching descriptor's primitives, skips other descriptors", () => {
const engine = new ChessEngine();
// Seed two hooks for two different descriptors. Only descriptor
// 'rule-A' should fire when we dispatch for 'rule-A'.
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "rule-A",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } },
],
},
{
descriptorId: "rule-B",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } },
],
},
]);
fireOnRuleExpireHooks(engine, "rule-A");
// Inner primitives target GAME_ENTITY (the dispatcher passes it
// as `pieceId` into runPrimitives).
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(7);
// rule-B's primitive did NOT fire — RangeBonus on GAME_ENTITY
// remains undefined.
expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined();
// Guard list now contains the fired descriptor id.
const fired = engine.session.get(
PRESET_STATE_ENTITY,
"RuleExpireFiredFor",
) as readonly string[] | undefined;
expect(fired).toEqual(["rule-A"]);
});
it("fire-once guard: second call for same descriptor is a no-op", () => {
const engine = new ChessEngine();
// Use add-to-attribute so we can detect double-firing: each fire
// would increment HpBonus by 1; with the guard, exactly one fire
// → final value 1.
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "rule-once",
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
],
},
]);
fireOnRuleExpireHooks(engine, "rule-once");
fireOnRuleExpireHooks(engine, "rule-once"); // idempotent on double-detach
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(1);
const fired = engine.session.get(
PRESET_STATE_ENTITY,
"RuleExpireFiredFor",
) as readonly string[] | undefined;
expect(fired).toEqual(["rule-once"]);
});
it("DIFFERENT descriptor ids each fire independently (one-shot per id, not global)", () => {
const engine = new ChessEngine();
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "rule-A",
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
],
},
{
descriptorId: "rule-B",
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 4 } },
],
},
]);
fireOnRuleExpireHooks(engine, "rule-A");
fireOnRuleExpireHooks(engine, "rule-B");
// Both fired exactly once: 1 + 4 = 5.
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(5);
const fired = engine.session.get(
PRESET_STATE_ENTITY,
"RuleExpireFiredFor",
) as readonly string[] | undefined;
expect(fired).toEqual(["rule-A", "rule-B"]);
});
it("simulates save→load: pre-seeded RuleExpireFiredFor blocks re-fire", () => {
const engine = new ChessEngine();
// Simulate a rehydrated game where the descriptor previously
// expired (the persisted fact is in the session).
engine.session.insert(PRESET_STATE_ENTITY, "RuleExpireFiredFor", [
"rule-rehydrated",
]);
engine.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
{
descriptorId: "rule-rehydrated",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 99 } },
],
},
]);
fireOnRuleExpireHooks(engine, "rule-rehydrated");
// Did NOT fire — HpBonus on GAME_ENTITY remains undefined.
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
// Guard list unchanged (no re-append since already present).
expect(
engine.session.get(PRESET_STATE_ENTITY, "RuleExpireFiredFor"),
).toEqual(["rule-rehydrated"]);
});
it("no hooks seeded: dispatcher is a no-op but still records the guard", () => {
const engine = new ChessEngine();
// No OnRuleExpireHooks seeded. fireOnRuleExpireHooks should not
// throw, and the guard records the descriptor id (so a later
// re-detach attempt is also a no-op).
expect(() =>
fireOnRuleExpireHooks(engine, "rule-no-hooks"),
).not.toThrow();
const fired = engine.session.get(
PRESET_STATE_ENTITY,
"RuleExpireFiredFor",
) as readonly string[] | undefined;
expect(fired).toEqual(["rule-no-hooks"]);
});
});

View file

@ -0,0 +1,97 @@
/**
* `on-rule-expire` trigger primitive (T17).
*
* Mirror image of T16's `on-rule-activated` for the descriptor-detach
* side. Seeds an entry into `OnRuleExpireHooks` on `GAME_ENTITY` at
* profile-apply time; the dispatcher (`fireOnRuleExpireHooks` in
* `triggers.ts`) fires the matching hooks EXACTLY ONCE when the
* descriptor detaches. The fire-once guard lives on
* `PRESET_STATE_ENTITY` under `RuleExpireFiredFor`.
*
* V1 wiring status: descriptor lifetimes are not yet wired into any
* detach pipeline (no `removeModifier` / `detachDescriptor` path
* exists today). The seeding side IS active so descriptors that
* include `on-rule-expire` blocks register them at apply time; the
* fire side is exposed as the public stub `fireOnRuleExpireHooks`
* for the future T19 lifetime decrementer and the eventual
* remove-modifier action handler to invoke. Until a caller exists,
* the hooks remain dormant — by design, expire only fires on actual
* detach.
*
* Storage rationale: hooks live on `GAME_ENTITY` (not the piece) so
* a single descriptor attached to multiple pieces still only fires
* its `on-rule-expire` block once — the descriptor-id guard dedups
* across all attachments on the expire side, mirroring T16's
* activation behaviour.
*/
import { z } from "zod";
import { GAME_ENTITY, type ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Inline NodeSchema (mirrors `on-rule-activated.ts`). The tree
* validator (T19) handles deep kind-validation; here we only assert
* the structural shape `{ kind, params }` so the params schema is a
* one-line Zod definition.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-rule-expire",
label: "On Rule Expire",
description:
"Seeds OnRuleExpireHooks entries fired exactly once when the descriptor detaches.",
longDescription:
"Wraps nested primitives that fire ONCE when this descriptor detaches from the game (lifetime expires, manual remove-modifier action, or — for piece-bound modifiers — when the holding piece is captured). Typical uses: tear down banners or counters previously seeded by `on-rule-activated`, retract per-game state, or grant a parting bonus/penalty. A per-game guard on PRESET_STATE_ENTITY (`RuleExpireFiredFor`) tracks which descriptor ids have already fired their expire block, so a re-attach + re-detach cycle in the same session does NOT re-fire — consistent with the once-per-descriptor-id activation contract.",
examples: [
{
title: "Cleanup banner on expire",
params: {
primitives: [
{
kind: "seed-attribute",
params: { attr: "RangeBonus", value: 0 },
},
],
},
effect:
"When the rule detaches, runs the inner seed-attribute once to reset RangeBonus. Subsequent re-attach-detach cycles do NOT re-trigger; only the FIRST detach fires.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnRuleExpireHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(GAME_ENTITY, "OnRuleExpireHooks") as
| ChessAttrMap["OnRuleExpireHooks"]
| undefined) ?? [];
ctx.session.insert(GAME_ENTITY, "OnRuleExpireHooks", [
...existing,
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_RULE_EXPIRE_PRIMITIVE };

View file

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

View file

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

View file

@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
target: "self" as const,
event: undefined,
bindings: new Map(),
};
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
}
describe("OVERRIDE_PROMOTION_PRIMITIVE", () => {

View file

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

View file

@ -18,7 +18,10 @@ function makeContext(session: Session, pieceId: EntityId) {
target: "self" as const,
event: undefined,
bindings: new Map(),
};
pendingTriggers: [],
cascadeDepth: 0,
suppressTriggers: false,
};
}
describe("REFLECT_DAMAGE_PRIMITIVE", () => {

View file

@ -2,9 +2,13 @@ import { describe, it, expect } from "vitest";
import { PRIMITIVE_REGISTRY } from "./index.js";
describe("PRIMITIVE_REGISTRY", () => {
it("should have exactly 22 registered primitives after barrel import", () => {
it("should have exactly 26 registered primitives after barrel import", () => {
// T16 added "on-rule-activated"; T18 added "on-piece-entered-marker"
// (22 → 24). T17 added "on-rule-expire" (24 → 25). T19 added
// "on-marker-expire" (25 → 26). Each new primitive is a
// plan-amending event — bump this number with intent.
const count = PRIMITIVE_REGISTRY.list().length;
expect(count).toBe(22);
expect(count).toBe(26);
});
it("should list all primitive kinds with non-empty descriptor objects", () => {

View file

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

View file

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

View file

@ -8,6 +8,48 @@ import type {
TargetResolver,
} from "./context.js";
/**
* Trigger kinds (T15).
*
* Names of the existing `fire*Hooks` family in `triggers.ts`,
* surfaced here as a type so deferred-trigger entries are
* statically constrained. The four Wave-4 trigger kinds
* (`on-rule-activated`, `on-rule-expire`, `on-piece-entered-marker`,
* `on-marker-expire`) are listed eagerly so T16-T19 can mirror this
* dispatcher without extending the union.
*/
export type TriggerName =
| "on-capture"
| "on-captured"
| "on-move"
| "on-damaged"
| "on-promotion"
| "on-check-received"
| "on-check-delivered"
| "on-moved-onto-square"
| "on-turn-start"
| "on-turn-end"
| "on-rule-activated"
| "on-rule-expire"
| "on-piece-entered-marker"
| "on-marker-expire";
/**
* A trigger event enqueued by an imperative primitive for deferred
* firing (T15). Drained AFTER the current arm completes (not
* mid-iteration). Each drained entry runs against the next cascade
* depth — see `runPrimitives` in `triggers.ts` for the guard.
*
* `payload` is event-specific; the dispatcher's `fireTriggerByKind`
* narrows it per-kind (e.g. `{attackerId, defenderId}` for
* `on-captured`, `{moved: EntityId[]}` for `on-move`).
*/
export interface PendingTrigger {
readonly kind: TriggerName;
readonly pieceId: EntityId;
readonly payload?: unknown;
}
/**
* T3 primitive ids (ADR-2).
*/
@ -33,6 +75,10 @@ export type PrimitiveKind =
| "on-check-received"
| "on-check-delivered"
| "on-moved-onto-square"
| "on-rule-activated"
| "on-rule-expire"
| "on-piece-entered-marker"
| "on-marker-expire"
| "conditional";
/**
@ -101,6 +147,60 @@ export interface PrimitiveApplyContext {
* preserves their behaviour byte-identically.
*/
readonly bindings: ReadonlyMap<string, BindingValue>;
/**
* Deferred-trigger queue (T15). Imperative primitives that
* synthesize secondary triggers (e.g. `destroy-piece` causing
* `on-captured`) push entries here via
* `enqueueTrigger(ctx, trigger)`; the dispatcher drains the queue
* AFTER the current arm completes — never mid-iteration. The
* queue is per-arm (each `runPrimitives` invocation seeds a fresh
* `[]`), so it does NOT leak between top-level dispatches.
*
* Mutable (an array, not a Map) by design — the immutability
* guarantee that applies to `bindings` (T11) does NOT apply here:
* the queue is intentionally shared between siblings of one arm
* so they can collectively defer secondary fires.
*
* Default at every construction site: `[]`. Primitives that
* never enqueue (the 22 pre-T15 primitives) inherit a no-op
* empty queue and behave byte-identically.
*/
readonly pendingTriggers: PendingTrigger[];
/**
* Cascade depth (T15). Top-level dispatcher entry runs at 0; each
* deferred trigger drained from `pendingTriggers` re-enters the
* dispatcher at `cascadeDepth + 1`. Hard cap = 8 (mirrors
* `RUNTIME_DEPTH_HARD_CAP`); breach throws a runtime error with
* code `runtime.cascade-depth-exceeded`.
*
* Orthogonal to the existing `depth` field — `depth` counts
* nested primitive arrays inside ONE arm; `cascadeDepth` counts
* cross-arm chains via the deferred queue. Do NOT mix.
*/
readonly cascadeDepth: number;
/**
* Move-generation dry-mode flag (T20). When `true`, imperative
* primitives — those listed in `IMPERATIVE_KINDS` (exported from
* `../custom/validate.js`) — are NO-OPS. The dispatcher
* (`runPrimitives` in `triggers.ts`) skips dispatching them
* entirely; non-imperative primitives (predicates, conditionals,
* iteration introspection) still execute normally so legality
* analysis can branch on the same data the wet path would.
*
* Set by the move-generator when probing "what-if" legality (e.g.
* check detection during legal-move enumeration) so triggers
* cannot mutate state during the probe and corrupt subsequent
* what-if iterations. Default: `false` (real commit / regular
* trigger flow). The 22 pre-T20 primitives are NOT in
* IMPERATIVE_KINDS so they fire normally regardless of this
* flag — backward-compatible by construction.
*
* Single source of truth: branched only at the dispatcher entry
* point in `runPrimitives`; individual primitives MUST NOT
* inspect this field. (See `decisions.md` § Move-Generation Dry
* Mode for the locked invariant.)
*/
readonly suppressTriggers: boolean;
}
/**

View file

@ -15,15 +15,25 @@ import { algebraicToSquare } from "../coord.js";
import { clearBoard, pieceAt, placePiece } from "../presets/test-utils.js";
import type { ModifierProfile } from "./types.js";
import {
enqueueTrigger,
fireOnCapturedHooks,
fireOnCheckDeliveredHooks,
fireOnCheckReceivedHooks,
fireOnMoveHooks,
fireOnMovedOntoSquareHooks,
fireOnPromotionHooks,
fireOnRuleActivatedHooks,
fireOnTurnEndHooks,
runPrimitives,
type PreMoveCheckStateLike,
} from "./triggers.js";
import { PRIMITIVE_REGISTRY } from "./primitives/registry.js";
import { z } from "zod";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
} from "./primitives/types.js";
import "./primitives/index.js";
import "../presets/index.js";
@ -635,3 +645,464 @@ describe("fireOnCapturedHooks", () => {
expect(engine.session.get(whitePawn, "HpBonus")).toBeUndefined();
});
});
// ─── T20: move-gen suppressTriggers flag ─────────────────────────────────────
//
// Move-gen dry-mode (legality probing for check detection / what-if
// analysis) MUST NOT fire imperative primitives — they would mutate the
// session mid-probe and corrupt subsequent iterations. The dispatcher
// (`runPrimitives`) skips primitives whose kind is in IMPERATIVE_KINDS
// when `ctx.suppressTriggers === true`. Non-imperative primitives still
// run normally so legality analysis can branch on the same data the
// wet path would.
//
// IMPERATIVE_KINDS (T14, locked at T0 ADR) = 10 future Wave-5/6 kinds:
// place-piece, destroy-piece, move-piece, swap-pieces,
// convert-piece-type, set-piece-attr, cancel-capture, spawn-marker,
// spawn-marker-pair, destroy-marker.
// None are registered yet; the suite below registers a SYNTHETIC
// primitive under one of those kind-names so the gate can be exercised
// today without waiting for Wave 5/6 implementations.
describe("move-gen suppressTriggers flag (T20)", () => {
// Track imperative-primitive side effects via a module-scoped flag.
// Each test resets it via the per-test setup. The synthetic primitive
// is registered ONCE at first describe entry — `PRIMITIVE_REGISTRY`
// has no unregister, but using a kind-name from IMPERATIVE_KINDS
// (`destroy-piece`) doesn't collide because Wave 5/6 hasn't landed.
let imperativeFired = false;
let predicateFired = false;
// Register the synthetic imperative primitive on first entry. The
// try/catch handles repeat registrations from test re-runs (vitest
// module re-evaluation in watch mode would otherwise throw on the
// duplicate-kind guard).
try {
PRIMITIVE_REGISTRY.register({
// Cast through unknown — the registry's PrimitiveKind union does
// NOT include `destroy-piece` yet (Wave 6 will add it). The
// runtime registry stores the kind as a plain string key, so the
// lookup in `runPrimitives` works regardless of static typing.
kind: "destroy-piece" as unknown as EffectPrimitive["kind"],
label: "T20 synthetic destroy-piece",
description: "Test-only stub for the suppressTriggers gate.",
paramsSchema: z.object({}).passthrough(),
apply: () => {
imperativeFired = true;
},
} as unknown as EffectPrimitive);
} catch {
// already registered (test file re-evaluated)
}
// Register a synthetic NON-imperative primitive whose kind is NOT in
// IMPERATIVE_KINDS — used to prove that suppressTriggers does NOT
// affect non-imperative primitives. Using a fresh kind-name avoids
// colliding with the 22 real primitives.
try {
PRIMITIVE_REGISTRY.register({
kind: "__t20_predicate__" as unknown as EffectPrimitive["kind"],
label: "T20 synthetic predicate",
description: "Test-only non-imperative stub.",
paramsSchema: z.object({}).passthrough(),
apply: () => {
predicateFired = true;
},
} as unknown as EffectPrimitive);
} catch {
// already registered
}
function makeBareEngine(): ChessEngine {
// Empty profile — we don't need a full preset; runPrimitives only
// touches the engine's session.
return new ChessEngine({
profile: makeProfileWithCustomKind("t20-bare"),
});
}
function resetFlags() {
imperativeFired = false;
predicateFired = false;
}
it("dry-mode (suppressTriggers=true) does NOT fire imperative primitives", () => {
resetFlags();
const engine = makeBareEngine();
const pieceId = findPiece(engine, 12); // some real piece on the board
const nodes: EffectPrimitiveNode[] = [
{
// Cast: kind is in IMPERATIVE_KINDS but not in PrimitiveKind union.
kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"],
params: {},
},
];
// Invoke runPrimitives directly with suppressTriggers=true. The
// dispatcher must skip the imperative; `imperativeFired` stays
// false.
runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true);
expect(imperativeFired).toBe(false);
});
it("real commit (suppressTriggers=false) DOES fire imperative primitives", () => {
resetFlags();
const engine = makeBareEngine();
const pieceId = findPiece(engine, 12);
const nodes: EffectPrimitiveNode[] = [
{
kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"],
params: {},
},
];
// Default suppressTriggers=false → the synthetic apply runs.
runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, false);
expect(imperativeFired).toBe(true);
});
it("non-imperative primitives still run under suppressTriggers", () => {
resetFlags();
const engine = makeBareEngine();
const pieceId = findPiece(engine, 12);
const nodes: EffectPrimitiveNode[] = [
{
kind: "__t20_predicate__" as unknown as EffectPrimitiveNode["kind"],
params: {},
},
];
runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true);
// Non-imperative primitive fires regardless of suppress flag.
expect(predicateFired).toBe(true);
});
it("mixed list: only imperatives are skipped under suppressTriggers", () => {
resetFlags();
const engine = makeBareEngine();
const pieceId = findPiece(engine, 12);
const nodes: EffectPrimitiveNode[] = [
{
kind: "__t20_predicate__" as unknown as EffectPrimitiveNode["kind"],
params: {},
},
{
kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"],
params: {},
},
];
runPrimitives(engine, pieceId, nodes, 1, undefined, new Map(), 0, true);
expect(predicateFired).toBe(true); // non-imperative still ran
expect(imperativeFired).toBe(false); // imperative skipped
});
it("default suppressTriggers (omitted) is false — imperatives fire", () => {
resetFlags();
const engine = makeBareEngine();
const pieceId = findPiece(engine, 12);
const nodes: EffectPrimitiveNode[] = [
{
kind: "destroy-piece" as unknown as EffectPrimitiveNode["kind"],
params: {},
},
];
// Omit the suppressTriggers arg entirely; param default is `false`.
runPrimitives(engine, pieceId, nodes, 1);
expect(imperativeFired).toBe(true);
});
});
// ─── T15: deferred trigger queue + cascade depth guard ──────────────────────
//
// Imperative primitives that synthesize secondary triggers (e.g.
// destroy-piece causing on-captured) MUST enqueue events via
// `enqueueTrigger(ctx, ...)` rather than firing inline. The dispatcher
// drains the queue AFTER the current arm completes, never mid-iteration,
// and increments `cascadeDepth` per drained trigger arm. The guard
// throws `runtime.cascade-depth-exceeded` once cascadeDepth > 8 (matches
// `RUNTIME_DEPTH_HARD_CAP`).
//
// Tests below register synthetic primitives via the same pattern as the
// T20 suite: the kind-cast bypasses the static `PrimitiveKind` union;
// the runtime registry stores by string key.
describe("deferred queue + cascade depth (T15)", () => {
// Tracks ordering: `applyOrder` records each synthetic primitive's
// run + the queue length AT the moment of run, so the test can prove
// the deferred entry didn't fire mid-arm.
let applyOrder: Array<{ kind: string; queueLen: number }> = [];
// Counts every drained `on-move` arm fired during the test. Used as
// a write-only side-effect probe by the cascade-depth synthetic
// re-enqueuer; the test asserts behaviour via the throw, not the
// counter, so the variable is intentionally write-only here.
let _onMoveFired = 0;
// Synthetic primitive #1: enqueues an `on-move` event for its current
// pieceId. Used to inject a deferred trigger from inside an arm.
try {
PRIMITIVE_REGISTRY.register({
kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"],
label: "T15 synthetic enqueuer",
description: "Test-only stub that enqueues on-move.",
paramsSchema: z.object({}).passthrough(),
apply: (ctx: PrimitiveApplyContext) => {
applyOrder.push({
kind: "__t15_enqueuer__",
queueLen: ctx.pendingTriggers.length,
});
enqueueTrigger(ctx, {
kind: "on-move",
pieceId: ctx.pieceId,
payload: {},
});
},
} as unknown as EffectPrimitive);
} catch {
// already registered (vitest re-evaluation)
}
// Synthetic primitive #2: a no-op marker primitive used as a SIBLING
// after the enqueuer. Records `queueLen` at its own run-time so the
// test can prove the deferred trigger didn't fire BETWEEN the two
// primitives.
try {
PRIMITIVE_REGISTRY.register({
kind: "__t15_marker__" as unknown as EffectPrimitive["kind"],
label: "T15 synthetic marker",
description: "Test-only stub that records queue state.",
paramsSchema: z.object({}).passthrough(),
apply: (ctx: PrimitiveApplyContext) => {
applyOrder.push({
kind: "__t15_marker__",
queueLen: ctx.pendingTriggers.length,
});
},
} as unknown as EffectPrimitive);
} catch {
// already registered
}
// Synthetic primitive #3: re-enqueues an `on-move` AND records a
// counter every time it runs. Used to build the cascade-depth test:
// each drained trigger fires `OnMoveHooks` whose inner primitive list
// includes this kind, which re-enqueues forever.
try {
PRIMITIVE_REGISTRY.register({
kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"],
label: "T15 synthetic re-enqueuer",
description: "Test-only stub that re-enqueues on-move infinitely.",
paramsSchema: z.object({}).passthrough(),
apply: (ctx: PrimitiveApplyContext) => {
_onMoveFired += 1;
enqueueTrigger(ctx, {
kind: "on-move",
pieceId: ctx.pieceId,
payload: {},
});
},
} as unknown as EffectPrimitive);
} catch {
// already registered
}
function reset() {
applyOrder = [];
_onMoveFired = 0;
}
function bareEngine(): ChessEngine {
return new ChessEngine({
profile: makeProfileWithCustomKind("t15-bare"),
});
}
it("enqueued trigger fires AFTER the current arm (not mid-iteration)", () => {
reset();
const engine = bareEngine();
const pieceId = findPiece(engine, 12);
// Wire the enqueued `on-move` to bump RangeBonus so we can prove
// the deferred trigger eventually fired. Use a real existing
// primitive (`add-to-attribute`) so no further synthetic kinds
// are needed.
engine.session.insert(pieceId, "OnMoveHooks", [
[
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
],
]);
// Two-node arm: enqueuer first, marker second. The marker MUST
// observe queueLen === 1 (the enqueued trigger is sitting in the
// queue, not fired yet). RangeBonus must NOT be set until the
// arm completes and the dispatcher drains.
const nodes = [
{
kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"],
params: {},
},
{
kind: "__t15_marker__" as unknown as EffectPrimitive["kind"],
params: {},
},
];
// Snapshot RangeBonus before the call (should remain undefined
// until the deferred drain runs).
expect(engine.session.get(pieceId, "RangeBonus")).toBeUndefined();
runPrimitives(engine, pieceId, nodes, 1);
// Order check: enqueuer ran first with queueLen=0 (empty before
// its push), marker ran second with queueLen=1 (the queue holds
// the deferred entry, not yet drained).
expect(applyOrder).toEqual([
{ kind: "__t15_enqueuer__", queueLen: 0 },
{ kind: "__t15_marker__", queueLen: 1 },
]);
// After runPrimitives returns, the dispatcher has drained the
// queue → on-move fired → add-to-attribute bumped RangeBonus by 1.
expect(engine.session.get(pieceId, "RangeBonus")).toBe(1);
});
it("cascade depth=8 limit throws runtime.cascade-depth-exceeded", () => {
reset();
const engine = bareEngine();
const pieceId = findPiece(engine, 12);
// Wire OnMoveHooks to the re-enqueuer: each drained on-move runs
// the re-enqueuer, which enqueues another on-move, which drains
// and runs the re-enqueuer again, etc. The guard throws once
// cascadeDepth > 8 (i.e. on the 10th invocation: top-level=0,
// then drains 1..9 = 9 cascades; the 10th re-entry hits depth 9
// which triggers the > 8 check).
engine.session.insert(pieceId, "OnMoveHooks", [
[
{
kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"],
params: {},
},
],
]);
const nodes = [
{
kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"],
params: {},
},
];
expect(() => runPrimitives(engine, pieceId, nodes, 1)).toThrow(
/cascade-depth-exceeded/,
);
});
it("cascade depth resets between top-level dispatches", () => {
reset();
const engine = bareEngine();
const pieceId = findPiece(engine, 12);
// Single-shot enqueuer wired to a benign attr-bump on-move hook.
// Each top-level call: enqueuer runs at cascadeDepth=0 → drains
// at cascadeDepth=1 → done. Subsequent top-level calls MUST also
// start at cascadeDepth=0; if the depth leaked, we'd accumulate
// toward the 8-limit and throw on call 9 or 10.
engine.session.insert(pieceId, "OnMoveHooks", [
[
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
],
]);
const nodes = [
{
kind: "__t15_enqueuer__" as unknown as EffectPrimitive["kind"],
params: {},
},
];
// Twenty independent top-level dispatches. If cascadeDepth leaked
// across calls, this would throw cascade-depth-exceeded long
// before reaching call 20 (the cap is 8).
for (let i = 0; i < 20; i += 1) {
expect(() => runPrimitives(engine, pieceId, nodes, 1)).not.toThrow();
}
// 20 deferred-drain on-move hooks each bumped HpBonus by 1.
expect(engine.session.get(pieceId, "HpBonus")).toBe(20);
});
it("explicit cascadeDepth=9 at entry throws (boundary case)", () => {
// Direct boundary verification: pass cascadeDepth=9 to a
// top-level invocation. The guard fires before any primitive
// applies, regardless of queue contents.
reset();
const engine = bareEngine();
const pieceId = findPiece(engine, 12);
expect(() =>
runPrimitives(engine, pieceId, [], 1, undefined, new Map(), 9),
).toThrow(/cascade-depth-exceeded/);
});
it("cascadeDepth=8 at entry is at the boundary and does NOT throw", () => {
// The guard is `cascadeDepth > HARD_CASCADE_DEPTH` (strictly
// greater than 8). Entry at exactly 8 is the last legal depth;
// it runs the arm and only throws if a drained child would
// re-enter at 9.
reset();
const engine = bareEngine();
const pieceId = findPiece(engine, 12);
// No nodes, no enqueues → no drain → safe.
expect(() =>
runPrimitives(engine, pieceId, [], 1, undefined, new Map(), 8),
).not.toThrow();
});
});
// ─── T16: fireOnRuleActivatedHooks ──────────────────────────────────────
//
// The dispatcher reads `OnRuleActivatedHooks` from GAME_ENTITY and
// fires only entries whose `descriptorId` matches the requested id.
// Inner primitives run against GAME_ENTITY (the canonical "rule-
// activated" target). Other descriptors' entries are skipped.
describe("fireOnRuleActivatedHooks (T16)", () => {
it("fires the matching descriptor's primitives, skips other descriptors", () => {
const engine = new ChessEngine({
profile: makeProfileWithCustomKind("on-rule-activated-dispatch"),
});
// Seed two hooks for two different descriptors. Only descriptor
// 'rule-A' should fire when we dispatch for 'rule-A'.
engine.session.insert(GAME_ENTITY, "OnRuleActivatedHooks", [
{
descriptorId: "rule-A",
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 3 } },
],
},
{
descriptorId: "rule-B",
primitives: [
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } },
],
},
]);
fireOnRuleActivatedHooks(engine, "rule-A");
// Inner primitives target GAME_ENTITY (the dispatcher passes it
// as `pieceId` into runPrimitives).
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(3);
// rule-B's primitive did NOT fire — RangeBonus on GAME_ENTITY
// remains undefined.
expect(engine.session.get(GAME_ENTITY, "RangeBonus")).toBeUndefined();
});
});

View file

@ -54,12 +54,15 @@
* preMoveHp)` and `fireOnCaptureHooks(engine, attackerId)`.
*/
import type { EntityId, Session } from "@paratype/rete";
import type {
ChessAttrMap,
ConditionSpec,
PieceColor,
PieceType,
Square,
import {
GAME_ENTITY,
PRESET_STATE_ENTITY,
type ChessAttrMap,
type ConditionSpec,
type MarkerKindValue,
type PieceColor,
type PieceType,
type Square,
} from "../schema.js";
import { fileOf, rankOf } from "../coord.js";
import { PIECE_TYPE_REGISTRY } from "../presets/piece-type-registry.js";
@ -72,10 +75,37 @@ import {
import { resolveParams } from "./primitives/param-resolver.js";
import type {
EffectPrimitiveNode,
PendingTrigger,
PrimitiveApplyContext,
} from "./primitives/types.js";
import { IMPERATIVE_KINDS } from "./custom/validate.js";
import type { ChessEngine } from "../engine.js";
/**
* Cascade-depth hard cap (T15). Mirrors the existing
* `RUNTIME_DEPTH_HARD_CAP` precedent (validate.ts) — same constant
* is reused for the request-choice stack depth, so all three
* recursion-control limits stay in lockstep.
*/
const HARD_CASCADE_DEPTH = 8;
/**
* Append a pending trigger to the current arm's deferred queue
* (T15). Imperative primitives that synthesise secondary triggers
* call this helper instead of firing inline — the dispatcher
* drains the queue once the current arm finishes.
*
* Mutating the array is intentional: `pendingTriggers` is the
* single per-arm shared queue. The "no in-place mutation" rule
* for `bindings` does NOT apply here.
*/
export function enqueueTrigger(
ctx: PrimitiveApplyContext,
trigger: PendingTrigger,
): void {
ctx.pendingTriggers.push(trigger);
}
/**
* Per-color royal→attackers map (mirrors the shape exported from
* `apply.ts` as `PreMoveCheckState`). Re-declared structurally here so
@ -115,19 +145,53 @@ function* eachPiece(
* the trigger metadata that fired them. Existing callers that don't
* supply an event get `event: undefined` — backward compatible.
*/
function runPrimitives(
export function runPrimitives(
engine: ChessEngine,
pieceId: EntityId,
nodes: readonly EffectPrimitiveNode[],
depth: number,
event?: PrimitiveEvent,
bindings: ReadonlyMap<string, BindingValue> = new Map(),
cascadeDepth: number = 0,
suppressTriggers: boolean = false,
): void {
if (depth > 8) return; // hard runtime cap, mirrors validator
// T15: cascade-depth guard. Distinct from `depth` (nested primitive
// arrays in the same arm) — `cascadeDepth` counts cross-arm chains
// formed by the deferred-trigger queue. A breach is a runtime
// error, not validator-detectable: the cascade is data-dependent.
if (cascadeDepth > HARD_CASCADE_DEPTH) {
throw new Error(
`runtime.cascade-depth-exceeded: cascade depth ${cascadeDepth} > ${HARD_CASCADE_DEPTH}`,
);
}
// T15: per-arm deferred-trigger queue. Each `runPrimitives`
// invocation seeds a FRESH array — the queue does not leak
// across sibling arms, only across the parent arm and its drained
// descendants (which run at `cascadeDepth + 1`).
const pendingTriggers: PendingTrigger[] = [];
for (const node of nodes) {
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
if (primitive === undefined) continue;
// T20: dry-mode skips imperative primitives entirely. The check
// is THE single source of truth for `suppressTriggers` (per
// decisions.md § Move-Generation Dry Mode); individual primitives
// do NOT branch on it. IMPERATIVE_KINDS is the locked T14 set of
// 10 future Wave-5/6 kinds (none registered yet — but the gate
// is in place so they fire correctly when they land).
//
// Non-imperative primitives (predicates, conditionals, the 22
// pre-Wave-5 kinds) still run normally so legality analysis can
// branch on the same data the wet path would. This preserves
// backward compat: the 22 existing primitives are not in
// IMPERATIVE_KINDS, so suppressTriggers never affects them.
if (suppressTriggers && IMPERATIVE_KINDS.has(node.kind)) {
continue;
}
const ctx: PrimitiveApplyContext = {
engine,
session: engine.session,
@ -149,6 +213,18 @@ function runPrimitives(
// request-choice primitives extend it via `withBinding` before
// re-entering `runPrimitives` for nested children.
bindings,
// T15: deferred-trigger queue + cascade depth. The same
// `pendingTriggers` instance is shared across all primitives
// in this arm (siblings + nested children) so they can
// collectively defer secondary fires; drain happens once the
// top of THIS arm completes.
pendingTriggers,
cascadeDepth,
// T20: thread the dry-mode flag into the context so any future
// primitive author who needs it can read it (canonical use is
// the dispatcher-level skip above; individual primitives do
// NOT branch on this — see types.ts contract).
suppressTriggers,
};
// T12: resolve `$var` / `ctx-attr` / `ctx-build` shapes inside
// params BEFORE handing them to the primitive's apply(). Existing
@ -169,11 +245,133 @@ function runPrimitives(
children = [];
}
if (children.length > 0) {
// Thread the SAME bindings through nested children so a name
// introduced by an outer iteration primitive remains in scope.
runPrimitives(engine, pieceId, children, depth + 1, event, bindings);
// Thread the SAME bindings + cascadeDepth + suppressTriggers
// through nested children so a name introduced by an outer
// iteration primitive remains in scope, the cascade counter
// doesn't jump artificially, and dry-mode propagates into
// nested arms (a conditional inside a dry-probe must NOT
// suddenly fire imperatives via its `then` branch).
runPrimitives(
engine,
pieceId,
children,
depth + 1,
event,
bindings,
cascadeDepth,
suppressTriggers,
);
}
}
// T15: drain the deferred-trigger queue AFTER the current arm
// completes. FIFO order — the order primitives enqueued. Each
// drained trigger re-enters dispatch at `cascadeDepth + 1`.
// The drain happens with the SAME engine snapshot the arm built;
// primitives that died mid-arm have already retracted facts, so
// hook lookups in `fireTriggerByKind` find what's still present.
//
// T20: under suppressTriggers the queue should be empty (the
// imperative-skip above prevents apply() from running and so
// prevents any enqueueTrigger() calls), but defensive-skip the
// drain anyway so a future primitive that mistakenly enqueues
// mid-dry-mode can't leak side effects.
if (suppressTriggers) return;
for (const t of pendingTriggers) {
fireTriggerByKind(engine, t, cascadeDepth + 1);
}
}
/**
* Dispatch a deferred trigger (T15). Maps a `TriggerName` onto the
* existing `fire*Hooks` family, threading the new cascade depth so
* the receiver enforces the hard cap.
*
* Wave-4 trigger kinds (`on-rule-activated`, `on-rule-expire`,
* `on-piece-entered-marker`, `on-marker-expire`) are listed in
* `TriggerName` already but not yet wired here — when T16-T19 land,
* add their cases. The default branch logs + skips so a
* descriptor that enqueues an unsupported kind today doesn't crash
* production: it just doesn't fire (and the warning surfaces the
* mismatch in dev).
*/
function fireTriggerByKind(
engine: ChessEngine,
trigger: PendingTrigger,
cascadeDepth: number,
): void {
switch (trigger.kind) {
case "on-captured": {
const payload = (trigger.payload ?? {}) as { attackerId?: EntityId };
if (payload.attackerId === undefined) return;
fireOnCapturedHooks(
engine,
trigger.pieceId,
payload.attackerId,
cascadeDepth,
);
return;
}
case "on-capture": {
fireOnCaptureHooks(engine, trigger.pieceId, cascadeDepth);
return;
}
case "on-move": {
fireOnMoveHooks(engine, [trigger.pieceId], cascadeDepth);
return;
}
case "on-promotion": {
const payload = (trigger.payload ?? {}) as {
promotedFrom?: PieceType;
promotedTo?: PieceType;
};
if (payload.promotedFrom === undefined || payload.promotedTo === undefined) {
return;
}
fireOnPromotionHooks(
engine,
trigger.pieceId,
payload.promotedFrom,
payload.promotedTo,
cascadeDepth,
);
return;
}
case "on-moved-onto-square": {
const payload = (trigger.payload ?? {}) as { square?: Square };
if (payload.square === undefined) return;
fireOnMovedOntoSquareHooks(
engine,
trigger.pieceId,
payload.square,
cascadeDepth,
);
return;
}
// Triggers that require richer pre/post snapshots
// (on-damaged / on-check-*) and the per-color turn ticks are
// not enqueueable from primitives in V1 — they're driven only by
// the integration preset's onAfterMove pipeline. Listing them in
// `TriggerName` keeps the union complete without forcing a
// dispatcher entry that primitives can't currently produce.
case "on-damaged":
case "on-check-received":
case "on-check-delivered":
case "on-turn-start":
case "on-turn-end":
case "on-rule-activated":
case "on-rule-expire":
case "on-piece-entered-marker":
case "on-marker-expire":
default:
// Unsupported-from-primitives kind: log + skip rather than
// throw, so a forward-compatible descriptor authored against
// future Wave-4 wiring degrades gracefully today.
console.warn(
`fireTriggerByKind: trigger kind '${trigger.kind}' not dispatchable from deferred queue (yet)`,
);
return;
}
}
/**
@ -212,6 +410,7 @@ function evaluateCondition(
export function fireOnTurnStartHooks(
engine: ChessEngine,
whoseTurn: PieceColor,
cascadeDepth: number = 0,
): void {
for (const { id, color } of eachPiece(engine.session)) {
if (color !== whoseTurn) continue;
@ -220,7 +419,7 @@ export function fireOnTurnStartHooks(
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1);
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
}
}
}
@ -237,6 +436,7 @@ export function fireOnTurnStartHooks(
export function fireOnTurnEndHooks(
engine: ChessEngine,
endedColor: PieceColor,
cascadeDepth: number = 0,
): void {
for (const { id } of eachPiece(engine.session)) {
const hooks = engine.session.get(id, "OnTurnEndHooks") as
@ -245,7 +445,7 @@ export function fireOnTurnEndHooks(
if (hooks === undefined) continue;
for (const hook of hooks) {
if (hook.color !== "both" && hook.color !== endedColor) continue;
runPrimitives(engine, id, hook.primitives, 1);
runPrimitives(engine, id, hook.primitives, 1, undefined, new Map(), cascadeDepth);
}
}
}
@ -262,6 +462,7 @@ export function fireOnTurnEndHooks(
export function fireOnCaptureHooks(
engine: ChessEngine,
attackerId: EntityId | null,
cascadeDepth: number = 0,
): void {
if (attackerId === null) return;
const hooks = engine.session.get(attackerId, "OnCaptureHooks") as
@ -269,7 +470,7 @@ export function fireOnCaptureHooks(
| undefined;
if (hooks === undefined) return;
for (const primitives of hooks) {
runPrimitives(engine, attackerId, primitives, 1);
runPrimitives(engine, attackerId, primitives, 1, undefined, new Map(), cascadeDepth);
}
}
@ -297,6 +498,7 @@ export function snapshotHp(session: Session): Map<EntityId, number> {
export function fireOnDamagedHooks(
engine: ChessEngine,
preMoveHp: ReadonlyMap<EntityId, number>,
cascadeDepth: number = 0,
): void {
for (const [id, prev] of preMoveHp) {
const current = engine.session.get(id, "Hp") as number | undefined;
@ -308,7 +510,7 @@ export function fireOnDamagedHooks(
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1);
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
}
}
}
@ -319,7 +521,10 @@ export function fireOnDamagedHooks(
* — conditions are re-evaluated against current facts so a hook that
* reacts to "Hp < 2" fires the moment HP drops below threshold.
*/
export function fireConditionalHooks(engine: ChessEngine): void {
export function fireConditionalHooks(
engine: ChessEngine,
cascadeDepth: number = 0,
): void {
for (const { id } of eachPiece(engine.session)) {
const hooks = engine.session.get(id, "ConditionalHooks") as
| ChessAttrMap["ConditionalHooks"]
@ -329,7 +534,7 @@ export function fireConditionalHooks(engine: ChessEngine): void {
const matches = evaluateCondition(engine.session, id, hook.condition);
const branch = matches ? hook.then : hook.else;
if (branch === undefined || branch.length === 0) continue;
runPrimitives(engine, id, branch, 1);
runPrimitives(engine, id, branch, 1, undefined, new Map(), cascadeDepth);
}
}
}
@ -347,6 +552,7 @@ export function fireConditionalHooks(engine: ChessEngine): void {
export function fireOnMoveHooks(
engine: ChessEngine,
movedPieceIds: readonly EntityId[],
cascadeDepth: number = 0,
): void {
for (const id of movedPieceIds) {
const hooks = engine.session.get(id, "OnMoveHooks") as
@ -354,7 +560,7 @@ export function fireOnMoveHooks(
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1);
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
}
}
}
@ -373,6 +579,7 @@ export function fireOnPromotionHooks(
promotedPieceId: EntityId,
promotedFrom: PieceType,
promotedTo: PieceType,
cascadeDepth: number = 0,
): void {
const hooks = engine.session.get(promotedPieceId, "OnPromotionHooks") as
| ChessAttrMap["OnPromotionHooks"]
@ -384,7 +591,7 @@ export function fireOnPromotionHooks(
promotedTo,
};
for (const primitives of hooks) {
runPrimitives(engine, promotedPieceId, primitives, 1, event);
runPrimitives(engine, promotedPieceId, primitives, 1, event, new Map(), cascadeDepth);
}
}
@ -404,6 +611,7 @@ export function fireOnPromotionHooks(
export function fireOnCheckReceivedHooks(
engine: ChessEngine,
preMoveCheckState: PreMoveCheckStateLike,
cascadeDepth: number = 0,
): void {
for (const color of ["white", "black"] as const) {
const preColor = preMoveCheckState[color];
@ -418,7 +626,7 @@ export function fireOnCheckReceivedHooks(
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, royalId, primitives, 1);
runPrimitives(engine, royalId, primitives, 1, undefined, new Map(), cascadeDepth);
}
}
}
@ -435,6 +643,7 @@ export function fireOnCheckReceivedHooks(
export function fireOnCheckDeliveredHooks(
engine: ChessEngine,
preMoveCheckState: PreMoveCheckStateLike,
cascadeDepth: number = 0,
): void {
for (const color of ["white", "black"] as const) {
const preColor = preMoveCheckState[color];
@ -449,7 +658,7 @@ export function fireOnCheckDeliveredHooks(
) as ChessAttrMap["OnCheckDeliveredHooks"] | undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, attackerId, primitives, 1);
runPrimitives(engine, attackerId, primitives, 1, undefined, new Map(), cascadeDepth);
}
}
}
@ -466,6 +675,7 @@ export function fireOnMovedOntoSquareHooks(
engine: ChessEngine,
movedPieceId: EntityId,
destSquare: Square,
cascadeDepth: number = 0,
): void {
const hooks = engine.session.get(
movedPieceId,
@ -474,7 +684,93 @@ export function fireOnMovedOntoSquareHooks(
if (hooks === undefined) return;
for (const hook of hooks) {
if (!squareMatchesFilter(destSquare, hook.filter)) continue;
runPrimitives(engine, movedPieceId, hook.primitives, 1);
runPrimitives(engine, movedPieceId, hook.primitives, 1, undefined, new Map(), cascadeDepth);
}
}
/**
* T18 — fire `on-piece-entered-marker` hooks for every piece in
* `movedPieceIds` that landed on a square containing one or more
* matching markers.
*
* Hook list lives on `GAME_ENTITY` (game-level — the rule applies
* to ANY piece, not a specific one). For each moved piece, the
* dispatcher reads the piece's CURRENT `Position` (post-move) and
* asks `engine.getMarkersAtSquare(square)` for every marker
* occupying it. T10 already returns markers SORTED BY PRIORITY
* (lowest number first — portal-end fires before mine before
* pit, etc.) with entity-id ascending tie-break, so iterating the
* result inherits the locked dispatch order without re-sorting.
*
* Per marker, the dispatcher fires every hook whose `markerKind`
* matches that marker's MarkerKind exactly. `event.kind =
* "piece-entered-marker"` is supplied so inner primitives can read
* `markerId` / `pieceId` / `square` if they need to (e.g.
* `destroy-marker` cleanup, square-specific tombstone effects).
*
* Pieces whose Position fact is unexpectedly missing (capture in
* the same arm, etc.) are skipped — this dispatcher only fires for
* pieces that ACTUALLY exist on a square at the moment of the
* call.
*
* The hook list is read fresh per piece. Markers spawned during
* THIS arm by a sibling primitive are NOT visible to a piece that
* already moved earlier in the arm: `getMarkersAtSquare` reflects
* the live session state at the moment of THIS dispatch call (the
* post-move state captured by onAfterMove). T15's deferred-trigger
* queue is what handles cross-arm cascades.
*/
export function fireOnPieceEnteredMarkerHooks(
engine: ChessEngine,
movedPieceIds: readonly EntityId[],
cascadeDepth: number = 0,
): void {
const hooks = engine.session.get(
GAME_ENTITY,
"OnPieceEnteredMarkerHooks",
) as ChessAttrMap["OnPieceEnteredMarkerHooks"] | undefined;
if (hooks === undefined || hooks.length === 0) return;
for (const pieceId of movedPieceIds) {
const square = engine.session.get(pieceId, "Position") as
| Square
| undefined;
if (typeof square !== "number") continue;
// T10: getMarkersAtSquare returns markers sorted ASC by hardcoded
// MARKER_KIND_PRIORITY (portal-end first), tie-break by entity
// id ASC. Iterating the result inherits the locked dispatch
// order — DO NOT re-sort.
const markerIds = engine.getMarkersAtSquare(square);
for (const markerId of markerIds) {
const markerKind = engine.session.get(markerId, "MarkerKind") as
| MarkerKindValue
| undefined;
if (markerKind === undefined) continue;
// Match by exact kind only — no wildcard. A descriptor that
// wants two kinds installs two hook entries.
for (const hook of hooks) {
if (hook.markerKind !== markerKind) continue;
const event: PrimitiveEvent = {
kind: "piece-entered-marker",
markerId,
markerKind,
pieceId,
square,
};
runPrimitives(
engine,
pieceId,
hook.primitives,
1,
event,
new Map(),
cascadeDepth,
false,
);
}
}
}
}
@ -496,6 +792,7 @@ export function fireOnCapturedHooks(
engine: ChessEngine,
capturedPieceId: EntityId,
attackerId: EntityId,
cascadeDepth: number = 0,
): void {
const hooks = engine.session.get(
capturedPieceId,
@ -524,14 +821,227 @@ export function fireOnCapturedHooks(
// iteration scope. Inner primitives that introduce bindings
// extend via `withBinding` once `runPrimitives` recurses.
bindings: new Map(),
// T15: resolver-only ctx — `runPrimitives` below allocates its
// own per-arm queue. The stub satisfies the type contract;
// resolveTargets does not enqueue.
pendingTriggers: [],
cascadeDepth,
// T20: on-captured hooks fire on the wet path (real commit).
// Dry-mode probing never reaches this dispatcher — `false` is
// the only correct default.
suppressTriggers: false,
};
const targets = resolveTargets(resolverCtx, hook.target);
for (const targetId of targets) {
runPrimitives(engine, targetId, hook.primitives, 1, event);
runPrimitives(engine, targetId, hook.primitives, 1, event, new Map(), cascadeDepth);
}
}
}
/**
* Fire `on-rule-activated` hooks for a descriptor that just attached
* (T16). Reads the GAME_ENTITY-scoped `OnRuleActivatedHooks` list,
* filters to entries whose `descriptorId` matches, and runs each
* hook's inner primitive list ONCE against `GAME_ENTITY` (the
* canonical "game-level" target — `on-rule-activated` is per-game,
* not per-piece).
*
* The fire-once guard lives at the CALLER (`applyCustomDescriptor`),
* not here — this dispatcher is a pure firing helper. Callers must
* check `RuleActivatedFiredFor` on `PRESET_STATE_ENTITY` before
* invoking, and append the descriptor id afterward to prevent
* re-fires on subsequent attachments / game reload.
*
* Inner primitives see `event = { kind: "rule-activated",
* descriptorId, chooserColor }` so they can branch on which rule
* activated and on the chooser's color (read from
* `LastModifierChooser` if present — undefined otherwise).
*/
export function fireOnRuleActivatedHooks(
engine: ChessEngine,
descriptorId: string,
cascadeDepth: number = 0,
): void {
const hooks = engine.session.get(GAME_ENTITY, "OnRuleActivatedHooks") as
| ChessAttrMap["OnRuleActivatedHooks"]
| undefined;
if (hooks === undefined) return;
const chooser = engine.session.get(
PRESET_STATE_ENTITY,
"LastModifierChooser",
) as PieceColor | undefined;
const event: PrimitiveEvent = {
kind: "rule-activated",
descriptorId,
...(chooser !== undefined ? { chooserColor: chooser } : {}),
};
for (const hook of hooks) {
if (hook.descriptorId !== descriptorId) continue;
runPrimitives(
engine,
GAME_ENTITY,
hook.primitives,
1,
event,
new Map(),
cascadeDepth,
);
}
}
/**
* Fire `on-rule-expire` hooks for a descriptor that just detached
* (T17). Mirror image of `fireOnRuleActivatedHooks` — reads the
* GAME_ENTITY-scoped `OnRuleExpireHooks` list, filters to entries
* whose `descriptorId` matches, and runs each hook's inner primitive
* list ONCE against `GAME_ENTITY` (the canonical "rule-expire"
* target — `on-rule-expire` is per-game, not per-piece).
*
* Fire-once guard lives HERE (unlike T16's activation guard, which
* lives in `applyCustomDescriptor` because activation has a clean
* call-site). Expire's call sites will be plural — descriptor
* lifetime decrement (T19), explicit remove-modifier action, and
* piece-modifier capture cascade. Centralising the guard in this
* dispatcher means every future caller inherits it for free; the
* caller just invokes `fireOnRuleExpireHooks(engine, descriptorId)`
* and the function self-dedups via `RuleExpireFiredFor` on
* `PRESET_STATE_ENTITY`.
*
* V1 stub status: no detach pipeline currently invokes this
* function — it's exposed for T19 (lifetime decrementer) and the
* eventual remove-modifier action handler to wire up. Until then,
* `on-rule-expire` blocks register their hooks at descriptor apply
* time but never fire. This is by design: expire fires on actual
* detach, never on game shutdown.
*
* Inner primitives see `event = { kind: "rule-expire", descriptorId }`
* so they can branch on which rule expired. No chooser color is
* supplied — expire is a system event (lifetime tick / capture),
* not a player action, and the original chooser may not be
* available by the time the descriptor detaches.
*/
export function fireOnRuleExpireHooks(
engine: ChessEngine,
descriptorId: string,
cascadeDepth: number = 0,
): void {
// Fire-once guard: list of descriptor ids whose expire hooks have
// already fired. Persisted on PRESET_STATE_ENTITY so save→load
// inherits the dedup, and so a re-attach + re-detach cycle within
// the same session also doesn't re-fire (mirroring T16's
// activation guard semantics).
const fired =
(engine.session.get(PRESET_STATE_ENTITY, "RuleExpireFiredFor") as
| ChessAttrMap["RuleExpireFiredFor"]
| undefined) ?? [];
if (fired.includes(descriptorId)) return;
const hooks = engine.session.get(GAME_ENTITY, "OnRuleExpireHooks") as
| ChessAttrMap["OnRuleExpireHooks"]
| undefined;
// Append the descriptor to the guard list BEFORE firing so a
// primitive that re-enters this dispatcher (cascade) can't
// double-fire. Idempotent on double-detach calls — the guard skip
// above handles repeats.
engine.session.insert(PRESET_STATE_ENTITY, "RuleExpireFiredFor", [
...fired,
descriptorId,
]);
if (hooks === undefined) return;
const event: PrimitiveEvent = {
kind: "rule-expire",
descriptorId,
};
for (const hook of hooks) {
if (hook.descriptorId !== descriptorId) continue;
runPrimitives(
engine,
GAME_ENTITY,
hook.primitives,
1,
event,
new Map(),
cascadeDepth,
);
}
}
/**
* T19 — fire `on-marker-expire` hooks for the marker `markerId`
* that just expired (lifetime sweep) but is NOT YET removed.
*
* The dispatcher must be called BEFORE `engine.removeMarker(markerId)`
* retracts the marker's facts so:
* 1. The marker's MarkerKind / Position can still be read here
* (event payload construction).
* 2. Inner primitives that consult MarkerOwner / MarkerLinks via
* `ctx.session.get` find them present (e.g. portal-pair cleanup).
*
* Hook list lives on `GAME_ENTITY` (game-level — the rule applies to
* ANY marker of the matching kind, not a specific marker entity).
* Match is exact-kind only — a hook for `mine` does NOT fire on
* `pit`. Multiple hook entries across descriptors compose naturally:
* the dispatcher runs every matching entry's inner primitives in
* insertion order.
*
* Inner primitives see `event = { kind: "marker-expire", markerId,
* markerKind, square }`. Target defaults to `'self'` which resolves
* to `markerId` (the dying marker entity) — primitives that want to
* act on the square or on pieces standing there must explicitly use
* a target redirect (`{ squares: [square] }`) or read `event.square`.
*
* If the marker's MarkerKind / Position fact is missing (defensive —
* shouldn't happen because the caller `decrementMarkerLifetimes`
* just read both), the dispatcher silently skips: a half-retracted
* marker is not a kind we can match against.
*/
export function fireOnMarkerExpireHooks(
engine: ChessEngine,
markerId: EntityId,
cascadeDepth: number = 0,
): void {
const markerKind = engine.session.get(markerId, "MarkerKind") as
| MarkerKindValue
| undefined;
const square = engine.session.get(markerId, "Position") as
| Square
| undefined;
if (markerKind === undefined || typeof square !== "number") return;
const hooks = engine.session.get(GAME_ENTITY, "OnMarkerExpireHooks") as
| ChessAttrMap["OnMarkerExpireHooks"]
| undefined;
if (hooks === undefined || hooks.length === 0) return;
const event: PrimitiveEvent = {
kind: "marker-expire",
markerId,
markerKind,
square,
};
for (const hook of hooks) {
if (hook.markerKind !== markerKind) continue;
runPrimitives(
engine,
markerId,
hook.primitives,
1,
event,
new Map(),
cascadeDepth,
false,
);
}
}
/**
* Predicate matcher for OnMovedOntoSquare's filter union.
* - `kind: "squares"` matches if the destination is in the list.

View file

@ -242,6 +242,156 @@ export interface ChessAttrMap {
* "undefined" as "no chooser available" and surface a runtime error.
*/
LastModifierChooser: PieceColor;
/**
* T16 — on-rule-activated trigger storage. Game-level (per
* `GAME_ENTITY`) list of hook entries seeded by the
* `on-rule-activated` primitive at descriptor-apply time. Each
* entry records the descriptor id that registered the hook and the
* inner primitive list to fire on activation. The dispatcher
* (`fireOnRuleActivatedHooks` in triggers.ts) filters by
* descriptorId so each hook only fires for its OWN descriptor's
* activation event. The list grows as descriptors attach; entries
* are NOT removed when their descriptor expires (T17 owns expire
* cleanup if needed — for V1 the entries are immortal because
* `on-rule-activated` only fires once per descriptor activation,
* not on every move).
*/
OnRuleActivatedHooks: readonly OnRuleActivatedHookEntry[];
/**
* T16 — fire-once guard for `on-rule-activated`. List of descriptor
* ids whose `on-rule-activated` hooks have already fired in THIS
* game session. Stored on `PRESET_STATE_ENTITY` so it's distinct
* from the per-piece hook registration storage. Checked at
* `applyCustomDescriptor` time; descriptors whose id is already in
* the list skip the activation fire (handles game-reload from save:
* the persisted fact prevents re-firing on session rehydrate).
*/
RuleActivatedFiredFor: readonly string[];
/**
* T18 — `on-piece-entered-marker` hook entries. Stored on
* `GAME_ENTITY` (game-level, not per-piece) because the descriptor
* that seeded the hook is conceptually a rule about ANY piece
* entering a marker of the matching kind, not a property of the
* descriptor's apply target.
*
* Dispatched in priority order via `engine.getMarkersAtSquare`
* (T10) — that helper sorts by hardcoded MARKER_KIND_PRIORITY
* ascending (portal-end first), tie-break by entity id ascending.
*
* Each entry stores its source `descriptorId` (debugging / dedup),
* the exact `markerKind` it filters by (no wildcard — a descriptor
* that wants to fire for two kinds installs two hook entries), and
* the inner primitive list to run.
*/
OnPieceEnteredMarkerHooks: readonly OnPieceEnteredMarkerHookEntry[];
/**
* T17 — `on-rule-expire` trigger storage. Mirror image of
* `OnRuleActivatedHooks` for the descriptor-detach side. Stored on
* `GAME_ENTITY` (one list per game). Each entry pairs a descriptor
* id with the inner primitive list to fire when THAT descriptor
* detaches (lifetime ends, manual remove, piece-modifier captured
* with its piece). The dispatcher (`fireOnRuleExpireHooks` in
* triggers.ts) filters by descriptorId so each hook only fires
* for its OWN descriptor's expire event.
*
* V1 wire-in status: descriptor lifetimes are not yet wired into
* any detach pipeline, so `fireOnRuleExpireHooks` is exposed as a
* STUB callable from future work (T19 lifetime decrementer,
* eventual remove-modifier action). The seeding side (this attr +
* the primitive's `apply()`) IS active today so descriptors that
* include `on-rule-expire` blocks register the hooks at apply
* time; they simply never fire until a detach pipeline lands.
*/
OnRuleExpireHooks: readonly OnRuleExpireHookEntry[];
/**
* T17 — fire-once guard for `on-rule-expire`. List of descriptor
* ids whose `on-rule-expire` hooks have already fired in THIS
* game session. Stored on `PRESET_STATE_ENTITY` (mirror of
* T16's `RuleActivatedFiredFor`). Checked at the top of
* `fireOnRuleExpireHooks`; descriptors whose id is already in
* the list skip the expire fire. Persists across save→load via
* normal session fact rehydrate, so a re-attach + re-detach
* cycle in the SAME game does NOT re-fire (consistent with the
* "once per descriptor id per game session" contract pinned by
* T16's activation guard).
*/
RuleExpireFiredFor: readonly string[];
/**
* T19 — `on-marker-expire` hook entries. Stored on `GAME_ENTITY`
* (game-level, not per-marker) — the descriptor that seeded the
* hook is conceptually a rule about ANY marker of the matching
* kind expiring, not a property of a specific marker entity.
*
* Dispatched by `fireOnMarkerExpireHooks` (triggers.ts) which is
* called from `decrementMarkerLifetimes` (util/marker-lifetime.ts)
* during onAfterMove stage 7c — AFTER on-piece-entered-marker
* (stage 7b) so a piece entering a marker can still trigger its
* entry effects in the SAME move that the lifetime expires (the
* decrementer fires expire hooks AFTER entry hooks).
*
* Each entry stores its source `descriptorId` (debugging/dedup),
* the exact `markerKind` it filters by (no wildcard — a
* descriptor watching two kinds installs two entries), and the
* inner primitive list to run when a matching marker expires.
*/
OnMarkerExpireHooks: readonly OnMarkerExpireHookEntry[];
}
/**
* T16 — single entry in `OnRuleActivatedHooks`. Stored on
* `GAME_ENTITY` (one list per game). Each entry pairs the
* descriptor id that seeded the hook with the inner primitive list
* the dispatcher will fire when that descriptor activates. Multiple
* entries from the same descriptor may coexist (composite
* `on-rule-activated` blocks); the dispatcher fires every matching
* entry on activation.
*/
export interface OnRuleActivatedHookEntry {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}
/**
* T18 — single entry in `OnPieceEnteredMarkerHooks`. Stored on
* `GAME_ENTITY` (one list per game). Each entry pairs the source
* descriptor id, the marker kind it watches for (exact match, no
* wildcard), and the inner primitive list to run when a piece
* enters a square containing that marker kind. Multiple entries
* from the same descriptor may coexist for different marker kinds.
*/
export interface OnPieceEnteredMarkerHookEntry {
readonly descriptorId: string;
readonly markerKind: MarkerKindValue;
readonly primitives: readonly EffectPrimitiveNode[];
}
/**
* T17 — single entry in `OnRuleExpireHooks`. Stored on
* `GAME_ENTITY` (one list per game). Each entry pairs the
* descriptor id that seeded the hook with the inner primitive list
* the dispatcher fires when that descriptor detaches. Mirrors
* `OnRuleActivatedHookEntry` for the expire side; multiple entries
* from the same descriptor compose (each `on-rule-expire` block
* inside a descriptor tree appends one entry).
*/
export interface OnRuleExpireHookEntry {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}
/**
* T19 — single entry in `OnMarkerExpireHooks`. Stored on
* `GAME_ENTITY` (one list per game). Each entry pairs the source
* descriptor id, the marker kind it watches for (exact match, no
* wildcard), and the inner primitive list to run when a marker of
* that kind expires (lifetime-driven removal via
* `decrementMarkerLifetimes`). Multiple entries from the same
* descriptor may coexist for different marker kinds.
*/
export interface OnMarkerExpireHookEntry {
readonly descriptorId: string;
readonly markerKind: MarkerKindValue;
readonly primitives: readonly EffectPrimitiveNode[];
}
export type ChessAttrKey = keyof ChessAttrMap;

View file

@ -210,3 +210,214 @@ exports[`ParamField rendering (T14 regression baseline) > set-capture-flag rende
&quot;flag&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CAN_CAPTURE_OWN — the piece may capture its own color&#x27;s pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">flag</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) absorb-damage-with-attribute renders attr + rate 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Absorb Damage with Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">absorb-damage-with-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Declares that incoming damage should first deplete a user-chosen counter (rate points per damage) before touching HP. You must seed the counter itself with seed-attribute — this primitive only wires the absorb mechanic, not the charge supply.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">3-charge shield (pair with seed-attribute)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;rate&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pair with seed-attribute {attr: &#x27;ShieldCharges&#x27;, value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Hardened armor (rate=2)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ArmorPlates&quot;,
&quot;rate&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-absorb-damage-with-attribute-attr" data-recognized="false" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-absorb-damage-with-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/><span class="text-[10px] font-semibold px-1.5 py-0.5 rounded text-red-800 bg-red-50 border border-red-200" title="This attribute isn&#x27;t a built-in and isn&#x27;t seeded by any primitive in this descriptor. Reading it will be a no-op unless seeded elsewhere." data-testid="primitive-absorb-damage-with-attribute-attr-badge">not seeded</span></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">rate</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) add-aura renders radius + targetAttr + delta 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Aura</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-aura</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Radiates a numeric contribution to targetAttr onto every piece within \`radius\` (Chebyshev / king-move distance — radius 1 = 8 neighbours). Recomputes after every move; pieces moving out of range lose the contribution on the next pass. Self-application is skipped. Multiple auras to the same targetAttr from different sources accumulate additively.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">King aura: +1 HP within 2 squares</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;radius&quot;: 2,
&quot;targetAttr&quot;: &quot;HpBonus&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Adjacent range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;radius&quot;: 1,
&quot;targetAttr&quot;: &quot;RangeBonus&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Anyone standing next to this piece (8 neighbouring squares) gets +1 to range.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">radius</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">targetAttr</label><div class="relative" data-testid="primitive-add-aura-targetAttr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-aura-targetAttr-input" aria-autocomplete="list" aria-expanded="false" value="HpBonus"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) add-direction renders directions array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Direction</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-direction</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Appends one or more color-relative named directions into the piece&#x27;s DirectionAdditions array, deduplicated by name. Composes with the built-in Direction Additions modifier — both write to the same fact. Valid directions: forward, backward, left, right, diagonal-fl, diagonal-fr, diagonal-bl, diagonal-br.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Backward-capable pawn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;directions&quot;: [
&quot;backward&quot;
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Lets a pawn step backward as well as forward.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Full omnidirectional king-lite</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;directions&quot;: [
&quot;forward&quot;,
&quot;backward&quot;,
&quot;left&quot;,
&quot;right&quot;
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">directions</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
&quot;forward&quot;,
&quot;backward&quot;
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) add-to-attribute renders attr + delta fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add To Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-to-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reads the current numeric value of attr (0 if unset) and writes existing + delta. Delta may be negative. Composes additively with other primitives and built-in modifiers — multiple add-to-attribute primitives for the same attr simply accumulate.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+2 HP bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;HpBonus&quot;,
&quot;delta&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds 2 to whatever HpBonus is already there.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Heal 1/turn (inside on-turn-start)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color&#x27;s turn.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-add-to-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-to-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) block-move-type renders moveType enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Block Move Type</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">block-move-type</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Filters out generated moves matching the given type. Multiple block primitives accumulate into a blocked-move-type set (deduped). Useful for pacifist pieces that still slide, or for pieces that can capture but not reposition silently.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Pacifist piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;moveType&quot;: &quot;capture&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece can step and slide freely but cannot capture — a pure support piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Charge-only attacker</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;moveType&quot;: &quot;step&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Removes simple step moves; piece can only capture or slide.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">moveType</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="capture" selected="">capture</option><option value="step">step</option><option value="slide">slide</option></select></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) conditional renders complex-schema JSON fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Conditional</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">conditional</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Branches on a condition. If true → runs every primitive in \`then\`; if false and \`else\` is set → runs \`else\`. Condition types: attr-lt (numeric less-than), attr-gt (numeric greater-than), attr-eq (exact match against string/number/boolean/null), always (unconditional then), never (forces else path only).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Low-HP fortress</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;condition&quot;: {
&quot;type&quot;: &quot;attr-lt&quot;,
&quot;attr&quot;: &quot;Hp&quot;,
&quot;value&quot;: 2
},
&quot;then&quot;: [
{
&quot;kind&quot;: &quot;set-capture-flag&quot;,
&quot;params&quot;: {
&quot;flag&quot;: 2
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Unconditional thorns example</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;condition&quot;: {
&quot;type&quot;: &quot;always&quot;
},
&quot;then&quot;: [
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 10
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">condition</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="[object Object]"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">then</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;set-capture-flag&quot;,
&quot;params&quot;: {
&quot;flag&quot;: 2
}
}
]</textarea></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">else</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) modify-movement-range renders delta 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Modify Movement Range</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">modify-movement-range</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Adds delta to the piece&#x27;s RangeBonus. Composes additively with the built-in Range Bonus modifier and with other modify-movement-range primitives. Delta is clamped to integer range [-7, 7]. Rook/bishop/queen sliding is extended/reduced by this amount; knight/king ranges are treated by their own pipeline.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+1 range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A rook&#x27;s horizontal slide reaches one square further than its baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">-2 range debuff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;delta&quot;: -2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Cuts 2 squares from the piece&#x27;s reach (useful for &#x27;slowed&#x27; tokens).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) multiply-attribute renders attr + factor fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Multiply Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">multiply-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reads the existing numeric value of attr and writes existing * factor. No-op if the attribute is unset — it does NOT treat absent as 1. Use after seed-attribute or add-to-attribute when you need a baseline to scale.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Double HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;factor&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If the piece already has 4 HP, becomes 8 HP.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Halve range bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;RangeBonus&quot;,
&quot;factor&quot;: 0.5
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-multiply-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-multiply-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">factor</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) on-capture renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Capture</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-capture</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps nested primitives that fire when this piece captures another. Typical uses: &#x27;vampire&#x27; lifesteal (heal on capture), stacking buffs, or power-up triggers. Fires only on actual captures, not on quiet moves.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Vampire lifesteal</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) on-damaged renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Damaged</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-damaged</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps nested primitives that fire whenever this piece takes damage. Useful for reactive behaviours: auto-thorns, emergency buffs, or conditional transformations when HP crosses a threshold (combine with \`conditional\`).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Thorns on hit</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 25
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When this piece takes damage, reflects 25% back to the attacker for that hit.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 25
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) on-turn-start renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Turn Start</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-turn-start</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps a list of nested primitives that fire at the start of this piece&#x27;s color&#x27;s turn. Use for recurring buffs/healing/debuffs tied to turn cadence. The editor&#x27;s Parameter Inspector accepts the nested \`primitives\` array as JSON; copy snippets from the simpler primitives into that array.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Regenerate 1 HP/turn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) override-promotion renders target enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Override Promotion</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">override-promotion</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Forces this piece (typically a pawn) to promote to a specific type regardless of player choice. Mirrors the built-in Promotion Override modifier, but expressable inside a custom primitive tree. Last write wins if multiple sources set it.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Knights-only promotion</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;target&quot;: &quot;knight&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pawn always promotes to a knight.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Underpromote to rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;target&quot;: &quot;rook&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pawn always promotes to a rook — useful for themed variants.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">target</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="pawn">pawn</option><option value="knight" selected="">knight</option><option value="bishop">bishop</option><option value="rook">rook</option><option value="queen">queen</option><option value="king">king</option></select></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) reflect-damage renders percentage 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Reflect Damage</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">reflect-damage</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reflects a percentage of incoming damage back to the attacker. Integer percent, 0-100. Multiple reflect primitives on the same piece do NOT stack — the most recent value wins. Great inside on-damaged if you want a one-time thorns reaction instead of a permanent aura.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Half-reflective armour</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;percentage&quot;: 50
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">50% of incoming damage is dealt back to the attacker.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Total thorns</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;percentage&quot;: 100
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Full reflection — the attacker takes whatever they dealt.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">percentage</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="25"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) seed-attribute renders attr + value fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Seed Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">seed-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Writes { attr, value } directly onto the piece, overwriting any existing value. Use to introduce new attributes (like a custom ShieldCharges counter) or to force a baseline (e.g. set HP to an exact number regardless of inheritance). Pair with add-to-attribute / multiply-attribute to build up a final value.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Force exact HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;value&quot;: 5
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece always starts with 5 HP regardless of baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Declare shield charges</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;value&quot;: 3
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-seed-attribute-attr" data-recognized="true" data-mode="declare"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-seed-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">value</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="3"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) set-capture-flag renders flag enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Set Capture Flag</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">set-capture-flag</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Turns on one capture-flag bit. Flags combine (OR) so stacking multiple primitives is fine. Supported: 1 = CAN_CAPTURE_OWN (piece may capture its own color), 2 = CANNOT_BE_CAPTURED (untargetable by enemies), 4 = EN_PASSANT (piece participates in en-passant capture resolution).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Untouchable piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;flag&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Friendly-fire rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;flag&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CAN_CAPTURE_OWN — the piece may capture its own color&#x27;s pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">flag</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;

View file

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