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:
parent
8b9d3a7a4c
commit
b35d758c57
2 changed files with 241 additions and 0 deletions
147
packages/chess/src/modifiers/custom/schema.test.ts
Normal file
147
packages/chess/src/modifiers/custom/schema.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
94
packages/chess/src/modifiers/custom/schema.ts
Normal file
94
packages/chess/src/modifiers/custom/schema.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue