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:
Joey Yakimowich-Payne 2026-04-20 17:47:38 -06:00
commit 019f3987b4
No known key found for this signature in database
24 changed files with 452 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ?? [];

View file

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

View file

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

View file

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

View 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");
});
});

View 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();
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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