feat(engine): modifier registry pattern
This commit is contained in:
parent
fab8a8115b
commit
72ca8f4bd8
3 changed files with 181 additions and 0 deletions
28
packages/chess/src/modifiers/index.ts
Normal file
28
packages/chess/src/modifiers/index.ts
Normal 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";
|
||||
87
packages/chess/src/modifiers/registry.test.ts
Normal file
87
packages/chess/src/modifiers/registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
66
packages/chess/src/modifiers/registry.ts
Normal file
66
packages/chess/src/modifiers/registry.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue