feat(engine): primitive seed manifest + consumer integrity check + zombie cleanup
T3 audit follow-ups Q3.2 (declared consumers), Q4.6 (zombie fact
cleanup on capture), Q4.7 (aura convergence semantics locked in).
Q3.2 — Load-time consumer integrity check:
- EffectPrimitive gains optional seedsAttrs + seedsAttrsFor fields
declaring which ChessAttrKey facts a primitive writes during
apply(). Every T3 primitive now annotates its seeds:
* Static (single attr always written): reflect-damage,
absorb-damage-with-attribute (the two control facts),
add-aura, add-direction, set-capture-flag, modify-movement-
range, override-promotion, block-move-type, on-turn-start,
on-capture, on-damaged, conditional.
* Dynamic (attr is a param): seed-attribute, add-to-attribute,
multiply-attribute, absorb-damage-with-attribute (also
dynamic via params.attr for the charge-holding attribute).
- new primitives/manifest.ts:
* getStaticPrimitiveSeedManifest() union of all static
seedsAttrs, cached after first call.
* collectDynamicSeedsInTree(nodes) walks a primitive tree
collecting both static and dynamic seeds, used by the
zombie-cleanup path with the in-engine CustomModifierRegistry.
* registerAttrConsumer(attr) + getRegisteredConsumers() for
engine subsystems to declare 'I read this attr'.
* assertSeedConsumerIntegrity() asserts every declared seed
has a registered consumer; throws loudly with the full list
of unsatisfied attrs.
- Consumer registrations:
* apply.ts: damage pipeline + movegen filter + trigger
dispatchers register AbsorbDamage*, ReflectDamagePercent,
BlockedMoveTypes, DamageResistance, CaptureFlags,
DirectionAdditions, On*Hooks, ConditionalHooks, AuraSpec.
* effective-attr.ts: HpBonus, RangeBonus, AuraContributions.
* rules/promotion.ts: PromotionOverride.
- Integrity check fires once lazily in ChessEngine constructor;
engine boot throws if a primitive declares a seed that no
subsystem reads. Silent-inert primitives are now impossible.
Q4.6 — Zombie fact cleanup:
- engine.dealDamage default-kill path + piece-hp's onDamage kill
path both extend their retract-on-death loop to include
getEngineSeedManifest(customModifiers). Captures now fully
clean up primitive-seeded attrs (HpBonus, AuraSpec,
ReflectDamagePercent, trigger hooks, user-authored dynamic
targets) in addition to the preset-declared core attrs.
Q4.7 — Aura convergence:
- New test in auras.test.ts 'mutual auras converge in a single
pass' locks in the declarative semantics: mutual auras each see
the OTHER piece's contribution against a positional snapshot,
producing commutative deltas in one compute pass. Second
compute pass is idempotent (no cascade/loop). Decision
documented for downstream 'aura of aura' consumers.
5 new manifest.test.ts scenarios + 1 aura convergence test.
1394 → 1399 unit tests green.
This commit is contained in:
parent
15c1757ff1
commit
019f3987b4
24 changed files with 452 additions and 5 deletions
|
|
@ -80,6 +80,24 @@ import {
|
|||
import type { ModifierProfile } from "./modifiers/types.js";
|
||||
import { CustomModifierRegistry } from "./modifiers/custom/registry.js";
|
||||
import type { CustomModifierDescriptor } from "./modifiers/custom/types.js";
|
||||
import {
|
||||
assertSeedConsumerIntegrity,
|
||||
getEngineSeedManifest,
|
||||
} from "./modifiers/primitives/manifest.js";
|
||||
|
||||
// Q3.2: run the primitive-seed consumer integrity check once, lazily
|
||||
// on first engine construction. Module load order (primitive
|
||||
// side-effect registrations, consumer registerAttrConsumer calls)
|
||||
// may still be resolving at top-level import time; deferring to the
|
||||
// first engine ensures every module in the graph has had a chance
|
||||
// to self-register. The check throws loudly if any primitive's
|
||||
// declared seedsAttrs has no corresponding registered consumer.
|
||||
let integrityChecked = false;
|
||||
function runIntegrityCheckOnce(): void {
|
||||
if (integrityChecked) return;
|
||||
integrityChecked = true;
|
||||
assertSeedConsumerIntegrity();
|
||||
}
|
||||
|
||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||
|
||||
|
|
@ -463,6 +481,7 @@ export class ChessEngine {
|
|||
constructor(activePresets?: ActivePresetSet);
|
||||
constructor(opts: EngineOptions);
|
||||
constructor(arg?: ActivePresetSet | EngineOptions) {
|
||||
runIntegrityCheckOnce();
|
||||
this.session = new Session({ autoFire: false });
|
||||
|
||||
// Normalize the argument: ActivePresetSet stays as-is (legacy
|
||||
|
|
@ -812,13 +831,20 @@ export class ChessEngine {
|
|||
}
|
||||
|
||||
// Default: any damage is lethal. Retract every effective piece
|
||||
// attribute (core + preset-declared) so downstream queries see the
|
||||
// piece as truly gone.
|
||||
// attribute (core + preset-declared + primitive-seeded) so
|
||||
// downstream queries see the piece as truly gone. The
|
||||
// primitive-seed manifest (Q4.6) covers T3-authored attrs that
|
||||
// aren't declared by any preset's `pieceAttributes` list.
|
||||
for (const attr of this.effectivePieceAttrs) {
|
||||
if (this.session.contains(target, attr)) {
|
||||
this.session.retract(target, attr);
|
||||
}
|
||||
}
|
||||
for (const attr of getEngineSeedManifest(this.customModifiers)) {
|
||||
if (this.session.contains(target, attr)) {
|
||||
this.session.retract(target, attr);
|
||||
}
|
||||
}
|
||||
return { died: true };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,25 @@ import {
|
|||
fireOnTurnStartHooks,
|
||||
snapshotHp,
|
||||
} from "./triggers.js";
|
||||
import { registerAttrConsumer } from "./primitives/manifest.js";
|
||||
|
||||
// Q3.2 consumer declarations: this module reads every attr listed
|
||||
// below in the integration preset's onDamage / filterMoves / after-
|
||||
// move trigger dispatchers. Declaring them here anchors the load-
|
||||
// time integrity check — if a primitive later starts writing an
|
||||
// attr not in this list, the engine boot surfaces it loudly.
|
||||
registerAttrConsumer("AbsorbDamageAttr");
|
||||
registerAttrConsumer("AbsorbDamageRate");
|
||||
registerAttrConsumer("ReflectDamagePercent");
|
||||
registerAttrConsumer("BlockedMoveTypes");
|
||||
registerAttrConsumer("DamageResistance");
|
||||
registerAttrConsumer("CaptureFlags");
|
||||
registerAttrConsumer("DirectionAdditions");
|
||||
registerAttrConsumer("OnTurnStartHooks");
|
||||
registerAttrConsumer("OnCaptureHooks");
|
||||
registerAttrConsumer("OnDamagedHooks");
|
||||
registerAttrConsumer("ConditionalHooks");
|
||||
registerAttrConsumer("AuraSpec");
|
||||
|
||||
/**
|
||||
* Per-engine pre-move HP snapshot, used by the on-damaged trigger
|
||||
|
|
|
|||
|
|
@ -171,6 +171,48 @@ describe("computeAuraFacts", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("mutual auras converge in a single pass (Q4.7)", () => {
|
||||
// Two pieces each emit an aura targeting the other. The walker
|
||||
// iterates sources independently, so each aura's contribution is
|
||||
// computed against the SNAPSHOT of positions at compute time —
|
||||
// meaning A sees B's aura and B sees A's aura in a single pass.
|
||||
// This is the intended semantics: auras are declarative (based on
|
||||
// radius + delta + geometry), not iterative (no fixpoint loop).
|
||||
const engine = new ChessEngine();
|
||||
|
||||
// White king (e1, sq 4) and black king (e8, sq 60) are 7 squares
|
||||
// apart — Chebyshev 7. Use radius 7 so both can reach each other.
|
||||
const whiteKing = findPieceAtSquare(engine, 4);
|
||||
const blackKing = findPieceAtSquare(engine, 60);
|
||||
seedAura(engine, whiteKing, {
|
||||
radius: 7,
|
||||
targetAttr: "HpBonus",
|
||||
delta: 1,
|
||||
});
|
||||
seedAura(engine, blackKing, {
|
||||
radius: 7,
|
||||
targetAttr: "HpBonus",
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
|
||||
// White king receives black king's aura (+2). Black king receives
|
||||
// white king's aura (+1). Self-application is skipped (unit-tested
|
||||
// elsewhere) so the auras don't compound on their own sources.
|
||||
expect(getContribs(engine, whiteKing)?.["HpBonus"]).toBe(2);
|
||||
expect(getContribs(engine, blackKing)?.["HpBonus"]).toBe(1);
|
||||
|
||||
// A second compute pass produces identical results — mutual auras
|
||||
// don't cascade or multiply across recomputes. The engine's
|
||||
// onAfterMove cadence is once-per-move; if users ever want
|
||||
// "aura of aura" semantics they can layer add-to-attribute on
|
||||
// top of seed-attribute deliberately.
|
||||
computeAuraFacts(engine.session);
|
||||
expect(getContribs(engine, whiteKing)?.["HpBonus"]).toBe(2);
|
||||
expect(getContribs(engine, blackKing)?.["HpBonus"]).toBe(1);
|
||||
});
|
||||
|
||||
it("engine's onAfterMove hook recomputes auras automatically when a profile is active", async () => {
|
||||
// Use a no-op modifier profile so the __modifier-profile-integration__
|
||||
// preset auto-activates — that's the only way onAfterMove fires
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@
|
|||
*/
|
||||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type { ChessAttrKey, ChessAttrMap } from "../schema.js";
|
||||
import { registerAttrConsumer } from "./primitives/manifest.js";
|
||||
|
||||
// Q3.2: this helper is the primary consumer of numeric attrs that
|
||||
// layer aura contributions. It reads HpBonus and RangeBonus (via
|
||||
// callers), and AuraContributions itself.
|
||||
registerAttrConsumer("HpBonus");
|
||||
registerAttrConsumer("RangeBonus");
|
||||
registerAttrConsumer("AuraContributions");
|
||||
|
||||
/**
|
||||
* Read a numeric attribute and layer the aura contribution (if any).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrKey } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
|
|
@ -15,6 +16,20 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
description:
|
||||
"Seed absorb-damage facts so damage can consume an attribute before HP.",
|
||||
paramsSchema: schema,
|
||||
// Static portion: the two absorb control facts. The dynamic
|
||||
// `params.attr` (e.g. "ShieldCharges") is also touched but the
|
||||
// USER seeds it separately via seed-attribute; this primitive
|
||||
// doesn't own that attr's lifecycle.
|
||||
seedsAttrs: ["AbsorbDamageAttr", "AbsorbDamageRate"],
|
||||
seedsAttrsFor(params: unknown): readonly ChessAttrKey[] {
|
||||
const p = params as Partial<Params> | undefined;
|
||||
const dynamic = p?.attr !== undefined ? [p.attr as ChessAttrKey] : [];
|
||||
return [
|
||||
"AbsorbDamageAttr" as ChessAttrKey,
|
||||
"AbsorbDamageRate" as ChessAttrKey,
|
||||
...dynamic,
|
||||
];
|
||||
},
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
ctx.session.insert(ctx.pieceId, "AbsorbDamageAttr", params.attr);
|
||||
ctx.session.insert(ctx.pieceId, "AbsorbDamageRate", params.rate);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
description:
|
||||
"Seeds an AuraSpec fact entry consumed by the aura engine's recomputation phase.",
|
||||
paramsSchema: schema,
|
||||
// Source writes AuraSpec. Target pieces receive AuraContributions
|
||||
// derived by computeAuraFacts — that's a separate pass, not a
|
||||
// direct primitive write, so we don't declare it here.
|
||||
seedsAttrs: ["AuraSpec"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "AuraSpec") as
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Add Direction",
|
||||
description: "Appends movement directions into DirectionAdditions with dedupe.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["DirectionAdditions"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, "DirectionAdditions");
|
||||
const existingDirections = existing ?? [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrKey } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
|
|
@ -13,6 +14,10 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Add To Attribute",
|
||||
description: "Adds delta to the current attribute value, treating missing as 0.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrsFor(params: unknown): readonly ChessAttrKey[] {
|
||||
const p = params as Partial<Params> | undefined;
|
||||
return p?.attr !== undefined ? [p.attr as ChessAttrKey] : [];
|
||||
},
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||
const baseValue = existing === undefined ? 0 : existing;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Block Move Type",
|
||||
description: "Seed blocked move types for deferred move-filter integration.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["BlockedMoveTypes"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existingRaw = ctx.session.contains(ctx.pieceId, "BlockedMoveTypes")
|
||||
? ctx.session.get(ctx.pieceId, "BlockedMoveTypes")
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
description:
|
||||
"Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["ConditionalHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "ConditionalHooks") as
|
||||
|
|
|
|||
114
packages/chess/src/modifiers/primitives/manifest.test.ts
Normal file
114
packages/chess/src/modifiers/primitives/manifest.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Manifest integrity tests (T3 audit Q3.2, Q4.6).
|
||||
*
|
||||
* These tests exercise the static manifest + consumer-registration
|
||||
* API in isolation so the load-time integrity check stays honest as
|
||||
* future primitives are added.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertSeedConsumerIntegrity,
|
||||
collectDynamicSeedsInTree,
|
||||
getStaticPrimitiveSeedManifest,
|
||||
getRegisteredConsumers,
|
||||
} from "./manifest.js";
|
||||
import "./index.js";
|
||||
// Load consumer modules so their registerAttrConsumer calls fire.
|
||||
// Normal engine construction pulls these in transitively; the
|
||||
// isolated test needs them explicit.
|
||||
import "../apply.js";
|
||||
import "../effective-attr.js";
|
||||
import "../../rules/promotion.js";
|
||||
|
||||
describe("primitive seed manifest (Q4.6)", () => {
|
||||
it("includes every statically-declared primitive attr", () => {
|
||||
const manifest = getStaticPrimitiveSeedManifest();
|
||||
// At minimum these ten are known from the 15 T3 primitives that
|
||||
// declare static seedsAttrs. The dynamic-attr primitives
|
||||
// (seed-attribute, add-to-attribute, multiply-attribute) don't
|
||||
// appear in the static list and are handled via
|
||||
// seedsAttrsFor at cleanup time.
|
||||
const expected = [
|
||||
"ReflectDamagePercent",
|
||||
"BlockedMoveTypes",
|
||||
"RangeBonus",
|
||||
"PromotionOverride",
|
||||
"DirectionAdditions",
|
||||
"CaptureFlags",
|
||||
"AuraSpec",
|
||||
"OnTurnStartHooks",
|
||||
"OnCaptureHooks",
|
||||
"OnDamagedHooks",
|
||||
"ConditionalHooks",
|
||||
"AbsorbDamageAttr",
|
||||
"AbsorbDamageRate",
|
||||
];
|
||||
for (const attr of expected) {
|
||||
expect(manifest).toContain(attr);
|
||||
}
|
||||
});
|
||||
|
||||
it("collectDynamicSeedsInTree walks nested primitives and collects per-descriptor attrs", () => {
|
||||
// Descriptor with a seed-attribute inside an on-capture trigger —
|
||||
// the cleanup path needs to discover BOTH "OnCaptureHooks" (from
|
||||
// on-capture's static seedsAttrs) AND "HpBonus" (from the nested
|
||||
// seed-attribute's dynamic seedsAttrsFor).
|
||||
const tree = [
|
||||
{
|
||||
kind: "on-capture" as const,
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "seed-attribute" as const,
|
||||
params: { attr: "HpBonus", value: 7 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
const attrs = collectDynamicSeedsInTree(tree);
|
||||
expect(attrs).toContain("OnCaptureHooks");
|
||||
expect(attrs).toContain("HpBonus");
|
||||
});
|
||||
|
||||
it("collectDynamicSeedsInTree tolerates malformed params silently", () => {
|
||||
const tree = [
|
||||
{
|
||||
kind: "seed-attribute" as const,
|
||||
params: { /* missing attr */ value: 1 },
|
||||
},
|
||||
];
|
||||
// No throw — just no dynamic attrs collected for this node.
|
||||
const attrs = collectDynamicSeedsInTree(tree);
|
||||
expect(attrs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("load-time consumer integrity (Q3.2)", () => {
|
||||
it("every statically-declared primitive seed has a registered consumer", () => {
|
||||
// The engine construction path triggers this assertion; we call
|
||||
// it directly here to surface the failure mode in isolation.
|
||||
// If this test fails, find the missing attr in the error message
|
||||
// and either add a registerAttrConsumer call in the consuming
|
||||
// subsystem or remove the primitive's seedsAttrs declaration.
|
||||
expect(() => assertSeedConsumerIntegrity()).not.toThrow();
|
||||
});
|
||||
|
||||
it("getRegisteredConsumers includes the attrs we expect", () => {
|
||||
const consumers = getRegisteredConsumers();
|
||||
// Core attrs layered by effective-attr.ts:
|
||||
expect(consumers).toContain("HpBonus");
|
||||
expect(consumers).toContain("RangeBonus");
|
||||
expect(consumers).toContain("AuraContributions");
|
||||
// Damage pipeline in apply.ts:
|
||||
expect(consumers).toContain("ReflectDamagePercent");
|
||||
expect(consumers).toContain("AbsorbDamageAttr");
|
||||
// Movegen filter:
|
||||
expect(consumers).toContain("BlockedMoveTypes");
|
||||
// Trigger dispatchers:
|
||||
expect(consumers).toContain("OnCaptureHooks");
|
||||
expect(consumers).toContain("ConditionalHooks");
|
||||
// Promotion:
|
||||
expect(consumers).toContain("PromotionOverride");
|
||||
});
|
||||
});
|
||||
159
packages/chess/src/modifiers/primitives/manifest.ts
Normal file
159
packages/chess/src/modifiers/primitives/manifest.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Primitive-seeded-attribute manifest (T3 audit Q3.2 + Q4.6).
|
||||
*
|
||||
* Walks PRIMITIVE_REGISTRY and collects the union of every
|
||||
* ChessAttrKey any registered primitive could seed. Two consumers:
|
||||
*
|
||||
* - Q4.6 (zombie cleanup): the engine's death-retract path retracts
|
||||
* every attr in the manifest from the dying piece so primitive-
|
||||
* seeded facts don't leak onto captured entities.
|
||||
*
|
||||
* - Q3.2 (load-time consumer integrity): on engine boot we assert
|
||||
* every manifest entry has a registered consumer (reader) in at
|
||||
* least one of the known engine subsystems. Missing consumers
|
||||
* turn silent-inert primitives into a loud load-time failure.
|
||||
*
|
||||
* Dynamic-attr primitives (seed-attribute, add-to-attribute,
|
||||
* multiply-attribute) use `seedsAttrsFor(params)` which needs per-
|
||||
* entry params. The manifest used by cleanup collects the STATIC
|
||||
* `seedsAttrs` plus any attrs observed from `seedsAttrsFor` on all
|
||||
* currently-registered custom descriptors in the engine — callers
|
||||
* that know the descriptor context pass it in; cleanup falls back
|
||||
* to the static list on its own.
|
||||
*/
|
||||
import type { ChessAttrKey } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { CustomModifierRegistry } from "../custom/registry.js";
|
||||
import type { EffectPrimitiveNode } from "./types.js";
|
||||
|
||||
/**
|
||||
* The static half of the manifest — attrs every primitive declares
|
||||
* it writes regardless of params. Cached once on first call; safe
|
||||
* because PRIMITIVE_REGISTRY is append-only after module load (all
|
||||
* registrations happen via side-effect imports at load time).
|
||||
*/
|
||||
let staticManifestCache: readonly ChessAttrKey[] | null = null;
|
||||
|
||||
export function getStaticPrimitiveSeedManifest(): readonly ChessAttrKey[] {
|
||||
if (staticManifestCache !== null) return staticManifestCache;
|
||||
const seen = new Set<ChessAttrKey>();
|
||||
for (const primitive of PRIMITIVE_REGISTRY.list()) {
|
||||
const attrs = primitive.seedsAttrs;
|
||||
if (attrs === undefined) continue;
|
||||
for (const a of attrs) seen.add(a);
|
||||
}
|
||||
staticManifestCache = [...seen];
|
||||
return staticManifestCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a primitive-node tree and collect every attr it could seed
|
||||
* via `seedsAttrsFor` (dynamic) AND `seedsAttrs` (static). Used by
|
||||
* the zombie-cleanup path when a custom descriptor is in scope;
|
||||
* combines with the static manifest above so the caller retracts
|
||||
* both the broad always-written set AND the per-descriptor
|
||||
* dynamic targets.
|
||||
*/
|
||||
export function collectDynamicSeedsInTree(
|
||||
nodes: readonly EffectPrimitiveNode[],
|
||||
): readonly ChessAttrKey[] {
|
||||
const seen = new Set<ChessAttrKey>();
|
||||
const walk = (ns: readonly EffectPrimitiveNode[]): void => {
|
||||
for (const node of ns) {
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
if (primitive === undefined) continue;
|
||||
if (primitive.seedsAttrs !== undefined) {
|
||||
for (const a of primitive.seedsAttrs) seen.add(a);
|
||||
}
|
||||
if (primitive.seedsAttrsFor !== undefined) {
|
||||
try {
|
||||
for (const a of primitive.seedsAttrsFor(node.params)) seen.add(a);
|
||||
} catch {
|
||||
// Malformed params — skip this node's dynamic seeds.
|
||||
}
|
||||
}
|
||||
if (primitive.childPrimitives !== undefined) {
|
||||
try {
|
||||
walk(primitive.childPrimitives(node.params));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full per-engine seed manifest: static attrs + every attr any
|
||||
* currently-registered custom descriptor in the engine could seed
|
||||
* (walking their primitive trees). The death-retract path uses this
|
||||
* so both 'always-written' and 'user-authored dynamic target' attrs
|
||||
* get cleaned up when a piece dies.
|
||||
*/
|
||||
export function getEngineSeedManifest(
|
||||
customRegistry: CustomModifierRegistry | undefined,
|
||||
): readonly ChessAttrKey[] {
|
||||
const seen = new Set<ChessAttrKey>(getStaticPrimitiveSeedManifest());
|
||||
if (customRegistry === undefined) return [...seen];
|
||||
for (const descriptor of customRegistry.list()) {
|
||||
for (const a of collectDynamicSeedsInTree(descriptor.primitives)) {
|
||||
seen.add(a);
|
||||
}
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// ─── Q3.2: load-time consumer integrity ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Registered consumer set — engine subsystems declare here that they
|
||||
* read an attr, so the integrity check can verify every primitive-
|
||||
* seeded attr has at least one reader.
|
||||
*/
|
||||
const REGISTERED_CONSUMERS = new Set<ChessAttrKey>();
|
||||
|
||||
/**
|
||||
* Called by engine subsystems (movegen filters, damage pipeline,
|
||||
* effective-attr readers, aura recompute, etc.) at module-load time
|
||||
* to declare 'I read this attr'. Idempotent.
|
||||
*/
|
||||
export function registerAttrConsumer(attr: ChessAttrKey): void {
|
||||
REGISTERED_CONSUMERS.add(attr);
|
||||
}
|
||||
|
||||
/** Snapshot for tests and the integrity check. */
|
||||
export function getRegisteredConsumers(): readonly ChessAttrKey[] {
|
||||
return [...REGISTERED_CONSUMERS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that every statically-declared primitive-seeded attr has at
|
||||
* least one engine consumer. Call once after the full module graph
|
||||
* has loaded (e.g. the first time an engine is constructed).
|
||||
*
|
||||
* Throws a single aggregated error listing missing consumers so users
|
||||
* see all gaps at once rather than rebuilding per-missing.
|
||||
*/
|
||||
export function assertSeedConsumerIntegrity(): void {
|
||||
const unsatisfied: ChessAttrKey[] = [];
|
||||
for (const attr of getStaticPrimitiveSeedManifest()) {
|
||||
if (!REGISTERED_CONSUMERS.has(attr)) unsatisfied.push(attr);
|
||||
}
|
||||
if (unsatisfied.length > 0) {
|
||||
throw new Error(
|
||||
`primitive-seed consumer integrity check failed: ` +
|
||||
`${unsatisfied.length} attr(s) have no registered consumer ` +
|
||||
`[${unsatisfied.join(", ")}]. Register via registerAttrConsumer() ` +
|
||||
`from the subsystem that reads the attr, or remove the primitive.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for tests only. Clears the static manifest cache so a
|
||||
// test can re-register primitives and re-run the integrity check.
|
||||
export function __resetManifestCacheForTesting(): void {
|
||||
staticManifestCache = null;
|
||||
REGISTERED_CONSUMERS.clear();
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Modify Movement Range",
|
||||
description: "Additively contributes to the existing RangeBonus attribute.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["RangeBonus"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.contains(ctx.pieceId, "RangeBonus")
|
||||
? ctx.session.get(ctx.pieceId, "RangeBonus")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrKey } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
|
|
@ -13,6 +14,10 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Multiply Attribute",
|
||||
description: "Multiplies an existing attribute value by the provided factor.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrsFor(params: unknown): readonly ChessAttrKey[] {
|
||||
const p = params as Partial<Params> | undefined;
|
||||
return p?.attr !== undefined ? [p.attr as ChessAttrKey] : [];
|
||||
},
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||
if (existing === undefined) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "On Capture",
|
||||
description: "Seeds OnCaptureHooks entries consumed during capture events.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["OnCaptureHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "OnCaptureHooks") as
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "On Damaged",
|
||||
description: "Seeds OnDamagedHooks entries consumed during damage events.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["OnDamagedHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "OnDamagedHooks") as
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
description:
|
||||
"Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["OnTurnStartHooks"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "OnTurnStartHooks") as
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Override Promotion",
|
||||
description: "Write PromotionOverride directly to enforce a promotion target.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["PromotionOverride"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const target: PieceType = params.target;
|
||||
ctx.session.insert(ctx.pieceId, "PromotionOverride", target);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Reflect Damage",
|
||||
description: "Seed reflected-damage percentage for deferred damage-pipeline wiring.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["ReflectDamagePercent"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
ctx.session.insert(ctx.pieceId, "ReflectDamagePercent", params.percentage);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Seed Attribute",
|
||||
description: "Seeds a fact on the target piece, overwriting existing value.",
|
||||
paramsSchema: schema,
|
||||
// Dynamic seed: writes whatever attr the user chose in params.
|
||||
// Consumer registry trust-mapping falls back to "any attr the user
|
||||
// targets" — load-time manifest can't narrow this further.
|
||||
seedsAttrsFor(params: unknown): readonly ChessAttrKey[] {
|
||||
const p = params as Partial<Params> | undefined;
|
||||
return p?.attr !== undefined ? [p.attr as ChessAttrKey] : [];
|
||||
},
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
if (!isChessAttrKey(params.attr)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const descriptor: EffectPrimitive<Params> = {
|
|||
label: "Set Capture Flag",
|
||||
description: "Bitwise-ORs one capture flag into CaptureFlags.",
|
||||
paramsSchema: schema,
|
||||
seedsAttrs: ["CaptureFlags"],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, "CaptureFlags");
|
||||
const baseFlags = existing === undefined ? 0 : existing;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type { ZodType } from "zod";
|
||||
import type { ChessEngine } from "../../engine.js";
|
||||
import type { ChessAttrKey } from "../../schema.js";
|
||||
|
||||
/**
|
||||
* T3 primitive ids (ADR-2).
|
||||
|
|
@ -57,6 +58,25 @@ export interface PrimitiveApplyContext {
|
|||
*
|
||||
* `childPrimitives` is provided by primitives that own nested primitive lists
|
||||
* (e.g. trigger/conditional primitives) so validators can walk the tree.
|
||||
*
|
||||
* Seed declarations (`seedsAttrs` / `seedsAttrsFor`) are consumed by:
|
||||
* - Load-time manifest check (Q3.2): engine boot walks
|
||||
* PRIMITIVE_REGISTRY and asserts every declared attr has a
|
||||
* consumer subsystem registered (effective-attr helper, movegen
|
||||
* filters, damage pipeline, etc.). Missing consumers turn silent
|
||||
* runtime failure into a loud load-time assertion.
|
||||
* - Zombie-fact cleanup (Q4.6): when a piece dies, the engine
|
||||
* retracts every attr a primitive could have seeded, not just
|
||||
* the preset-declared core attrs.
|
||||
*
|
||||
* A primitive may declare EITHER or BOTH:
|
||||
* - `seedsAttrs`: static list of attrs always written.
|
||||
* - `seedsAttrsFor(params)`: dynamic list for params-dependent
|
||||
* targets (seed-attribute's `attr` param, etc.). Called with
|
||||
* per-instance params at cleanup/manifest time.
|
||||
*
|
||||
* Consumers merge both lists. Primitives that only orchestrate
|
||||
* nested children may declare empty arrays / omit both.
|
||||
*/
|
||||
export interface EffectPrimitive<Params = unknown> {
|
||||
readonly kind: PrimitiveKind;
|
||||
|
|
@ -66,6 +86,8 @@ export interface EffectPrimitive<Params = unknown> {
|
|||
readonly apply: (ctx: PrimitiveApplyContext, params: Params) => void;
|
||||
readonly maxDepth?: number;
|
||||
readonly childPrimitives?: (params: Params) => EffectPrimitiveNode[];
|
||||
readonly seedsAttrs?: readonly ChessAttrKey[];
|
||||
readonly seedsAttrsFor?: (params: unknown) => readonly ChessAttrKey[];
|
||||
}
|
||||
|
||||
export type { Session };
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import type { GameResult } from "../engine.js";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { getEngineSeedManifest } from "../modifiers/primitives/manifest.js";
|
||||
|
||||
/** Starting HP for every piece. Future work: make this per-type so
|
||||
* pawns have 1 HP and queens have 3, etc. */
|
||||
|
|
@ -150,12 +151,17 @@ PRESET_REGISTRY.register({
|
|||
}
|
||||
|
||||
// Lethal. Retract all effective piece attrs ourselves (core +
|
||||
// whatever other presets declared) so nothing is left behind.
|
||||
// Returning died:true tells upstream callers (capture path) that
|
||||
// the attacker should advance.
|
||||
// whatever other presets declared) AND every primitive-seeded
|
||||
// attr (Q4.6 — HpBonus, ReflectDamagePercent, AuraSpec, trigger
|
||||
// hooks, etc.) so nothing is left behind. Returning died:true
|
||||
// tells upstream callers (capture path) that the attacker
|
||||
// should advance.
|
||||
for (const attr of engine.effectivePieceAttrs) {
|
||||
if (session.contains(target, attr)) session.retract(target, attr);
|
||||
}
|
||||
for (const attr of getEngineSeedManifest(engine.customModifiers)) {
|
||||
if (session.contains(target, attr)) session.retract(target, attr);
|
||||
}
|
||||
return { consume: true, died: true };
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@
|
|||
*/
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import type { PieceType, PieceColor, Square } from "../schema.js";
|
||||
import { registerAttrConsumer } from "../modifiers/primitives/manifest.js";
|
||||
|
||||
// Q3.2: promotion.ts reads PromotionOverride to gate / override
|
||||
// promotion candidates.
|
||||
registerAttrConsumer("PromotionOverride");
|
||||
import { PROMOTION_PIECES } from "../schema.js";
|
||||
import { rankOf } from "../coord.js";
|
||||
import type { LegalMove } from "./types.js";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue