feat(engine): apply custom modifier descriptors + multi-profile stacking

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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 18:12:19 -06:00
commit 1d5efaa95f
No known key found for this signature in database
6 changed files with 690 additions and 45 deletions

View file

@ -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);

View file

@ -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<EntityId, Map<string, unknown[]>>,
): 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<EntityId, Map<ModifierKindId, unknown[]>>();
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<EntityId, ReadonlyMap<string, readonly unknown[]>>,
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<EntityId, Map<string, unknown[]>>();
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

View file

@ -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> = {},
): 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> = {},
): 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",
};
}

View file

@ -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<P>` 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);
}

View file

@ -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<CustomModifierId, CustomModifierDescriptor>();
/** 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();
}
}

View file

@ -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);
}