feat(engine): hp-bonus modifier descriptor

This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:24:13 -06:00
commit 7d27620507
No known key found for this signature in database
4 changed files with 96 additions and 3 deletions

View file

@ -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();
});
});

View file

@ -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<typeof schema>;
const descriptor: ModifierDescriptor<Value> = {
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 };

View file

@ -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<number>`) can be passed without a
* type assertion at the call site. The registry stores them as
* `ModifierDescriptor<unknown>` — 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<V>(descriptor: ModifierDescriptor<V>): 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);
}
/**

View file

@ -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;