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:
parent
795207e8b4
commit
1d5efaa95f
6 changed files with 690 additions and 45 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
332
packages/chess/src/modifiers/custom/apply.test.ts
Normal file
332
packages/chess/src/modifiers/custom/apply.test.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
126
packages/chess/src/modifiers/custom/apply.ts
Normal file
126
packages/chess/src/modifiers/custom/apply.ts
Normal 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);
|
||||
}
|
||||
41
packages/chess/src/modifiers/custom/registry.ts
Normal file
41
packages/chess/src/modifiers/custom/registry.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue