feat(engine): custom modifier Zod schema

T3 Wave 3 (T20). Structural Zod schema for CustomModifierDescriptor.

- EffectPrimitiveNodeSchema: recursive via z.lazy(); kind is z.string()
  + min(1) (semantic check belongs to T19's validator); params is
  z.unknown() so nested trees pass through cleanly.
- CustomModifierDescriptorSchema: literal discriminators on
  type/version/uiForm/source; min/max bounds on name/description.
- parse/safeParse/serialize wrappers; parse() carries a single boundary
  cast to bridge Zod's structural inference (kind: string,
  optional: T | undefined) with the typed interface (kind: PrimitiveKind,
  optional: ?). Documented in JSDoc; T19 enforces every narrowing
  separately.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 17:59:45 -06:00
commit b35d758c57
No known key found for this signature in database
2 changed files with 241 additions and 0 deletions

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import {
EffectPrimitiveNodeSchema,
parseCustomModifierDescriptor,
safeParseCustomModifierDescriptor,
serializeCustomModifierDescriptor,
} from "./schema.js";
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
function validDescriptor(
overrides: Partial<CustomModifierDescriptor> = {},
): CustomModifierDescriptor {
return {
type: "data",
id: asCustomModifierId("custom:test"),
name: "Test",
description: "",
version: 1,
primitives: [],
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
...overrides,
};
}
describe("EffectPrimitiveNodeSchema", () => {
it("accepts a leaf node", () => {
const node = { kind: "seed-attribute", params: { attr: "Hp", value: 5 } };
expect(EffectPrimitiveNodeSchema.parse(node)).toEqual(node);
});
it("accepts a nested-tree node (params holds more nodes)", () => {
const nested = {
kind: "on-turn-start",
params: {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
};
expect(EffectPrimitiveNodeSchema.parse(nested)).toEqual(nested);
});
it("rejects a node missing the kind field", () => {
expect(() =>
EffectPrimitiveNodeSchema.parse({ params: {} }),
).toThrow();
});
it("rejects a node with an empty kind string", () => {
expect(() =>
EffectPrimitiveNodeSchema.parse({ kind: "", params: {} }),
).toThrow();
});
});
describe("CustomModifierDescriptorSchema — happy path", () => {
it("round-trips a minimal valid descriptor", () => {
const desc = validDescriptor();
const parsed = parseCustomModifierDescriptor(desc);
expect(parsed).toEqual(desc);
});
it("round-trips a descriptor with primitives + author + createdAt", () => {
const desc = validDescriptor({
name: "Shield",
description: "Absorbs damage.",
primitives: [
{ kind: "seed-attribute", params: { attr: "ShieldCharges", value: 3 } },
],
targetAttrs: ["AbsorbDamageAttr", "AbsorbDamageRate"],
author: "alice",
createdAt: 1700000000000,
});
const parsed = parseCustomModifierDescriptor(desc);
expect(parsed.author).toBe("alice");
expect(parsed.createdAt).toBe(1700000000000);
});
it("safeParse returns success on a valid descriptor", () => {
const result = safeParseCustomModifierDescriptor(validDescriptor());
expect(result.success).toBe(true);
});
it("serialize round-trips the same shape", () => {
const desc = validDescriptor({ name: "Roundtrip" });
const out = serializeCustomModifierDescriptor(desc) as CustomModifierDescriptor;
expect(out).toEqual(desc);
});
});
describe("CustomModifierDescriptorSchema — rejections", () => {
it("rejects type !== 'data'", () => {
const bad = { ...validDescriptor(), type: "scripted" };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects version !== 1", () => {
const bad = { ...validDescriptor(), version: 2 };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects name longer than 40 chars", () => {
const bad = validDescriptor({ name: "x".repeat(41) });
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects description longer than 200 chars", () => {
const bad = validDescriptor({ description: "x".repeat(201) });
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects empty id", () => {
const bad = { ...validDescriptor(), id: "" };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects negative createdAt", () => {
const bad = validDescriptor({ createdAt: -1 });
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects uiForm !== 'primitive-composer'", () => {
const bad = { ...validDescriptor(), uiForm: "number" };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects source !== 'custom'", () => {
const bad = { ...validDescriptor(), source: "premade" };
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
});
it("rejects when primitives is missing", () => {
const { primitives: _drop, ...rest } = validDescriptor();
expect(() => parseCustomModifierDescriptor(rest)).toThrow();
});
});
describe("CustomModifierDescriptorSchema — optional fields", () => {
it("accepts a descriptor without author / createdAt", () => {
const desc = validDescriptor();
const parsed = parseCustomModifierDescriptor(desc);
expect(parsed.author).toBeUndefined();
expect(parsed.createdAt).toBeUndefined();
});
});

View file

@ -0,0 +1,94 @@
/**
* Zod schemas for CustomModifierDescriptor serialization (T20).
*
* Validates STRUCTURE only — kind-in-registry and per-primitive params
* checks live in `validate.ts` (T19), which is the semantic layer that
* runs after a clean structural parse.
*
* The `EffectPrimitiveNode` schema is recursive: a primitive's `params`
* may embed more nodes (e.g. `on-turn-start` carries a `primitives: []`
* inside its params). We model this with `z.lazy()` and treat `params`
* as `z.unknown()` at the schema layer — the validator drills into it
* per-kind.
*/
import { z } from "zod";
import {
asCustomModifierId,
type CustomModifierDescriptor,
} from "./types.js";
// ---------------------------------------------------------------------------
// Primitive node — recursive
// ---------------------------------------------------------------------------
/**
* Structural shape of a primitive node. `kind` is `string` here (not the
* `PrimitiveKind` literal union) because the schema is structure-only;
* the validator (T19) checks `kind ∈ PRIMITIVE_REGISTRY`. `params` is
* `unknown` so nested trees pass through this schema cleanly.
*
* Inferred output: `{ kind: string; params: unknown }`. Consumers that
* want the branded `PrimitiveKind` shape go through `parseCustomModifierDescriptor`
* which performs the narrowing at the boundary.
*/
export const EffectPrimitiveNodeSchema = z.lazy(() =>
z.object({
kind: z.string().min(1),
params: z.unknown(),
}),
);
// ---------------------------------------------------------------------------
// CustomModifierDescriptor
// ---------------------------------------------------------------------------
const CustomModifierIdSchema = z
.string()
.min(1)
.transform((s) => asCustomModifierId(s));
export const CustomModifierDescriptorSchema = z.object({
type: z.literal("data"),
id: CustomModifierIdSchema,
name: z.string().min(1).max(40),
description: z.string().max(200),
version: z.literal(1),
primitives: z.array(EffectPrimitiveNodeSchema),
targetAttrs: z.array(z.string()),
uiForm: z.literal("primitive-composer"),
source: z.literal("custom"),
author: z.string().optional(),
createdAt: z.number().int().nonnegative().optional(),
});
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parse an unknown value as a CustomModifierDescriptor. Throws ZodError.
*
* The inferred Zod output type is structurally compatible but Zod's
* representation of optional fields and `string`-typed `kind` doesn't
* exactly match the `CustomModifierDescriptor` interface (which has
* `kind: PrimitiveKind` and uses `?` not `| undefined`). The single
* boundary cast below is the pragmatic way to bridge this — every
* narrowing it implies is enforced separately by T19's validator.
*/
export function parseCustomModifierDescriptor(
raw: unknown,
): CustomModifierDescriptor {
return CustomModifierDescriptorSchema.parse(raw) as CustomModifierDescriptor;
}
/** Non-throwing variant, returns Zod's discriminated SafeParseReturnType. */
export function safeParseCustomModifierDescriptor(raw: unknown) {
return CustomModifierDescriptorSchema.safeParse(raw);
}
/** Serialize a CustomModifierDescriptor to a JSON-safe plain object. */
export function serializeCustomModifierDescriptor(
descriptor: CustomModifierDescriptor,
): unknown {
return CustomModifierDescriptorSchema.parse(descriptor);
}