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