diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index cb0e49c..17678fe 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -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 }; } diff --git a/packages/chess/src/modifiers/apply.ts b/packages/chess/src/modifiers/apply.ts index 9204095..72128fb 100644 --- a/packages/chess/src/modifiers/apply.ts +++ b/packages/chess/src/modifiers/apply.ts @@ -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 diff --git a/packages/chess/src/modifiers/auras.test.ts b/packages/chess/src/modifiers/auras.test.ts index 151228f..61b959b 100644 --- a/packages/chess/src/modifiers/auras.test.ts +++ b/packages/chess/src/modifiers/auras.test.ts @@ -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 diff --git a/packages/chess/src/modifiers/effective-attr.ts b/packages/chess/src/modifiers/effective-attr.ts index 7d9ebc0..ca881fd 100644 --- a/packages/chess/src/modifiers/effective-attr.ts +++ b/packages/chess/src/modifiers/effective-attr.ts @@ -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). diff --git a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts index add977b..0c6e4dc 100644 --- a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts +++ b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts @@ -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 = { 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 | 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); diff --git a/packages/chess/src/modifiers/primitives/add-aura.ts b/packages/chess/src/modifiers/primitives/add-aura.ts index 5962c8e..c8d3385 100644 --- a/packages/chess/src/modifiers/primitives/add-aura.ts +++ b/packages/chess/src/modifiers/primitives/add-aura.ts @@ -16,6 +16,10 @@ const descriptor: EffectPrimitive = { 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 diff --git a/packages/chess/src/modifiers/primitives/add-direction.ts b/packages/chess/src/modifiers/primitives/add-direction.ts index 7424b30..06a3c5a 100644 --- a/packages/chess/src/modifiers/primitives/add-direction.ts +++ b/packages/chess/src/modifiers/primitives/add-direction.ts @@ -43,6 +43,7 @@ const descriptor: EffectPrimitive = { 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 ?? []; diff --git a/packages/chess/src/modifiers/primitives/add-to-attribute.ts b/packages/chess/src/modifiers/primitives/add-to-attribute.ts index 9edbf90..7c7ec59 100644 --- a/packages/chess/src/modifiers/primitives/add-to-attribute.ts +++ b/packages/chess/src/modifiers/primitives/add-to-attribute.ts @@ -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 = { 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 | 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; diff --git a/packages/chess/src/modifiers/primitives/block-move-type.ts b/packages/chess/src/modifiers/primitives/block-move-type.ts index 22c3429..ff68ee0 100644 --- a/packages/chess/src/modifiers/primitives/block-move-type.ts +++ b/packages/chess/src/modifiers/primitives/block-move-type.ts @@ -15,6 +15,7 @@ const descriptor: EffectPrimitive = { 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") diff --git a/packages/chess/src/modifiers/primitives/conditional.ts b/packages/chess/src/modifiers/primitives/conditional.ts index ab55717..8b50ebb 100644 --- a/packages/chess/src/modifiers/primitives/conditional.ts +++ b/packages/chess/src/modifiers/primitives/conditional.ts @@ -55,6 +55,7 @@ const descriptor: EffectPrimitive = { 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 diff --git a/packages/chess/src/modifiers/primitives/manifest.test.ts b/packages/chess/src/modifiers/primitives/manifest.test.ts new file mode 100644 index 0000000..5ab3d19 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/manifest.test.ts @@ -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"); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/manifest.ts b/packages/chess/src/modifiers/primitives/manifest.ts new file mode 100644 index 0000000..11ec4c6 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/manifest.ts @@ -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(); + 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(); + 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(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(); + +/** + * 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(); +} diff --git a/packages/chess/src/modifiers/primitives/modify-movement-range.ts b/packages/chess/src/modifiers/primitives/modify-movement-range.ts index c868cff..8f7745c 100644 --- a/packages/chess/src/modifiers/primitives/modify-movement-range.ts +++ b/packages/chess/src/modifiers/primitives/modify-movement-range.ts @@ -13,6 +13,7 @@ const descriptor: EffectPrimitive = { 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") diff --git a/packages/chess/src/modifiers/primitives/multiply-attribute.ts b/packages/chess/src/modifiers/primitives/multiply-attribute.ts index 53fbb6f..16511b5 100644 --- a/packages/chess/src/modifiers/primitives/multiply-attribute.ts +++ b/packages/chess/src/modifiers/primitives/multiply-attribute.ts @@ -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 = { 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 | 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) { diff --git a/packages/chess/src/modifiers/primitives/on-capture.ts b/packages/chess/src/modifiers/primitives/on-capture.ts index 7581e6f..212b9e8 100644 --- a/packages/chess/src/modifiers/primitives/on-capture.ts +++ b/packages/chess/src/modifiers/primitives/on-capture.ts @@ -27,6 +27,7 @@ const descriptor: EffectPrimitive = { 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 diff --git a/packages/chess/src/modifiers/primitives/on-damaged.ts b/packages/chess/src/modifiers/primitives/on-damaged.ts index b70ec41..9f11774 100644 --- a/packages/chess/src/modifiers/primitives/on-damaged.ts +++ b/packages/chess/src/modifiers/primitives/on-damaged.ts @@ -27,6 +27,7 @@ const descriptor: EffectPrimitive = { 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 diff --git a/packages/chess/src/modifiers/primitives/on-turn-start.ts b/packages/chess/src/modifiers/primitives/on-turn-start.ts index a16db5e..f2826cb 100644 --- a/packages/chess/src/modifiers/primitives/on-turn-start.ts +++ b/packages/chess/src/modifiers/primitives/on-turn-start.ts @@ -28,6 +28,7 @@ const descriptor: EffectPrimitive = { 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 diff --git a/packages/chess/src/modifiers/primitives/override-promotion.ts b/packages/chess/src/modifiers/primitives/override-promotion.ts index 856eedf..7f02f9c 100644 --- a/packages/chess/src/modifiers/primitives/override-promotion.ts +++ b/packages/chess/src/modifiers/primitives/override-promotion.ts @@ -14,6 +14,7 @@ const descriptor: EffectPrimitive = { 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); diff --git a/packages/chess/src/modifiers/primitives/reflect-damage.ts b/packages/chess/src/modifiers/primitives/reflect-damage.ts index a7baa32..eb685ef 100644 --- a/packages/chess/src/modifiers/primitives/reflect-damage.ts +++ b/packages/chess/src/modifiers/primitives/reflect-damage.ts @@ -13,6 +13,7 @@ const descriptor: EffectPrimitive = { 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); }, diff --git a/packages/chess/src/modifiers/primitives/seed-attribute.ts b/packages/chess/src/modifiers/primitives/seed-attribute.ts index f60ac95..9aa6ee4 100644 --- a/packages/chess/src/modifiers/primitives/seed-attribute.ts +++ b/packages/chess/src/modifiers/primitives/seed-attribute.ts @@ -39,6 +39,13 @@ const descriptor: EffectPrimitive = { 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 | undefined; + return p?.attr !== undefined ? [p.attr as ChessAttrKey] : []; + }, apply(ctx: PrimitiveApplyContext, params: Params): void { if (!isChessAttrKey(params.attr)) { return; diff --git a/packages/chess/src/modifiers/primitives/set-capture-flag.ts b/packages/chess/src/modifiers/primitives/set-capture-flag.ts index 53e9704..10d5886 100644 --- a/packages/chess/src/modifiers/primitives/set-capture-flag.ts +++ b/packages/chess/src/modifiers/primitives/set-capture-flag.ts @@ -22,6 +22,7 @@ const descriptor: EffectPrimitive = { 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; diff --git a/packages/chess/src/modifiers/primitives/types.ts b/packages/chess/src/modifiers/primitives/types.ts index d81725c..4c016de 100644 --- a/packages/chess/src/modifiers/primitives/types.ts +++ b/packages/chess/src/modifiers/primitives/types.ts @@ -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 { readonly kind: PrimitiveKind; @@ -66,6 +86,8 @@ export interface EffectPrimitive { 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 }; diff --git a/packages/chess/src/presets/piece-hp.ts b/packages/chess/src/presets/piece-hp.ts index e3a14b3..0421322 100644 --- a/packages/chess/src/presets/piece-hp.ts +++ b/packages/chess/src/presets/piece-hp.ts @@ -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 }; }, diff --git a/packages/chess/src/rules/promotion.ts b/packages/chess/src/rules/promotion.ts index 9c81ac1..2234c5c 100644 --- a/packages/chess/src/rules/promotion.ts +++ b/packages/chess/src/rules/promotion.ts @@ -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";