From 2e655a0c1aef47a083bbea6ffd491e3c1307b1b7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 17:24:07 -0600 Subject: [PATCH] feat(engine): primitive types and registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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, throws on duplicate, list() preserves registration order). Side-effect registration of individual primitives lands in Wave 2. --- .../chess/src/modifiers/primitives/index.ts | 11 ++ .../src/modifiers/primitives/registry.test.ts | 125 ++++++++++++++++++ .../src/modifiers/primitives/registry.ts | 30 +++++ .../chess/src/modifiers/primitives/types.ts | 71 ++++++++++ 4 files changed, 237 insertions(+) create mode 100644 packages/chess/src/modifiers/primitives/index.ts create mode 100644 packages/chess/src/modifiers/primitives/registry.test.ts create mode 100644 packages/chess/src/modifiers/primitives/registry.ts create mode 100644 packages/chess/src/modifiers/primitives/types.ts 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(); + + register

(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 { + readonly kind: PrimitiveKind; + readonly label: string; + readonly description: string; + readonly paramsSchema: ZodType; + readonly apply: (ctx: PrimitiveApplyContext, params: Params) => void; + readonly maxDepth?: number; + readonly childPrimitives?: (params: Params) => EffectPrimitiveNode[]; +} + +export type { Session };