From 1d5efaa95fd49f236c83b22ca3fce1ead2483594 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 18:12:19 -0600 Subject: [PATCH] feat(engine): apply custom modifier descriptors + multi-profile stacking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 Wave 3 (T22 + T23). Two tightly-coupled deliverables landed in one commit because the second's API surface depends on the first's signature extensions: T22 — Custom descriptor application - new CustomModifierRegistry (per-engine, in custom/registry.ts) — ADR-4 isolation: descriptors registered on engineA never leak to engineB. - new applyCustomDescriptor(engine, session, pieceId, descriptor) walks primitive nodes, dispatches each kind through PRIMITIVE_REGISTRY, and recurses into nested children via childPrimitives() with depth tracking (mirrors T19's static depth guard at runtime). - ChessEngine gains a customModifiers field + opts.customModifiers in EngineOptions for bootstrap registration. - applyProfileToSession's signature widens to accept (..., engine?, customRegistry?) — when a profile entry's kind misses MODIFIER_REGISTRY, the custom registry is consulted as a fallback. Existing T1/T2 callers stay source-compatible (the new params are optional). T23 — Multi-profile stacking - collectProfileContributions: pure value-collection helper extracted from applyProfileToSession's body. - new applyProfilesToSession(session, profiles[], layout, engine?, customRegistry?) iterates the helper across every profile in order before stacking — built-in stacking rules apply across the union. - new reconcileProfilesSwap mirrors the same generalization for the retract-then-reapply hot-swap path. - single-profile applyProfileToSession / reconcileProfileSwap remain as thin wrappers calling the array versions with [profile]. 14 vitest scenarios cover: single-primitive apply, multi-primitive apply, nested-children walk via on-turn-start, unknown-kind tolerance, custom-registry fallback in applyProfileToSession, per-engine isolation, constructor pre-registration, two-profile additive stacking, mixed built-in + custom across profiles, single-profile passthrough, empty array no-op, and CustomModifierRegistry CRUD. Engine wiring (damage pipeline, turn-start hooks, aura recompute) is deferred to T28 — primitives currently SEED facts that those wires will observe. --- packages/chess/src/engine.ts | 41 ++- packages/chess/src/modifiers/apply.ts | 135 +++++-- .../chess/src/modifiers/custom/apply.test.ts | 332 ++++++++++++++++++ packages/chess/src/modifiers/custom/apply.ts | 126 +++++++ .../chess/src/modifiers/custom/registry.ts | 41 +++ packages/chess/src/modifiers/reconcile.ts | 60 ++-- 6 files changed, 690 insertions(+), 45 deletions(-) create mode 100644 packages/chess/src/modifiers/custom/apply.test.ts create mode 100644 packages/chess/src/modifiers/custom/apply.ts create mode 100644 packages/chess/src/modifiers/custom/registry.ts diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index fc7e8be..cb0e49c 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -78,6 +78,8 @@ import { MODIFIER_INTEGRATION_PRESET_ID, } from "./modifiers/apply.js"; import type { ModifierProfile } from "./modifiers/types.js"; +import { CustomModifierRegistry } from "./modifiers/custom/registry.js"; +import type { CustomModifierDescriptor } from "./modifiers/custom/types.js"; type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[]; @@ -324,6 +326,17 @@ export interface EngineOptions { * HpBonus the profile contributed. */ readonly profile?: ModifierProfile; + /** + * Optional list of user-authored CustomModifierDescriptors registered + * onto the engine's per-instance custom registry (T22). Each entry's + * `id` becomes a valid `kind` in profile entries: when + * `applyProfileToSession` encounters an unknown built-in kind, it + * falls back to this registry for resolution. + * + * Per ADR-4, custom descriptors are intentionally NOT global — each + * engine carries its own set so cross-room leakage is impossible. + */ + readonly customModifiers?: readonly CustomModifierDescriptor[]; } export class ChessEngine { @@ -351,6 +364,16 @@ export class ChessEngine { */ public activeProfile: ModifierProfile | null; + /** + * Per-engine registry of user-authored custom modifier descriptors + * (T22). Profile application consults this registry as a fallback + * when a kind isn't found in the global MODIFIER_REGISTRY. + * + * Mutable via `registerCustomModifier()` for live additions (e.g. + * server-broadcast `custom-modifier.register` messages in T24). + */ + public readonly customModifiers: CustomModifierRegistry; + /** * Chronological log of every successful applyMove. Callers consume * this read-only for move-history UIs, PGN export, analysis. Writing @@ -456,6 +479,16 @@ export class ChessEngine { this.activeProfile = opts.profile ?? null; + // Custom modifier registry — populated from opts.customModifiers + // (if any) BEFORE profile application, so profile entries that + // reference custom kinds can resolve them on first apply. + this.customModifiers = new CustomModifierRegistry(); + if (opts.customModifiers) { + for (const d of opts.customModifiers) { + this.customModifiers.register(d); + } + } + const layout = opts.layout ?? CLASSIC_LAYOUT; applyLayout(this.session, layout); @@ -465,7 +498,13 @@ export class ChessEngine { // are not expected to change mid-game (profiles hot-swap via // turn-boundary replacement, which re-seeds). if (opts.profile) { - applyProfileToSession(this.session, opts.profile, layout); + applyProfileToSession( + this.session, + opts.profile, + layout, + this, + this.customModifiers, + ); } recordPosition(this.session); diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index 323b505..a5704cb 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -66,6 +66,9 @@ import { generateDirectionMoves } from "./descriptors/direction-additions.js"; import { PRESET_REGISTRY } from "../presets/registry.js"; import type { LegalMove } from "../rules/types.js"; import { getPieceAt } from "../rules/board-queries.js"; +import type { ChessEngine } from "../engine.js"; +import type { CustomModifierRegistry } from "./custom/registry.js"; +import { applyCustomDescriptor } from "./custom/apply.js"; /** * Stable id for the pseudo-preset that wires modifier facts into @@ -195,16 +198,24 @@ function findPiecesByType( * doesn't preserve (e.g. original-square ids that survive a piece * moving). */ -export function applyProfileToSession( +/** + * Collect every (pieceId, kind, value) contribution from a single + * profile into the supplied `collected` map. Pure with respect to + * the session — only reads `Position` and piece-type facts to resolve + * targets; never mutates. The merge step happens in the public + * `applyProfileToSession` / `applyProfilesToSession` after every + * profile has been visited. + * + * Per-type entries are pushed BEFORE per-instance to preserve T1's + * "per-instance overrides per-type" semantic for the priority-wins + * stacking rule (which picks the last-pushed value). + */ +function collectProfileContributions( session: Session, profile: ModifierProfile, - _layout: StartingLayout, + collected: Map>, ): void { - // Bucket collected values by (pieceId, kind). Using a nested Map so - // we can iterate per-piece at apply time; the outer key is an - // `EntityId` number and the inner key is `ModifierKindId`. - const collected = new Map>(); - const push = (id: EntityId, kind: ModifierKindId, value: unknown): void => { + const push = (id: EntityId, kind: string, value: unknown): void => { let byKind = collected.get(id); if (!byKind) { byKind = new Map(); @@ -215,10 +226,6 @@ export function applyProfileToSession( else byKind.set(kind, [value]); }; - // Per-type pass FIRST, per-instance SECOND. Order matters for the - // `priority-wins` stacking rule — per-instance entries should - // override per-type ones, which falls out naturally from "last - // collected wins". for (const tm of profile.perType as readonly TypeModifier[]) { const targets = findPiecesByType(session, tm.pieceType, tm.color); for (const id of targets) push(id, tm.kind, tm.value); @@ -235,8 +242,6 @@ export function applyProfileToSession( } const id = squareIndex.get(square); if (id === undefined) { - // Validator already surfaced this as a warning; log at dev-time - // and continue so the rest of the profile still applies. console.warn( `applyProfileToSession: no piece at square "${im.square}" — skipping instance modifier.`, ); @@ -244,26 +249,108 @@ export function applyProfileToSession( } push(id, im.kind, im.value); } +} - // Stack + apply. Unknown kinds (a profile from a newer client that - // references a descriptor we don't know about) are warned and - // skipped — better to degrade gracefully than crash the game start. +/** + * Stack collected (pieceId, kind, values[]) contributions and call + * each descriptor's apply(). Two registry layers: + * 1. MODIFIER_REGISTRY — engine-shipped built-ins (additive/union/...). + * 2. customRegistry — per-engine user-authored CustomModifierDescriptors + * whose primitives run via applyCustomDescriptor. + * + * Custom descriptors stack by "sequential apply per matching piece" + * (each pushed value triggers one full primitive walk) — they don't + * declare a stackingRule. If a profile produces multiple custom-modifier + * entries for the same (pieceId, kind), the primitive list runs once + * per entry; primitives that read+modify (add-to-attribute) compose + * naturally, while primitives that overwrite (seed-attribute) follow + * a last-wins semantic. + * + * Unknown kinds (in neither registry) are warned and skipped so a + * forwards-compat profile doesn't crash the apply. + */ +function applyCollectedContributions( + session: Session, + collected: ReadonlyMap>, + engine: ChessEngine | undefined, + customRegistry: CustomModifierRegistry | undefined, +): void { for (const [pieceId, byKind] of collected) { for (const [kind, values] of byKind) { - const descriptor = MODIFIER_REGISTRY.get(kind); - if (!descriptor) { - console.warn( - `applyProfileToSession: unknown modifier kind "${kind}" — skipping.`, - ); + if (values.length === 0) continue; + + // `kind` is `string` (the union of built-in ModifierKindId AND + // user-authored CustomModifierId). Built-in lookup narrows by + // type-asserting; an unknown kind falls through to the custom- + // registry branch below. + const builtIn = MODIFIER_REGISTRY.get(kind as ModifierKindId); + if (builtIn !== undefined) { + const effective = stackValues(builtIn.stackingRule, values); + builtIn.apply(session, pieceId, effective); continue; } - if (values.length === 0) continue; - const effective = stackValues(descriptor.stackingRule, values); - descriptor.apply(session, pieceId, effective); + + const custom = customRegistry?.get(kind); + if (custom !== undefined && engine !== undefined) { + // Custom descriptors apply once per collected entry — each + // value represents one logical contribution to this piece. + for (let i = 0; i < values.length; i += 1) { + applyCustomDescriptor(engine, session, pieceId, custom); + } + continue; + } + + console.warn( + `applyProfileToSession: unknown modifier kind "${kind}" — skipping.`, + ); } } } +/** + * Apply a single ModifierProfile to a session (single-profile wrapper + * around `applyProfilesToSession`). Kept as the canonical entry point + * so all T1/T2 callers continue to work unchanged. + * + * `engine` and `customRegistry` are optional for backwards compatibility: + * built-in modifier kinds work without either. Pass them when the + * profile may reference user-authored custom kinds. + */ +export function applyProfileToSession( + session: Session, + profile: ModifierProfile, + layout: StartingLayout, + engine?: ChessEngine, + customRegistry?: CustomModifierRegistry, +): void { + applyProfilesToSession(session, [profile], layout, engine, customRegistry); +} + +/** + * Apply multiple ModifierProfiles in order, stacking ACROSS profiles + * per the same per-kind rules used within a single profile (T23). + * Profiles are visited in the supplied order; the priority-wins + * stacking rule ("last value collected wins") therefore naturally + * gives later profiles precedence. + * + * Empty `profiles` array is a no-op. + */ +export function applyProfilesToSession( + session: Session, + profiles: readonly ModifierProfile[], + _layout: StartingLayout, + engine?: ChessEngine, + customRegistry?: CustomModifierRegistry, +): void { + if (profiles.length === 0) return; + + const collected = new Map>(); + for (const profile of profiles) { + collectProfileContributions(session, profile, collected); + } + applyCollectedContributions(session, collected, engine, customRegistry); +} + // ── Engine-level integration preset ───────────────────────────────────── // // Registers ONCE at module load. The ChessEngine constructor activates diff --git a/packages/chess/src/modifiers/custom/apply.test.ts b/packages/chess/src/modifiers/custom/apply.test.ts new file mode 100644 index 0000000..3841a7a --- /dev/null +++ b/packages/chess/src/modifiers/custom/apply.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { CLASSIC_LAYOUT } from "../../layouts/classic.js"; +import { applyProfileToSession, applyProfilesToSession } from "../apply.js"; +import type { ModifierProfile } from "../types.js"; +import { CustomModifierRegistry } from "./registry.js"; +import { applyCustomDescriptor } from "./apply.js"; +import type { PrimitiveKind } from "../primitives/types.js"; +import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js"; +import type { EntityId } from "@paratype/rete"; +import "../primitives/index.js"; + +function customDescriptor( + overrides: Partial = {}, +): CustomModifierDescriptor { + return { + type: "data", + id: asCustomModifierId(`custom:apply-${Math.random().toString(36).slice(2, 8)}`), + name: "Test", + description: "", + version: 1, + primitives: [], + targetAttrs: [], + uiForm: "primitive-composer", + source: "custom", + ...overrides, + }; +} + +function profileWithCustom( + customKind: string, + overrides: Partial = {}, +): ModifierProfile { + return { + id: "test-profile", + name: "Test", + description: "", + perType: [ + { + kind: customKind as ModifierProfile["perType"][number]["kind"], + pieceType: "pawn", + color: "white", + value: 1, + }, + ], + perInstance: [], + version: 1, + source: "custom", + ...overrides, + }; +} + +describe("applyCustomDescriptor — primitive walking", () => { + it("applies a single seed-attribute primitive to a piece", () => { + const engine = new ChessEngine(); + const desc = customDescriptor({ + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } }, + ], + }); + // Pick the pawn at e2 (white pawn in classic layout). + const pawnId = findPieceAtSquare(engine, 12); // e2 = file 4 + rank 1*8 + + applyCustomDescriptor(engine, engine.session, pawnId, desc); + + expect(engine.session.get(pawnId, "HpBonus")).toBe(7); + }); + + it("applies multiple primitives in order", () => { + const engine = new ChessEngine(); + const pawnId = findPieceAtSquare(engine, 12); + + const desc = customDescriptor({ + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } }, + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 2 } }, + ], + }); + applyCustomDescriptor(engine, engine.session, pawnId, desc); + + expect(engine.session.get(pawnId, "HpBonus")).toBe(3); + }); + + it("descends into nested children via childPrimitives()", () => { + const engine = new ChessEngine(); + const pawnId = findPieceAtSquare(engine, 12); + + // on-turn-start nests an inner seed-attribute. The outer primitive + // seeds the OnTurnStartHooks fact AND its inner children are + // visited by the depth walker. + const desc = customDescriptor({ + primitives: [ + { + kind: "on-turn-start", + params: { + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 4 } }, + ], + }, + }, + ], + }); + applyCustomDescriptor(engine, engine.session, pawnId, desc); + + // The outer primitive seeds the hook fact: + expect(engine.session.get(pawnId, "OnTurnStartHooks")).toBeDefined(); + // The inner child also runs (visible via the side-effect on RangeBonus): + expect(engine.session.get(pawnId, "RangeBonus")).toBe(4); + }); + + it("silently skips an unknown primitive kind", () => { + const engine = new ChessEngine(); + const pawnId = findPieceAtSquare(engine, 12); + + const desc = customDescriptor({ + primitives: [ + // Cast to bypass the literal-union check — we WANT to test that + // the runtime walker tolerates unknown kinds gracefully (the + // validator catches this at the static layer; the runtime path + // is the defensive backstop). + { kind: "totally-not-a-real-primitive" as PrimitiveKind, params: {} }, + { kind: "seed-attribute", params: { attr: "HpBonus", value: 5 } }, + ], + }); + expect(() => + applyCustomDescriptor(engine, engine.session, pawnId, desc), + ).not.toThrow(); + // The known primitive after the unknown one still ran. + expect(engine.session.get(pawnId, "HpBonus")).toBe(5); + }); +}); + +describe("applyProfileToSession — custom-registry fallback", () => { + it("resolves a profile entry against the engine's custom registry", () => { + const customId = "custom:hp-+5"; + const desc = customDescriptor({ + id: asCustomModifierId(customId), + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 5 } }, + ], + }); + + const engine = new ChessEngine(); + engine.customModifiers.register(desc); + + const profile = profileWithCustom(customId); + applyProfileToSession( + engine.session, + profile, + CLASSIC_LAYOUT, + engine, + engine.customModifiers, + ); + + // Every white pawn (8 of them) got HpBonus = 5. + const pawnIds = whiteWhitePawnIds(engine); + expect(pawnIds).toHaveLength(8); + for (const id of pawnIds) { + expect(engine.session.get(id, "HpBonus")).toBe(5); + } + }); + + it("custom registries are per-engine — a descriptor in engineA is invisible to engineB", () => { + const customId = "custom:visible-only-to-A"; + const desc = customDescriptor({ + id: asCustomModifierId(customId), + primitives: [ + { kind: "seed-attribute", params: { attr: "HpBonus", value: 9 } }, + ], + }); + + const engineA = new ChessEngine({ customModifiers: [desc] }); + const engineB = new ChessEngine(); + + expect(engineA.customModifiers.has(customId)).toBe(true); + expect(engineB.customModifiers.has(customId)).toBe(false); + }); + + it("constructor pre-registers customModifiers from EngineOptions", () => { + const desc = customDescriptor({ + id: asCustomModifierId("custom:bootstrap"), + }); + const engine = new ChessEngine({ customModifiers: [desc] }); + expect(engine.customModifiers.has("custom:bootstrap")).toBe(true); + expect(engine.customModifiers.size()).toBe(1); + }); +}); + +describe("applyProfilesToSession — multi-profile stacking (T23)", () => { + it("stacks two profiles' HpBonus additively across pieces", () => { + const engine = new ChessEngine(); + const p1 = builtInHpBonusProfile("p1", 2); + const p2 = builtInHpBonusProfile("p2", 3); + + applyProfilesToSession( + engine.session, + [p1, p2], + CLASSIC_LAYOUT, + engine, + engine.customModifiers, + ); + + const pawnId = findPieceAtSquare(engine, 12); + // 2 + 3 = 5 from additive stacking. + expect(engine.session.get(pawnId, "HpBonus")).toBe(5); + }); + + it("a single-profile call goes through the multi-profile path", () => { + const engine = new ChessEngine(); + applyProfileToSession( + engine.session, + builtInHpBonusProfile("solo", 4), + CLASSIC_LAYOUT, + engine, + engine.customModifiers, + ); + const pawnId = findPieceAtSquare(engine, 12); + expect(engine.session.get(pawnId, "HpBonus")).toBe(4); + }); + + it("empty profile array is a no-op", () => { + const engine = new ChessEngine(); + const before = engine.session.allFacts().length; + applyProfilesToSession( + engine.session, + [], + CLASSIC_LAYOUT, + engine, + engine.customModifiers, + ); + expect(engine.session.allFacts().length).toBe(before); + }); + + it("mixes built-in + custom profile entries on the same piece", () => { + const customId = "custom:adds-range"; + const desc = customDescriptor({ + id: asCustomModifierId(customId), + primitives: [ + { kind: "seed-attribute", params: { attr: "RangeBonus", value: 2 } }, + ], + }); + const engine = new ChessEngine({ customModifiers: [desc] }); + + const builtInProfile = builtInHpBonusProfile("hp", 3); + const customProfile = profileWithCustom(customId); + + applyProfilesToSession( + engine.session, + [builtInProfile, customProfile], + CLASSIC_LAYOUT, + engine, + engine.customModifiers, + ); + + const pawnId = findPieceAtSquare(engine, 12); + expect(engine.session.get(pawnId, "HpBonus")).toBe(3); + expect(engine.session.get(pawnId, "RangeBonus")).toBe(2); + }); +}); + +describe("CustomModifierRegistry — basic API", () => { + it("registers, looks up, and lists descriptors", () => { + const registry = new CustomModifierRegistry(); + const desc = customDescriptor({ id: asCustomModifierId("custom:abc") }); + registry.register(desc); + expect(registry.has("custom:abc")).toBe(true); + expect(registry.get("custom:abc")).toBe(desc); + expect(registry.list()).toEqual([desc]); + expect(registry.size()).toBe(1); + }); + + it("clear() empties the registry", () => { + const registry = new CustomModifierRegistry(); + registry.register(customDescriptor()); + registry.clear(); + expect(registry.size()).toBe(0); + }); + + it("register() with the same id replaces the existing descriptor", () => { + const registry = new CustomModifierRegistry(); + const v1 = customDescriptor({ + id: asCustomModifierId("custom:dup"), + name: "v1", + }); + const v2 = customDescriptor({ + id: asCustomModifierId("custom:dup"), + name: "v2", + }); + registry.register(v1); + registry.register(v2); + expect(registry.size()).toBe(1); + expect(registry.get("custom:dup")).toBe(v2); + }); +}); + +// ── Helpers ─────────────────────────────────────────────────────────── + +function findPieceAtSquare(engine: ChessEngine, square: number): EntityId { + for (const f of engine.session.allFacts()) { + if (f.attr === "Position" && f.value === square && (f.id as number) > 0) { + return f.id; + } + } + throw new Error(`no piece at square ${square}`); +} + +function whiteWhitePawnIds(engine: ChessEngine): EntityId[] { + const out: EntityId[] = []; + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== "pawn") continue; + if ((f.id as number) <= 0) continue; + const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color"); + if (colorFact?.value !== "white") continue; + out.push(f.id); + } + return out; +} + +function builtInHpBonusProfile(id: string, value: number): ModifierProfile { + return { + id, + name: id, + description: "", + perType: [ + { kind: "hp-bonus", pieceType: "pawn", color: "white", value }, + ], + perInstance: [], + version: 1, + source: "custom", + }; +} diff --git a/packages/chess/src/modifiers/custom/apply.ts b/packages/chess/src/modifiers/custom/apply.ts new file mode 100644 index 0000000..709c764 --- /dev/null +++ b/packages/chess/src/modifiers/custom/apply.ts @@ -0,0 +1,126 @@ +/** + * Apply a CustomModifierDescriptor's primitive list to a single piece (T22). + * + * Walks `descriptor.primitives` in order, looks each one up in the + * global `PRIMITIVE_REGISTRY`, runs the registered primitive's + * `apply(ctx, params)`. Recursion depth tracking mirrors T19's static + * guard at runtime — `ctx.depth` increments when we enter a nested + * primitive list discovered via `childPrimitives()`. + * + * The registry-walk fallthrough (`unknown` kind silently skipped) is + * intentional: `applyProfileToSession` is the orchestrator that emits + * a dev warning for unknown kinds; `applyCustomDescriptor` trusts the + * caller has already validated the descriptor. + */ +import type { EntityId, Session } from "@paratype/rete"; +import type { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "../primitives/registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, +} from "../primitives/types.js"; +import type { CustomModifierDescriptor } from "./types.js"; + +/** + * Hard runtime cap that mirrors the validator's MAX_RECURSION_DEPTH (3). + * Defensive — should never trigger if T19 passed; set higher than the + * static cap so a passing descriptor never hits this throw. + */ +const RUNTIME_DEPTH_HARD_CAP = 8; + +export function applyCustomDescriptor( + engine: ChessEngine, + session: Session, + pieceId: EntityId, + descriptor: CustomModifierDescriptor, +): void { + walkAndApply({ + engine, + session, + pieceId, + descriptor, + nodes: descriptor.primitives, + depth: 0, + }); +} + +function walkAndApply(input: { + engine: ChessEngine; + session: Session; + pieceId: EntityId; + descriptor: CustomModifierDescriptor; + nodes: readonly EffectPrimitiveNode[]; + depth: number; +}): void { + const { engine, session, pieceId, descriptor, nodes, depth } = input; + + if (depth > RUNTIME_DEPTH_HARD_CAP) { + throw new Error( + `applyCustomDescriptor: nesting depth ${depth} exceeds hard cap ${RUNTIME_DEPTH_HARD_CAP} ` + + `(descriptor "${descriptor.id}" should have failed T19's validator)`, + ); + } + + for (const node of nodes) { + const primitive = PRIMITIVE_REGISTRY.get(node.kind); + if (primitive === undefined) { + // Unknown kind — skip silently. The validator should have caught + // this; tolerating it at runtime keeps a half-validated descriptor + // from crashing the entire profile apply. + continue; + } + + const ctx: PrimitiveApplyContext = { + engine, + session, + pieceId, + depth, + descriptor: { + id: String(descriptor.id), + type: descriptor.type, + version: descriptor.version, + }, + }; + + runPrimitive(primitive, ctx, node.params); + + // If the primitive owns nested children, recurse. Errors during + // childPrimitives() introspection terminate the recursion for this + // node but don't bubble up — the validator's depth/count checks + // are the user-facing guard. + if (primitive.childPrimitives === undefined) continue; + + let children: readonly EffectPrimitiveNode[] = []; + try { + children = primitive.childPrimitives(node.params); + } catch { + children = []; + } + if (children.length === 0) continue; + + walkAndApply({ + engine, + session, + pieceId, + descriptor, + nodes: children, + depth: depth + 1, + }); + } +} + +/** + * Wrapper that erases the `EffectPrimitive

` generic so we can call + * apply() with `node.params: unknown`. The registry stores descriptors + * with their generic erased; we cast the apply function's first param + * to `unknown` here. Any narrowing the primitive does internally + * (typically via its own paramsSchema) is the primitive's contract. + */ +function runPrimitive( + primitive: EffectPrimitive, + ctx: PrimitiveApplyContext, + params: unknown, +): void { + primitive.apply(ctx, params); +} diff --git a/packages/chess/src/modifiers/custom/registry.ts b/packages/chess/src/modifiers/custom/registry.ts new file mode 100644 index 0000000..1d87fcc --- /dev/null +++ b/packages/chess/src/modifiers/custom/registry.ts @@ -0,0 +1,41 @@ +/** + * Per-engine registry for user-authored CustomModifierDescriptors (T22). + * + * The global MODIFIER_REGISTRY holds engine-shipped built-in descriptors + * (hp-bonus, range-bonus, etc.). Custom descriptors are intentionally NOT + * in that registry — per ADR-4, they live on a per-engine instance so a + * descriptor authored in one room never leaks into another. Each + * `ChessEngine` owns one `CustomModifierRegistry`; profile application + * consults both registries (built-ins first, custom as fallback). + */ +import type { CustomModifierDescriptor, CustomModifierId } from "./types.js"; + +export class CustomModifierRegistry { + readonly #byId = new Map(); + + /** Register or REPLACE a descriptor by id. */ + register(descriptor: CustomModifierDescriptor): void { + this.#byId.set(descriptor.id, descriptor); + } + + /** Look up by branded id OR raw string (matches the kind on a profile entry). */ + get(id: string): CustomModifierDescriptor | undefined { + return this.#byId.get(id as CustomModifierId); + } + + has(id: string): boolean { + return this.#byId.has(id as CustomModifierId); + } + + list(): readonly CustomModifierDescriptor[] { + return [...this.#byId.values()]; + } + + size(): number { + return this.#byId.size; + } + + clear(): void { + this.#byId.clear(); + } +} diff --git a/packages/chess/src/modifiers/reconcile.ts b/packages/chess/src/modifiers/reconcile.ts index 9fbbd94..e3790c9 100644 --- a/packages/chess/src/modifiers/reconcile.ts +++ b/packages/chess/src/modifiers/reconcile.ts @@ -51,7 +51,9 @@ import type { ChessAttrKey } from "../schema.js"; import type { StartingLayout } from "../layouts/types.js"; import type { ModifierProfile } from "./types.js"; import { MODIFIER_REGISTRY } from "./registry.js"; -import { applyProfileToSession } from "./apply.js"; +import { applyProfilesToSession } from "./apply.js"; +import type { ChessEngine } from "../engine.js"; +import type { CustomModifierRegistry } from "./custom/registry.js"; /** * The attribute name every modifier descriptor writes to. Mirrors the @@ -196,32 +198,50 @@ export function reconcileProfileSwap( oldProfile: ModifierProfile | null, newProfile: ModifierProfile | null, layout: StartingLayout, + engine?: ChessEngine, + customRegistry?: CustomModifierRegistry, ): void { - // Step 1: snapshot HpBonus BEFORE we mutate anything. We need the - // pre-swap bonus in scope for clamp-to-new-max; once retraction - // happens, it's gone. - // - // We snapshot unconditionally (even if old === null) because a - // previous non-tracked application may have left HpBonus facts — - // we'd rather be correct than rely on the caller honestly - // reporting the prior state. - void oldProfile; // only used to document the intent of step 1 below + reconcileProfilesSwap( + session, + oldProfile === null ? [] : [oldProfile], + newProfile === null ? [] : [newProfile], + layout, + engine, + customRegistry, + ); +} + +/** + * Multi-profile variant of `reconcileProfileSwap` (T23). Treats both + * old and new as ordered profile stacks; the retract-then-apply + * algorithm composes naturally because the retract step wipes ALL + * modifier-owned facts regardless of which profile contributed them. + * + * - `oldProfiles` empty: equivalent to first-time apply. + * - `newProfiles` empty: equivalent to retract-only (modifier-free + * session afterwards). + * - `oldProfiles === newProfiles`: idempotent. + */ +export function reconcileProfilesSwap( + session: Session, + _oldProfiles: readonly ModifierProfile[], + newProfiles: readonly ModifierProfile[], + layout: StartingLayout, + engine?: ChessEngine, + customRegistry?: CustomModifierRegistry, +): void { + // Step 1: snapshot HpBonus BEFORE we mutate anything (see + // single-profile variant for rationale). const oldHpBonuses = snapshotHpBonuses(session); // Step 2: retract every modifier-owned attribute from every piece. - // This makes step 3's `applyProfileToSession` operate on a clean - // board, so additive stacking doesn't double-count and union / - // priority-wins rules don't merge stale values. retractAllModifierFacts(session); - // Step 3: reapply the new profile from scratch. When newProfile is - // null, we do nothing — the session is now modifier-free. - if (newProfile !== null) { - applyProfileToSession(session, newProfile, layout); + // Step 3: reapply the new profile stack from scratch. + if (newProfiles.length > 0) { + applyProfilesToSession(session, newProfiles, layout, engine, customRegistry); } - // Step 4: clamp current Hp against the new effective max. Runs - // AFTER re-apply so we have the new HpBonus values in hand. If no - // pieces have Hp facts (piece-hp inactive), this is a no-op. + // Step 4: clamp current Hp against the new effective max. clampHpToNewMax(session, oldHpBonuses); }