feat(thressgame-100): Wave 1 partial — resolver V3 + add-to-attribute.target

Wave 1 (W1.0–W1.6) of the thressgame-100 epic — push ThressGame coverage from
27 % toward 85 %+ via resolver expressiveness. Foundation for Layer-1 rules
(self-targeting destroys, mass-mover, adjacent splash). Recipes (W1.7–W1.12)
land in subsequent commits.

Resolver V3 (param-resolver.ts) — 6 new shapes:
- {ctx-self-id: null}        → ctx.pieceId
- {ctx-self-marker-id: null} → ctx.markerId (throws when undefined)
- {add: [<resolver>, <int>]} → recursive arithmetic, MAX_SAFE_INTEGER overflow throws
- {sub: [...]}, {mul: [...]}, {mod: [...]} — same pattern; mod uses positive-modulo
  formula ((l % r) + r) % r so column-wrap recipes work for any sign of l

V3 union order locked (param-resolver-schema.ts):
[literal, $var, ctx-attr, ctx-build, ctx-self-id, ctx-self-marker-id, add, sub, mul, mod]

PrimitiveApplyContext (types.ts): added optional readonly markerId? field.
runPrimitives (triggers.ts): populates markerId in ctx for piece-entered-marker
and marker-expire events from event.markerId (single source of truth).

W1.6 — add-to-attribute.target:
- Schema gains optional target?: numberOrResolver({ min: 0 }) field
- apply() resolves target then defaults to ctx.pieceId when undefined
- Closes the long-documented adjacent-splash sharp edge — splash damage now
  expressible via target redirection instead of forcing set-piece-attr

Test surface:
- param-resolver.test.ts: +22 tests (39 total) — overflow boundary, recursive
  nesting, mixed shapes, replay determinism, ctx.markerId failure modes
- param-resolver-schema.test.ts: +19 tests (38 total) — V3 union for each helper,
  arithmetic shape parsing, ctx-self-id payload validation
- add-to-attribute.test.ts: +6 tests (10 total) — target literal, target $var,
  target omitted (backward-compat), reject string target, apply with/without target
- ParamField snapshot regenerated for add-to-attribute.target rendering

bun run check: 2983 tests pass (was 2941, +42).

Plan: .sisyphus/plans/thressgame-100.md (5 waves + cross-ref + final verification,
~73 atomic tasks locked end-to-end).
Notepads: .sisyphus/notepads/thressgame-100/

Locked architectural decisions (irrevocable across all 6 waves):
  A. Arithmetic resolver shapes — arity 2, no comparisons, no booleans
  B. Self-targeting via ctx-self-id / ctx-self-marker-id (NOT 'self' literal)
  C. add-to-attribute.target optional, defaults to ctx.pieceId
  D. Multi-turn countdowns via on-attr-expire trigger (Wave 2)
  E. Piece-pair lifecycle via PieceLink + on-piece-pair-link-broken (Wave 4)
  F. Resource accumulation on GAME_ENTITY (Wave 5)
  G. Board topology via BoardTopology attr (Wave 4)
  H. Validator V3 — superset of V2, all V2 fixtures auto-validate
  J. User-explicit overrides — no backward-compat constraint, no time/cost limit
This commit is contained in:
Joey Yakimowich-Payne 2026-04-27 13:48:28 -06:00
commit 6a38be6fc6
No known key found for this signature in database
13 changed files with 1812 additions and 10 deletions

View file

@ -38,6 +38,24 @@ describe("add-to-attribute primitive — registry", () => {
});
});
describe("add-to-attribute primitive — schema", () => {
it("schema accepts optional target field with literal", () => {
expect(ADD_TO_ATTRIBUTE_PRIMITIVE.paramsSchema.safeParse({ attr: "Hp", delta: -1, target: 5 }).success).toBe(true);
});
it("schema accepts target as $var binding", () => {
expect(ADD_TO_ATTRIBUTE_PRIMITIVE.paramsSchema.safeParse({ attr: "Hp", delta: -1, target: { $var: "adj" } }).success).toBe(true);
});
it("schema accepts target omitted (backward-compat)", () => {
expect(ADD_TO_ATTRIBUTE_PRIMITIVE.paramsSchema.safeParse({ attr: "Hp", delta: -1 }).success).toBe(true);
});
it("schema rejects non-numeric target", () => {
expect(ADD_TO_ATTRIBUTE_PRIMITIVE.paramsSchema.safeParse({ attr: "Hp", delta: -1, target: "x" }).success).toBe(false);
});
});
describe("add-to-attribute primitive — apply()", () => {
it("adds delta to an existing number", () => {
const { ctx, session } = makeContext();
@ -64,4 +82,25 @@ describe("add-to-attribute primitive — apply()", () => {
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "PieceType", delta: 1 });
}).toThrow(/expected numeric value/);
});
it("apply with target acts on the specified entity, not ctx.pieceId", () => {
const { ctx, session } = makeContext();
const targetId = session.nextId();
session.insert(ctx.pieceId, "Hp", 10);
session.insert(targetId, "Hp", 5);
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "Hp", delta: -1, target: targetId });
expect(session.get(ctx.pieceId, "Hp")).toBe(10);
expect(session.get(targetId, "Hp")).toBe(4);
});
it("apply without target falls back to ctx.pieceId", () => {
const { ctx, session } = makeContext();
session.insert(ctx.pieceId, "Hp", 5);
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "Hp", delta: 3 });
expect(session.get(ctx.pieceId, "Hp")).toBe(8);
});
});

View file

@ -1,11 +1,14 @@
import { z } from "zod";
import type { EntityId } from "@paratype/rete";
import type { ChessAttrKey } from "../../schema.js";
import { numberOrResolver } from "./param-resolver-schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
const schema = z.object({
attr: z.string(),
delta: z.number(),
target: numberOrResolver({ min: 0 }).optional(),
});
type Params = z.infer<typeof schema>;
@ -14,18 +17,18 @@ const descriptor: EffectPrimitive<Params> = {
label: "Add To Attribute",
description: "Adds delta to the current attribute value, treating missing as 0.",
longDescription:
"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.",
"Adds a number to a value the piece already has (treating a missing value as 0). The number can be negative to subtract. Multiple Add To Attribute steps on the same property simply pile on, so you can layer bonuses from different rules.",
examples: [
{
title: "+2 HP bonus",
params: { attr: "HpBonus", delta: 2 },
effect: "Adds 2 to whatever HpBonus is already there.",
effect: "Adds 2 on top of whatever HP bonus the piece already has.",
},
{
title: "Heal 1/turn (inside on-turn-start)",
params: { attr: "Hp", delta: 1 },
effect:
"Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color's turn.",
"Placed inside an on-turn-start trigger, this heals the piece for 1 HP at the start of its side's turn.",
},
],
paramsSchema: schema,
@ -34,14 +37,20 @@ const descriptor: EffectPrimitive<Params> = {
return p?.attr !== undefined ? [p.attr as ChessAttrKey] : [];
},
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing = ctx.session.get(ctx.pieceId, params.attr);
// params.target is pre-substituted by param-resolver; by apply-time it is a literal number or undefined
const targetNum = ((params as unknown) as { target?: unknown }).target as number | undefined ?? ctx.pieceId;
if (typeof targetNum !== "number") {
throw new Error("add-to-attribute.apply: target must be substituted to a number before apply()");
}
const targetId = targetNum as EntityId;
const existing = ctx.session.get(targetId, params.attr);
const baseValue = existing === undefined ? 0 : existing;
if (typeof baseValue !== "number" || Number.isNaN(baseValue)) {
throw new Error(
`add-to-attribute expected numeric value for attr "${params.attr}" but got ${typeof baseValue}`,
);
}
ctx.session.insert(ctx.pieceId, params.attr, baseValue + params.delta);
ctx.session.insert(targetId, params.attr, baseValue + params.delta);
},
};

View file

@ -0,0 +1,324 @@
/**
* Validator widening helpers (V2) — unit tests.
*
* Pins the contract that T2-T8 (the 9 imperative-primitive schema
* widenings) and T9 (ParamField UI widening) depend on:
*
* 1. Each helper accepts the literal scalar AND each of the 3
* runtime-recognized resolver shapes from `param-resolver.ts`.
* 2. Each helper rejects non-literal, non-resolver-shape inputs
* (bare strings into a number-typed field, off-enum values,
* out-of-range numbers).
* 3. `enumOrResolverFor` exposes `__resolverEnumValues` as a
* runtime-readable property — T9's ParamField widening consumes
* this without traversing union internals.
* 4. The structural type guards (`isResolverShape`,
* `isLiteralNumber`) discriminate cleanly between the two cases.
*
* If any of these assertions break, the resolver-runtime ↔ static-
* validator contract is desynchronized and authors will see false
* rejections / acceptances.
*/
import { describe, expect, it } from "vitest";
import {
enumOrResolverFor,
isLiteralNumber,
isResolverShape,
numberOrResolver,
stringOrResolver,
} from "./param-resolver-schema.js";
describe("numberOrResolver()", () => {
it("parses a plain integer literal", () => {
const schema = numberOrResolver();
expect(schema.parse(42)).toBe(42);
});
it("parses { $var: 'name' } binding refs", () => {
const schema = numberOrResolver();
expect(schema.parse({ $var: "p" })).toEqual({ $var: "p" });
});
it("parses { 'ctx-attr': { entity, attr } } shape", () => {
const schema = numberOrResolver();
const input = {
"ctx-attr": { entity: "self", attr: "Position" },
};
expect(schema.parse(input)).toEqual(input);
});
it("parses { 'ctx-build': { col, row } } shape", () => {
const schema = numberOrResolver();
const input = { "ctx-build": { col: 4, row: 3 } };
expect(schema.parse(input)).toEqual(input);
});
it("parses ctx-build with $var col/row", () => {
const schema = numberOrResolver();
const input = { "ctx-build": { col: { $var: "c" }, row: 0 } };
expect(schema.parse(input)).toEqual(input);
});
it("rejects a bare string", () => {
const schema = numberOrResolver();
expect(schema.safeParse("nope").success).toBe(false);
});
it("respects min/max on the literal branch", () => {
const schema = numberOrResolver({ min: 0, max: 63 });
expect(schema.safeParse(0).success).toBe(true);
expect(schema.safeParse(63).success).toBe(true);
expect(schema.safeParse(64).success).toBe(false);
expect(schema.safeParse(-1).success).toBe(false);
});
it("rejects a non-integer literal", () => {
const schema = numberOrResolver();
expect(schema.safeParse(3.14).success).toBe(false);
});
it("rejects a $var with empty name", () => {
const schema = numberOrResolver();
expect(schema.safeParse({ $var: "" }).success).toBe(false);
});
it("rejects ctx-attr with extra unknown keys (strict)", () => {
const schema = numberOrResolver();
expect(
schema.safeParse({
"ctx-attr": { entity: "self", attr: "HP", extra: 1 },
}).success,
).toBe(false);
});
it("rejects ctx-build with col out of [0..7]", () => {
const schema = numberOrResolver();
expect(
schema.safeParse({ "ctx-build": { col: 8, row: 0 } }).success,
).toBe(false);
});
});
describe("enumOrResolverFor()", () => {
it("parses each enum literal AND each resolver shape", () => {
const schema = enumOrResolverFor(["white", "black"] as const);
expect(schema.parse("white")).toBe("white");
expect(schema.parse("black")).toBe("black");
expect(schema.parse({ $var: "color" })).toEqual({ $var: "color" });
expect(
schema.parse({
"ctx-attr": { entity: "self", attr: "Color" },
}),
).toEqual({ "ctx-attr": { entity: "self", attr: "Color" } });
});
it("rejects values outside the enum", () => {
const schema = enumOrResolverFor(["white", "black"] as const);
expect(schema.safeParse("green").success).toBe(false);
});
it("exposes __resolverEnumValues for ParamField introspection", () => {
const schema = enumOrResolverFor(["white", "black"] as const);
expect(schema.__resolverEnumValues).toEqual(["white", "black"]);
});
it("__resolverEnumValues survives a parse() call (no Zod mutation)", () => {
const schema = enumOrResolverFor(["a", "b", "c"] as const);
schema.parse("a");
schema.parse({ $var: "x" });
expect(schema.__resolverEnumValues).toEqual(["a", "b", "c"]);
});
});
describe("stringOrResolver()", () => {
it("parses bare strings", () => {
const schema = stringOrResolver();
expect(schema.parse("hello")).toBe("hello");
expect(schema.parse("")).toBe("");
});
it("parses each resolver shape", () => {
const schema = stringOrResolver();
expect(schema.parse({ $var: "n" })).toEqual({ $var: "n" });
expect(
schema.parse({
"ctx-attr": { entity: "chooser", attr: "Color" },
}),
).toEqual({ "ctx-attr": { entity: "chooser", attr: "Color" } });
});
it("rejects numbers", () => {
const schema = stringOrResolver();
expect(schema.safeParse(42).success).toBe(false);
});
});
describe("type-narrowing guards", () => {
it("isResolverShape recognizes each of the 3 shapes", () => {
expect(isResolverShape({ $var: "x" })).toBe(true);
expect(
isResolverShape({
"ctx-attr": { entity: "self", attr: "HP" },
}),
).toBe(true);
expect(isResolverShape({ "ctx-build": { col: 0, row: 0 } })).toBe(true);
});
it("isResolverShape rejects literals and non-magic objects", () => {
expect(isResolverShape(42)).toBe(false);
expect(isResolverShape("white")).toBe(false);
expect(isResolverShape(null)).toBe(false);
expect(isResolverShape(undefined)).toBe(false);
expect(isResolverShape([])).toBe(false);
expect(isResolverShape({ foo: "bar" })).toBe(false);
// Two keys disqualifies — runtime walker also requires keys.length === 1.
expect(isResolverShape({ $var: "x", extra: 1 })).toBe(false);
});
it("isLiteralNumber accepts finite numbers, rejects everything else", () => {
expect(isLiteralNumber(0)).toBe(true);
expect(isLiteralNumber(-1.5)).toBe(true);
expect(isLiteralNumber(Number.NaN)).toBe(false);
expect(isLiteralNumber(Infinity)).toBe(false);
expect(isLiteralNumber("42")).toBe(false);
expect(isLiteralNumber({ $var: "n" })).toBe(false);
expect(isLiteralNumber(null)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// V3 (Wave-1 thressgame-100) — schema acceptance + rejection of new shapes
// ---------------------------------------------------------------------------
describe("V3 — numberOrResolver accepts new shapes", () => {
it("accepts ctx-self-id with null payload", () => {
const schema = numberOrResolver();
expect(schema.parse({ "ctx-self-id": null })).toEqual({
"ctx-self-id": null,
});
});
it("accepts ctx-self-marker-id with null payload", () => {
const schema = numberOrResolver();
expect(schema.parse({ "ctx-self-marker-id": null })).toEqual({
"ctx-self-marker-id": null,
});
});
it("accepts each arithmetic shape with literal operands", () => {
const schema = numberOrResolver();
expect(schema.parse({ add: [3, 4] })).toEqual({ add: [3, 4] });
expect(schema.parse({ sub: [10, 3] })).toEqual({ sub: [10, 3] });
expect(schema.parse({ mul: [6, 7] })).toEqual({ mul: [6, 7] });
expect(schema.parse({ mod: [13, 5] })).toEqual({ mod: [13, 5] });
});
it("accepts arithmetic with $var on the left operand", () => {
const schema = numberOrResolver();
const input = { add: [{ $var: "x" }, 1] };
expect(schema.parse(input)).toEqual(input);
});
it("accepts arithmetic with ctx-attr on the left operand", () => {
const schema = numberOrResolver();
const input = {
add: [{ "ctx-attr": { entity: "self", attr: "Hp" } }, 1],
};
expect(schema.parse(input)).toEqual(input);
});
it("accepts NESTED arithmetic — recursion works (lazy union)", () => {
const schema = numberOrResolver();
const input = {
mod: [{ add: [{ $var: "col" }, 1] }, 8],
};
expect(schema.parse(input)).toEqual(input);
});
it("rejects ctx-self-id with non-null payload", () => {
const schema = numberOrResolver();
expect(schema.safeParse({ "ctx-self-id": true }).success).toBe(false);
expect(schema.safeParse({ "ctx-self-id": 0 }).success).toBe(false);
expect(schema.safeParse({ "ctx-self-id": "yes" }).success).toBe(false);
});
it("rejects arithmetic with non-integer right operand", () => {
const schema = numberOrResolver();
expect(schema.safeParse({ add: [1, 2.5] }).success).toBe(false);
expect(schema.safeParse({ mul: [1, "2"] }).success).toBe(false);
});
it("rejects arithmetic with wrong arity", () => {
const schema = numberOrResolver();
expect(schema.safeParse({ add: [1] }).success).toBe(false);
expect(schema.safeParse({ add: [1, 2, 3] }).success).toBe(false);
expect(schema.safeParse({ add: { left: 1, right: 2 } }).success).toBe(
false,
);
});
it("rejects arithmetic with extra unknown keys (strict)", () => {
const schema = numberOrResolver();
expect(
schema.safeParse({ add: [1, 2], extra: 1 } as unknown).success,
).toBe(false);
});
});
describe("V3 — backward compatibility (V2 shapes still parse)", () => {
it("numberOrResolver still accepts every V2 shape", () => {
const schema = numberOrResolver();
expect(schema.safeParse(42).success).toBe(true);
expect(schema.safeParse({ $var: "x" }).success).toBe(true);
expect(
schema.safeParse({ "ctx-attr": { entity: "self", attr: "Hp" } })
.success,
).toBe(true);
expect(
schema.safeParse({ "ctx-build": { col: 3, row: 4 } }).success,
).toBe(true);
});
it("enumOrResolverFor still accepts V2 shapes + new V3 ones", () => {
const schema = enumOrResolverFor(["white", "black"] as const);
expect(schema.safeParse("white").success).toBe(true);
expect(schema.safeParse({ $var: "c" }).success).toBe(true);
expect(schema.safeParse({ "ctx-self-id": null }).success).toBe(true);
expect(schema.safeParse({ add: [1, 2] }).success).toBe(true);
});
it("__resolverEnumValues still surfaces the original tuple under V3", () => {
const schema = enumOrResolverFor(["a", "b"] as const);
schema.parse("a");
schema.parse({ "ctx-self-id": null });
expect(schema.__resolverEnumValues).toEqual(["a", "b"]);
});
it("stringOrResolver accepts new shapes alongside strings", () => {
const schema = stringOrResolver();
expect(schema.safeParse("hello").success).toBe(true);
expect(schema.safeParse({ "ctx-self-marker-id": null }).success).toBe(
true,
);
expect(schema.safeParse({ mod: [1, 2] }).success).toBe(true);
});
});
describe("V3 — isResolverShape recognizes 6 new shapes", () => {
it("recognizes ctx-self-id and ctx-self-marker-id", () => {
expect(isResolverShape({ "ctx-self-id": null })).toBe(true);
expect(isResolverShape({ "ctx-self-marker-id": null })).toBe(true);
});
it("recognizes each arithmetic shape", () => {
expect(isResolverShape({ add: [1, 2] })).toBe(true);
expect(isResolverShape({ sub: [1, 2] })).toBe(true);
expect(isResolverShape({ mul: [1, 2] })).toBe(true);
expect(isResolverShape({ mod: [1, 2] })).toBe(true);
});
it("rejects an unknown single-key object", () => {
expect(isResolverShape({ unknownOp: [1, 2] })).toBe(false);
expect(isResolverShape({ div: [1, 2] })).toBe(false);
});
});

View file

@ -0,0 +1,430 @@
/**
* Validator widening helpers (V3 — Wave-1 thressgame-100) — Zod
* schema factories that accept either a LITERAL value or one of the
* 9 runtime-recognized resolver shapes that
* {@link ./param-resolver.ts} substitutes BEFORE a primitive's
* `apply()` runs.
*
* ## Why this exists
*
* Pre-V2, every imperative primitive's `paramsSchema` declared its
* positional fields (`target`, `square`, `to`, `a`, `b`, `owner`,
* etc.) as bare scalar Zod types — `z.number().int().min(0).max(63)`,
* `z.enum([...])`, etc. The runtime param-resolver substitutes
* resolver shapes BEFORE `apply()`, so by the time `apply()` runs
* the field is always a literal — but the static schema rejects
* authors who write the resolver shape into descriptor JSON. V2/V3
* widens the schema for those positional fields to accept BOTH the
* literal AND any resolver shape, so author-time validation no
* longer false-rejects valid descriptors.
*
* ## The 9 recognized shapes (mirror of param-resolver.ts walk())
*
* Each requires EXACTLY ONE key in the outer object — the runtime
* walker checks `keys.length === 1` before treating an object as a
* resolver shape. We mirror that with `.strict()` on each inner
* object: an extra unknown key would NOT trigger the runtime
* resolver, so accepting it at validation time would silently pass
* through to `apply()` where the primitive's own literal-typed code
* would crash. Better to reject loudly here.
*
* V2 (3 shapes, original):
* { "$var": "name" } // bind lookup
* { "ctx-attr": { entity: <selector>, attr: "<key>" } } // session.get
* { "ctx-build": { col: 0..7, row: 0..7 } } // square build
*
* V3 (6 new shapes, Wave-1):
* { "ctx-self-id": null } // ctx.pieceId identity
* { "ctx-self-marker-id": null } // ctx.markerId (in marker arms)
* { "add": [<resolver>, <integer>] } // left + right
* { "sub": [<resolver>, <integer>] } // left - right
* { "mul": [<resolver>, <integer>] } // left * right
* { "mod": [<resolver>, <integer>] } // positive-mod for column wrap
*
* Arithmetic operands are RECURSIVE — the right operand is locked
* to `z.number().int()` at validation (literal arity / overflow
* lives at runtime), but the LEFT operand may itself be any
* numeric-producing resolver shape, including a nested arithmetic
* shape (`add(add($var, 1), 2)`). Recursion is expressed via
* `z.lazy()` on the left-operand union.
*
* ## Union order: literal FIRST (decisions.md § A)
*
* Locked V3 union order across every helper:
*
* [literal, $var, ctx-attr, ctx-build, ctx-self-id,
* ctx-self-marker-id, add, sub, mul, mod]
*
* Per `decisions.md` § "Resolver shape order in unions", the literal
* is by far the most common shape on the hot path (every fixture
* descriptor passes literals through), so Zod's left-to-right union
* dispatch picks the cheapest branch first. This is also why
* {@link enumOrResolverFor} returns a union with the `z.enum(...)`
* at index 0 — `__resolverEnumValues` exposes the value list so
* consumers (notably `ParamField.tsx`) don't have to introspect
* `_def.options[0]._def.entries`.
*
* ## Backward compatibility
*
* Pre-V2 schemas using bare `z.number()`, `z.enum(...)`, `z.string()`
* etc. continue to work unchanged. V3 is a STRICT superset of V2 —
* every input that parsed under V2 still parses under V3.
*/
import { z } from "zod";
// ---------------------------------------------------------------------------
// Resolver-shape primitives
// ---------------------------------------------------------------------------
/**
* `{ $var: "name" }` — bind lookup. The `name` is a non-empty string
* because `param-resolver.ts:142-153` uses `ctx.bindings.has(name)`
* which requires a string key.
*/
const VarShape = z.object({ $var: z.string().min(1) }).strict();
/**
* `ctx-attr.entity` selector — recursively allows a nested `$var`
* shape because the runtime walker (`resolveEntity` in
* param-resolver.ts:241-279) re-walks the entity before resolving.
*
* Wrapped in `z.lazy` only for forward-compatibility (the union has
* no self-reference today, but the walker DOES recursively call
* `walk` on the entity, so an author could nest a `$var` whose own
* binding resolves to a number). The lazy wrapper keeps the door
* open for future widening without an API break.
*/
const EntitySelectorSchema: z.ZodType<unknown> = z.lazy(() =>
z.union([
z.literal("self"),
z.literal("chooser"),
z.number().int(),
VarShape,
]),
);
/**
* `{ "ctx-attr": { entity, attr } }` — runtime calls
* `ctx.session.get(resolvedEntityId, attr)`. `attr` is a non-empty
* string (downstream cast to `ChessAttrKey`); we don't enum-pin the
* attr name here because the universe of attr keys grows as the
* schema evolves and over-strict validation would block legitimate
* authoring of newly-added attrs.
*/
const CtxAttrShape = z
.object({
"ctx-attr": z
.object({
entity: EntitySelectorSchema,
attr: z.string().min(1),
})
.strict(),
})
.strict();
/**
* `{ "ctx-build": { col, row } }` — runtime computes
* `col + row * 8`. Both axes accept a literal `0..7` integer OR a
* `{ $var: "..." }` reference whose binding must resolve to an
* integer in the same range (the walker does that range check at
* resolve time, but we still enforce the literal range here so
* obvious typos like `col: 8` fail at validation).
*/
const CtxBuildShape = z
.object({
"ctx-build": z
.object({
col: z.union([z.number().int().min(0).max(7), VarShape]),
row: z.union([z.number().int().min(0).max(7), VarShape]),
})
.strict(),
})
.strict();
// ---------------------------------------------------------------------------
// V3 shapes (Wave-1 thressgame-100)
// ---------------------------------------------------------------------------
/**
* `{ "ctx-self-id": null }` — identity shape returning
* `ctx.pieceId` at runtime. Payload is locked to `null` (mirrors
* the runtime check in `param-resolver.ts`); `true`, `0`, `""`, etc.
* fail validation rather than producing surprising results.
*/
const CtxSelfIdShape = z.object({ "ctx-self-id": z.null() }).strict();
/**
* `{ "ctx-self-marker-id": null }` — identity shape returning
* `ctx.markerId`. Only valid inside marker-trigger arms; the
* runtime walker throws if `ctx.markerId` is `undefined` (the
* validator can't tell — that's a runtime-context concern).
*/
const CtxSelfMarkerIdShape = z
.object({ "ctx-self-marker-id": z.null() })
.strict();
/**
* Numeric-producing resolver shapes — i.e. every shape whose
* runtime resolution yields a number. Used as the LEFT operand of
* arithmetic shapes (the recursive arm) AND as the body of
* `numberOrResolver`. Wrapped in `z.lazy` because `ArithmeticShape`
* (defined just below) self-references this union for its left
* operand.
*
* Order matches the locked V3 union order — literal numeric first,
* resolver shapes after. The right operand of arithmetic shapes is
* NOT this union; it's locked to `z.number().int()` so authors
* can't bury an unbounded resolver chain on the right where
* overflow risk compounds. (Multi-resolver expressions can still be
* built via nested arithmetic on the left operand.)
*/
const NumericResolverInput: z.ZodType<unknown> = z.lazy(() =>
z.union([
z.number().int(),
VarShape,
CtxAttrShape,
CtxBuildShape,
CtxSelfIdShape,
CtxSelfMarkerIdShape,
ArithmeticShape,
]),
);
/**
* `{ "add" | "sub" | "mul" | "mod": [<resolver>, <integer>] }` —
* 2-arity arithmetic. Left operand is `NumericResolverInput`
* (recursive: nested arithmetic, $var, ctx-attr, etc.); right
* operand is a literal integer. Range / overflow / divide-by-zero
* checks live in the walker — the schema only pins shape.
*
* Each op is a strict object; the union order inside is fixed but
* not load-bearing (the helper's own union is what consumers see).
*/
const ArithmeticShape: z.ZodType<unknown> = z.lazy(() =>
z.union([
z
.object({
add: z.tuple([NumericResolverInput, z.number().int()]),
})
.strict(),
z
.object({
sub: z.tuple([NumericResolverInput, z.number().int()]),
})
.strict(),
z
.object({
mul: z.tuple([NumericResolverInput, z.number().int()]),
})
.strict(),
z
.object({
mod: z.tuple([NumericResolverInput, z.number().int()]),
})
.strict(),
]),
);
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* The runtime-recognized resolver shapes (V3 — Wave-1
* thressgame-100), as a TypeScript discriminated union. Tests and
* downstream introspection (e.g. type guards) use this to narrow
* `unknown` to "definitely a resolver shape".
*
* NOTE: `ctx-attr.entity` is typed as `unknown` because the runtime
* accepts a recursive `$var` nest there; pinning it tighter at the
* type level would reject valid author input. Arithmetic operands
* are `unknown` for the same reason — the left operand is
* recursive, and pinning it tighter would force every consumer
* touching the type to disambiguate the recursion themselves.
*/
export type ResolverShape =
| { readonly $var: string }
| {
readonly "ctx-attr": {
readonly entity: unknown;
readonly attr: string;
};
}
| {
readonly "ctx-build": {
readonly col: number | { readonly $var: string };
readonly row: number | { readonly $var: string };
};
}
| { readonly "ctx-self-id": null }
| { readonly "ctx-self-marker-id": null }
| { readonly add: readonly [unknown, number] }
| { readonly sub: readonly [unknown, number] }
| { readonly mul: readonly [unknown, number] }
| { readonly mod: readonly [unknown, number] };
/**
* Tuple of every recognised V3 resolver-shape key. Single source of
* truth for {@link isResolverShape} so adding a shape requires one
* edit (here) — the structural guard inherits the addition for free.
*/
const RESOLVER_SHAPE_KEYS = [
"$var",
"ctx-attr",
"ctx-build",
"ctx-self-id",
"ctx-self-marker-id",
"add",
"sub",
"mul",
"mod",
] as const;
const RESOLVER_SHAPE_KEY_SET: ReadonlySet<string> = new Set(
RESOLVER_SHAPE_KEYS,
);
// ---------------------------------------------------------------------------
// Public helpers
// ---------------------------------------------------------------------------
/**
* Number-or-resolver union. Use for positional fields that the
* runtime resolver substitutes to a literal integer before `apply()`
* runs (e.g. `square` 0..63, `target` EntityId, `to` square).
*
* Optional `min` / `max` clamp the LITERAL branch only — the
* resolver-shape branches are unconstrained at validation time
* because the runtime walker enforces its own range checks (e.g.
* `ctx-build` col/row must land in `[0..7]`) and a `$var` binding
* may legitimately point to any integer.
*/
export function numberOrResolver(opts?: {
min?: number;
max?: number;
}): z.ZodType<unknown> {
let numberSchema = z.number().int();
if (opts?.min !== undefined) numberSchema = numberSchema.min(opts.min);
if (opts?.max !== undefined) numberSchema = numberSchema.max(opts.max);
// V3 union order: literal first, then 6 V2/V3 resolver shapes,
// then the 4 arithmetic shapes. Arithmetic is a single
// `ArithmeticShape` lazy union but expanded here so each branch
// appears at a stable position in `_def.options` for any consumer
// that introspects (e.g. ParamField widening).
return z.union([
numberSchema,
VarShape,
CtxAttrShape,
CtxBuildShape,
CtxSelfIdShape,
CtxSelfMarkerIdShape,
ArithmeticShape,
]) as unknown as z.ZodType<unknown>;
}
/**
* Augmented union returned by {@link enumOrResolverFor}. The
* `__resolverEnumValues` discriminator exposes the original enum
* value list so consumers (notably `ParamField.tsx`) can detect "this
* is a resolver-widened enum, render an enum picker for the literal
* branch" WITHOUT having to dig into `_def.options[0]._def.entries`.
*
* Typed as `z.ZodType<unknown>` to accommodate the V3 union (lazy
* arithmetic recursion); consumers downcast as they did under V2.
*/
export type EnumOrResolverSchema<T extends readonly [string, ...string[]]> =
z.ZodType<unknown> & { readonly __resolverEnumValues: T };
/**
* Enum-or-resolver union. Use for positional fields that ALSO accept
* a closed string set (e.g. `owner: "white" | "black"`).
*
* The returned schema carries a `__resolverEnumValues` property
* pointing at the original `values` tuple. T9 (ParamField widening)
* reads this property to render the enum picker without traversing
* the union internals — keeping the UI introspection logic simple.
*
* The enum branch is at `_def.options[0]` so Zod's left-to-right
* union dispatch tries the literal first (hot path).
*/
export function enumOrResolverFor<
const T extends readonly [string, ...string[]],
>(values: T): EnumOrResolverSchema<T> {
const baseEnum = z.enum(values);
// V3 union order — enum literal first, V2 shapes, V3 shapes,
// arithmetic. Numeric arithmetic is a strange fit for an enum
// field at the SEMANTIC level (an enum like `"white"|"black"`
// can't legally hold an integer), but we include the branches
// for shape-level uniformity: a `$var` binding could resolve to
// either color or to a number that's then compared upstream, and
// forcing the helper's union to omit arithmetic would create a
// confusing asymmetry with `numberOrResolver`. Authoring-time
// intent (color-vs-arithmetic) is the descriptor author's
// responsibility; the runtime walker enforces type after resolve.
const schema = z.union([
baseEnum,
VarShape,
CtxAttrShape,
CtxBuildShape,
CtxSelfIdShape,
CtxSelfMarkerIdShape,
ArithmeticShape,
]);
// Attach the enum value list directly so consumers can read it via
// an `'__resolverEnumValues' in schema` check. Verified to survive
// Zod 4.x parse calls — Zod stores its own state in `_def`, not on
// the public surface, so plain expando assignment is safe.
Object.assign(schema, { __resolverEnumValues: values });
return schema as unknown as EnumOrResolverSchema<T>;
}
/**
* String-or-resolver union. Use sparingly: most string-typed
* positional fields are actually closed enums (use {@link
* enumOrResolverFor} for those). Reserved for free-form string
* fields like `markerLabel` or descriptor display names where any
* non-empty string is valid.
*/
export function stringOrResolver(): z.ZodType<unknown> {
// V3 union — same shape order as `numberOrResolver`. Arithmetic
// included for shape-uniformity (see `enumOrResolverFor` rationale);
// a string-typed positional field that resolves arithmetic to a
// number will fail downstream type checking but the SHAPE-level
// schema is decoupled from the runtime type contract by design.
return z.union([
z.string(),
VarShape,
CtxAttrShape,
CtxBuildShape,
CtxSelfIdShape,
CtxSelfMarkerIdShape,
ArithmeticShape,
]) as unknown as z.ZodType<unknown>;
}
// ---------------------------------------------------------------------------
// Type-narrowing guards
// ---------------------------------------------------------------------------
/**
* Returns `true` when `v` matches one of the V3 resolver shapes
* structurally (single key, expected key name). Does NOT validate the
* inner payload — for full validation use one of the helper schemas
* above and call `.parse()` / `.safeParse()`.
*/
export function isResolverShape(v: unknown): v is ResolverShape {
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
const obj = v as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length !== 1) return false;
const only = keys[0];
return only !== undefined && RESOLVER_SHAPE_KEY_SET.has(only);
}
/**
* Trivial narrowing helper paired with {@link isResolverShape} so a
* caller can branch `isLiteralNumber(v) ? ... : isResolverShape(v) ?
* ... : ...` without re-deriving the negative case.
*/
export function isLiteralNumber(v: unknown): v is number {
return typeof v === "number" && Number.isFinite(v);
}

View file

@ -315,3 +315,252 @@ describe("resolveParams — deep walk", () => {
expect(out).toEqual({ $var: "x", extra: 1 });
});
});
// ---------------------------------------------------------------------------
// V3 (Wave-1 thressgame-100) — new resolver shapes
// ---------------------------------------------------------------------------
describe("resolveParams (V3) — ctx-self-id", () => {
it("returns ctx.pieceId for the simple identity shape", () => {
const { ctx } = makeCtx({ pieceId: 7 as EntityId });
expect(resolveParams({ "ctx-self-id": null }, ctx)).toBe(7);
});
it("survives a deep walk (substituted at any nesting depth)", () => {
const { ctx } = makeCtx({ pieceId: 12 as EntityId });
const out = resolveParams(
{
target: { "ctx-self-id": null },
meta: { nested: { "ctx-self-id": null } },
},
ctx,
) as { target: number; meta: { nested: number } };
expect(out.target).toBe(12);
expect(out.meta.nested).toBe(12);
});
it("rejects non-null payload (true / 0 / string)", () => {
const { ctx } = makeCtx();
expect(() =>
resolveParams(
{ "ctx-self-id": true } as unknown as Record<string, unknown>,
ctx,
),
).toThrow(/payload must be null/);
expect(() =>
resolveParams(
{ "ctx-self-id": 0 } as unknown as Record<string, unknown>,
ctx,
),
).toThrow(/payload must be null/);
});
});
describe("resolveParams (V3) — ctx-self-marker-id", () => {
it("returns ctx.markerId when populated (inside a marker trigger)", () => {
const { ctx: base } = makeCtx({ pieceId: 3 as EntityId });
const ctx = { ...base, markerId: 42 as EntityId };
expect(resolveParams({ "ctx-self-marker-id": null }, ctx)).toBe(42);
});
it("throws BindingError-style when ctx.markerId is undefined", () => {
// Default ctx has no markerId — this models authoring the shape
// outside a marker trigger arm, which is the canonical mistake
// the loud throw is meant to catch.
const { ctx } = makeCtx();
expect(() =>
resolveParams({ "ctx-self-marker-id": null }, ctx),
).toThrow(/not inside a marker trigger/);
});
it("rejects non-null payload", () => {
const { ctx: base } = makeCtx();
const ctx = { ...base, markerId: 9 as EntityId };
expect(() =>
resolveParams(
{ "ctx-self-marker-id": "yes" } as unknown as Record<string, unknown>,
ctx,
),
).toThrow(/payload must be null/);
});
});
describe("resolveParams (V3) — arithmetic add/sub/mul/mod", () => {
it("computes basic arithmetic with literal operands", () => {
const { ctx } = makeCtx();
expect(resolveParams({ add: [3, 4] }, ctx)).toBe(7);
expect(resolveParams({ sub: [10, 3] }, ctx)).toBe(7);
expect(resolveParams({ mul: [6, 7] }, ctx)).toBe(42);
expect(resolveParams({ mod: [13, 5] }, ctx)).toBe(3);
});
it("uses positive modulo so mod handles negative operands (column wrap)", () => {
const { ctx } = makeCtx();
// -1 % 8 in JS = -1, but we want 7 for column-wrap recipes.
expect(resolveParams({ mod: [-1, 8] }, ctx)).toBe(7);
expect(resolveParams({ mod: [-9, 8] }, ctx)).toBe(7);
expect(resolveParams({ mod: [8, 8] }, ctx)).toBe(0);
});
it("resolves $var operands recursively", () => {
const { ctx } = makeCtx({
bindings: new Map<string, BindingValue>([
["a", 10],
["b", 3],
]),
});
expect(resolveParams({ add: [{ $var: "a" }, 5] }, ctx)).toBe(15);
expect(resolveParams({ sub: [{ $var: "a" }, { $var: "b" }] }, ctx)).toBe(
7,
);
});
it("resolves ctx-attr operand recursively", () => {
const { ctx } = makeCtx({
setupSession: (s) => {
s.insert(1 as EntityId, "Hp", 5);
},
});
expect(
resolveParams(
{
add: [{ "ctx-attr": { entity: "self", attr: "Hp" } }, 7],
},
ctx,
),
).toBe(12);
});
it("nests arithmetic — add inside add", () => {
const { ctx } = makeCtx({
bindings: new Map<string, BindingValue>([["x", 4]]),
});
// add(add($x, 1), 2) = add(5, 2) = 7
expect(
resolveParams({ add: [{ add: [{ $var: "x" }, 1] }, 2] }, ctx),
).toBe(7);
});
it("column-wrap pattern: mod(add($col, 1), 8)", () => {
const { ctx } = makeCtx({
bindings: new Map<string, BindingValue>([["col", 7]]),
});
// 7 + 1 = 8, 8 mod 8 = 0 (wraps file h → file a)
expect(
resolveParams(
{ mod: [{ add: [{ $var: "col" }, 1] }, 8] },
ctx,
),
).toBe(0);
// From file a (col 0): wraps via -1 → 7
const { ctx: ctx2 } = makeCtx({
bindings: new Map<string, BindingValue>([["col", 0]]),
});
expect(
resolveParams(
{ mod: [{ sub: [{ $var: "col" }, 1] }, 8] },
ctx2,
),
).toBe(7);
});
it("throws when payload is not a 2-element array", () => {
const { ctx } = makeCtx();
expect(() =>
resolveParams({ add: [1] } as unknown as Record<string, unknown>, ctx),
).toThrow(/2-element array/);
expect(() =>
resolveParams(
{ mul: [1, 2, 3] } as unknown as Record<string, unknown>,
ctx,
),
).toThrow(/2-element array/);
expect(() =>
resolveParams(
{ sub: { left: 1, right: 2 } } as unknown as Record<string, unknown>,
ctx,
),
).toThrow(/2-element array/);
});
it("throws when an operand resolves to a non-number", () => {
const { ctx } = makeCtx({
bindings: new Map<string, BindingValue>([["color", "white"]]),
});
expect(() =>
resolveParams({ add: [{ $var: "color" }, 1] }, ctx),
).toThrow(/operands must resolve to numbers/);
});
it("throws when mod divisor is zero", () => {
const { ctx } = makeCtx();
expect(() => resolveParams({ mod: [10, 0] }, ctx)).toThrow(
/divisor is zero/,
);
});
it("throws on integer overflow at MAX_SAFE_INTEGER boundary", () => {
const { ctx } = makeCtx();
// MAX_SAFE_INTEGER * 2 overflows to ±Infinity domain (loses
// precision); our check rejects results outside the safe range.
const big = Number.MAX_SAFE_INTEGER;
expect(() => resolveParams({ mul: [big, 2] }, ctx)).toThrow(
/result overflow/,
);
expect(() => resolveParams({ add: [big, big] }, ctx)).toThrow(
/result overflow/,
);
});
it("throws on non-finite operand (NaN / Infinity injected via binding)", () => {
const { ctx } = makeCtx({
bindings: new Map<string, BindingValue>([
["nan", Number.NaN as unknown as BindingValue],
["inf", Infinity as unknown as BindingValue],
]),
});
expect(() =>
resolveParams({ add: [{ $var: "nan" }, 1] }, ctx),
).toThrow(/must be finite/);
expect(() =>
resolveParams({ sub: [{ $var: "inf" }, 1] }, ctx),
).toThrow(/must be finite/);
});
});
describe("resolveParams (V3) — composition of new shapes with old", () => {
it("ctx-build can take an arithmetic shape for col / row", () => {
const { ctx } = makeCtx({
bindings: new Map<string, BindingValue>([["c", 3]]),
});
// ctx-build with col = (c + 1) = 4, row = 2 → square 4 + 2*8 = 20
expect(
resolveParams(
{
"ctx-build": {
col: { add: [{ $var: "c" }, 1] },
row: 2,
},
},
ctx,
),
).toBe(20);
});
it("ctx-attr.entity can be a ctx-self-id shape (resolves to self)", () => {
const { ctx } = makeCtx({
pieceId: 5 as EntityId,
setupSession: (s) => {
s.insert(5 as EntityId, "Hp", 11);
},
});
expect(
resolveParams(
{
"ctx-attr": { entity: { "ctx-self-id": null }, attr: "Hp" },
},
ctx,
),
).toBe(11);
});
});

View file

@ -1,10 +1,10 @@
/**
* Param walker (T12) — runtime substitution of binding refs and
* context-attribute / context-build references inside primitive
* `params` trees, performed BEFORE the primitive's `apply()` is
* invoked.
* Param walker (T12; V3 — Wave-1 thressgame-100) — runtime
* substitution of binding refs and context-attribute /
* context-build references inside primitive `params` trees,
* performed BEFORE the primitive's `apply()` is invoked.
*
* Three shape recognisers, each triggered ONLY when an object has
* Nine shape recognisers, each triggered ONLY when an object has
* exactly one matching key (so a plain `{ $var: ... }` field never
* collides with a primitive that legitimately stores a key starting
* with `$`):
@ -25,6 +25,30 @@
* → `col` / `row` may themselves be `{ $var }` shapes (walked first)
* → throws if either falls outside the integer range [0..7]
*
* { "ctx-self-id": null } // V3
* → `ctx.pieceId`
* → identity shape used for self-targeting destroy/move
* primitives where the primitive's `target` field is widened
* to a resolver union and the descriptor wants to be explicit
* rather than rely on the implicit "self" default.
*
* { "ctx-self-marker-id": null } // V3
* → `ctx.markerId`
* → only valid inside `on-piece-entered-marker` /
* `on-marker-expire` trigger arms; throws
* BindingError-style if `ctx.markerId` is `undefined`.
*
* { "add" / "sub" / "mul" / "mod": [<resolver>, <resolver>] } // V3
* → arithmetic on two operands, each of which may be a literal
* integer OR another resolver shape (the recursive `walk()`
* handles nesting naturally — `add(add($var, 1), 2)` works).
* → throws on non-numeric / non-finite operands, on `mod` with
* a zero divisor, and on integer-overflow at the
* `Number.MAX_SAFE_INTEGER` boundary (no silent wrap).
* → `mod` uses the positive-modulo formula `((l % r) + r) % r`
* so column-wrapping recipes (`mod(add($col, 1), 8)`) produce
* a 0..r-1 result for any sign of `l`.
*
* Anything else (primitive value, plain object, array) is returned
* unchanged — arrays + plain-object values are deep-walked so a
* resolver shape buried at any depth is still substituted.
@ -213,6 +237,108 @@ function walk(node: unknown, ctx: PrimitiveApplyContext): unknown {
}
return col + row * 8;
}
// V3 (Wave-1 thressgame-100) — identity shape returning the
// current apply target's piece id. Payload MUST be `null` (not
// `true`, `0`, `""`, etc.) so authoring typos surface here
// rather than silently passing through to apply().
if (only === "ctx-self-id") {
const inner = obj["ctx-self-id"];
if (inner !== null) {
throw new Error(
`ctx-self-id: payload must be null, got ${typeof inner} (${JSON.stringify(inner)})`,
);
}
return ctx.pieceId;
}
// V3 — identity shape returning the marker entity id of the
// currently-firing marker trigger (`on-piece-entered-marker` /
// `on-marker-expire`). Throws when used outside a marker arm
// because `ctx.markerId` is only populated by `runPrimitives`
// for those two event kinds. The error mirrors `BindingError`'s
// fail-loud philosophy: silently producing `undefined` would
// corrupt downstream facts (NaN squares, invalid EntityIds).
if (only === "ctx-self-marker-id") {
const inner = obj["ctx-self-marker-id"];
if (inner !== null) {
throw new Error(
`ctx-self-marker-id: payload must be null, got ${typeof inner} (${JSON.stringify(inner)})`,
);
}
if (ctx.markerId === undefined) {
throw new Error(
"ctx-self-marker-id: not inside a marker trigger (ctx.markerId is undefined). " +
"This shape is only valid inside on-piece-entered-marker / on-marker-expire arms.",
);
}
return ctx.markerId;
}
// V3 — arithmetic shapes. Operands recursively walked so a
// nested resolver (`add(add($var, 1), 2)`, `mod(ctx-attr, 8)`)
// is fully resolved before the operation runs. Overflow check
// at `Number.MAX_SAFE_INTEGER` rather than silent JS-number
// wrap so authors discover bad arithmetic statically. `mod`
// uses the positive-modulo formula so column-wrapping recipes
// (the canonical Layer-1 mass-mover usage:
// `mod(add($col, 1), 8)`) always produce a non-negative
// result regardless of operand sign.
if (
only === "add" ||
only === "sub" ||
only === "mul" ||
only === "mod"
) {
const op = only;
const inner = obj[op];
if (!Array.isArray(inner) || inner.length !== 2) {
throw new Error(
`${op}: payload must be a 2-element array, got ${JSON.stringify(inner)}`,
);
}
const left = walk(inner[0], ctx);
const right = walk(inner[1], ctx);
if (typeof left !== "number" || typeof right !== "number") {
throw new Error(
`${op}: operands must resolve to numbers, got left=${typeof left} right=${typeof right}`,
);
}
if (!Number.isFinite(left) || !Number.isFinite(right)) {
throw new Error(
`${op}: operands must be finite, got left=${left} right=${right}`,
);
}
let result: number;
switch (op) {
case "add":
result = left + right;
break;
case "sub":
result = left - right;
break;
case "mul":
result = left * right;
break;
case "mod":
if (right === 0) {
throw new Error(`mod: divisor is zero`);
}
// Positive modulo — column-wrap pattern needs a 0..r-1
// result for any sign of `left`.
result = ((left % right) + right) % right;
break;
}
if (
!Number.isFinite(result) ||
Math.abs(result) > Number.MAX_SAFE_INTEGER
) {
throw new Error(
`${op}: result overflow (${left} ${op} ${right} = ${result})`,
);
}
return result;
}
}
// Plain object — recurse on each value.

View file

@ -158,6 +158,27 @@ export interface PrimitiveApplyContext {
* applies and for triggers that don't carry per-event payload.
*/
readonly event: PrimitiveEvent | undefined;
/**
* Wave-1 (thressgame-100) — the marker entity id this apply is
* running ON BEHALF OF, populated EXCLUSIVELY when the dispatcher
* is firing a marker-scoped trigger (`on-piece-entered-marker`,
* `on-marker-expire`). Mirrors `event.markerId` but surfaces the
* value at the top level so the resolver shape
* `{ "ctx-self-marker-id": null }` can read it without having to
* peek inside the event union (which is otherwise opaque to the
* resolver). `undefined` for every non-marker trigger and every
* profile-time apply — the resolver throws BindingError-style when
* an author writes `ctx-self-marker-id` outside a marker-trigger
* arm, so confusion surfaces loudly rather than silently producing
* a numeric NaN downstream.
*
* Single source of truth: populated by `runPrimitives` in
* `triggers.ts` from `event?.markerId` for the two marker
* trigger kinds. Construction sites that don't fire marker
* triggers (every test ctx, every direct apply call) inherit
* `undefined` via the optional default.
*/
readonly markerId?: EntityId | undefined;
/**
* Lexically-scoped bindings (T11). Iteration primitives (T31-T35)
* and request-choice (T47) introduce names into this map via the

View file

@ -253,6 +253,19 @@ export function runPrimitives(
// concern, not a runner concern.
target: "self",
event,
// Wave-1 (thressgame-100) — surface markerId at ctx top level
// so the `ctx-self-marker-id` resolver shape can read it
// without peeking into the discriminated `event` union. Only
// the two marker-scoped trigger events carry a markerId
// (piece-entered-marker, marker-expire); every other event /
// every profile-time apply gets `undefined` and the resolver
// throws if an author misuses the shape outside a marker arm.
markerId:
event !== undefined &&
(event.kind === "piece-entered-marker" ||
event.kind === "marker-expire")
? event.markerId
: undefined,
// T11: bindings flow inward through recursion. Trigger
// dispatchers seed an empty map at hook entry; iteration /
// request-choice primitives extend it via `withBinding` before