diff --git a/packages/chess/src/modifiers/index.ts b/packages/chess/src/modifiers/index.ts new file mode 100644 index 0000000..013a863 --- /dev/null +++ b/packages/chess/src/modifiers/index.ts @@ -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"; diff --git a/packages/chess/src/modifiers/registry.test.ts b/packages/chess/src/modifiers/registry.test.ts new file mode 100644 index 0000000..eaeff98 --- /dev/null +++ b/packages/chess/src/modifiers/registry.test.ts @@ -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(); + }); +}); diff --git a/packages/chess/src/modifiers/registry.ts b/packages/chess/src/modifiers/registry.ts new file mode 100644 index 0000000..b7aeeb1 --- /dev/null +++ b/packages/chess/src/modifiers/registry.ts @@ -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(); + + /** + * 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();