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,
|
MODIFIER_INTEGRATION_PRESET_ID,
|
||||||
} from "./modifiers/apply.js";
|
} from "./modifiers/apply.js";
|
||||||
import type { ModifierProfile } from "./modifiers/types.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[];
|
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||||
|
|
||||||
|
|
@ -324,6 +326,17 @@ export interface EngineOptions {
|
||||||
* HpBonus the profile contributed.
|
* HpBonus the profile contributed.
|
||||||
*/
|
*/
|
||||||
readonly profile?: ModifierProfile;
|
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 {
|
export class ChessEngine {
|
||||||
|
|
@ -351,6 +364,16 @@ export class ChessEngine {
|
||||||
*/
|
*/
|
||||||
public activeProfile: ModifierProfile | null;
|
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
|
* Chronological log of every successful applyMove. Callers consume
|
||||||
* this read-only for move-history UIs, PGN export, analysis. Writing
|
* this read-only for move-history UIs, PGN export, analysis. Writing
|
||||||
|
|
@ -456,6 +479,16 @@ export class ChessEngine {
|
||||||
|
|
||||||
this.activeProfile = opts.profile ?? null;
|
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;
|
const layout = opts.layout ?? CLASSIC_LAYOUT;
|
||||||
applyLayout(this.session, layout);
|
applyLayout(this.session, layout);
|
||||||
|
|
||||||
|
|
@ -465,7 +498,13 @@ export class ChessEngine {
|
||||||
// are not expected to change mid-game (profiles hot-swap via
|
// are not expected to change mid-game (profiles hot-swap via
|
||||||
// turn-boundary replacement, which re-seeds).
|
// turn-boundary replacement, which re-seeds).
|
||||||
if (opts.profile) {
|
if (opts.profile) {
|
||||||
applyProfileToSession(this.session, opts.profile, layout);
|
applyProfileToSession(
|
||||||
|
this.session,
|
||||||
|
opts.profile,
|
||||||
|
layout,
|
||||||
|
this,
|
||||||
|
this.customModifiers,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
recordPosition(this.session);
|
recordPosition(this.session);
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ import { generateDirectionMoves } from "./descriptors/direction-additions.js";
|
||||||
import { PRESET_REGISTRY } from "../presets/registry.js";
|
import { PRESET_REGISTRY } from "../presets/registry.js";
|
||||||
import type { LegalMove } from "../rules/types.js";
|
import type { LegalMove } from "../rules/types.js";
|
||||||
import { getPieceAt } from "../rules/board-queries.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
|
* 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
|
* doesn't preserve (e.g. original-square ids that survive a piece
|
||||||
* moving).
|
* 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,
|
session: Session,
|
||||||
profile: ModifierProfile,
|
profile: ModifierProfile,
|
||||||
_layout: StartingLayout,
|
collected: Map<EntityId, Map<string, unknown[]>>,
|
||||||
): void {
|
): void {
|
||||||
// Bucket collected values by (pieceId, kind). Using a nested Map so
|
const push = (id: EntityId, kind: string, value: unknown): void => {
|
||||||
// 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 => {
|
|
||||||
let byKind = collected.get(id);
|
let byKind = collected.get(id);
|
||||||
if (!byKind) {
|
if (!byKind) {
|
||||||
byKind = new Map();
|
byKind = new Map();
|
||||||
|
|
@ -215,10 +226,6 @@ export function applyProfileToSession(
|
||||||
else byKind.set(kind, [value]);
|
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[]) {
|
for (const tm of profile.perType as readonly TypeModifier[]) {
|
||||||
const targets = findPiecesByType(session, tm.pieceType, tm.color);
|
const targets = findPiecesByType(session, tm.pieceType, tm.color);
|
||||||
for (const id of targets) push(id, tm.kind, tm.value);
|
for (const id of targets) push(id, tm.kind, tm.value);
|
||||||
|
|
@ -235,8 +242,6 @@ export function applyProfileToSession(
|
||||||
}
|
}
|
||||||
const id = squareIndex.get(square);
|
const id = squareIndex.get(square);
|
||||||
if (id === undefined) {
|
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(
|
console.warn(
|
||||||
`applyProfileToSession: no piece at square "${im.square}" — skipping instance modifier.`,
|
`applyProfileToSession: no piece at square "${im.square}" — skipping instance modifier.`,
|
||||||
);
|
);
|
||||||
|
|
@ -244,26 +249,108 @@ export function applyProfileToSession(
|
||||||
}
|
}
|
||||||
push(id, im.kind, im.value);
|
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
|
* Stack collected (pieceId, kind, values[]) contributions and call
|
||||||
// skipped — better to degrade gracefully than crash the game start.
|
* 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 [pieceId, byKind] of collected) {
|
||||||
for (const [kind, values] of byKind) {
|
for (const [kind, values] of byKind) {
|
||||||
const descriptor = MODIFIER_REGISTRY.get(kind);
|
if (values.length === 0) continue;
|
||||||
if (!descriptor) {
|
|
||||||
|
// `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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
console.warn(
|
||||||
`applyProfileToSession: unknown modifier kind "${kind}" — skipping.`,
|
`applyProfileToSession: unknown modifier kind "${kind}" — skipping.`,
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (values.length === 0) continue;
|
|
||||||
const effective = stackValues(descriptor.stackingRule, values);
|
|
||||||
descriptor.apply(session, pieceId, effective);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ─────────────────────────────────────
|
// ── Engine-level integration preset ─────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Registers ONCE at module load. The ChessEngine constructor activates
|
// 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 { StartingLayout } from "../layouts/types.js";
|
||||||
import type { ModifierProfile } from "./types.js";
|
import type { ModifierProfile } from "./types.js";
|
||||||
import { MODIFIER_REGISTRY } from "./registry.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
|
* The attribute name every modifier descriptor writes to. Mirrors the
|
||||||
|
|
@ -196,32 +198,50 @@ export function reconcileProfileSwap(
|
||||||
oldProfile: ModifierProfile | null,
|
oldProfile: ModifierProfile | null,
|
||||||
newProfile: ModifierProfile | null,
|
newProfile: ModifierProfile | null,
|
||||||
layout: StartingLayout,
|
layout: StartingLayout,
|
||||||
|
engine?: ChessEngine,
|
||||||
|
customRegistry?: CustomModifierRegistry,
|
||||||
): void {
|
): void {
|
||||||
// Step 1: snapshot HpBonus BEFORE we mutate anything. We need the
|
reconcileProfilesSwap(
|
||||||
// pre-swap bonus in scope for clamp-to-new-max; once retraction
|
session,
|
||||||
// happens, it's gone.
|
oldProfile === null ? [] : [oldProfile],
|
||||||
//
|
newProfile === null ? [] : [newProfile],
|
||||||
// We snapshot unconditionally (even if old === null) because a
|
layout,
|
||||||
// previous non-tracked application may have left HpBonus facts —
|
engine,
|
||||||
// we'd rather be correct than rely on the caller honestly
|
customRegistry,
|
||||||
// reporting the prior state.
|
);
|
||||||
void oldProfile; // only used to document the intent of step 1 below
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
const oldHpBonuses = snapshotHpBonuses(session);
|
||||||
|
|
||||||
// Step 2: retract every modifier-owned attribute from every piece.
|
// 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);
|
retractAllModifierFacts(session);
|
||||||
|
|
||||||
// Step 3: reapply the new profile from scratch. When newProfile is
|
// Step 3: reapply the new profile stack from scratch.
|
||||||
// null, we do nothing — the session is now modifier-free.
|
if (newProfiles.length > 0) {
|
||||||
if (newProfile !== null) {
|
applyProfilesToSession(session, newProfiles, layout, engine, customRegistry);
|
||||||
applyProfileToSession(session, newProfile, layout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: clamp current Hp against the new effective max. Runs
|
// Step 4: clamp current Hp against the new effective max.
|
||||||
// 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.
|
|
||||||
clampHpToNewMax(session, oldHpBonuses);
|
clampHpToNewMax(session, oldHpBonuses);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue