feat(engine): primitive types and registry

T3 Wave 1 (T2). Lays the type-system foundation that the 15 effect
primitives in Wave 2 will conform to.

- PrimitiveKind: discriminated literal of all 15 T3 primitive ids (ADR-2).
- EffectPrimitive<Params>: descriptor contract with paramsSchema (Zod),
  apply(ctx, params), and optional childPrimitives() for nested-tree
  walking by the validator.
- EffectPrimitiveNode: runtime instance shape — kind + opaque params.
- PrimitiveApplyContext: { engine, session, pieceId, depth, descriptor } —
  depth threads through for the recursion cap (ADR-3).
- CustomModifierDescriptorRef: forward-declared trunk so primitives
  doesn't import custom (one-way import graph; full descriptor lives in
  custom/types.ts).
- PrimitiveRegistryClass + PRIMITIVE_REGISTRY singleton mirroring the
  MODIFIER_REGISTRY pattern (Map<kind, descriptor>, throws on duplicate,
  list() preserves registration order).

Side-effect registration of individual primitives lands in Wave 2.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 17:24:07 -06:00
commit 2e655a0c1a
No known key found for this signature in database
4 changed files with 237 additions and 0 deletions

View file

@ -0,0 +1,11 @@
export { PrimitiveRegistryClass, PRIMITIVE_REGISTRY } from "./registry.js";
export type {
PrimitiveKind,
EffectPrimitive,
EffectPrimitiveNode,
PrimitiveApplyContext,
CustomModifierDescriptorRef,
Session,
} from "./types.js";
// Side-effect registration of individual primitives is added by Wave 2 (T4-T18).

View file

@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
PrimitiveRegistryClass,
type EffectPrimitive,
type PrimitiveKind,
} from "./index.js";
function primitive<P>(args: {
kind: PrimitiveKind;
label?: string;
paramsSchema: EffectPrimitive<P>["paramsSchema"];
}): EffectPrimitive<P> {
return {
kind: args.kind,
label: args.label ?? args.kind,
description: `${args.kind} primitive`,
paramsSchema: args.paramsSchema,
apply: () => {
// no-op in registry tests
},
};
}
describe("PrimitiveRegistryClass", () => {
it("register() + get() round-trip returns the same descriptor", () => {
const registry = new PrimitiveRegistryClass();
const descriptor = primitive({
kind: "seed-attribute",
paramsSchema: z.object({ key: z.string(), value: z.number() }),
});
registry.register(descriptor);
expect(registry.get("seed-attribute")).toBe(descriptor);
});
it("register() throws on duplicate primitive kind", () => {
const registry = new PrimitiveRegistryClass();
registry.register(
primitive({
kind: "add-to-attribute",
paramsSchema: z.object({ key: z.string(), delta: z.number() }),
}),
);
expect(() => {
registry.register(
primitive({
kind: "add-to-attribute",
paramsSchema: z.object({ key: z.string(), delta: z.number() }),
}),
);
}).toThrow(/add-to-attribute/);
});
it("list() preserves registration order", () => {
const registry = new PrimitiveRegistryClass();
const first = primitive({
kind: "set-capture-flag",
paramsSchema: z.object({ flag: z.string() }),
});
const second = primitive({
kind: "reflect-damage",
paramsSchema: z.object({ ratio: z.number() }),
});
const third = primitive({
kind: "override-promotion",
paramsSchema: z.object({ to: z.string() }),
});
registry.register(first);
registry.register(second);
registry.register(third);
expect(registry.list()).toEqual([first, second, third]);
});
it("has() is false before registration and true after", () => {
const registry = new PrimitiveRegistryClass();
expect(registry.has("add-direction")).toBe(false);
registry.register(
primitive({
kind: "add-direction",
paramsSchema: z.object({ direction: z.string() }),
}),
);
expect(registry.has("add-direction")).toBe(true);
});
it("get() returns undefined for an unregistered known kind", () => {
const registry = new PrimitiveRegistryClass();
expect(registry.get("conditional")).toBeUndefined();
});
it("generic params type is preserved at register call site", () => {
const registry = new PrimitiveRegistryClass();
const typed = primitive({
kind: "modify-movement-range",
paramsSchema: z.object({
mode: z.literal("set"),
value: z.number(),
}),
});
function registerAndReturn<P>(
localRegistry: PrimitiveRegistryClass,
descriptor: EffectPrimitive<P>,
): EffectPrimitive<P> {
localRegistry.register(descriptor);
return descriptor;
}
const registered = registerAndReturn(registry, typed);
const parsed = registered.paramsSchema.parse({ mode: "set", value: 3 });
expect(parsed.value).toBe(3);
expect(registry.get("modify-movement-range")).toBe(typed);
});
});

View file

@ -0,0 +1,30 @@
import type { EffectPrimitive, PrimitiveKind } from "./types.js";
class PrimitiveRegistryClass {
readonly #byId = new Map<PrimitiveKind, EffectPrimitive>();
register<P>(primitive: EffectPrimitive<P>): void {
if (this.#byId.has(primitive.kind)) {
throw new Error(
`PrimitiveRegistry: duplicate primitive kind "${primitive.kind}". ` +
`Each primitive descriptor must have a unique kind.`,
);
}
this.#byId.set(primitive.kind, primitive as EffectPrimitive);
}
get(kind: PrimitiveKind): EffectPrimitive | undefined {
return this.#byId.get(kind);
}
list(): readonly EffectPrimitive[] {
return [...this.#byId.values()];
}
has(kind: PrimitiveKind): boolean {
return this.#byId.has(kind);
}
}
export { PrimitiveRegistryClass };
export const PRIMITIVE_REGISTRY = new PrimitiveRegistryClass();

View file

@ -0,0 +1,71 @@
import type { EntityId, Session } from "@paratype/rete";
import type { ZodType } from "zod";
import type { ChessEngine } from "../../engine.js";
/**
* T3 primitive ids (ADR-2).
*/
export type PrimitiveKind =
| "seed-attribute"
| "add-to-attribute"
| "multiply-attribute"
| "add-direction"
| "set-capture-flag"
| "absorb-damage-with-attribute"
| "reflect-damage"
| "block-move-type"
| "modify-movement-range"
| "override-promotion"
| "add-aura"
| "on-turn-start"
| "on-capture"
| "on-damaged"
| "conditional";
/**
* Forward-declared shape of the back-reference passed to primitive
* `apply()` calls. The full descriptor lives in `../custom/types.js`
* declaring only the trunk here keeps the import graph one-way
* (custom imports primitives, never the reverse).
*
* Adding a structurally-compatible field here is fine, but the
* canonical shape is owned by `../custom/types.ts`.
*/
export interface CustomModifierDescriptorRef {
readonly id: string;
readonly type: "data";
readonly version: 1;
}
/** Runtime primitive node embedded in custom descriptor trees. */
export interface EffectPrimitiveNode {
readonly kind: PrimitiveKind;
readonly params: unknown;
}
/** Context passed to primitive apply functions. */
export interface PrimitiveApplyContext {
readonly engine: ChessEngine;
readonly session: Session;
readonly pieceId: EntityId;
readonly depth: number;
readonly descriptor: CustomModifierDescriptorRef;
}
/**
* Primitive descriptor contract implemented by each T3 primitive.
*
* `childPrimitives` is provided by primitives that own nested primitive lists
* (e.g. trigger/conditional primitives) so validators can walk the tree.
*/
export interface EffectPrimitive<Params = unknown> {
readonly kind: PrimitiveKind;
readonly label: string;
readonly description: string;
readonly paramsSchema: ZodType<Params>;
readonly apply: (ctx: PrimitiveApplyContext, params: Params) => void;
readonly maxDepth?: number;
readonly childPrimitives?: (params: Params) => EffectPrimitiveNode[];
}
export type { Session };