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