diff --git a/packages/chess/src/modifiers/custom/schema.test.ts b/packages/chess/src/modifiers/custom/schema.test.ts new file mode 100644 index 0000000..6748cc7 --- /dev/null +++ b/packages/chess/src/modifiers/custom/schema.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/chess/src/modifiers/custom/schema.ts b/packages/chess/src/modifiers/custom/schema.ts new file mode 100644 index 0000000..3e899ef --- /dev/null +++ b/packages/chess/src/modifiers/custom/schema.ts @@ -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); +}