diff --git a/packages/chess/src/modifiers/descriptors/capture-flags.ts b/packages/chess/src/modifiers/descriptors/capture-flags.ts new file mode 100644 index 0000000..1449306 --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/capture-flags.ts @@ -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; + +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 = { + 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; +} diff --git a/packages/chess/src/modifiers/schema.test.ts b/packages/chess/src/modifiers/schema.test.ts new file mode 100644 index 0000000..43d94ea --- /dev/null +++ b/packages/chess/src/modifiers/schema.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/modifiers/schema.ts b/packages/chess/src/modifiers/schema.ts new file mode 100644 index 0000000..5ace534 --- /dev/null +++ b/packages/chess/src/modifiers/schema.ts @@ -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); +}