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:
parent
60b89d8c5e
commit
2e655a0c1a
4 changed files with 237 additions and 0 deletions
11
packages/chess/src/modifiers/primitives/index.ts
Normal file
11
packages/chess/src/modifiers/primitives/index.ts
Normal 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).
|
||||
125
packages/chess/src/modifiers/primitives/registry.test.ts
Normal file
125
packages/chess/src/modifiers/primitives/registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
packages/chess/src/modifiers/primitives/registry.ts
Normal file
30
packages/chess/src/modifiers/primitives/registry.ts
Normal 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();
|
||||
71
packages/chess/src/modifiers/primitives/types.ts
Normal file
71
packages/chess/src/modifiers/primitives/types.ts
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue