feat(engine): ModifierProfile Zod schema + roundtrip tests
This commit is contained in:
parent
72ca8f4bd8
commit
e58bb02605
3 changed files with 412 additions and 0 deletions
39
packages/chess/src/modifiers/descriptors/capture-flags.ts
Normal file
39
packages/chess/src/modifiers/descriptors/capture-flags.ts
Normal 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;
|
||||
}
|
||||
283
packages/chess/src/modifiers/schema.test.ts
Normal file
283
packages/chess/src/modifiers/schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
packages/chess/src/modifiers/schema.ts
Normal file
90
packages/chess/src/modifiers/schema.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue