diff --git a/packages/chess/src/modifiers/descriptors/hp-bonus.test.ts b/packages/chess/src/modifiers/descriptors/hp-bonus.test.ts new file mode 100644 index 0000000..6f6079e --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/hp-bonus.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { Session } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import "./hp-bonus.js"; +import { HP_BONUS_DESCRIPTOR } from "./hp-bonus.js"; + +describe("hp-bonus descriptor — registry", () => { + it("registers in MODIFIER_REGISTRY under key 'hp-bonus'", () => { + expect(MODIFIER_REGISTRY.has("hp-bonus")).toBe(true); + expect(MODIFIER_REGISTRY.get("hp-bonus")).toBe(HP_BONUS_DESCRIPTOR); + }); +}); + +describe("hp-bonus descriptor — describe()", () => { + it("formats positive values with a leading '+'", () => { + expect(HP_BONUS_DESCRIPTOR.describe(3)).toBe("HP +3"); + }); + + it("formats negative values without an extra sign", () => { + expect(HP_BONUS_DESCRIPTOR.describe(-1)).toBe("HP -1"); + }); + + it("formats zero as '+'", () => { + expect(HP_BONUS_DESCRIPTOR.describe(0)).toBe("HP +0"); + }); +}); + +describe("hp-bonus descriptor — apply()", () => { + it("seeds HpBonus fact on piece entity in session", () => { + const session = new Session(); + const pieceId = session.nextId(); + // Seed a PieceType fact so it's a "real" piece entity. + session.insert(pieceId, "PieceType", "pawn"); + + HP_BONUS_DESCRIPTOR.apply(session, pieceId, 3); + + expect(session.contains(pieceId, "HpBonus")).toBe(true); + expect(session.get(pieceId, "HpBonus")).toBe(3); + }); +}); + +describe("hp-bonus descriptor — valueSchema", () => { + const schema = HP_BONUS_DESCRIPTOR.valueSchema; + + it("accepts values within range [-10, 10]", () => { + expect(() => schema.parse(10)).not.toThrow(); + expect(() => schema.parse(-10)).not.toThrow(); + expect(() => schema.parse(0)).not.toThrow(); + }); + + it("rejects values above 10", () => { + expect(() => schema.parse(11)).toThrow(); + }); + + it("rejects non-integer values", () => { + expect(() => schema.parse(1.5)).toThrow(); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/hp-bonus.ts b/packages/chess/src/modifiers/descriptors/hp-bonus.ts new file mode 100644 index 0000000..5ccffee --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/hp-bonus.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import type { Session, EntityId } from "@paratype/rete"; +import { MODIFIER_REGISTRY } from "../registry.js"; +import type { ModifierDescriptor } from "../types.js"; + +const schema = z.number().int().min(-10).max(10); +type Value = z.infer; + +const descriptor: ModifierDescriptor = { + id: "hp-bonus", + attrName: "HpBonus", + label: "HP Bonus", + valueSchema: schema, + stackingRule: "additive", + uiForm: "number", + apply(session: Session, pieceId: EntityId, effectiveValue: Value): void { + session.insert(pieceId, "HpBonus", effectiveValue); + }, + describe(value: Value): string { + return `HP ${value >= 0 ? "+" : ""}${value}`; + }, +}; + +MODIFIER_REGISTRY.register(descriptor); +export { descriptor as HP_BONUS_DESCRIPTOR }; diff --git a/packages/chess/src/modifiers/registry.ts b/packages/chess/src/modifiers/registry.ts index b7aeeb1..cc9571e 100644 --- a/packages/chess/src/modifiers/registry.ts +++ b/packages/chess/src/modifiers/registry.ts @@ -28,15 +28,25 @@ class ModifierRegistryClass { * taken — defensive check because silently overwriting would create * confusing debugging ("which of my two hp-bonus descriptors won?" * races on module import order). + * + * Generic over V so descriptors with concrete value types + * (e.g. `ModifierDescriptor`) can be passed without a + * type assertion at the call site. The registry stores them as + * `ModifierDescriptor` — a heterogeneous container that + * loses V intentionally; callers that need the typed value should + * import the descriptor directly from its own module. */ - register(descriptor: ModifierDescriptor): void { + register(descriptor: ModifierDescriptor): void { if (this.#byId.has(descriptor.id)) { throw new Error( `ModifierRegistry: duplicate modifier id "${descriptor.id}". ` + `Each modifier descriptor must have a unique id.`, ); } - this.#byId.set(descriptor.id, descriptor); + // Widen to the internal unknown-typed representation. The double-cast + // through `unknown` is intentional: the registry is a heterogeneous + // store and V is intentionally erased at storage time. + this.#byId.set(descriptor.id, descriptor as unknown as ModifierDescriptor); } /** diff --git a/packages/chess/src/modifiers/types.ts b/packages/chess/src/modifiers/types.ts index ffdeb0c..d668135 100644 --- a/packages/chess/src/modifiers/types.ts +++ b/packages/chess/src/modifiers/types.ts @@ -80,7 +80,7 @@ export interface ModifierProfile { readonly name: string; readonly description: string; /** Layout this profile's per-instance modifiers are bound to. Optional. */ - readonly layoutId?: string; + readonly layoutId?: string | undefined; readonly perType: readonly TypeModifier[]; readonly perInstance: readonly InstanceModifier[]; readonly version: 1;