feat(engine): modifier registry pattern

This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:11:16 -06:00
commit 72ca8f4bd8
No known key found for this signature in database
3 changed files with 181 additions and 0 deletions

View file

@ -0,0 +1,28 @@
/**
* Barrel for the piece modifier profile system.
*
* Importing this module ensures every T1 modifier descriptor is
* registered in `MODIFIER_REGISTRY` via side-effect imports (ADR-8).
* Descriptor imports are added by T6-T11 as each modifier is
* implemented; until then the registry starts empty.
*/
// Re-export registry and types for consumers.
export { MODIFIER_REGISTRY } from "./registry.js";
export type {
ModifierKindId,
Direction,
TypeModifier,
InstanceModifier,
ModifierProfile,
ModifierDescriptor,
} from "./types.js";
export { typeModifier, instanceModifier } from "./types.js";
// Descriptor side-effect imports (added by T6-T11):
// import "./descriptors/hp-bonus.js";
// import "./descriptors/range-bonus.js";
// import "./descriptors/direction-additions.js";
// import "./descriptors/capture-flags.js";
// import "./descriptors/promotion-override.js";
// import "./descriptors/damage-resistance.js";

View file

@ -0,0 +1,87 @@
/**
* Unit tests for MODIFIER_REGISTRY.
*
* Deliberately does NOT import `./index.js` that would pull in every
* T1 descriptor via side-effects and pre-populate the registry, which
* would break the "starts empty" assertion. Once descriptors land
* (T6-T11), those tests live in their own files.
*
* IMPORTANT: these tests mutate the shared `MODIFIER_REGISTRY`
* singleton (there's no reset/clear API by design the real registry
* is populated once at module load time). To stay isolated we use
* fresh, unique ids inside the test and only assert properties of
* descriptors we own.
*/
import { describe, it, expect } from "vitest";
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "./registry.js";
import type { ModifierDescriptor, ModifierKindId } from "./types.js";
/**
* Build a minimal mock descriptor. We cast the id through string
* because the test descriptors use synthetic ids ("test-*") to avoid
* colliding with real ones that T6-T11 will register.
*/
function mockDescriptor(id: string): ModifierDescriptor {
return {
id: id as ModifierKindId,
attrName: "Hp",
label: `Mock ${id}`,
valueSchema: z.unknown(),
stackingRule: "additive",
apply: (_session: Session, _pieceId: EntityId, _value: unknown) => {
// no-op
},
describe: (value) => `mock=${String(value)}`,
uiForm: "number",
};
}
describe("MODIFIER_REGISTRY", () => {
it("starts empty (no T1 descriptors registered yet)", () => {
// At the time this test file is loaded, no descriptor side-effects
// have run (we don't import ./index.js), so the registry should be
// empty. If this ever fires, someone registered a descriptor at
// module scope without a corresponding import guard — investigate
// before relaxing.
expect(MODIFIER_REGISTRY.list()).toEqual([]);
});
it("register(descriptor) makes it retrievable via get(id)", () => {
const desc = mockDescriptor("test-get");
MODIFIER_REGISTRY.register(desc);
expect(MODIFIER_REGISTRY.get("test-get" as ModifierKindId)).toBe(desc);
});
it("has(id) returns true after registration, false before", () => {
const id = "test-has" as ModifierKindId;
expect(MODIFIER_REGISTRY.has(id)).toBe(false);
MODIFIER_REGISTRY.register(mockDescriptor("test-has"));
expect(MODIFIER_REGISTRY.has(id)).toBe(true);
});
it("list() returns every registered descriptor", () => {
const a = mockDescriptor("test-list-a");
const b = mockDescriptor("test-list-b");
MODIFIER_REGISTRY.register(a);
MODIFIER_REGISTRY.register(b);
const all = MODIFIER_REGISTRY.list();
expect(all).toContain(a);
expect(all).toContain(b);
});
it("register() with duplicate id throws an error mentioning the id", () => {
const id = "test-dup";
MODIFIER_REGISTRY.register(mockDescriptor(id));
expect(() => MODIFIER_REGISTRY.register(mockDescriptor(id))).toThrow(
/test-dup/,
);
});
it("get() returns undefined for unknown ids", () => {
expect(
MODIFIER_REGISTRY.get("test-never-registered" as ModifierKindId),
).toBeUndefined();
});
});

View file

@ -0,0 +1,66 @@
/**
* Modifier descriptor registry.
*
* Mirrors `LAYOUT_REGISTRY`: the six T1 modifier categories register
* themselves via side-effect imports from their own module files, and
* `./index.ts` is the barrel that imports every descriptor so that
* consumers get them all registered by importing anywhere from
* `./modifiers`.
*
* Duplicate-id registration throws this is the signal that two
* modules are trying to claim the same `ModifierKindId`, which would
* silently overwrite in a Map. Throwing surfaces the collision at load
* time rather than at runtime when a modifier would mysteriously
* "disappear" because a later import clobbered the earlier descriptor.
*
* Descriptors are read-only once registered; the registry exposes no
* mutation paths. The set of modifier kinds is fixed at module-load
* time (ADR-8) there's no runtime registration of new modifier
* categories from userland.
*/
import type { ModifierDescriptor, ModifierKindId } from "./types.js";
class ModifierRegistryClass {
readonly #byId = new Map<ModifierKindId, ModifierDescriptor>();
/**
* Register a descriptor under its `id`. Throws if the id is already
* taken defensive check because silently overwriting would create
* confusing debugging ("which of my two hp-bonus descriptors won?"
* races on module import order).
*/
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);
}
/**
* Look up a descriptor by id. Returns undefined for unknown ids
* the caller decides whether that's an error (validator rejecting a
* profile referencing an unknown modifier kind) or a benign miss.
*/
get(id: ModifierKindId): ModifierDescriptor | undefined {
return this.#byId.get(id);
}
/**
* All registered descriptors, in registration (= import) order. UI
* code iterating descriptors to render form rows relies on this
* being a stable order.
*/
list(): readonly ModifierDescriptor[] {
return [...this.#byId.values()];
}
/** True if a descriptor with the given id is registered. */
has(id: ModifierKindId): boolean {
return this.#byId.has(id);
}
}
export const MODIFIER_REGISTRY = new ModifierRegistryClass();