feat(engine): advanced effect primitives (aura/triggers/conditional)

T3 Wave 2 batch C. Implements 5 advanced primitives — auras and event
triggers — that nest other primitives via childPrimitives() so the
T19 validator can walk the tree for depth/count enforcement:

- add-aura: leaf primitive seeding AuraSpec[] entries
  ({radius:1-7, targetAttr, delta}). T28 wires the per-move recompute.
- on-turn-start: nesting primitive seeding OnTurnStartHooks (an array of
  primitive lists). childPrimitives() returns the inner list.
- on-capture: same pattern with OnCaptureHooks.
- on-damaged: same pattern with OnDamagedHooks.
- conditional: discriminated-union ConditionSpec (attr-lt/gt/eq, always,
  never), then[], optional else[]. childPrimitives() returns then+else
  combined so the validator can count both branches.

ChessAttrMap gains AuraSpec, OnTurnStartHooks, OnCaptureHooks,
OnDamagedHooks, ConditionalHooks; ConditionSpec is a public discriminated
union. Engine integration (trigger evaluation, aura recompute) deferred
to T22/T28.

The primitives/index.ts barrel now imports all 15 T3 primitives in
batch order (state → mechanic → advanced).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 17:42:09 -06:00
commit ce49b55a60
No known key found for this signature in database
9 changed files with 587 additions and 1 deletions

View file

@ -0,0 +1,123 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { CONDITIONAL_PRIMITIVE } from "./conditional.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./conditional.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-conditional",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("conditional primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'conditional'", () => {
expect(PRIMITIVE_REGISTRY.has("conditional")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("conditional")).toBe(CONDITIONAL_PRIMITIVE);
});
});
describe("conditional primitive — apply()", () => {
it("seeds one conditional hook", () => {
const { ctx, session } = makeContext();
const thenBranch: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
];
const elseBranch: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
];
CONDITIONAL_PRIMITIVE.apply(ctx, {
condition: { type: "attr-lt", attr: "Hp", value: 3 },
then: thenBranch,
else: elseBranch,
});
expect(session.get(ctx.pieceId, "ConditionalHooks")).toEqual([
{
condition: { type: "attr-lt", attr: "Hp", value: 3 },
then: thenBranch,
else: elseBranch,
},
]);
});
it("appends to existing ConditionalHooks", () => {
const { ctx, session } = makeContext();
CONDITIONAL_PRIMITIVE.apply(ctx, {
condition: { type: "always" },
then: [{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } }],
});
CONDITIONAL_PRIMITIVE.apply(ctx, {
condition: { type: "never" },
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
else: [{ kind: "set-capture-flag", params: { flag: 1 } }],
});
expect(session.get(ctx.pieceId, "ConditionalHooks")).toEqual([
{
condition: { type: "always" },
then: [{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } }],
else: undefined,
},
{
condition: { type: "never" },
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
else: [{ kind: "set-capture-flag", params: { flag: 1 } }],
},
]);
});
});
describe("conditional primitive — childPrimitives()", () => {
it("returns then + else primitives combined", () => {
const thenBranch: EffectPrimitiveNode[] = [
{ kind: "add-direction", params: { direction: "forward" } },
];
const elseBranch: EffectPrimitiveNode[] = [
{ kind: "block-move-type", params: { moveType: "capture" } },
];
const children = CONDITIONAL_PRIMITIVE.childPrimitives?.({
condition: { type: "always" },
then: thenBranch,
else: elseBranch,
});
expect(children).toEqual([...thenBranch, ...elseBranch]);
});
it("accepts conditional params with no else branch", () => {
const parsed = CONDITIONAL_PRIMITIVE.paramsSchema.parse({
condition: { type: "attr-gt", attr: "Hp", value: 2 },
then: [{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } }],
});
expect(parsed.else).toBeUndefined();
});
});
describe("conditional primitive — paramsSchema", () => {
it("rejects unknown condition.type values", () => {
expect(() =>
CONDITIONAL_PRIMITIVE.paramsSchema.parse({
condition: { type: "attr-between", attr: "Hp", min: 1, max: 3 },
then: [],
}),
).toThrow();
});
});

View file

@ -0,0 +1,86 @@
import { z } from "zod";
import type { ChessAttrMap, ConditionSpec } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Primitive kinds are runtime-validated later by the tree validator (T19).
* Keeping this as `string` avoids hard-coding every primitive literal here.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const ConditionSchema: z.ZodType<ConditionSpec> = z.discriminatedUnion("type", [
z.object({
type: z.literal("attr-lt"),
attr: z.string(),
value: z.number(),
}),
z.object({
type: z.literal("attr-gt"),
attr: z.string(),
value: z.number(),
}),
z.object({
type: z.literal("attr-eq"),
attr: z.string(),
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
}),
z.object({
type: z.literal("always"),
}),
z.object({
type: z.literal("never"),
}),
]);
const schema = z.object({
condition: ConditionSchema,
then: z.array(NodeSchema),
else: z.array(NodeSchema).optional(),
});
type Params = z.infer<typeof schema>;
type ConditionalHook = ChessAttrMap["ConditionalHooks"][number];
const descriptor: EffectPrimitive<Params> = {
kind: "conditional",
label: "Conditional",
description:
"Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "ConditionalHooks") as
| ChessAttrMap["ConditionalHooks"]
| undefined) ?? [];
let next: ConditionalHook;
if (params.else !== undefined) {
next = {
condition: params.condition,
then: [...params.then],
else: [...params.else],
};
} else {
next = {
condition: params.condition,
then: [...params.then],
};
}
ctx.session.insert(ctx.pieceId, "ConditionalHooks", [...existing, next]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.then, ...(params.else ?? [])];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as CONDITIONAL_PRIMITIVE };

View file

@ -8,4 +8,23 @@ export type {
Session,
} from "./types.js";
// Side-effect registration of individual primitives is added by Wave 2 (T4-T18).
// State primitives (Wave 2 batch A — T4T8):
import "./seed-attribute.js";
import "./add-to-attribute.js";
import "./multiply-attribute.js";
import "./add-direction.js";
import "./set-capture-flag.js";
// Mechanic primitives (Wave 2 batch B — T9T13):
import "./absorb-damage-with-attribute.js";
import "./reflect-damage.js";
import "./modify-movement-range.js";
import "./block-move-type.js";
import "./override-promotion.js";
// Advanced primitives (Wave 2 batch C — T14T18):
import "./add-aura.js";
import "./on-turn-start.js";
import "./on-capture.js";
import "./on-damaged.js";
import "./conditional.js";

View file

@ -0,0 +1,72 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_CAPTURE_PRIMITIVE } from "./on-capture.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-capture.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-on-capture",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("on-capture primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-capture'", () => {
expect(PRIMITIVE_REGISTRY.has("on-capture")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-capture")).toBe(ON_CAPTURE_PRIMITIVE);
});
});
describe("on-capture primitive — apply()", () => {
it("seeds one capture hook", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
];
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([primitives]);
});
it("appends additional hooks to existing OnCaptureHooks", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 2 } },
];
const second: EffectPrimitiveNode[] = [
{ kind: "multiply-attribute", params: { attr: "HpBonus", factor: 2 } },
];
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: first });
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([first, second]);
});
});
describe("on-capture primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-direction", params: { direction: "left" } },
{ kind: "set-capture-flag", params: { flag: 2 } },
];
const children = ON_CAPTURE_PRIMITIVE.childPrimitives?.({ primitives });
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,47 @@
import { z } from "zod";
import type { ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Primitive kinds are runtime-validated later by the tree validator (T19).
* Keeping this as `string` avoids hard-coding every primitive literal here.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-capture",
label: "On Capture",
description: "Seeds OnCaptureHooks entries consumed during capture events.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnCaptureHooks") as
| ChessAttrMap["OnCaptureHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnCaptureHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_CAPTURE_PRIMITIVE };

View file

@ -0,0 +1,72 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_DAMAGED_PRIMITIVE } from "./on-damaged.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-damaged.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-on-damaged",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("on-damaged primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-damaged'", () => {
expect(PRIMITIVE_REGISTRY.has("on-damaged")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-damaged")).toBe(ON_DAMAGED_PRIMITIVE);
});
});
describe("on-damaged primitive — apply()", () => {
it("seeds one damaged hook", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
];
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([primitives]);
});
it("appends additional hooks to existing OnDamagedHooks", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 1 } },
];
const second: EffectPrimitiveNode[] = [
{ kind: "reflect-damage", params: { percent: 0.5 } },
];
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: first });
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([first, second]);
});
});
describe("on-damaged primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "absorb-damage-with-attribute", params: { attr: "Hp", rate: 1 } },
{ kind: "set-capture-flag", params: { flag: 4 } },
];
const children = ON_DAMAGED_PRIMITIVE.childPrimitives?.({ primitives });
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,47 @@
import { z } from "zod";
import type { ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Primitive kinds are runtime-validated later by the tree validator (T19).
* Keeping this as `string` avoids hard-coding every primitive literal here.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-damaged",
label: "On Damaged",
description: "Seeds OnDamagedHooks entries consumed during damage events.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnDamagedHooks") as
| ChessAttrMap["OnDamagedHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnDamagedHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_DAMAGED_PRIMITIVE };

View file

@ -0,0 +1,72 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_TURN_START_PRIMITIVE } from "./on-turn-start.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-turn-start.js";
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
const session = new Session();
const pieceId = session.nextId();
const ctx: PrimitiveApplyContext = {
engine: new ChessEngine(),
session,
pieceId,
depth: 0,
descriptor: {
id: "custom:test-on-turn-start",
type: "data",
version: 1,
},
};
return { ctx, session };
}
describe("on-turn-start primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-turn-start'", () => {
expect(PRIMITIVE_REGISTRY.has("on-turn-start")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-turn-start")).toBe(ON_TURN_START_PRIMITIVE);
});
});
describe("on-turn-start primitive — apply()", () => {
it("seeds one turn-start hook", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
];
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([primitives]);
});
it("appends additional hooks to existing OnTurnStartHooks", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 3 } },
];
const second: EffectPrimitiveNode[] = [
{ kind: "set-capture-flag", params: { flag: 1 } },
];
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]);
});
});
describe("on-turn-start primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-direction", params: { direction: "backward" } },
{ kind: "block-move-type", params: { moveType: "slide" } },
];
const children = ON_TURN_START_PRIMITIVE.childPrimitives?.({ primitives });
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,48 @@
import { z } from "zod";
import type { ChessAttrMap } from "../../schema.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import type {
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
PrimitiveKind,
} from "./types.js";
/**
* Primitive kinds are runtime-validated later by the tree validator (T19).
* Keeping this as `string` avoids hard-coding every primitive literal here.
*/
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
kind: z.string() as z.ZodType<PrimitiveKind>,
params: z.unknown(),
});
const schema = z.object({
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-turn-start",
label: "On Turn Start",
description:
"Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.",
paramsSchema: schema,
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnTurnStartHooks") as
| ChessAttrMap["OnTurnStartHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnTurnStartHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_TURN_START_PRIMITIVE };