feat(chess/primitives): add 7 new trigger primitives (on-move, on-turn-end, on-promotion, on-check-*, on-moved-onto-square, on-captured)

Each primitive mirrors on-capture.ts structure: Zod paramsSchema with
nested primitives array, apply() that seeds its dedicated hook attr on
ctx.pieceId, childPrimitives() for tree-walker integration, seedsAttrs
declaration for consumer-integrity enforcement, docstring + ≥2 examples.

The kinds, attrs, and firing semantics (verified end-to-end in T21):
- on-move — OnMoveHooks, fires on any Position WME change (including
  captures and castling rook)
- on-turn-end — OnTurnEndHooks with color filter ('white'|'black'|'both'),
  fires at end of matching-color turn, BEFORE opponent's on-turn-start
- on-promotion — OnPromotionHooks, fires AFTER PieceType flip; evaluator
  populates ctx.event.promotedFrom + promotedTo
- on-check-received — OnCheckReceivedHooks, edge-triggered (transition
  into check only); royal-piece filter applied by evaluator
- on-check-delivered — OnCheckDeliveredHooks, attributes to revealing
  piece for discovered check (computed via pre/post snapshot diff)
- on-moved-onto-square — OnMovedOntoSquareHooks, discriminated-union
  filter: {kind:'squares', squares[]} | {kind:'predicate', file?, rank?};
  Zod refine rejects empty predicate + empty squares list
- on-captured — OnCapturedHooks with per-hook target redirection
  (self|attacker|defender|{squares[]}|{relation, filter?}); fires BEFORE
  retraction so nested primitives can still read the defender's attrs.
  Narrow-cast through unknown to paper over Zod → TargetResolver variance
  under exactOptionalPropertyTypes:true (runtime shapes identical; pure
  TS-level optional vs explicit-undefined mismatch)

Extends PrimitiveKind union from 15 → 22 kinds. Barrel imports added
to primitives/index.ts for side-effect registry registration.

Per-primitive tests assert seeding (8-12 cases each): registry keying,
seedsAttrs declaration, attribute-insertion, stacking across multiple
apply calls, Zod validation boundary cases, childPrimitives passthrough.
End-to-end trigger dispatching is wired and tested in T12 (triggers.ts)
and T21 (apply.ts onAfterMove ordering).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 17:46:37 -06:00
commit e87d38ae2c
No known key found for this signature in database
16 changed files with 1573 additions and 0 deletions

View file

@ -25,6 +25,13 @@ import "./override-promotion.js";
// Advanced primitives (Wave 2 batch C — T14T18):
import "./add-aura.js";
import "./on-turn-start.js";
import "./on-turn-end.js";
import "./on-capture.js";
import "./on-captured.js";
import "./on-move.js";
import "./on-damaged.js";
import "./on-promotion.js";
import "./on-check-received.js";
import "./on-check-delivered.js";
import "./on-moved-onto-square.js";
import "./conditional.js";

View file

@ -0,0 +1,205 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_CAPTURED_PRIMITIVE } from "./on-captured.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import type { ChessAttrMap } from "../../schema.js";
import "./on-captured.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-captured",
type: "data",
version: 1,
},
target: "self",
event: undefined,
};
return { ctx, session };
}
describe("on-captured primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-captured'", () => {
expect(PRIMITIVE_REGISTRY.has("on-captured")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-captured")).toBe(ON_CAPTURED_PRIMITIVE);
});
it("declares seedsAttrs = ['OnCapturedHooks']", () => {
expect(ON_CAPTURED_PRIMITIVE.seedsAttrs).toEqual(["OnCapturedHooks"]);
});
});
describe("on-captured primitive — apply() seeding", () => {
it("seeds OnCapturedHooks with default target 'self' when omitted", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -2 } },
];
ON_CAPTURED_PRIMITIVE.apply(ctx, { primitives });
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([{ target: "self", primitives }]);
});
it("seeds with target='attacker'", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -2 } },
];
ON_CAPTURED_PRIMITIVE.apply(ctx, { target: "attacker", primitives });
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([{ target: "attacker", primitives }]);
});
it("seeds with target={relation:'ally', filter:{pieceType:'queen'}}", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
const target = {
relation: "ally" as const,
filter: { pieceType: "queen" as const },
};
ON_CAPTURED_PRIMITIVE.apply(ctx, { target, primitives });
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([{ target, primitives }]);
});
it("seeds with target={squares:[28,35]}", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 3 } },
];
const target = { squares: [28, 35] };
ON_CAPTURED_PRIMITIVE.apply(ctx, { target, primitives });
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([{ target, primitives }]);
});
it("appends additional hooks to existing OnCapturedHooks", () => {
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_CAPTURED_PRIMITIVE.apply(ctx, { primitives: first });
ON_CAPTURED_PRIMITIVE.apply(ctx, {
target: "attacker",
primitives: second,
});
const hooks = session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined;
expect(hooks).toEqual([
{ target: "self", primitives: first },
{ target: "attacker", primitives: second },
]);
});
});
describe("on-captured primitive — paramsSchema validation", () => {
it("accepts valid params with all target variants", () => {
const schema = ON_CAPTURED_PRIMITIVE.paramsSchema;
expect(schema.safeParse({ primitives: [] }).success).toBe(true);
expect(schema.safeParse({ target: "self", primitives: [] }).success).toBe(
true,
);
expect(
schema.safeParse({ target: "attacker", primitives: [] }).success,
).toBe(true);
expect(
schema.safeParse({ target: "defender", primitives: [] }).success,
).toBe(true);
expect(
schema.safeParse({ target: { squares: [0, 63] }, primitives: [] })
.success,
).toBe(true);
expect(
schema.safeParse({
target: { relation: "ally", filter: { pieceType: "queen" } },
primitives: [],
}).success,
).toBe(true);
expect(
schema.safeParse({ target: { relation: "enemy" }, primitives: [] })
.success,
).toBe(true);
});
it("Zod rejects malformed target (e.g., bare object)", () => {
const schema = ON_CAPTURED_PRIMITIVE.paramsSchema;
// Bare object — not a literal, not squares, not relation.
expect(schema.safeParse({ target: {}, primitives: [] }).success).toBe(
false,
);
// Unknown string literal.
expect(
schema.safeParse({ target: "nobody", primitives: [] }).success,
).toBe(false);
// Out-of-range square.
expect(
schema.safeParse({ target: { squares: [64] }, primitives: [] }).success,
).toBe(false);
// Non-integer square.
expect(
schema.safeParse({ target: { squares: [3.5] }, primitives: [] }).success,
).toBe(false);
// Bad relation value.
expect(
schema.safeParse({ target: { relation: "neutral" }, primitives: [] })
.success,
).toBe(false);
// Bad piece type in filter.
expect(
schema.safeParse({
target: { relation: "ally", filter: { pieceType: "giant" } },
primitives: [],
}).success,
).toBe(false);
// Missing primitives key.
expect(schema.safeParse({ target: "self" }).success).toBe(false);
});
});
describe("on-captured 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_CAPTURED_PRIMITIVE.childPrimitives?.({
target: "self",
primitives,
});
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,113 @@
import { z } from "zod";
import type { ChessAttrMap } from "../../schema.js";
import type { TargetResolver } from "./context.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(),
});
/**
* Zod schema mirroring the `TargetResolver` type from `context.ts`.
* Owned by this primitive file per plan; may be promoted to a shared
* export in `context.ts` once reused by siblings.
*
* The explicit type annotation is omitted (Zod's inferred output type
* cannot be asserted to `TargetResolver` directly under
* `exactOptionalPropertyTypes: true` readonly/optional variance
* differs). The `apply()` body narrows via assignment to `TargetResolver`.
*/
const TargetResolverSchema = z.union([
z.literal("self"),
z.literal("attacker"),
z.literal("defender"),
z.object({
squares: z.array(z.number().int().min(0).max(63)),
}),
z.object({
relation: z.enum(["ally", "enemy"]),
filter: z
.object({
pieceType: z
.enum(["pawn", "knight", "bishop", "rook", "queen", "king"])
.optional(),
})
.optional(),
}),
]);
const schema = z.object({
target: TargetResolverSchema.optional(),
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-captured",
label: "On Captured",
description:
"Seeds OnCapturedHooks entries that fire when this piece is captured (death-rattle).",
longDescription:
"Wraps nested primitives that fire the MOMENT this piece is captured, BEFORE the engine retracts its facts. Because the hook runs pre-retraction, nested primitives retain read access to the defender's attrs (Hp, PieceType, Position, etc.) and can redirect their effect via the `target` param: 'self' (default) hits the dying piece, 'attacker' or 'defender' leverage the capture event metadata, and relation/square targets let a death-rattle buff nearby allies or debuff the attacker. This enables classic ally-buff (\"Martyr\") and enemy-debuff (\"Kamikaze\") death-rattle patterns without special-casing the dispatcher. Fired by the T12 trigger evaluator, which populates ctx.event with {attackerId, defenderId} and sets ctx.target = hook.target before invoking each inner primitive.",
examples: [
{
title: "Kamikaze — damage the attacker on death",
params: {
target: "attacker",
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -2 } },
],
},
effect:
"When this piece is captured, the capturing piece loses 2 HP. Turns every exchange into a mutual wound.",
},
{
title: "Martyr — buff a random ally queen on death",
params: {
target: { relation: "ally", filter: { pieceType: "queen" } },
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
effect:
"When this piece is captured, every allied queen gains 1 HP — a classic death-rattle rally.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnCapturedHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnCapturedHooks") as
| ChessAttrMap["OnCapturedHooks"]
| undefined) ?? [];
// Narrow via `unknown` — Zod's inferred output encodes optional
// fields as `T | undefined`, while `TargetResolver` under
// `exactOptionalPropertyTypes: true` requires absence (`T?`). The
// runtime values are structurally identical; TS-only variance.
const target: TargetResolver =
(params.target as unknown as TargetResolver | undefined) ?? "self";
ctx.session.insert(ctx.pieceId, "OnCapturedHooks", [
...existing,
{ target, primitives: [...params.primitives] },
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_CAPTURED_PRIMITIVE };

View file

@ -0,0 +1,116 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_CHECK_DELIVERED_PRIMITIVE } from "./on-check-delivered.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-check-delivered.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-check-delivered",
type: "data",
version: 1,
},
target: "self",
event: undefined,
};
return { ctx, session };
}
describe("on-check-delivered primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-check-delivered'", () => {
expect(PRIMITIVE_REGISTRY.has("on-check-delivered")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-check-delivered")).toBe(
ON_CHECK_DELIVERED_PRIMITIVE,
);
});
it("declares OnCheckDeliveredHooks in seedsAttrs", () => {
expect(ON_CHECK_DELIVERED_PRIMITIVE.seedsAttrs).toEqual([
"OnCheckDeliveredHooks",
]);
});
});
describe("on-check-delivered primitive — apply()", () => {
it("seeds OnCheckDeliveredHooks attr with one hook entry", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
ON_CHECK_DELIVERED_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnCheckDeliveredHooks")).toEqual([
primitives,
]);
});
it("stacks: appends additional hooks to existing OnCheckDeliveredHooks", () => {
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_CHECK_DELIVERED_PRIMITIVE.apply(ctx, { primitives: first });
ON_CHECK_DELIVERED_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnCheckDeliveredHooks")).toEqual([
first,
second,
]);
});
});
describe("on-check-delivered primitive — Zod validation", () => {
it("validates well-formed params (empty and non-empty primitives array)", () => {
const empty = ON_CHECK_DELIVERED_PRIMITIVE.paramsSchema.safeParse({
primitives: [],
});
expect(empty.success).toBe(true);
const nonEmpty = ON_CHECK_DELIVERED_PRIMITIVE.paramsSchema.safeParse({
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
});
expect(nonEmpty.success).toBe(true);
});
it("rejects params missing primitives array", () => {
const result = ON_CHECK_DELIVERED_PRIMITIVE.paramsSchema.safeParse({});
expect(result.success).toBe(false);
});
it("rejects primitives entries missing kind/params fields", () => {
const result = ON_CHECK_DELIVERED_PRIMITIVE.paramsSchema.safeParse({
primitives: [{ notAKind: "oops" }],
});
expect(result.success).toBe(false);
});
});
describe("on-check-delivered primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "AttackBonus", delta: 1 } },
];
const children = ON_CHECK_DELIVERED_PRIMITIVE.childPrimitives?.({
primitives,
});
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,76 @@
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-check-delivered",
label: "On Check Delivered",
description:
"Seeds OnCheckDeliveredHooks entries consumed when this piece newly threatens the enemy royal.",
longDescription:
"Fires on the attacker piece whose threat-line newly reaches the enemy royal after a move resolves. Edge-triggered: it fires only on the TRANSITION from not-threatening-the-king to threatening-the-king, not while the threat persists. For DISCOVERED check, the hook fires on the REVEALING attacker (the piece whose line-of-sight was unblocked by the mover) — NOT on the piece that actually moved. Double-check fires the hook on both newly-attacking pieces. Evaluation uses the pre-move vs post-move attacker-set diff captured by PRE_MOVE_CHECK_STATE_SNAPSHOTS.",
examples: [
{
title: "Vampire on check delivered — gain HP",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
effect:
"Every time this piece newly delivers check to the enemy king (including discovered checks where this piece was the revealer), it gains 1 HP.",
},
{
title: "Stun the king — apply debuff to royal via target redirect",
params: {
primitives: [
{
kind: "add-to-attribute",
params: { attr: "StunCounter", delta: 1 },
},
],
},
effect:
"Delivering check stacks a StunCounter — combined with a target-redirect wrapper, this can debuff the royal itself rather than the attacker. Pressures the opponent to resolve check quickly before the stun accumulates.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnCheckDeliveredHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnCheckDeliveredHooks") as
| ChessAttrMap["OnCheckDeliveredHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnCheckDeliveredHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_CHECK_DELIVERED_PRIMITIVE };

View file

@ -0,0 +1,111 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_CHECK_RECEIVED_PRIMITIVE } from "./on-check-received.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-check-received.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-check-received",
type: "data",
version: 1,
},
target: "self",
event: undefined,
};
return { ctx, session };
}
describe("on-check-received primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-check-received'", () => {
expect(PRIMITIVE_REGISTRY.has("on-check-received")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-check-received")).toBe(
ON_CHECK_RECEIVED_PRIMITIVE,
);
});
});
describe("on-check-received primitive — apply()", () => {
it("seeds OnCheckReceivedHooks attr with one hook entry", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Shield", delta: 2 } },
];
ON_CHECK_RECEIVED_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnCheckReceivedHooks")).toEqual([
primitives,
]);
});
it("stacks across multiple apply calls (appends to existing hooks)", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 3 } },
];
const second: EffectPrimitiveNode[] = [
{
kind: "add-to-attribute",
params: { attr: "DamageBonus", delta: 1 },
},
];
ON_CHECK_RECEIVED_PRIMITIVE.apply(ctx, { primitives: first });
ON_CHECK_RECEIVED_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnCheckReceivedHooks")).toEqual([
first,
second,
]);
});
});
describe("on-check-received primitive — paramsSchema", () => {
it("Zod validates the primitives nested array shape", () => {
const schema = ON_CHECK_RECEIVED_PRIMITIVE.paramsSchema;
const valid = schema.safeParse({
primitives: [
{ kind: "add-to-attribute", params: { attr: "Shield", delta: 1 } },
{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } },
],
});
expect(valid.success).toBe(true);
const missingPrimitives = schema.safeParse({});
expect(missingPrimitives.success).toBe(false);
const wrongType = schema.safeParse({ primitives: "not-an-array" });
expect(wrongType.success).toBe(false);
const badNode = schema.safeParse({
primitives: [{ params: { attr: "Hp", value: 1 } }], // missing kind
});
expect(badNode.success).toBe(false);
});
});
describe("on-check-received primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Shield", delta: 1 } },
{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } },
];
const children = ON_CHECK_RECEIVED_PRIMITIVE.childPrimitives?.({
primitives,
});
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,73 @@
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-check-received",
label: "On Check Received",
description:
"Seeds OnCheckReceivedHooks entries fired when this royal enters check.",
longDescription:
"Wraps nested primitives that fire the MOMENT this piece transitions from not-in-check to in-check. Fires on the EDGE only: a royal that stays in check across consecutive moves (e.g. the attacker persists) will NOT re-trigger until the check is broken and re-delivered. Applies only to ROYAL pieces (as resolved by the active preset's royalty set, defaulting to kings). Enforcement happens in the T12 trigger evaluator, which compares the pre-move check-state snapshot recorded by apply.ts (see `getPreMoveCheckState` / `PRE_MOVE_CHECK_STATE_SNAPSHOTS`, T4) against the post-move state to detect the transition and filter non-royals. This primitive is purely declarative — it only seeds the hook list; royal-filtering and edge-detection are NOT performed here.",
examples: [
{
title: "Panic Mode — gain Shield on check",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Shield", delta: 2 } },
],
},
effect:
"The first time this king is checked, it gains 2 Shield. Does not re-trigger while the check persists across turns.",
},
{
title: "Berserker King — +Damage on check",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "DamageBonus", delta: 1 } },
],
},
effect:
"Each time this king is newly put in check, its attack damage increases by 1. Rage compounds over the game as enemies repeatedly threaten the crown.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnCheckReceivedHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnCheckReceivedHooks") as
| ChessAttrMap["OnCheckReceivedHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnCheckReceivedHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_CHECK_RECEIVED_PRIMITIVE };

View file

@ -0,0 +1,117 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_MOVE_PRIMITIVE } from "./on-move.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-move.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-move",
type: "data",
version: 1,
},
target: "self",
event: undefined,
};
return { ctx, session };
}
describe("on-move primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-move'", () => {
expect(PRIMITIVE_REGISTRY.has("on-move")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-move")).toBe(ON_MOVE_PRIMITIVE);
});
it("declares seedsAttrs: ['OnMoveHooks']", () => {
expect(ON_MOVE_PRIMITIVE.seedsAttrs).toEqual(["OnMoveHooks"]);
});
});
describe("on-move primitive — apply() seeds hook attr", () => {
// The "fires when…" scenarios are expressed here as attr-seeding tests
// because the actual dispatch pipeline lands in T12. Mirroring
// on-capture.test.ts: we verify the primitive populates the hook queue
// correctly; the evaluator wiring is covered end-to-end in T21.
it("fires when piece moves to new square — seeds one hook for a quiet move", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "AttackBonus", delta: 1 } },
];
ON_MOVE_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnMoveHooks")).toEqual([primitives]);
});
it("fires on captures too — hook list accumulates across apply calls", () => {
const { ctx, session } = makeContext();
const quietMoveHook: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "AttackBonus", delta: 1 } },
];
const captureHook: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
ON_MOVE_PRIMITIVE.apply(ctx, { primitives: quietMoveHook });
ON_MOVE_PRIMITIVE.apply(ctx, { primitives: captureHook });
expect(session.get(ctx.pieceId, "OnMoveHooks")).toEqual([
quietMoveHook,
captureHook,
]);
});
it("fires on castling rook — seeding works on a separate piece entity", () => {
const { ctx: kingCtx, session } = makeContext();
const rookId = session.nextId();
const rookCtx: PrimitiveApplyContext = { ...kingCtx, pieceId: rookId };
const rookHook: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 2 } },
];
ON_MOVE_PRIMITIVE.apply(rookCtx, { primitives: rookHook });
expect(session.get(rookId, "OnMoveHooks")).toEqual([rookHook]);
// The king (which did not have the primitive applied) must NOT have a hook.
expect(session.get(kingCtx.pieceId, "OnMoveHooks")).toBeUndefined();
});
it("does NOT fire when piece is static — pieces without apply() have no OnMoveHooks attr", () => {
const { ctx, session } = makeContext();
const opponentId = session.nextId();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "AttackBonus", delta: 1 } },
];
ON_MOVE_PRIMITIVE.apply(ctx, { primitives });
// 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(opponentId, "OnMoveHooks")).toBeUndefined();
});
});
describe("on-move primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-direction", params: { direction: "left" } },
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
const children = ON_MOVE_PRIMITIVE.childPrimitives?.({ primitives });
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,72 @@
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-move",
label: "On Move",
description: "Seeds OnMoveHooks entries consumed whenever this piece moves.",
longDescription:
"Wraps nested primitives that fire whenever this piece's Position WME changes — normal moves, captures, castling-rook relocations, and en-passant pawn advances all count. Use for 'per-move' stacking buffs, movement-driven resource gain, or any effect that should proc each time the piece physically changes squares. Fires AFTER the move resolves, on the mover itself.",
examples: [
{
title: "Berserker — stacking attack on every move",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "AttackBonus", delta: 1 } },
],
},
effect:
"Each time the piece moves (any kind of move), it gains +1 AttackBonus. Stacks indefinitely — the longer it keeps marching, the harder it hits.",
},
{
title: "Nomad — heals 1 HP per step",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
effect:
"Every move (including captures and castling) restores 1 HP. Rewards active play and punishes idling.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnMoveHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnMoveHooks") as
| ChessAttrMap["OnMoveHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnMoveHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_MOVE_PRIMITIVE };

View file

@ -0,0 +1,196 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_MOVED_ONTO_SQUARE_PRIMITIVE } from "./on-moved-onto-square.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-moved-onto-square.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-moved-onto-square",
type: "data",
version: 1,
},
target: "self",
event: undefined,
};
return { ctx, session };
}
describe("on-moved-onto-square primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-moved-onto-square'", () => {
expect(PRIMITIVE_REGISTRY.has("on-moved-onto-square")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-moved-onto-square")).toBe(
ON_MOVED_ONTO_SQUARE_PRIMITIVE,
);
});
it("declares OnMovedOntoSquareHooks in seedsAttrs", () => {
expect(ON_MOVED_ONTO_SQUARE_PRIMITIVE.seedsAttrs).toEqual([
"OnMovedOntoSquareHooks",
]);
});
});
describe("on-moved-onto-square primitive — apply()", () => {
it("seeds attr with squares filter", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
ON_MOVED_ONTO_SQUARE_PRIMITIVE.apply(ctx, {
filter: { kind: "squares", squares: [27, 28, 35, 36] },
primitives,
});
expect(session.get(ctx.pieceId, "OnMovedOntoSquareHooks")).toEqual([
{
filter: { kind: "squares", squares: [27, 28, 35, 36] },
primitives,
},
]);
});
it("seeds attr with predicate filter", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{
kind: "add-aura",
params: { radius: 1, targetAttr: "HpBonus", delta: 1 },
},
];
ON_MOVED_ONTO_SQUARE_PRIMITIVE.apply(ctx, {
filter: { kind: "predicate", rank: 7 },
primitives,
});
expect(session.get(ctx.pieceId, "OnMovedOntoSquareHooks")).toEqual([
{
filter: { kind: "predicate", rank: 7 },
primitives,
},
]);
});
it("appends additional hooks to existing OnMovedOntoSquareHooks", () => {
const { ctx, session } = makeContext();
const firstPrims: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
];
const secondPrims: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 2 } },
];
ON_MOVED_ONTO_SQUARE_PRIMITIVE.apply(ctx, {
filter: { kind: "squares", squares: [28] },
primitives: firstPrims,
});
ON_MOVED_ONTO_SQUARE_PRIMITIVE.apply(ctx, {
filter: { kind: "predicate", file: 4 },
primitives: secondPrims,
});
expect(session.get(ctx.pieceId, "OnMovedOntoSquareHooks")).toEqual([
{
filter: { kind: "squares", squares: [28] },
primitives: firstPrims,
},
{
filter: { kind: "predicate", file: 4 },
primitives: secondPrims,
},
]);
});
});
describe("on-moved-onto-square primitive — paramsSchema (Zod)", () => {
it("accepts squares filter with one square", () => {
const result = ON_MOVED_ONTO_SQUARE_PRIMITIVE.paramsSchema.safeParse({
filter: { kind: "squares", squares: [0] },
primitives: [],
});
expect(result.success).toBe(true);
});
it("accepts predicate filter with only file", () => {
const result = ON_MOVED_ONTO_SQUARE_PRIMITIVE.paramsSchema.safeParse({
filter: { kind: "predicate", file: 3 },
primitives: [],
});
expect(result.success).toBe(true);
});
it("accepts predicate filter with only rank", () => {
const result = ON_MOVED_ONTO_SQUARE_PRIMITIVE.paramsSchema.safeParse({
filter: { kind: "predicate", rank: 7 },
primitives: [],
});
expect(result.success).toBe(true);
});
it("accepts predicate filter with both file and rank", () => {
const result = ON_MOVED_ONTO_SQUARE_PRIMITIVE.paramsSchema.safeParse({
filter: { kind: "predicate", file: 4, rank: 3 },
primitives: [],
});
expect(result.success).toBe(true);
});
it("Zod rejects empty predicate {} — both file and rank absent", () => {
const result = ON_MOVED_ONTO_SQUARE_PRIMITIVE.paramsSchema.safeParse({
filter: { kind: "predicate" },
primitives: [],
});
expect(result.success).toBe(false);
});
it("Zod rejects empty squares array", () => {
const result = ON_MOVED_ONTO_SQUARE_PRIMITIVE.paramsSchema.safeParse({
filter: { kind: "squares", squares: [] },
primitives: [],
});
expect(result.success).toBe(false);
});
it("Zod rejects square index out of range", () => {
const result = ON_MOVED_ONTO_SQUARE_PRIMITIVE.paramsSchema.safeParse({
filter: { kind: "squares", squares: [64] },
primitives: [],
});
expect(result.success).toBe(false);
});
it("Zod rejects file literal out of range", () => {
const result = ON_MOVED_ONTO_SQUARE_PRIMITIVE.paramsSchema.safeParse({
filter: { kind: "predicate", file: 8 },
primitives: [],
});
expect(result.success).toBe(false);
});
});
describe("on-moved-onto-square primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
{ kind: "add-direction", params: { direction: "left" } },
];
const children = ON_MOVED_ONTO_SQUARE_PRIMITIVE.childPrimitives?.({
filter: { kind: "squares", squares: [28] },
primitives,
});
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,132 @@
import { z } from "zod";
import type { ChessAttrMap, SquareFilter } 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 FileRankLiteral = z.union([
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
z.literal(7),
]);
const FilterSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("squares"),
squares: z.array(z.number().int().min(0).max(63)).min(1),
}),
z
.object({
kind: z.literal("predicate"),
file: FileRankLiteral.optional(),
rank: FileRankLiteral.optional(),
})
.refine((p) => p.file !== undefined || p.rank !== undefined, {
message: "predicate must have file, rank, or both",
}),
]);
const schema = z.object({
filter: FilterSchema,
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
type OnMovedOntoSquareHook = ChessAttrMap["OnMovedOntoSquareHooks"][number];
const descriptor: EffectPrimitive<Params> = {
kind: "on-moved-onto-square",
label: "On Moved Onto Square",
description:
"Seeds OnMovedOntoSquareHooks entries consumed when this piece lands on a matching square.",
longDescription:
"Wraps nested primitives that fire when this piece finishes a move on a square selected by the `filter`. Two filter kinds: `squares` — an explicit list of square indices (0..63, a1=0, h8=63); `predicate` — a file and/or rank match (files 0=a..7=h, ranks 0=rank1..7=rank8). A predicate with both file and rank set fires only on that exact square; a predicate with just one set fires for the whole file or rank. Fires AFTER the move resolves, only when the piece's final square matches — plain moves, captures, castling, and en-passant all qualify so long as the destination matches.",
examples: [
{
title: "Center Bonus — +1 HP when entering d4/e4/d5/e5",
params: {
filter: { kind: "squares", squares: [27, 28, 35, 36] },
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
effect:
"Landing on any of the four center squares (d4=27, e4=28, d5=35, e5=36) heals 1 HP. Rewards classical center control.",
},
{
title: "King Row — +Aura when reaching rank 8",
params: {
filter: { kind: "predicate", rank: 7 },
primitives: [
{
kind: "add-aura",
params: { radius: 1, targetAttr: "HpBonus", delta: 1 },
},
],
},
effect:
"As soon as this piece steps onto rank 8 (the last rank from White's perspective), it projects a +1 HpBonus aura to adjacent allies. Fires every time it re-enters the rank, so repositioning matters.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnMovedOntoSquareHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnMovedOntoSquareHooks") as
| ChessAttrMap["OnMovedOntoSquareHooks"]
| undefined) ?? [];
// Normalise predicate filter so that missing file/rank become
// truly absent keys (not `undefined`) — required by
// `exactOptionalPropertyTypes: true` on the `SquareFilter` shape
// defined in `schema.ts`.
let filter: SquareFilter;
if (params.filter.kind === "squares") {
filter = {
kind: "squares",
squares: [...params.filter.squares],
};
} else {
const { file, rank } = params.filter;
const predicate: { kind: "predicate"; file?: 0|1|2|3|4|5|6|7; rank?: 0|1|2|3|4|5|6|7 } = {
kind: "predicate",
};
if (file !== undefined) predicate.file = file;
if (rank !== undefined) predicate.rank = rank;
filter = predicate;
}
const next: OnMovedOntoSquareHook = {
filter,
primitives: [...params.primitives],
};
ctx.session.insert(ctx.pieceId, "OnMovedOntoSquareHooks", [
...existing,
next,
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_MOVED_ONTO_SQUARE_PRIMITIVE };

View file

@ -0,0 +1,100 @@
import { Session } from "@paratype/rete";
import { describe, expect, it } from "vitest";
import { ChessEngine } from "../../engine.js";
import { PRIMITIVE_REGISTRY } from "./registry.js";
import { ON_PROMOTION_PRIMITIVE } from "./on-promotion.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-promotion.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-promotion",
type: "data",
version: 1,
},
target: "self",
event: undefined,
};
return { ctx, session };
}
describe("on-promotion primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-promotion'", () => {
expect(PRIMITIVE_REGISTRY.has("on-promotion")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-promotion")).toBe(ON_PROMOTION_PRIMITIVE);
});
it("declares OnPromotionHooks as the seeded attr", () => {
expect(ON_PROMOTION_PRIMITIVE.seedsAttrs).toEqual(["OnPromotionHooks"]);
});
});
describe("on-promotion primitive — apply()", () => {
it("seeds OnPromotionHooks attr with one hook", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } },
];
ON_PROMOTION_PRIMITIVE.apply(ctx, { primitives });
expect(session.get(ctx.pieceId, "OnPromotionHooks")).toEqual([primitives]);
});
it("stacks across multiple apply calls", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } },
];
const second: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 2 } },
];
ON_PROMOTION_PRIMITIVE.apply(ctx, { primitives: first });
ON_PROMOTION_PRIMITIVE.apply(ctx, { primitives: second });
expect(session.get(ctx.pieceId, "OnPromotionHooks")).toEqual([
first,
second,
]);
});
});
describe("on-promotion primitive — paramsSchema", () => {
it("validates a primitives array via Zod", () => {
const valid = ON_PROMOTION_PRIMITIVE.paramsSchema.safeParse({
primitives: [
{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } },
],
});
expect(valid.success).toBe(true);
const missing = ON_PROMOTION_PRIMITIVE.paramsSchema.safeParse({});
expect(missing.success).toBe(false);
const wrongType = ON_PROMOTION_PRIMITIVE.paramsSchema.safeParse({
primitives: "not-an-array",
});
expect(wrongType.success).toBe(false);
});
});
describe("on-promotion primitive — childPrimitives()", () => {
it("returns the inner primitive list for validator tree traversal", () => {
const primitives: EffectPrimitiveNode[] = [
{ kind: "seed-attribute", params: { attr: "PieceType", value: "pawn" } },
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 3 } },
];
const children = ON_PROMOTION_PRIMITIVE.childPrimitives?.({ primitives });
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,75 @@
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-promotion",
label: "On Promotion",
description: "Seeds OnPromotionHooks entries consumed after pawn promotes.",
longDescription:
"Wraps nested primitives that fire AFTER this piece's PieceType flips from pawn to another type (queen, rook, bishop, knight, or a custom promotion target set by override-promotion). The dispatcher populates `ctx.event = { kind: 'promotion', promotedFrom: 'pawn', promotedTo: PieceType }` so nested primitives can inspect what the pawn became. Fires once per promotion event, after the flip is committed.",
examples: [
{
title: "Promotion Feast — seed 5 HP on promote",
params: {
primitives: [
{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } },
],
},
effect:
"When this pawn promotes, it immediately gains 5 HP — a reward for surviving the journey across the board.",
},
{
title: "Stay-As-Pawn — defensively revert the promotion",
params: {
primitives: [
{
kind: "seed-attribute",
params: { attr: "PieceType", value: "pawn" },
},
],
},
effect:
"Right after the engine flips this pawn to its promotion target, the hook overwrites PieceType back to 'pawn', preserving the original role.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnPromotionHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnPromotionHooks") as
| ChessAttrMap["OnPromotionHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnPromotionHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_PROMOTION_PRIMITIVE };

View file

@ -0,0 +1,97 @@
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_END_PRIMITIVE } from "./on-turn-end.js";
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
import "./on-turn-end.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-end",
type: "data",
version: 1,
},
target: "self",
event: undefined,
};
return { ctx, session };
}
describe("on-turn-end primitive — registry", () => {
it("registers in PRIMITIVE_REGISTRY under key 'on-turn-end'", () => {
expect(PRIMITIVE_REGISTRY.has("on-turn-end")).toBe(true);
expect(PRIMITIVE_REGISTRY.get("on-turn-end")).toBe(ON_TURN_END_PRIMITIVE);
});
});
describe("on-turn-end primitive — apply()", () => {
it("seeds OnTurnEndHooks attr correctly", () => {
const { ctx, session } = makeContext();
const primitives: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
];
ON_TURN_END_PRIMITIVE.apply(ctx, { color: "both", primitives });
expect(session.get(ctx.pieceId, "OnTurnEndHooks")).toEqual([primitives]);
});
it("stacks across multiple apply calls", () => {
const { ctx, session } = makeContext();
const first: EffectPrimitiveNode[] = [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
];
const second: EffectPrimitiveNode[] = [
{ kind: "set-capture-flag", params: { flag: 0 } },
];
ON_TURN_END_PRIMITIVE.apply(ctx, { color: "both", primitives: first });
ON_TURN_END_PRIMITIVE.apply(ctx, { color: "white", primitives: second });
expect(session.get(ctx.pieceId, "OnTurnEndHooks")).toEqual([first, second]);
});
});
describe("on-turn-end primitive — paramsSchema", () => {
it("params validates color enum — Zod rejects unknown color", () => {
const result = ON_TURN_END_PRIMITIVE.paramsSchema.safeParse({
color: "purple",
primitives: [],
});
expect(result.success).toBe(false);
});
it("accepts the three valid color values", () => {
for (const color of ["white", "black", "both"] as const) {
const result = ON_TURN_END_PRIMITIVE.paramsSchema.safeParse({
color,
primitives: [],
});
expect(result.success).toBe(true);
}
});
});
describe("on-turn-end 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_END_PRIMITIVE.childPrimitives?.({
color: "both",
primitives,
});
expect(children).toEqual(primitives);
});
});

View file

@ -0,0 +1,76 @@
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({
color: z.enum(["white", "black", "both"]),
primitives: z.array(NodeSchema),
});
type Params = z.infer<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
kind: "on-turn-end",
label: "On Turn End",
description:
"Seeds OnTurnEndHooks entries consumed during the engine's turn-end phase (fires for the mover at the end of their turn, before the opponent's on-turn-start).",
longDescription:
"Wraps a list of nested primitives that fire at the END of this piece's color's turn, immediately after the move commits and before control passes to the opponent (so before the opponent's on-turn-start). Use for decay effects, end-of-turn damage ticks, cooldown resets, or recurring debuffs tied to turn cadence. The `color` param filters which side's turn-end triggers this hook (`both` fires on either side). The editor's Parameter Inspector accepts the nested `primitives` array as JSON; copy snippets from the simpler primitives into that array.",
examples: [
{
title: "Decay — lose 1 HP per turn end",
params: {
color: "both",
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
],
},
effect:
"At the end of every turn (either color), this piece loses 1 HP — useful for poisoned/burning units that tick down regardless of who's moving.",
},
{
title: "Clear capture-flag at the end of white's turn",
params: {
color: "white",
primitives: [
{ kind: "set-capture-flag", params: { flag: 0 } },
],
},
effect:
"At the end of each white turn, reset this piece's capture flag to 0 — lets a one-shot-per-turn ability re-arm on the owner's own turn boundary.",
},
],
paramsSchema: schema,
seedsAttrs: ["OnTurnEndHooks"],
apply(ctx: PrimitiveApplyContext, params: Params): void {
const existing =
(ctx.session.get(ctx.pieceId, "OnTurnEndHooks") as
| ChessAttrMap["OnTurnEndHooks"]
| undefined) ?? [];
ctx.session.insert(ctx.pieceId, "OnTurnEndHooks", [
...existing,
[...params.primitives],
]);
},
childPrimitives(params: Params): EffectPrimitiveNode[] {
return [...params.primitives];
},
};
PRIMITIVE_REGISTRY.register(descriptor);
export { descriptor as ON_TURN_END_PRIMITIVE };

View file

@ -20,8 +20,15 @@ export type PrimitiveKind =
| "override-promotion"
| "add-aura"
| "on-turn-start"
| "on-turn-end"
| "on-capture"
| "on-captured"
| "on-move"
| "on-damaged"
| "on-promotion"
| "on-check-received"
| "on-check-delivered"
| "on-moved-onto-square"
| "conditional";
/**