diff --git a/packages/chess/src/modifiers/primitives/index.ts b/packages/chess/src/modifiers/primitives/index.ts new file mode 100644 index 0000000..a17eeb5 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/index.ts @@ -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). diff --git a/packages/chess/src/modifiers/primitives/registry.test.ts b/packages/chess/src/modifiers/primitives/registry.test.ts new file mode 100644 index 0000000..89fe931 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/registry.test.ts @@ -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
(args: { + kind: PrimitiveKind; + label?: string; + paramsSchema: EffectPrimitive
["paramsSchema"]; +}): EffectPrimitive
{ + 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
( + localRegistry: PrimitiveRegistryClass, + descriptor: EffectPrimitive
, + ): EffectPrimitive
{
+ 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);
+ });
+});
diff --git a/packages/chess/src/modifiers/primitives/registry.ts b/packages/chess/src/modifiers/primitives/registry.ts
new file mode 100644
index 0000000..35a82df
--- /dev/null
+++ b/packages/chess/src/modifiers/primitives/registry.ts
@@ -0,0 +1,30 @@
+import type { EffectPrimitive, PrimitiveKind } from "./types.js";
+
+class PrimitiveRegistryClass {
+ readonly #byId = new Map (primitive: EffectPrimitive ): 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();
diff --git a/packages/chess/src/modifiers/primitives/types.ts b/packages/chess/src/modifiers/primitives/types.ts
new file mode 100644
index 0000000..d81725c
--- /dev/null
+++ b/packages/chess/src/modifiers/primitives/types.ts
@@ -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