feat(engine): ModifierProfile Zod schema + roundtrip tests

This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:17:41 -06:00
commit e58bb02605
No known key found for this signature in database
3 changed files with 412 additions and 0 deletions

View file

@ -0,0 +1,39 @@
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor } from "../types.js";
import { CaptureFlag } from "../../schema.js";
// Bitflag validation: valid values are any combination of the 3 flags
const MAX_FLAGS = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT;
const schema = z.number().int().min(0).max(MAX_FLAGS);
type Value = z.infer<typeof schema>;
function describeFlags(value: Value): string {
const parts: string[] = [];
if (value & CaptureFlag.CAN_CAPTURE_OWN) parts.push("can-capture-own");
if (value & CaptureFlag.CANNOT_BE_CAPTURED) parts.push("cannot-be-captured");
if (value & CaptureFlag.EN_PASSANT) parts.push("en-passant");
return parts.length > 0 ? parts.join(", ") : "no flags";
}
export const CAPTURE_FLAGS_DESCRIPTOR: ModifierDescriptor<Value> = {
id: "capture-flags",
attrName: "CaptureFlags",
label: "Capture Behavior",
valueSchema: schema,
stackingRule: "union", // bitwise OR for stacking
uiForm: "capture-flags",
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
session.insert(pieceId, "CaptureFlags", effectiveValue);
},
describe: describeFlags,
};
MODIFIER_REGISTRY.register(CAPTURE_FLAGS_DESCRIPTOR);
/** Check if a piece has a specific capture flag set. */
export function hasCaptureFlag(session: Session, pieceId: EntityId, flag: number): boolean {
const flags = session.get(pieceId, "CaptureFlags");
return typeof flags === "number" && (flags & flag) !== 0;
}

View file

@ -0,0 +1,283 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import {
TypeModifierSchema,
InstanceModifierSchema,
ModifierProfileSchema,
parseModifierProfile,
serializeModifierProfile,
} from "./schema.js";
import type { ModifierProfile } from "./types.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const VALID_PROFILE: ModifierProfile = {
id: "profile-001",
name: "Knight Buff",
description: "Gives all white knights +2 HP",
layoutId: "classic",
perType: [
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 },
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
],
perInstance: [
{ kind: "hp-bonus", square: "b1", value: 5 },
],
version: 1,
source: "premade",
};
const VALID_PROFILE_NO_LAYOUT: ModifierProfile = {
id: "profile-002",
name: "Custom Pawns",
description: "Custom pawn modifiers",
perType: [],
perInstance: [],
version: 1,
source: "custom",
};
// ---------------------------------------------------------------------------
// TypeModifierSchema
// ---------------------------------------------------------------------------
describe("TypeModifierSchema", () => {
it("parses a valid hp-bonus TypeModifier", () => {
const result = TypeModifierSchema.parse({
kind: "hp-bonus",
pieceType: "knight",
color: "white",
value: 3,
});
expect(result.kind).toBe("hp-bonus");
expect(result.value).toBe(3);
});
it("parses direction-additions with 'both' color", () => {
const result = TypeModifierSchema.parse({
kind: "direction-additions",
pieceType: "pawn",
color: "both",
value: ["forward", "backward"],
});
expect(result.color).toBe("both");
});
it("parses capture-flags TypeModifier", () => {
const result = TypeModifierSchema.parse({
kind: "capture-flags",
pieceType: "queen",
color: "black",
value: 3,
});
expect(result.kind).toBe("capture-flags");
});
it("rejects unknown modifier kind", () => {
expect(() =>
TypeModifierSchema.parse({
kind: "nonexistent",
pieceType: "knight",
color: "white",
value: 1,
})
).toThrow(z.ZodError);
});
it("rejects unknown piece type", () => {
expect(() =>
TypeModifierSchema.parse({
kind: "hp-bonus",
pieceType: "dragon",
color: "white",
value: 1,
})
).toThrow(z.ZodError);
});
});
// ---------------------------------------------------------------------------
// InstanceModifierSchema
// ---------------------------------------------------------------------------
describe("InstanceModifierSchema", () => {
it("parses a valid instance modifier with square 'b1'", () => {
const result = InstanceModifierSchema.parse({
kind: "hp-bonus",
square: "b1",
value: 5,
});
expect(result.square).toBe("b1");
});
it("parses a valid instance modifier at 'h8'", () => {
const result = InstanceModifierSchema.parse({
kind: "range-bonus",
square: "h8",
value: 2,
});
expect(result.square).toBe("h8");
});
it("rejects invalid square 'z9'", () => {
expect(() =>
InstanceModifierSchema.parse({
kind: "hp-bonus",
square: "z9",
value: 1,
})
).toThrow(z.ZodError);
});
it("rejects invalid square 'a9' (rank out of range)", () => {
expect(() =>
InstanceModifierSchema.parse({
kind: "hp-bonus",
square: "a9",
value: 1,
})
).toThrow(z.ZodError);
});
it("rejects invalid square 'i1' (file out of range)", () => {
expect(() =>
InstanceModifierSchema.parse({
kind: "hp-bonus",
square: "i1",
value: 1,
})
).toThrow(z.ZodError);
});
});
// ---------------------------------------------------------------------------
// ModifierProfileSchema — valid cases
// ---------------------------------------------------------------------------
describe("ModifierProfileSchema — valid", () => {
it("parses a full valid profile", () => {
const result = ModifierProfileSchema.parse(VALID_PROFILE);
expect(result.id).toBe("profile-001");
expect(result.version).toBe(1);
expect(result.source).toBe("premade");
expect(result.perType).toHaveLength(2);
expect(result.perInstance).toHaveLength(1);
});
it("parses a profile without layoutId (optional field)", () => {
const result = ModifierProfileSchema.parse(VALID_PROFILE_NO_LAYOUT);
expect(result.layoutId).toBeUndefined();
});
it("parses a profile with empty perType and perInstance arrays", () => {
const result = ModifierProfileSchema.parse({
id: "empty-profile",
name: "Empty",
description: "",
perType: [],
perInstance: [],
version: 1,
source: "custom",
});
expect(result.perType).toHaveLength(0);
expect(result.perInstance).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// ModifierProfileSchema — invalid cases
// ---------------------------------------------------------------------------
describe("ModifierProfileSchema — invalid", () => {
it("rejects profile with version=2", () => {
expect(() =>
ModifierProfileSchema.parse({ ...VALID_PROFILE, version: 2 })
).toThrow(z.ZodError);
});
it("rejects profile with missing 'name' field", () => {
const { name: _name, ...rest } = VALID_PROFILE; // eslint-disable-line @typescript-eslint/no-unused-vars
expect(() => ModifierProfileSchema.parse(rest)).toThrow(z.ZodError);
});
it("rejects profile with source='admin'", () => {
expect(() =>
ModifierProfileSchema.parse({ ...VALID_PROFILE, source: "admin" })
).toThrow(z.ZodError);
});
it("rejects profile with empty id string", () => {
expect(() =>
ModifierProfileSchema.parse({ ...VALID_PROFILE, id: "" })
).toThrow(z.ZodError);
});
it("rejects profile with invalid perInstance square", () => {
expect(() =>
ModifierProfileSchema.parse({
...VALID_PROFILE,
perInstance: [{ kind: "hp-bonus", square: "z9", value: 1 }],
})
).toThrow(z.ZodError);
});
});
// ---------------------------------------------------------------------------
// parseModifierProfile / serializeModifierProfile
// ---------------------------------------------------------------------------
describe("parseModifierProfile", () => {
it("returns a typed ModifierProfile for valid input", () => {
const result = parseModifierProfile(VALID_PROFILE);
expect(result.id).toBe("profile-001");
});
it("throws ZodError for invalid input", () => {
expect(() => parseModifierProfile({ invalid: true })).toThrow(z.ZodError);
});
});
// ---------------------------------------------------------------------------
// Roundtrip tests
// ---------------------------------------------------------------------------
describe("roundtrip", () => {
it("full profile roundtrips through JSON.parse(JSON.stringify(...))", () => {
const serialized = JSON.parse(JSON.stringify(VALID_PROFILE));
const result = parseModifierProfile(serialized);
expect(result).toEqual(VALID_PROFILE);
});
it("profile without layoutId roundtrips losslessly", () => {
const serialized = JSON.parse(JSON.stringify(VALID_PROFILE_NO_LAYOUT));
const result = parseModifierProfile(serialized);
expect(result).toEqual(VALID_PROFILE_NO_LAYOUT);
});
it("serializeModifierProfile output can be re-parsed", () => {
const serialized = serializeModifierProfile(VALID_PROFILE);
const reparsed = parseModifierProfile(JSON.parse(JSON.stringify(serialized)));
expect(reparsed).toEqual(VALID_PROFILE);
});
it("profile with complex value types roundtrips", () => {
const profile: ModifierProfile = {
id: "complex-001",
name: "Complex Profile",
description: "Has various value types",
perType: [
{ kind: "direction-additions", pieceType: "pawn", color: "white", value: ["forward", "diagonal-fl"] },
{ kind: "promotion-override", pieceType: "pawn", color: "black", value: "queen" },
],
perInstance: [
{ kind: "capture-flags", square: "e4", value: 3 },
],
version: 1,
source: "custom",
};
const result = parseModifierProfile(JSON.parse(JSON.stringify(profile)));
expect(result).toEqual(profile);
});
});

View file

@ -0,0 +1,90 @@
/**
* Zod schemas for ModifierProfile serialization and validation.
*
* The `value` field on TypeModifier and InstanceModifier is intentionally
* `z.unknown()` per-kind value validation happens at descriptor
* registration time, not at profile-parse time. This keeps the schema
* forwards-compatible with future modifier kinds.
*/
import { z } from "zod";
import type { ModifierProfile } from "./types.js";
// ---------------------------------------------------------------------------
// Primitives
// ---------------------------------------------------------------------------
const ModifierKindIdSchema = z.enum([
"hp-bonus",
"range-bonus",
"direction-additions",
"capture-flags",
"promotion-override",
"damage-resistance",
]);
const PieceTypeSchema = z.enum([
"pawn",
"knight",
"bishop",
"rook",
"queen",
"king",
]);
// PieceColor extended with "both" for TypeModifier.color
const PieceColorExtSchema = z.enum(["white", "black", "both"]);
/** Algebraic notation square: "a1".."h8" */
const SquareStringSchema = z
.string()
.regex(/^[a-h][1-8]$/, "Must be algebraic notation (a1-h8)");
// ---------------------------------------------------------------------------
// TypeModifier
// ---------------------------------------------------------------------------
export const TypeModifierSchema = z.object({
kind: ModifierKindIdSchema,
pieceType: PieceTypeSchema,
color: PieceColorExtSchema,
value: z.unknown(),
});
// ---------------------------------------------------------------------------
// InstanceModifier
// ---------------------------------------------------------------------------
export const InstanceModifierSchema = z.object({
kind: ModifierKindIdSchema,
square: SquareStringSchema,
value: z.unknown(),
});
// ---------------------------------------------------------------------------
// ModifierProfile
// ---------------------------------------------------------------------------
export const ModifierProfileSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string(),
layoutId: z.string().optional(),
perType: z.array(TypeModifierSchema),
perInstance: z.array(InstanceModifierSchema),
version: z.literal(1),
source: z.enum(["premade", "custom"]),
});
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Parse an unknown value as a ModifierProfile. Throws ZodError on failure. */
export function parseModifierProfile(raw: unknown): ModifierProfile {
return ModifierProfileSchema.parse(raw) as unknown as ModifierProfile;
}
/** Serialize a ModifierProfile to a JSON-safe plain object. */
export function serializeModifierProfile(profile: ModifierProfile): unknown {
return ModifierProfileSchema.parse(profile);
}