fix(thressgame-coverage): Wave 14 (Gap G + H + I — descriptor-id threading + broadcast revert + dispatcher double-recurse)

Closes the 3 outstanding gaps from the post-Wave-13 audit:

- T80 (Gap G): per-piece trigger hook entries now carry descriptorId; fire*Hooks threads it into PrimitiveApplyContext instead of synthetic '__trigger__' placeholder. submitChoiceAndResume can now resolve trigger-fired choices. Unblocks parry/RPS resume path.

- T81 (Gap H): server suppresses post-action game.delta broadcasts while PendingChoices stack is non-empty; broadcasts only after stack drains (or fire revert delta when CaptureCancelled handled). Clients no longer render mid-cascade incorrect state.

- T82 (Gap I): selfRecurse: boolean flag on EffectPrimitive; iteration primitives (for-each-piece/square/adjacent/marker, for-column, for-row, random-pick, with-probability, request-choice) opt into self-iteration so dispatcher skips auto-recurse. Conditional remains selfRecurse=false (correct). Eliminates BindingError warns from outer-scope passes.

Tests: 2853 -> 2865 (+12). bun run check exit 0. Playwright e2e: 2 passing + 1 fixme (T68/3 ready to lift in Wave 15).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 17:01:47 -06:00
commit 17d8afa1f5
No known key found for this signature in database
46 changed files with 1732 additions and 170 deletions

View file

@ -131,14 +131,15 @@ describe("T78 — kamikaze REAL-pipeline", () => {
placePiece(engine, "pawn", "black", 18); // adjacent to d3 - AOE target
// Seed OnCaptureHooks directly (mirrors what a fixed
// applyCustomDescriptor would produce). OnCaptureHooks shape:
// Array<EffectPrimitiveNode[]>.
// applyCustomDescriptor would produce). T80: OnCaptureHooks
// entries carry `descriptorId` so the trigger dispatcher can
// thread the real id into runPrimitives (Gap G fix).
const onCaptureNode = descriptor.primitives[0]!;
const innerArm = (onCaptureNode.params as {
primitives: EffectPrimitiveNode[];
}).primitives;
engine.session.insert(attacker, "OnCaptureHooks", [
innerArm,
{ descriptorId: descriptor.id, primitives: innerArm },
] as ChessAttrMap["OnCaptureHooks"]);
const cap = engine

View file

@ -128,8 +128,12 @@ describe("T78 — mind_control REAL-pipeline", () => {
expect(top).toBeDefined();
expect(top!.kind).toBe("piece");
expect(top!.forPlayer).toBe("both");
// V1 sharp edge: trigger-fired choice tagged "__trigger__".
expect(top!.descriptorId).toBe("__trigger__");
// T80 (Wave 14, Gap G fix) — trigger-fired choices now carry
// the REAL descriptor id threaded from the hook entry through
// `runPrimitives` into the synthesised PrimitiveApplyContext.
// Pre-T80 the dispatcher emitted "__trigger__" as a placeholder
// which broke `submitChoiceAndResume` (descriptor-not-found).
expect(top!.descriptorId).toBe("parity:mind_control");
});
it("AutoChoiceResolver picks target ids deterministically via byId table", () => {
@ -217,9 +221,14 @@ describe("T78 — mind_control REAL-pipeline", () => {
popPendingChoice(engine);
});
it("submitChoiceAndResume on the trigger-fired frame hits descriptor-not-found (V1: '__trigger__')", () => {
// Same V1 sharp edge as mr_freeze. The sibling test works
// around it by re-pushing the frame with the registered id.
it("trigger-fired frame carries the REAL descriptor id (T80 Gap G fix)", () => {
// T80 (Wave 14, Gap G fix): the dispatcher now threads the
// owning descriptor's id through trigger dispatch so
// PendingChoice frames pushed inside a trigger arm carry the
// REAL id. `submitChoiceAndResume` resolves the descriptor by
// id (no longer throws `runtime.descriptor-not-found`).
// Pre-T80 this test asserted the throw; post-T80 we pin the
// real-id contract on the suspended frame.
const descriptor = parseCustomModifierDescriptor(RAW_FIXTURE);
const engine = new ChessEngine({ profile: emptyProfile() });
engine.setRngSeed(66);
@ -240,13 +249,7 @@ describe("T78 — mind_control REAL-pipeline", () => {
fireOnRuleActivatedHooks(engine, "parity:mind_control");
const top = peekPendingChoice(engine);
expect(top).toBeDefined();
// Use a placeholder integer id as the answer — the test
// exercises the early `descriptor-not-found` path which fires
// BEFORE the answer is consumed.
expect(() =>
submitChoiceAndResume(engine, top!.choiceId, 1),
).toThrow(/runtime\.descriptor-not-found/);
expect(top!.descriptorId).toBe("parity:mind_control");
});
it("kings remain unaffected — structural pin (no resolver answer ever points at a king id)", () => {

View file

@ -279,15 +279,14 @@ describe("T66 — mind_control ThressGame parity rule", () => {
const rawBlackFrame = stackAfterFire![0]!;
expect(rawBlackFrame.kind).toBe("piece");
expect(rawBlackFrame.forPlayer).toBe("both");
// Trigger-fired choices carry descriptorId = "__trigger__" —
// the dispatcher synthesises a placeholder descriptor ref when
// entering a hook arm (see triggers.ts § "Trigger evaluation
// has no parent descriptor — synthesise a minimal ref").
// submitChoiceAndResume needs the LIFTED id to look up the
// descriptor tree, so we pop + re-push with the corrected id
// before the LIFO injection step. Mr_freeze pins this same
// dispatcher-id behaviour at line 336 of mr_freeze.test.ts.
expect(rawBlackFrame.descriptorId).toBe("__trigger__");
// T80 (Wave 14 Gap G fix): trigger-fired choices now carry the
// REAL descriptor id (the lifted descriptor's id), threaded
// from the hook entry through `runPrimitives` into the
// synthesised `PrimitiveApplyContext.descriptor`. Pre-T80 the
// dispatcher emitted "__trigger__" as a placeholder which broke
// `submitChoiceAndResume` (descriptor-not-found). With the fix
// landed, no pop+repush dance is required for the resume path.
expect(rawBlackFrame.descriptorId).toBe(liftedId);
expect(rawBlackFrame.triggerPath).toEqual([]);
expect(rawBlackFrame.primitiveIndex).toBe(0);

View file

@ -44,10 +44,7 @@ import {
} from "../../schema.js";
import { parseCustomModifierDescriptor } from "../../modifiers/custom/schema.js";
import { fireOnRuleActivatedHooks } from "../../modifiers/triggers.js";
import {
peekPendingChoice,
submitChoiceAndResume,
} from "../../util/pending-choices.js";
import { peekPendingChoice } from "../../util/pending-choices.js";
import { AutoChoiceResolver } from "../choice-transport/auto-resolver.js";
import type { ModifierProfile } from "../../modifiers/types.js";
import type { EffectPrimitiveNode } from "../../modifiers/primitives/types.js";
@ -122,26 +119,26 @@ describe("T78 — mr_freeze REAL-pipeline", () => {
expect(resolver.resolve(top!)).toBe(4);
});
it("submitChoiceAndResume on the trigger-fired frame hits descriptor-not-found (V1: '__trigger__' placeholder)", () => {
// Documented V1 sharp edge: the dispatcher tags trigger-fired
// choice frames with descriptorId="__trigger__".
// submitChoiceAndResume looks that up in customModifiers and
// throws `runtime.descriptor-not-found`. The sibling test
// works around this by popping + re-pushing with the
// registered descriptor id.
it("trigger-fired frame carries the REAL descriptor id (T80 Gap G fix)", () => {
// T80 (Wave 14, Gap G fix): the dispatcher now threads the
// owning descriptor's id through trigger dispatch (every
// `fire*Hooks` reads `hook.descriptorId` and passes it into
// `runPrimitives`). PendingChoice frames pushed inside a
// trigger arm carry the REAL id, so `submitChoiceAndResume`
// resolves the descriptor by id and walks back into its
// primitive tree to continue execution — no longer throws
// `runtime.descriptor-not-found`.
//
// V2 work: thread the owning descriptor's id through trigger
// dispatch so the frame carries the real id. After the fix,
// this test should be tightened to assert the resume runs
// the spawn-marker cascade.
// Pre-T80 this test asserted the throw; post-T80 we pin the
// real-id contract. The follow-up resume cascade (for-row +
// spawn-marker) is exercised by mr_freeze.test.ts via the
// direct `FOR_ROW_PRIMITIVE.apply()` path because Gap I
// (dispatcher double-walk on for-each-adjacent / for-row
// children) is still open and tracked separately (T82).
const { engine } = buildAndFire();
const top = peekPendingChoice(engine);
expect(top).toBeDefined();
expect(top!.descriptorId).toBe("__trigger__");
expect(() =>
submitChoiceAndResume(engine, top!.choiceId, 4),
).toThrow(/runtime\.descriptor-not-found/);
expect(top!.descriptorId).toBe("parity:mr_freeze");
});
it("expected layout pin: 8 frozen-square markers at column 4 (e1..e8 = squares 4,12,20,28,36,44,52,60)", () => {

View file

@ -330,10 +330,14 @@ describe("T60 — mr_freeze ThressGame parity rule", () => {
expect(top).toBeDefined();
expect(top!.kind).toBe("column");
expect(top!.forPlayer).toBe("both");
// Trigger-fired choices carry descriptorId = "__trigger__" —
// see file docstring § "Resume mechanism" for why this is the
// dispatcher's synthetic placeholder rather than the lifted id.
expect(top!.descriptorId).toBe("__trigger__");
// T80 (Wave 14 Gap G fix): trigger-fired choices now carry the
// REAL descriptor id (the descriptor that owns the trigger
// hook). Pre-T80 the dispatcher synthesised "__trigger__" as a
// placeholder, which broke `submitChoiceAndResume` because the
// descriptor lookup at resume time failed. The lifted descriptor
// ID flows through `fireOnRuleActivatedHooks` → `runPrimitives`
// → request-choice's PendingChoice.descriptorId.
expect(top!.descriptorId).toBe(liftedId);
// The dispatcher entered with triggerPath=[]; the request-choice
// is the first primitive in the lifted descriptor's top arm.
expect(top!.triggerPath).toEqual([]);

View file

@ -227,6 +227,7 @@ describe("T72 — parry, REAL-pipeline capture undo (snapshot+restore)", () => {
}).primitives;
engine.session.insert(blackPawnId!, "OnCapturedHooks", [
{
descriptorId: descriptor.id,
target: "self",
primitives: innerArm,
},
@ -397,7 +398,7 @@ describe("T72 — parry, REAL-pipeline capture undo (snapshot+restore)", () => {
primitives: EffectPrimitiveNode[];
}).primitives;
engine.session.insert(blackPawnId!, "OnCapturedHooks", [
{ target: "self", primitives: innerArm },
{ descriptorId: descriptor.id, target: "self", primitives: innerArm },
] as ChessAttrMap["OnCapturedHooks"]);
m = engine

View file

@ -233,6 +233,7 @@ function runParryScenario(input: {
}).primitives;
engine.session.insert(defenderId, "OnCapturedHooks", [
{
descriptorId: descriptor.id,
target: "self",
primitives: innerArm,
},

View file

@ -130,7 +130,7 @@ describe("T78 — religious_conversion REAL-pipeline", () => {
).toThrow(/Binding '\$adj' is not in scope/);
});
it("seeding OnMoveHooks + engine.applyMove enters the real fireOnMoveHooks dispatcher (V1 sharp edge: dispatcher double-walks for-each-adjacent's children)", () => {
it("seeding OnMoveHooks + engine.applyMove enters the real fireOnMoveHooks dispatcher (T80 + selfRecurse=true: hook fires cleanly)", () => {
// This test pins the integration-preset wiring: when the
// descriptor's hook is seeded directly (bypassing the broken
// applyCustomDescriptor walker) and `engine.applyMove` is
@ -158,14 +158,15 @@ describe("T78 — religious_conversion REAL-pipeline", () => {
placePiece(engine, "pawn", "black", 29); // f4 - adj to e5
placePiece(engine, "pawn", "black", 44); // e6 - adj to e5
// Seed the OnMoveHooks fact directly with the inner arm.
// OnMoveHooks shape: Array<EffectPrimitiveNode[]>.
// Seed the OnMoveHooks fact directly with the inner arm. T80:
// OnMoveHooks entries now carry `descriptorId` so the trigger
// dispatcher can thread the real id into runPrimitives (Gap G).
const onMoveNode = descriptor.primitives[0]!;
const innerArm = (onMoveNode.params as {
primitives: EffectPrimitiveNode[];
}).primitives;
engine.session.insert(bishopId, "OnMoveHooks", [
innerArm,
{ descriptorId: descriptor.id, primitives: innerArm },
] as ChessAttrMap["OnMoveHooks"]);
// Find a non-capture move for the bishop to e5 (square 36).
@ -173,13 +174,14 @@ describe("T78 — religious_conversion REAL-pipeline", () => {
const moveToE5 = moves.find((m) => m.to === 36 && !m.isCapture);
expect(moveToE5).toBeDefined();
// applyMove triggers the V1 sharp edge: for-each-adjacent's
// post-apply child-walk crashes on $adj. The throw escapes
// applyMove (the integration preset's onAfterMove doesn't
// suppress trigger errors).
expect(() => engine.applyMove(moveToE5!)).toThrow(
/Binding '\$adj' is not in scope/,
);
// applyMove now succeeds — for-each-adjacent's `selfRecurse:
// true` flag prevents the dispatcher's auto child-walk from
// re-running children with the OUTER bindings, and T80 threads
// the real descriptor id through trigger dispatch so nested
// request-choices resume correctly.
const result = engine.applyMove(moveToE5!);
expect(result).toBe("ongoing");
expect(engine.session.get(bishopId, "Position")).toBe(36);
});
it("workaround: register + applyMove succeeds when the descriptor is NOT seeded onto the moved piece", () => {

View file

@ -126,6 +126,11 @@ const descriptor: EffectPrimitive<Params> = {
// Children that DO write are visible to manifest/cleanup walks via
// `childPrimitives` below.
seedsAttrs: [],
// T82 (Gap I) — apply() walks `then` itself with the column binding
// extended into ctx.bindings; the dispatcher must NOT auto-recurse
// (would re-run children with outer scope and throw BindingError on
// `$var` references). See for-row.ts / triggers.ts.
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Dedupe + sort ASC for deterministic iteration. Authoring
// [3,1,1,5] and [1,3,5] must produce byte-identical traces.

View file

@ -238,6 +238,11 @@ const descriptor: EffectPrimitive<Params> = {
// writer. Children that DO write are visible to manifest /
// cleanup walks via `childPrimitives` below.
seedsAttrs: [],
// T82 (Gap I) — apply() walks `then` itself per neighbour with the
// bound square / piece id extended into ctx.bindings; dispatcher
// auto-recursion would re-execute children with the outer scope
// (BindingError on `$var`, double-fire imperatives).
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Phase 1 — resolve the centre square.
let centreSquare: number;

View file

@ -148,6 +148,12 @@ const descriptor: EffectPrimitive<Params> = {
// Children that DO write are visible to manifest/cleanup walks via
// `childPrimitives` below.
seedsAttrs: [],
// T82 (Gap I) — apply() iterates markers and walks `then` itself
// with the per-iteration markerId bound; dispatcher auto-recursion
// would re-run children with the outer scope (BindingError on
// `$var`, double-fire imperatives). See triggers.ts § auto-recurse
// gate.
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Phase 1 — collect matching marker ids. Snapshot semantics: the
// list is materialised here; nested primitives that mutate marker

View file

@ -158,6 +158,11 @@ const descriptor: EffectPrimitive<Params> = {
// Children that DO write are visible to manifest/cleanup walks via
// `childPrimitives` below.
seedsAttrs: [],
// T82 (Gap I) — apply() iterates pieces and walks `then` itself with
// the per-iteration pieceId bound; the dispatcher must NOT
// auto-recurse (would re-run children with outer scope, throw
// BindingError on `$var` references, and double-fire imperatives).
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Phase 1 — collect matching piece ids. Snapshot semantics: the
// list is materialised here; nested primitives that mutate piece

View file

@ -157,6 +157,11 @@ const descriptor: EffectPrimitive<Params> = {
// Children that DO write are visible to manifest/cleanup walks via
// `childPrimitives` below.
seedsAttrs: [],
// T82 (Gap I) — apply() walks `then` itself per-square with the
// square index bound; dispatcher auto-recursion would double-execute
// children with the outer scope (BindingError on `$var`, double-fire
// imperatives). See triggers.ts § auto-recurse gate.
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Phase 1 — resolve the iteration list. `"all"` (the explicit
// literal) and `undefined` both expand to 0..63. An explicit

View file

@ -126,6 +126,12 @@ const descriptor: EffectPrimitive<Params> = {
// Children that DO write are visible to manifest/cleanup walks via
// `childPrimitives` below.
seedsAttrs: [],
// T82 (Gap I) — apply() walks `then` itself with the row binding
// extended into ctx.bindings; the dispatcher must NOT auto-recurse
// into childPrimitives() (which would re-execute children with the
// OUTER, un-extended bindings, throwing BindingError on `$var`
// references and double-firing imperatives).
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Dedupe + sort ASC for deterministic iteration. Authoring
// [3,1,1,5] and [1,3,5] must produce byte-identical traces.

View file

@ -45,7 +45,9 @@ describe("on-capture primitive — apply()", () => {
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([primitives]);
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives },
]);
});
it("appends additional hooks to existing OnCaptureHooks", () => {
@ -60,7 +62,10 @@ describe("on-capture primitive — apply()", () => {
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: first });
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([first, second]);
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives: first },
{ descriptorId: ctx.descriptor.id, primitives: second },
]);
});
});

View file

@ -50,7 +50,10 @@ const descriptor: EffectPrimitive<Params> = {
ctx.session.insert(ctx.pieceId, "OnCaptureHooks", [
...existing,
[...params.primitives],
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -53,7 +53,9 @@ describe("on-captured primitive — apply() seeding", () => {
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([{ target: "self", primitives }]);
expect(hooks).toEqual([
{ descriptorId: ctx.descriptor.id, target: "self", primitives },
]);
});
it("seeds with target='attacker'", () => {
@ -67,7 +69,9 @@ describe("on-captured primitive — apply() seeding", () => {
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([{ target: "attacker", primitives }]);
expect(hooks).toEqual([
{ descriptorId: ctx.descriptor.id, target: "attacker", primitives },
]);
});
it("seeds with target={relation:'ally', filter:{pieceType:'queen'}}", () => {
@ -85,7 +89,9 @@ describe("on-captured primitive — apply() seeding", () => {
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([{ target, primitives }]);
expect(hooks).toEqual([
{ descriptorId: ctx.descriptor.id, target, primitives },
]);
});
it("seeds with target={squares:[28,35]}", () => {
@ -100,7 +106,9 @@ describe("on-captured primitive — apply() seeding", () => {
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([{ target, primitives }]);
expect(hooks).toEqual([
{ descriptorId: ctx.descriptor.id, target, primitives },
]);
});
it("appends additional hooks to existing OnCapturedHooks", () => {
@ -122,8 +130,16 @@ describe("on-captured primitive — apply() seeding", () => {
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([
{ target: "self", primitives: first },
{ target: "attacker", primitives: second },
{
descriptorId: ctx.descriptor.id,
target: "self",
primitives: first,
},
{
descriptorId: ctx.descriptor.id,
target: "attacker",
primitives: second,
},
]);
});
});

View file

@ -101,7 +101,11 @@ const descriptor: EffectPrimitive<Params> = {
ctx.session.insert(ctx.pieceId, "OnCapturedHooks", [
...existing,
{ target, primitives: [...params.primitives] },
{
descriptorId: ctx.descriptor.id,
target,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -54,7 +54,7 @@ describe("on-check-delivered primitive — apply()", () => {
ON_CHECK_DELIVERED_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnCheckDeliveredHooks")).toEqual([
primitives,
{ descriptorId: ctx.descriptor.id, primitives },
]);
});
@ -71,8 +71,8 @@ describe("on-check-delivered primitive — apply()", () => {
ON_CHECK_DELIVERED_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnCheckDeliveredHooks")).toEqual([
first,
second,
{ descriptorId: ctx.descriptor.id, primitives: first },
{ descriptorId: ctx.descriptor.id, primitives: second },
]);
});
});

View file

@ -64,7 +64,10 @@ const descriptor: EffectPrimitive<Params> = {
ctx.session.insert(ctx.pieceId, "OnCheckDeliveredHooks", [
...existing,
[...params.primitives],
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -48,7 +48,7 @@ describe("on-check-received primitive — apply()", () => {
ON_CHECK_RECEIVED_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnCheckReceivedHooks")).toEqual([
primitives,
{ descriptorId: ctx.descriptor.id, primitives },
]);
});
@ -68,8 +68,8 @@ describe("on-check-received primitive — apply()", () => {
ON_CHECK_RECEIVED_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnCheckReceivedHooks")).toEqual([
first,
second,
{ descriptorId: ctx.descriptor.id, primitives: first },
{ descriptorId: ctx.descriptor.id, primitives: second },
]);
});
});

View file

@ -61,7 +61,10 @@ const descriptor: EffectPrimitive<Params> = {
ctx.session.insert(ctx.pieceId, "OnCheckReceivedHooks", [
...existing,
[...params.primitives],
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -45,7 +45,9 @@ describe("on-damaged primitive — apply()", () => {
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([primitives]);
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives },
]);
});
it("appends additional hooks to existing OnDamagedHooks", () => {
@ -60,7 +62,10 @@ describe("on-damaged primitive — apply()", () => {
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: first });
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([first, second]);
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives: first },
{ descriptorId: ctx.descriptor.id, primitives: second },
]);
});
});

View file

@ -50,7 +50,10 @@ const descriptor: EffectPrimitive<Params> = {
ctx.session.insert(ctx.pieceId, "OnDamagedHooks", [
...existing,
[...params.primitives],
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -54,7 +54,9 @@ describe("on-move primitive — apply() seeds hook attr", () => {
ON_MOVE_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnMoveHooks")).toEqual([primitives]);
expect(session.get(ctx.pieceId, "OnMoveHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives },
]);
});
it("fires on captures too — hook list accumulates across apply calls", () => {
@ -70,8 +72,8 @@ describe("on-move primitive — apply() seeds hook attr", () => {
ON_MOVE_PRIMITIVE.apply(ctx, { primitives: captureHook });
expect(session.get(ctx.pieceId, "OnMoveHooks")).toEqual([
quietMoveHook,
captureHook,
{ descriptorId: ctx.descriptor.id, primitives: quietMoveHook },
{ descriptorId: ctx.descriptor.id, primitives: captureHook },
]);
});
@ -85,7 +87,9 @@ describe("on-move primitive — apply() seeds hook attr", () => {
];
ON_MOVE_PRIMITIVE.apply(rookCtx, { primitives: rookHook });
expect(session.get(rookId, "OnMoveHooks")).toEqual([rookHook]);
expect(session.get(rookId, "OnMoveHooks")).toEqual([
{ descriptorId: rookCtx.descriptor.id, primitives: rookHook },
]);
// The king (which did not have the primitive applied) must NOT have a hook.
expect(session.get(kingCtx.pieceId, "OnMoveHooks")).toBeUndefined();
});
@ -102,7 +106,9 @@ describe("on-move primitive — apply() seeds hook attr", () => {
// Only the piece the primitive was applied to gets the hook seeded;
// an unrelated "opponent" piece gets nothing — mirrors "static piece
// doesn't fire" semantics at the seeding layer.
expect(session.get(ctx.pieceId, "OnMoveHooks")).toEqual([primitives]);
expect(session.get(ctx.pieceId, "OnMoveHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives },
]);
expect(session.get(opponentId, "OnMoveHooks")).toBeUndefined();
});
});

View file

@ -60,7 +60,10 @@ const descriptor: EffectPrimitive<Params> = {
ctx.session.insert(ctx.pieceId, "OnMoveHooks", [
...existing,
[...params.primitives],
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -58,6 +58,7 @@ describe("on-moved-onto-square primitive — apply()", () => {
expect(session.get(ctx.pieceId, "OnMovedOntoSquareHooks")).toEqual([
{
descriptorId: ctx.descriptor.id,
filter: { kind: "squares", squares: [27, 28, 35, 36] },
primitives,
},
@ -80,6 +81,7 @@ describe("on-moved-onto-square primitive — apply()", () => {
expect(session.get(ctx.pieceId, "OnMovedOntoSquareHooks")).toEqual([
{
descriptorId: ctx.descriptor.id,
filter: { kind: "predicate", rank: 7 },
primitives,
},
@ -106,10 +108,12 @@ describe("on-moved-onto-square primitive — apply()", () => {
expect(session.get(ctx.pieceId, "OnMovedOntoSquareHooks")).toEqual([
{
descriptorId: ctx.descriptor.id,
filter: { kind: "squares", squares: [28] },
primitives: firstPrims,
},
{
descriptorId: ctx.descriptor.id,
filter: { kind: "predicate", file: 4 },
primitives: secondPrims,
},

View file

@ -114,6 +114,7 @@ const descriptor: EffectPrimitive<Params> = {
}
const next: OnMovedOntoSquareHook = {
descriptorId: ctx.descriptor.id,
filter,
primitives: [...params.primitives],
};

View file

@ -49,7 +49,9 @@ describe("on-promotion primitive — apply()", () => {
ON_PROMOTION_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnPromotionHooks")).toEqual([primitives]);
expect(session.get(ctx.pieceId, "OnPromotionHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives },
]);
});
it("stacks across multiple apply calls", () => {
@ -65,8 +67,8 @@ describe("on-promotion primitive — apply()", () => {
ON_PROMOTION_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnPromotionHooks")).toEqual([
first,
second,
{ descriptorId: ctx.descriptor.id, primitives: first },
{ descriptorId: ctx.descriptor.id, primitives: second },
]);
});
});

View file

@ -63,7 +63,10 @@ const descriptor: EffectPrimitive<Params> = {
ctx.session.insert(ctx.pieceId, "OnPromotionHooks", [
...existing,
[...params.primitives],
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -46,7 +46,7 @@ describe("on-turn-end primitive — apply()", () => {
ON_TURN_END_PRIMITIVE.apply(ctx, { color: "both", primitives });
expect(session.get(ctx.pieceId, "OnTurnEndHooks")).toEqual([
{ color: "both", primitives },
{ descriptorId: ctx.descriptor.id, color: "both", primitives },
]);
});
@ -63,8 +63,8 @@ describe("on-turn-end primitive — apply()", () => {
ON_TURN_END_PRIMITIVE.apply(ctx, { color: "white", primitives: second });
expect(session.get(ctx.pieceId, "OnTurnEndHooks")).toEqual([
{ color: "both", primitives: first },
{ color: "white", primitives: second },
{ descriptorId: ctx.descriptor.id, color: "both", primitives: first },
{ descriptorId: ctx.descriptor.id, color: "white", primitives: second },
]);
});
});

View file

@ -67,7 +67,11 @@ const descriptor: EffectPrimitive<Params> = {
// turn's color at fire-time. Schema attr shape extended in T12.
ctx.session.insert(ctx.pieceId, "OnTurnEndHooks", [
...existing,
{ color: params.color, primitives: [...params.primitives] },
{
descriptorId: ctx.descriptor.id,
color: params.color,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -45,7 +45,9 @@ describe("on-turn-start primitive — apply()", () => {
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([primitives]);
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives },
]);
});
it("appends additional hooks to existing OnTurnStartHooks", () => {
@ -60,7 +62,10 @@ describe("on-turn-start primitive — apply()", () => {
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives: first });
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([first, second]);
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([
{ descriptorId: ctx.descriptor.id, primitives: first },
{ descriptorId: ctx.descriptor.id, primitives: second },
]);
});
});

View file

@ -51,7 +51,10 @@ const descriptor: EffectPrimitive<Params> = {
ctx.session.insert(ctx.pieceId, "OnTurnStartHooks", [
...existing,
[...params.primitives],
{
descriptorId: ctx.descriptor.id,
primitives: [...params.primitives],
},
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {

View file

@ -137,6 +137,12 @@ const descriptor: EffectPrimitive<Params> = {
// extension), not a writer. Children that DO write are visible to
// manifest/cleanup walks via `childPrimitives` below.
seedsAttrs: [],
// T82 (Gap I) — apply() draws a value, binds it, and walks `then`
// itself with the binding extended; dispatcher auto-recursion would
// re-execute `then` with the OUTER scope (BindingError on the
// `$var` referencing the picked value, plus a second imperative
// pass that wasn't intended).
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Defensive empty-array guard. The schema's `.min(1)` clamp
// rejects authored-empty `from`, but the param resolver (T12)

View file

@ -231,6 +231,17 @@ const descriptor: EffectPrimitive<Params> = {
// declared `seedsAttrs` (the consumer is registered in apply.ts
// already — see registerAttrConsumer("PendingChoices")).
seedsAttrs: [],
// T82 (Gap I) — apply() SUSPENDS execution (throws
// SuspendedExecution); the continuation `then` is NOT walked at
// apply() time but later by T46's resume mechanism after the
// player answers. The dispatcher catches the throw and `return`s
// before reaching the auto-recurse block, so in practice
// selfRecurse is never consulted on the suspending path. Setting
// it true is defence-in-depth for any future code path that
// bypasses the throw (e.g. a synthetic resume that calls apply()
// without throwing) — the continuation must remain a one-shot
// resume target, not a dispatcher-driven re-execution.
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Phase 1 — derive a deterministic choice id. nextInt advances
// the persistent RngStream by 1, so the id is reproducible from

View file

@ -286,6 +286,31 @@ export interface EffectPrimitive<Params = unknown> {
readonly longDescription?: string;
/** One or more worked examples shown under the explanation. */
readonly examples?: readonly PrimitiveDocExample[];
/**
* When true, the primitive's apply() handles its own descent into
* nested primitive arrays (e.g. for-each-piece walks `then` itself
* with extended bindings). The dispatcher (`runPrimitives` in
* `triggers.ts`) MUST NOT auto-recurse into the `childPrimitives()`
* result for such primitives; doing so would double-execute the
* children — once with correct (extended) bindings from inside
* apply(), again with the OUTER bindings from the dispatcher's
* standard child-traversal pass. The outer-binding pass throws
* `BindingError` because the iteration's `$var` is unbound at
* outer scope (Gap I).
*
* Default: false. When false, the dispatcher uses
* `childPrimitives()` to discover nested primitive lists and runs
* them automatically with the SAME bindings the parent saw — the
* historical contract that primitives like `conditional` rely on
* (conditional doesn't run its branches at apply() time; the
* dispatcher does it via auto-recursion).
*
* `childPrimitives()` is still consulted regardless of
* `selfRecurse` — manifest / cleanup / validator walkers use it
* to discover what attrs the descendant primitives seed; only
* the dispatcher's RUN-time auto-recursion is gated.
*/
readonly selfRecurse?: boolean;
}
export type { Session };

View file

@ -133,6 +133,12 @@ const descriptor: EffectPrimitive<Params> = {
// not a writer. Nested arms that DO write are visible to manifest /
// cleanup walks via `childPrimitives` below.
seedsAttrs: [],
// T82 (Gap I) — apply() draws once and runs EITHER `then` OR `else`
// (never both). Dispatcher auto-recursion would walk
// `[...then, ...else]` from childPrimitives() and run BOTH branches
// unconditionally, defeating the probability gate AND double-firing
// the chosen branch.
selfRecurse: true,
apply(ctx: PrimitiveApplyContext, params: Params): void {
// Phase 1 — draw. ALWAYS call engine.rng().next() so the
// persistent stream advances regardless of branch outcome. Two

View file

@ -0,0 +1,294 @@
/**
* T82 (Gap I) — dispatcher double-recurse regression tests.
*
* Before T82, the dispatcher (`runPrimitives` in `triggers.ts`)
* unconditionally walked `primitive.childPrimitives(node.params)`
* after calling `primitive.apply(ctx, params)`. For the iteration
* primitives (for-row / for-each-piece / random-pick / …),
* apply() ALREADY iterates the nested `then` list with the
* per-iteration binding extended into `ctx.bindings`. So the
* dispatcher's auto-recursion was a SECOND pass over the same
* children — but with the OUTER bindings, where the iteration
* variable (e.g. `$r` for for-row) is unbound. That second pass
* either threw `BindingError` (when children referenced `$var`)
* or silently double-fired imperatives (when children were
* binding-free).
*
* The fix: a `selfRecurse: boolean` flag on the EffectPrimitive
* descriptor. Iteration / RNG-binding / branching primitives
* (for-*, random-pick, with-probability, request-choice) declare
* `selfRecurse: true`; the dispatcher SKIPS its auto-recursion
* step for them. `conditional` keeps the historical behaviour
* (selfRecurse undefined ⇒ false ⇒ dispatcher walks both branches
* — but conditional is a HOOK SEEDER, not an evaluator, so that
* walk is just descriptor traversal, not double-execution).
*
* These tests exercise the dispatcher entry point (`runPrimitives`)
* — NOT the primitive's apply() directly — so the auto-recurse
* path is reachable. A direct apply() call would bypass the
* dispatcher and miss the bug entirely.
*/
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../engine.js";
import { runPrimitives } from "./triggers.js";
import type { EffectPrimitiveNode } from "./primitives/types.js";
import "./primitives/index.js";
describe("Gap I — dispatcher double-recurse fix (T82)", () => {
it("for-row binds $var:'r' correctly and does NOT throw BindingError on outer pass", () => {
// The historical bug: apply() runs `then` once with `r` bound
// (good); the dispatcher then walks `childPrimitives()` →
// `[...then]` again with the OUTER bindings (no `r` defined),
// and the inner `add-to-attribute(delta: $var:'r')` resolver
// throws BindingError. Correct behaviour: only ONE pass, with
// bindings.
const engine = new ChessEngine();
const pieceId = engine.session.nextId();
const nodes: EffectPrimitiveNode[] = [
{
kind: "for-row",
params: {
rows: [1, 2, 3],
bind: "r",
then: [
{
kind: "add-to-attribute",
params: {
attr: "AttackBonus",
delta: { $var: "r" },
},
},
],
},
},
];
// Must NOT throw — historically threw `BindingError: $var 'r'
// is unbound` on the second (outer-bindings) pass.
expect(() => {
runPrimitives(engine, pieceId, nodes, 1);
}).not.toThrow();
// Sum must match a SINGLE iteration sweep (1+2+3=6), proving
// the dispatcher did NOT also re-run `then` with outer scope
// (which would either throw OR double the sum if it didn't).
expect(engine.session.get(pieceId, "AttackBonus")).toBe(6);
});
it("for-each-piece binds $var:'p' and executes inner primitives EXACTLY ONCE per piece", () => {
// Set up two pieces with PieceType so for-each-piece sees them.
const engine = new ChessEngine();
const pieceA = engine.session.nextId();
const pieceB = engine.session.nextId();
engine.session.insert(pieceA, "PieceType", "rook");
engine.session.insert(pieceA, "Color", "white");
engine.session.insert(pieceB, "PieceType", "bishop");
engine.session.insert(pieceB, "Color", "white");
const outerCaller = engine.session.nextId();
// Inside the loop, add 1 to ShieldCharges on the ITERATED piece
// (referenced via $var:'p'). After two iterations, both pieces
// should have ShieldCharges=1. If the dispatcher double-recursed
// with the OUTER scope, the second pass would either throw
// (BindingError on `target: { $var: 'p' }`) or — if it somehow
// resolved — write to a different target entirely. Either way
// the count would be wrong.
// set-piece-attr supports `target: { $var: 'p' }` (writes to
// the bound piece). add-to-attribute would NOT work here — it
// hardcodes ctx.pieceId as the write target. Using set-piece-attr
// with target=$var gives us per-piece observability.
const nodes: EffectPrimitiveNode[] = [
{
kind: "for-each-piece",
params: {
filter: { color: "white" },
bind: "p",
then: [
{
kind: "set-piece-attr",
params: {
target: { $var: "p" },
attr: "ShieldCharges",
value: 1,
},
},
],
},
},
];
expect(() => {
runPrimitives(engine, outerCaller, nodes, 1);
}).not.toThrow();
// Each piece visited exactly once. If the dispatcher had
// double-recursed with the OUTER scope (no `p` binding), the
// resolver would have thrown BindingError on `target: $var:'p'`,
// failing the not.toThrow assertion. The exact-1 value here
// proves the inner pass DID resolve `$p` (and ran exactly once).
expect(engine.session.get(pieceA, "ShieldCharges")).toBe(1);
expect(engine.session.get(pieceB, "ShieldCharges")).toBe(1);
});
it("random-pick binds the picked value and runs `then` exactly once", () => {
// random-pick draws ONE value, binds it, runs `then` once.
// Pre-fix, the dispatcher walked `then` a second time with the
// outer scope; if the inner primitive used `$var:'pick'` as a
// delta value, the second pass threw BindingError. Even if the
// body was binding-free, the imperative would fire twice.
const engine = new ChessEngine();
const pieceId = engine.session.nextId();
const nodes: EffectPrimitiveNode[] = [
{
kind: "random-pick",
params: {
from: [5],
bind: "pick",
then: [
{
kind: "add-to-attribute",
params: {
attr: "ShieldCharges",
delta: { $var: "pick" },
},
},
],
},
},
];
expect(() => {
runPrimitives(engine, pieceId, nodes, 1);
}).not.toThrow();
// Single pass with `pick` = 5 → final value 5. A double pass
// would either throw (BindingError on the outer scope's missing
// `$pick`) or, with a fallback, the value would be wrong.
expect(engine.session.get(pieceId, "ShieldCharges")).toBe(5);
});
it("with-probability runs ONLY the chosen branch (not both via auto-recurse)", () => {
// p=1 always takes `then`. Pre-fix, the dispatcher walked
// childPrimitives() = [...then, ...else] AFTER apply() ran
// the chosen branch — defeating the probability gate AND
// double-firing the chosen branch.
const engine = new ChessEngine();
const pieceId = engine.session.nextId();
const nodes: EffectPrimitiveNode[] = [
{
kind: "with-probability",
params: {
p: 1,
then: [
{
kind: "add-to-attribute",
params: { attr: "AttackBonus", delta: 7 },
},
],
else: [
{
kind: "add-to-attribute",
params: { attr: "AttackBonus", delta: 100 },
},
],
},
},
];
runPrimitives(engine, pieceId, nodes, 1);
// Only `then` ran (delta=7). If `else` had ALSO run via the
// auto-recurse, we'd see 107.
expect(engine.session.get(pieceId, "AttackBonus")).toBe(7);
});
it("conditional still recurses correctly (selfRecurse: false)", () => {
// conditional is NOT in the iteration family — its apply()
// SEEDS a hook (ConditionalHooks) and never executes the
// branches at apply() time. The dispatcher's auto-recurse IS
// the right way to walk into nested validators / manifest
// checks. Ensure the dispatcher still descends into
// `then`/`else` for conditional (no regression on the historical
// contract).
const engine = new ChessEngine();
const pieceId = engine.session.nextId();
// The inner `add-to-attribute` only fires via the auto-recurse
// descent — conditional.apply() does NOT execute branches, it
// just seeds the hook. So observing AttackBonus=1 after
// runPrimitives proves the dispatcher walked into `then`.
const nodes: EffectPrimitiveNode[] = [
{
kind: "conditional",
params: {
condition: { type: "always" },
then: [
{
kind: "add-to-attribute",
params: { attr: "AttackBonus", delta: 1 },
},
],
},
},
];
runPrimitives(engine, pieceId, nodes, 1);
// ConditionalHooks seeded by apply() AND the inner primitive
// ran via the dispatcher's auto-recurse pass. AttackBonus=1
// proves the second.
expect(engine.session.get(pieceId, "AttackBonus")).toBe(1);
expect(
engine.session.get(pieceId, "ConditionalHooks"),
).toBeDefined();
});
it("nested for-row inside for-column (2D rectangle) binds both vars without doubling", () => {
// Stress test: nested iteration. for-column binds `c`, inner
// for-row binds `r`, leaf `add-to-attribute` uses BOTH via a
// composite. Pre-fix, EACH level's auto-recurse pass would
// throw BindingError on its respective unbound `$var`. Plus
// any successful pass would multiply the iteration count.
const engine = new ChessEngine();
const pieceId = engine.session.nextId();
const nodes: EffectPrimitiveNode[] = [
{
kind: "for-column",
params: {
columns: [0, 1],
bind: "c",
then: [
{
kind: "for-row",
params: {
rows: [0, 1, 2],
bind: "r",
then: [
{
kind: "add-to-attribute",
params: {
attr: "ShieldCharges",
delta: 1,
},
},
],
},
},
],
},
},
];
expect(() => {
runPrimitives(engine, pieceId, nodes, 1);
}).not.toThrow();
// 2 columns × 3 rows = 6 iterations, each delta=1 ⇒ total 6.
// A double-recurse at either level would yield 12, 18, 24, etc.
expect(engine.session.get(pieceId, "ShieldCharges")).toBe(6);
});
});

View file

@ -70,12 +70,15 @@ describe("on-turn-start triggers", () => {
// by 1 each turn.
const whiteQueen = findPiece(engine, 3); // d1
engine.session.insert(whiteQueen, "OnTurnStartHooks", [
[
{
kind: "add-to-attribute",
params: { attr: "RangeBonus", delta: 1 },
},
],
{
descriptorId: "__test_trigger_test__",
primitives: [
{
kind: "add-to-attribute",
params: { attr: "RangeBonus", delta: 1 },
},
],
},
]);
// Move e2-e4. After this move, the next turn (black) starts. Our
@ -107,12 +110,15 @@ describe("on-turn-start triggers", () => {
// Hook on the WHITE queen.
const whiteQueen = findPiece(engine, 3);
engine.session.insert(whiteQueen, "OnTurnStartHooks", [
[
{
kind: "add-to-attribute",
params: { attr: "RangeBonus", delta: 1 },
},
],
{
descriptorId: "__test_trigger_color__",
primitives: [
{
kind: "add-to-attribute",
params: { attr: "RangeBonus", delta: 1 },
},
],
},
]);
// Make ONE white move. Black's turn now begins. The hook is on a
@ -137,7 +143,12 @@ describe("on-capture triggers", () => {
// white pawn that ends up doing the capture.
const whitePawn = findPiece(engine, 12); // e2
engine.session.insert(whitePawn, "OnCaptureHooks", [
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 5 } }],
{
descriptorId: "__test_on_capture_test__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 5 } },
],
},
]);
// White e2-e4
@ -305,7 +316,12 @@ describe("fireOnMoveHooks", () => {
const whitePawn = findPiece(engine, 12); // e2
const whiteKnight = findPiece(engine, 6); // g1
engine.session.insert(whitePawn, "OnMoveHooks", [
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 2 } }],
{
descriptorId: "__test_on_move_fires__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 2 } },
],
},
]);
// Knight has no hook seeded — verifies "no-attr ⇒ no-op".
@ -323,7 +339,12 @@ describe("fireOnMoveHooks", () => {
const blackPawn = findPiece(engine, 52); // e7 — not moved
engine.session.insert(blackPawn, "OnMoveHooks", [
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 9 } }],
{
descriptorId: "__test_on_move_static__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 9 } },
],
},
]);
fireOnMoveHooks(engine, [whitePawn]); // only the white pawn moved
@ -344,12 +365,14 @@ describe("fireOnTurnEndHooks", () => {
// White queen has TWO hooks: one filtered to white, one to 'both'.
engine.session.insert(whiteQueen, "OnTurnEndHooks", [
{
descriptorId: "__test_on_turn_end_color__",
color: "white",
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
],
},
{
descriptorId: "__test_on_turn_end_color__",
color: "both",
primitives: [
{
@ -362,6 +385,7 @@ describe("fireOnTurnEndHooks", () => {
// Black queen has a 'black'-only hook.
engine.session.insert(blackQueen, "OnTurnEndHooks", [
{
descriptorId: "__test_on_turn_end_color__",
color: "black",
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 5 } },
@ -392,7 +416,12 @@ describe("fireOnPromotionHooks", () => {
const whitePawn = findPiece(engine, 12);
engine.session.insert(whitePawn, "OnPromotionHooks", [
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 4 } }],
{
descriptorId: "__test_on_promotion_event__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 4 } },
],
},
]);
fireOnPromotionHooks(engine, whitePawn, "pawn", "queen");
@ -428,7 +457,12 @@ describe("fireOnCheckReceivedHooks", () => {
engine.session.insert(GAME_ENTITY, "Turn", "white");
engine.session.insert(whiteKing, "OnCheckReceivedHooks", [
[{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 7 } }],
{
descriptorId: "__test_on_check_received_edge__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 7 } },
],
},
]);
// Pre-move snapshot: white king NOT in check (empty attacker list).
@ -476,7 +510,12 @@ describe("fireOnCheckDeliveredHooks", () => {
engine.session.insert(GAME_ENTITY, "Turn", "black");
engine.session.insert(whiteRook, "OnCheckDeliveredHooks", [
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 3 } }],
{
descriptorId: "__test_on_check_delivered_discover__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 3 } },
],
},
]);
const preDiscovered: PreMoveCheckStateLike = {
@ -502,7 +541,12 @@ describe("fireOnCheckDeliveredHooks", () => {
engine.session.insert(GAME_ENTITY, "Turn", "black");
engine.session.insert(whiteRook, "OnCheckDeliveredHooks", [
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 9 } }],
{
descriptorId: "__test_on_check_delivered_no_redundant__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 9 } },
],
},
]);
// Already attacking pre-move — so this is NOT an edge transition
@ -528,6 +572,7 @@ describe("fireOnMovedOntoSquareHooks", () => {
engine.session.insert(whitePawn, "OnMovedOntoSquareHooks", [
{
descriptorId: "__test_on_moved_onto_square_list__",
filter: { kind: "squares", squares: [28, 35] },
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 4 } },
@ -552,6 +597,7 @@ describe("fireOnMovedOntoSquareHooks", () => {
engine.session.insert(whitePawn, "OnMovedOntoSquareHooks", [
{
descriptorId: "__test_on_moved_onto_square_rank__",
filter: { kind: "predicate", rank: 3 }, // rank 4 (1-indexed) = rank 3 (0-indexed)
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
@ -577,6 +623,7 @@ describe("fireOnMovedOntoSquareHooks", () => {
engine.session.insert(whitePawn, "OnMovedOntoSquareHooks", [
{
descriptorId: "__test_on_moved_onto_square_file_rank__",
filter: { kind: "predicate", file: 4, rank: 3 }, // e4 only
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 5 } },
@ -607,6 +654,7 @@ describe("fireOnCapturedHooks", () => {
// ATTACKER (white pawn).
engine.session.insert(blackPawn, "OnCapturedHooks", [
{
descriptorId: "__test_on_captured_attacker__",
target: "attacker",
primitives: [
{
@ -633,6 +681,7 @@ describe("fireOnCapturedHooks", () => {
engine.session.insert(blackPawn, "OnCapturedHooks", [
{
descriptorId: "__test_on_captured_self__",
target: "self",
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 9 } },
@ -957,9 +1006,12 @@ describe("deferred queue + cascade depth (T15)", () => {
// 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 } },
],
{
descriptorId: "__test_t15_enqueued_fires_after_arm__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
],
},
]);
// Two-node arm: enqueuer first, marker second. The marker MUST
@ -1008,12 +1060,15 @@ describe("deferred queue + cascade depth (T15)", () => {
// 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: {},
},
],
{
descriptorId: "__test_t15_cascade_depth__",
primitives: [
{
kind: "__t15_reenqueuer__" as unknown as EffectPrimitive["kind"],
params: {},
},
],
},
]);
const nodes = [
@ -1039,9 +1094,12 @@ describe("deferred queue + cascade depth (T15)", () => {
// 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 } },
],
{
descriptorId: "__test_t15_no_leak__",
primitives: [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
],
},
]);
const nodes = [

View file

@ -175,6 +175,26 @@ export function runPrimitives(
* descriptor tree at resume time.
*/
triggerPath: readonly number[] = [],
/**
* T80 (Wave 14, Gap G) — id of the descriptor that OWNS this
* trigger arm. Threaded into the synthesised
* `PrimitiveApplyContext` so request-choice primitives running
* inside a trigger arm push a PendingChoice carrying the REAL
* descriptor id (not the synthetic `"__trigger__"` placeholder
* used pre-T80). `submitChoiceAndResume` resolves the descriptor
* by id and walks back into its primitive tree to continue
* execution; with the real id present, the lookup succeeds
* instead of throwing `runtime.descriptor-not-found`.
*
* Trigger dispatchers (every `fire*Hooks` below) read the id
* from the per-hook entry (`hook.descriptorId`) and pass it
* here — that is the canonical production path. Callers that
* lack a descriptor (legacy direct-fire paths in older tests,
* or any future synthetic dispatch) may omit this parameter;
* the fallback `"__trigger__"` preserves the pre-T80 behaviour
* for those code paths so the type contract remains satisfied.
*/
descriptorId: string = "__trigger__",
): void {
if (depth > 8) return; // hard runtime cap, mirrors validator
// T15: cascade-depth guard. Distinct from `depth` (nested primitive
@ -219,9 +239,12 @@ export function runPrimitives(
session: engine.session,
pieceId,
depth,
// Trigger evaluation has no parent descriptor — synthesise a
// minimal ref so the type contract is satisfied.
descriptor: { id: "__trigger__", type: "data", version: 1 },
// T80 (Wave 14, Gap G) — thread the REAL descriptor id when
// available so request-choice primitives running inside a
// trigger arm push a PendingChoice with a resolvable id.
// Pre-T80 we synthesised `"__trigger__"`; that placeholder is
// now only used by legacy callers that omit `descriptorId`.
descriptor: { id: descriptorId, type: "data", version: 1 },
// Default target stays 'self'. Hook entries that store their own
// `target` (on-captured) resolve it BEFORE invoking
// `runPrimitives`, calling once per resolved entity with that
@ -309,6 +332,20 @@ export function runPrimitives(
}
if (primitive.childPrimitives === undefined) continue;
// T82 (Gap I) — primitives that handle their own nested-list
// traversal (iteration / RNG-binding / conditional-branching
// primitives) MUST NOT have the dispatcher auto-recurse into
// their `childPrimitives()` output. apply() already walked
// `then` with the correctly extended bindings; an auto-recurse
// pass would re-execute every child a second time with the
// OUTER scope, throwing BindingError (the iteration's `$var`
// is unbound at outer scope) and double-firing imperatives.
//
// `childPrimitives()` is still defined on these primitives
// because manifest / cleanup / validator walkers need it to
// discover descendant seeds; only this RUN-time auto-recurse
// is gated by the flag.
if (primitive.selfRecurse === true) continue;
let children: readonly EffectPrimitiveNode[] = [];
try {
// childPrimitives() introspects the ORIGINAL params — the
@ -344,6 +381,7 @@ export function runPrimitives(
cascadeDepth,
suppressTriggers,
[...triggerPath, i],
descriptorId,
);
}
}
@ -502,8 +540,19 @@ export function fireOnTurnStartHooks(
| ChessAttrMap["OnTurnStartHooks"]
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
for (const hook of hooks) {
runPrimitives(
engine,
id,
hook.primitives,
1,
undefined,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
}
@ -529,7 +578,18 @@ 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, undefined, new Map(), cascadeDepth);
runPrimitives(
engine,
id,
hook.primitives,
1,
undefined,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
}
@ -553,8 +613,19 @@ export function fireOnCaptureHooks(
| ChessAttrMap["OnCaptureHooks"]
| undefined;
if (hooks === undefined) return;
for (const primitives of hooks) {
runPrimitives(engine, attackerId, primitives, 1, undefined, new Map(), cascadeDepth);
for (const hook of hooks) {
runPrimitives(
engine,
attackerId,
hook.primitives,
1,
undefined,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
@ -593,8 +664,19 @@ export function fireOnDamagedHooks(
| ChessAttrMap["OnDamagedHooks"]
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
for (const hook of hooks) {
runPrimitives(
engine,
id,
hook.primitives,
1,
undefined,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
}
@ -643,8 +725,19 @@ export function fireOnMoveHooks(
| ChessAttrMap["OnMoveHooks"]
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, id, primitives, 1, undefined, new Map(), cascadeDepth);
for (const hook of hooks) {
runPrimitives(
engine,
id,
hook.primitives,
1,
undefined,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
}
@ -674,8 +767,19 @@ export function fireOnPromotionHooks(
promotedFrom,
promotedTo,
};
for (const primitives of hooks) {
runPrimitives(engine, promotedPieceId, primitives, 1, event, new Map(), cascadeDepth);
for (const hook of hooks) {
runPrimitives(
engine,
promotedPieceId,
hook.primitives,
1,
event,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
@ -709,8 +813,19 @@ export function fireOnCheckReceivedHooks(
| ChessAttrMap["OnCheckReceivedHooks"]
| undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, royalId, primitives, 1, undefined, new Map(), cascadeDepth);
for (const hook of hooks) {
runPrimitives(
engine,
royalId,
hook.primitives,
1,
undefined,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
}
@ -741,8 +856,19 @@ export function fireOnCheckDeliveredHooks(
"OnCheckDeliveredHooks",
) as ChessAttrMap["OnCheckDeliveredHooks"] | undefined;
if (hooks === undefined) continue;
for (const primitives of hooks) {
runPrimitives(engine, attackerId, primitives, 1, undefined, new Map(), cascadeDepth);
for (const hook of hooks) {
runPrimitives(
engine,
attackerId,
hook.primitives,
1,
undefined,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
}
@ -768,7 +894,18 @@ export function fireOnMovedOntoSquareHooks(
if (hooks === undefined) return;
for (const hook of hooks) {
if (!squareMatchesFilter(destSquare, hook.filter)) continue;
runPrimitives(engine, movedPieceId, hook.primitives, 1, undefined, new Map(), cascadeDepth);
runPrimitives(
engine,
movedPieceId,
hook.primitives,
1,
undefined,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
@ -852,6 +989,8 @@ export function fireOnPieceEnteredMarkerHooks(
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
@ -898,7 +1037,14 @@ export function fireOnCapturedHooks(
session: engine.session,
pieceId: capturedPieceId,
depth: 0,
descriptor: { id: "__trigger__", type: "data", version: 1 },
// T80: thread the REAL descriptor id (the descriptor that
// seeded THIS on-captured hook) through the resolver context
// and into `runPrimitives` below. Pre-T80 used the synthetic
// `"__trigger__"`; that placeholder broke
// `submitChoiceAndResume` because PendingChoices pushed inside
// a trigger arm carried the placeholder id and the descriptor
// lookup at resume time failed (Gap G).
descriptor: { id: hook.descriptorId, type: "data", version: 1 },
target: hook.target,
event,
// T11: empty bindings — on-captured hook entry has no enclosing
@ -917,7 +1063,18 @@ export function fireOnCapturedHooks(
};
const targets = resolveTargets(resolverCtx, hook.target);
for (const targetId of targets) {
runPrimitives(engine, targetId, hook.primitives, 1, event, new Map(), cascadeDepth);
runPrimitives(
engine,
targetId,
hook.primitives,
1,
event,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
}
@ -972,6 +1129,9 @@ export function fireOnRuleActivatedHooks(
event,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
@ -1053,6 +1213,9 @@ export function fireOnRuleExpireHooks(
event,
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}
@ -1122,6 +1285,8 @@ export function fireOnMarkerExpireHooks(
new Map(),
cascadeDepth,
false,
[],
hook.descriptorId,
);
}
}

View file

@ -183,16 +183,43 @@ export interface ChessAttrMap {
* `computeAuraFacts` — never written directly by primitives.
*/
AuraContributions: Readonly<Record<string, number>>;
OnTurnStartHooks: readonly EffectPrimitiveNode[][];
OnCaptureHooks: readonly EffectPrimitiveNode[][];
OnDamagedHooks: readonly EffectPrimitiveNode[][];
/**
* T80 (Wave 14, Gap G fix) — every per-piece trigger entry carries
* `descriptorId` (the id of the descriptor that seeded the hook)
* alongside the inner primitive list. Threaded through
* `runPrimitives` by the trigger dispatcher so request-choice
* primitives running inside a trigger arm push a PendingChoice
* with the REAL descriptor id (instead of the synthetic
* `"__trigger__"` placeholder used pre-T80). This lets
* `submitChoiceAndResume` resolve the descriptor by id and walk
* back into the descriptor's primitive tree to continue execution.
*
* Mirrors the existing pattern on `OnRuleActivatedHooks` /
* `OnRuleExpireHooks` / `OnPieceEnteredMarkerHooks` /
* `OnMarkerExpireHooks`, which have always carried `descriptorId`.
*/
OnTurnStartHooks: readonly {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}[];
OnCaptureHooks: readonly {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}[];
OnDamagedHooks: readonly {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}[];
ConditionalHooks: readonly {
readonly condition: ConditionSpec;
readonly then: readonly EffectPrimitiveNode[];
readonly else?: readonly EffectPrimitiveNode[];
}[];
// T3-extension trigger hook attrs (read by triggers.ts evaluators added in T12)
OnMoveHooks: readonly EffectPrimitiveNode[][];
OnMoveHooks: readonly {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}[];
/**
* On-turn-end hooks carry their `color` filter alongside the inner
* primitives, because the filter must be evaluated at fire-time
@ -200,17 +227,29 @@ export interface ChessAttrMap {
* against this entry to decide whether to run the inner list).
*/
OnTurnEndHooks: readonly {
readonly descriptorId: string;
readonly color: "white" | "black" | "both";
readonly primitives: readonly EffectPrimitiveNode[];
}[];
OnPromotionHooks: readonly EffectPrimitiveNode[][];
OnCheckReceivedHooks: readonly EffectPrimitiveNode[][];
OnCheckDeliveredHooks: readonly EffectPrimitiveNode[][];
OnPromotionHooks: readonly {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}[];
OnCheckReceivedHooks: readonly {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}[];
OnCheckDeliveredHooks: readonly {
readonly descriptorId: string;
readonly primitives: readonly EffectPrimitiveNode[];
}[];
OnCapturedHooks: readonly {
readonly descriptorId: string;
readonly target: TargetResolver;
readonly primitives: readonly EffectPrimitiveNode[];
}[];
OnMovedOntoSquareHooks: readonly {
readonly descriptorId: string;
readonly filter: SquareFilter;
readonly primitives: readonly EffectPrimitiveNode[];
}[];

View file

@ -0,0 +1,256 @@
/**
* T80 (Wave 14, Gap G fix) — `submitChoiceAndResume` works on
* trigger-fired PendingChoice frames.
*
* Pre-T80, when a `request-choice` ran inside an `on-captured`
* (or any other) trigger arm, the dispatcher synthesised a fake
* `descriptor: { id: "__trigger__", … }` for the
* `PrimitiveApplyContext`. The request-choice primitive read
* `ctx.descriptor.id` while pushing its `PendingChoice` frame, so
* the frame's `descriptorId` was the literal string
* `"__trigger__"`. When the player submitted the choice,
* `submitChoiceAndResume` looked up
* `engine.customModifiers.get("__trigger__")` → undefined → threw
* `runtime.descriptor-not-found`. The resume could never proceed.
*
* T80 threads the REAL descriptor id through the trigger
* dispatcher. Each `On*Hooks` entry stores the id of the
* descriptor that seeded it; every `fire*Hooks` reads the entry
* and passes the id through `runPrimitives` into the synthesised
* apply-context. The PendingChoice frame now carries the real id,
* `submitChoiceAndResume` resolves the descriptor, and the resume
* cascade runs the continuation.
*
* This test pins the contract via a parry-shaped descriptor — an
* on-captured arm wrapping a request-choice whose `then` runs an
* observable seed-attribute write. After resume, the seeded value
* is present (proving the continuation ran), and the
* `PendingChoices` stack is empty (proving the frame popped
* cleanly).
*
* Note on `cancel-capture`: the production parry descriptor uses
* `cancel-capture` in the `then` continuation, but
* `cancel-capture` requires `ctx.event.kind === "capture"` and
* `submitChoiceAndResume` passes `event: undefined` to the
* resumed `runPrimitives`. The full parry-real fixture uses a
* custom `resumeWithCaptureEvent` helper to thread the event
* through; that's outside the scope of THIS test, which focuses on
* the descriptor-id threading. Once a future task wires event
* propagation into `submitChoiceAndResume`, an additional test in
* this file can swap the seed-attribute continuation for
* cancel-capture and assert `CaptureCancelled = true`.
*
* The test uses `fireOnCapturedHooks` directly rather than driving
* the full `engine.applyMove` capture pipeline so the focus stays
* on the descriptor-id threading + resume contract — neither the
* snapshot/restore writers nor the integration-preset wiring are
* relevant here, and bypassing them keeps the test minimal.
*/
import { describe, expect, it } from "vitest";
import type { EntityId } from "@paratype/rete";
import { ChessEngine } from "../engine.js";
import {
GAME_ENTITY,
type ChessAttrMap,
type PendingChoice,
} from "../schema.js";
import {
asCustomModifierId,
type CustomModifierDescriptor,
} from "../modifiers/custom/types.js";
import type { EffectPrimitiveNode } from "../modifiers/primitives/types.js";
import { fireOnCapturedHooks } from "../modifiers/triggers.js";
import {
peekPendingChoice,
submitChoiceAndResume,
} from "./pending-choices.js";
import "../modifiers/primitives/index.js";
/**
* The "lifted" inner-arm shape — what the dispatcher iterates when
* firing the on-captured hook:
*
* request-choice (kind: rps, bind: defenderThrow)
* └── then:
* └── seed-attribute (HpBonus = 99 on GAME_ENTITY)
*
* In production, the parry descriptor's top-level node is
* `on-captured` and the request-choice lives in its
* `params.primitives`. The `OnCapturedHooks` hook entry stores
* exactly the INNER ARM (the request-choice). Pre-T46 the resume
* mechanism walked from the descriptor's top-level via
* `triggerPath`; with the V1 contract that path starts at the
* lifted inner arm, the simplest test scaffold registers the
* inner arm AS the descriptor's top-level primitives. This
* matches the lifting trick `mr_freeze.test.ts` /
* `mind_control.test.ts` use, and the resume mechanism walks from
* `triggerPath: []` straight into the request-choice.
*
* `seed-attribute` is used as a side-effect probe rather than
* the production `cancel-capture` — the latter requires
* `ctx.event.kind === "capture"` and `submitChoiceAndResume`
* passes `event: undefined` to the resumed `runPrimitives` (see
* file docstring).
*/
function liftedInnerArm(): readonly EffectPrimitiveNode[] {
return [
{
kind: "request-choice",
params: {
kind: "rps",
prompt: "Defender, throw rps",
forPlayer: "both",
bind: "defenderThrow",
then: [
{
kind: "seed-attribute",
params: { attr: "HpBonus", value: 99 },
},
],
},
},
];
}
function makeParryDescriptor(id: string): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId(id),
name: id,
description: "T80 Gap G test descriptor (inner arm lifted to top level)",
version: 1,
// Lifted: top-level primitives ARE the inner arm. The resume
// mechanism's `walkTriggerPath` starts here for `triggerPath: []`.
primitives: liftedInnerArm(),
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
}
/**
* Spawn a single piece (id auto-allocated) with minimal facts so
* `fireOnCapturedHooks` can read its hook list. The defender needs
* `Position`, `PieceType`, and `Color` so resolveTargets / the
* inner arm can read it; the attacker needs the same so the event
* payload's attackerId resolves to a real entity.
*/
function spawnPiece(
engine: ChessEngine,
pieceType: "pawn" | "knight",
color: "white" | "black",
square: number,
): EntityId {
const id = engine.session.nextId();
engine.session.insert(id, "PieceType", pieceType);
engine.session.insert(id, "Color", color);
engine.session.insert(id, "Position", square);
engine.session.insert(id, "EntityKind", "piece");
return id;
}
describe("T80 (Gap G) — request-choice inside on-captured can be resumed", () => {
it("fireOnCapturedHooks pushes a PendingChoice with the REAL descriptor id", () => {
const engine = new ChessEngine();
const desc = makeParryDescriptor("test:gap-g-real-id");
engine.customModifiers.register(desc);
const defender = spawnPiece(engine, "pawn", "black", 35); // d5
const attacker = spawnPiece(engine, "pawn", "white", 28); // e4
// Seed the on-captured hook entry with the lifted inner arm
// (= desc.primitives) and the descriptor's REAL id. Mirrors
// the apply-time shape: hook entry stores the arm, not the
// wrapping on-captured node.
engine.session.insert(defender, "OnCapturedHooks", [
{
descriptorId: desc.id,
target: "self",
primitives: desc.primitives,
},
] as ChessAttrMap["OnCapturedHooks"]);
fireOnCapturedHooks(engine, defender, attacker);
const top = peekPendingChoice(engine);
expect(top).toBeDefined();
// The fix: the frame carries the REAL descriptor id, not
// the synthetic "__trigger__" placeholder.
expect(top!.descriptorId).toBe(desc.id);
expect(top!.kind).toBe("rps");
expect(top!.forPlayer).toBe("both");
// The request-choice is the first (and only) primitive in
// the on-captured arm — index 0, no nested triggerPath.
expect(top!.primitiveIndex).toBe(0);
expect(top!.triggerPath).toEqual([]);
});
it("submitChoiceAndResume succeeds — descriptor lookup resolves, continuation runs", () => {
const engine = new ChessEngine();
const desc = makeParryDescriptor("test:gap-g-resume");
engine.customModifiers.register(desc);
const defender = spawnPiece(engine, "pawn", "black", 35);
const attacker = spawnPiece(engine, "pawn", "white", 28);
engine.session.insert(defender, "OnCapturedHooks", [
{
descriptorId: desc.id,
target: "self",
primitives: desc.primitives,
},
] as ChessAttrMap["OnCapturedHooks"]);
fireOnCapturedHooks(engine, defender, attacker);
const top = peekPendingChoice(engine);
expect(top).toBeDefined();
// Pre-resume: the seed-attribute continuation has NOT yet run.
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
// Pre-T80, this would throw `runtime.descriptor-not-found`
// because the frame's descriptorId was the synthetic
// "__trigger__" placeholder. T80 threads the real id, so the
// descriptor lookup succeeds and the continuation runs.
expect(() =>
submitChoiceAndResume(engine, top!.choiceId, "rock"),
).not.toThrow();
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(99);
// Stack is empty — the resume popped the frame, the
// continuation didn't push another one.
expect(
engine.session.get(GAME_ENTITY, "PendingChoices"),
).toEqual([] as readonly PendingChoice[]);
});
it("pre-T80 placeholder is gone — descriptorId NEVER equals '__trigger__' on a real fire path", () => {
// Regression guard: any future refactor that re-introduces the
// synthetic placeholder on a production fire path would break
// submitChoiceAndResume the same way Gap G did. Pin the absence
// of the placeholder so the regression surfaces here, not in a
// user-facing parry/RPS e2e flake.
const engine = new ChessEngine();
const desc = makeParryDescriptor("test:gap-g-no-placeholder");
engine.customModifiers.register(desc);
const defender = spawnPiece(engine, "pawn", "black", 35);
const attacker = spawnPiece(engine, "pawn", "white", 28);
engine.session.insert(defender, "OnCapturedHooks", [
{
descriptorId: desc.id,
target: "self",
primitives: desc.primitives,
},
] as ChessAttrMap["OnCapturedHooks"]);
fireOnCapturedHooks(engine, defender, attacker);
const top = peekPendingChoice(engine);
expect(top).toBeDefined();
expect(top!.descriptorId).not.toBe("__trigger__");
});
});