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 type { ModifierProfile } from "./modifiers/types.js";
|
||||||
import { CustomModifierRegistry } from "./modifiers/custom/registry.js";
|
import { CustomModifierRegistry } from "./modifiers/custom/registry.js";
|
||||||
import type { CustomModifierDescriptor } from "./modifiers/custom/types.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[];
|
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||||
|
|
||||||
|
|
@ -463,6 +481,7 @@ export class ChessEngine {
|
||||||
constructor(activePresets?: ActivePresetSet);
|
constructor(activePresets?: ActivePresetSet);
|
||||||
constructor(opts: EngineOptions);
|
constructor(opts: EngineOptions);
|
||||||
constructor(arg?: ActivePresetSet | EngineOptions) {
|
constructor(arg?: ActivePresetSet | EngineOptions) {
|
||||||
|
runIntegrityCheckOnce();
|
||||||
this.session = new Session({ autoFire: false });
|
this.session = new Session({ autoFire: false });
|
||||||
|
|
||||||
// Normalize the argument: ActivePresetSet stays as-is (legacy
|
// Normalize the argument: ActivePresetSet stays as-is (legacy
|
||||||
|
|
@ -812,13 +831,20 @@ export class ChessEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: any damage is lethal. Retract every effective piece
|
// Default: any damage is lethal. Retract every effective piece
|
||||||
// attribute (core + preset-declared) so downstream queries see the
|
// attribute (core + preset-declared + primitive-seeded) so
|
||||||
// piece as truly gone.
|
// 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) {
|
for (const attr of this.effectivePieceAttrs) {
|
||||||
if (this.session.contains(target, attr)) {
|
if (this.session.contains(target, attr)) {
|
||||||
this.session.retract(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 };
|
return { died: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,25 @@ import {
|
||||||
fireOnTurnStartHooks,
|
fireOnTurnStartHooks,
|
||||||
snapshotHp,
|
snapshotHp,
|
||||||
} from "./triggers.js";
|
} 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
|
* 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 () => {
|
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__
|
// Use a no-op modifier profile so the __modifier-profile-integration__
|
||||||
// preset auto-activates — that's the only way onAfterMove fires
|
// preset auto-activates — that's the only way onAfterMove fires
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@
|
||||||
*/
|
*/
|
||||||
import type { EntityId, Session } from "@paratype/rete";
|
import type { EntityId, Session } from "@paratype/rete";
|
||||||
import type { ChessAttrKey, ChessAttrMap } from "../schema.js";
|
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).
|
* Read a numeric attribute and layer the aura contribution (if any).
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { ChessAttrKey } from "../../schema.js";
|
||||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
|
@ -15,6 +16,20 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
description:
|
description:
|
||||||
"Seed absorb-damage facts so damage can consume an attribute before HP.",
|
"Seed absorb-damage facts so damage can consume an attribute before HP.",
|
||||||
paramsSchema: schema,
|
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 {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
ctx.session.insert(ctx.pieceId, "AbsorbDamageAttr", params.attr);
|
ctx.session.insert(ctx.pieceId, "AbsorbDamageAttr", params.attr);
|
||||||
ctx.session.insert(ctx.pieceId, "AbsorbDamageRate", params.rate);
|
ctx.session.insert(ctx.pieceId, "AbsorbDamageRate", params.rate);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
description:
|
description:
|
||||||
"Seeds an AuraSpec fact entry consumed by the aura engine's recomputation phase.",
|
"Seeds an AuraSpec fact entry consumed by the aura engine's recomputation phase.",
|
||||||
paramsSchema: schema,
|
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 {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing =
|
const existing =
|
||||||
(ctx.session.get(ctx.pieceId, "AuraSpec") as
|
(ctx.session.get(ctx.pieceId, "AuraSpec") as
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "Add Direction",
|
label: "Add Direction",
|
||||||
description: "Appends movement directions into DirectionAdditions with dedupe.",
|
description: "Appends movement directions into DirectionAdditions with dedupe.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["DirectionAdditions"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing = ctx.session.get(ctx.pieceId, "DirectionAdditions");
|
const existing = ctx.session.get(ctx.pieceId, "DirectionAdditions");
|
||||||
const existingDirections = existing ?? [];
|
const existingDirections = existing ?? [];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { ChessAttrKey } from "../../schema.js";
|
||||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
|
@ -13,6 +14,10 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "Add To Attribute",
|
label: "Add To Attribute",
|
||||||
description: "Adds delta to the current attribute value, treating missing as 0.",
|
description: "Adds delta to the current attribute value, treating missing as 0.",
|
||||||
paramsSchema: schema,
|
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 {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||||
const baseValue = existing === undefined ? 0 : existing;
|
const baseValue = existing === undefined ? 0 : existing;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "Block Move Type",
|
label: "Block Move Type",
|
||||||
description: "Seed blocked move types for deferred move-filter integration.",
|
description: "Seed blocked move types for deferred move-filter integration.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["BlockedMoveTypes"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existingRaw = ctx.session.contains(ctx.pieceId, "BlockedMoveTypes")
|
const existingRaw = ctx.session.contains(ctx.pieceId, "BlockedMoveTypes")
|
||||||
? ctx.session.get(ctx.pieceId, "BlockedMoveTypes")
|
? ctx.session.get(ctx.pieceId, "BlockedMoveTypes")
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
description:
|
description:
|
||||||
"Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.",
|
"Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["ConditionalHooks"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing =
|
const existing =
|
||||||
(ctx.session.get(ctx.pieceId, "ConditionalHooks") as
|
(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",
|
label: "Modify Movement Range",
|
||||||
description: "Additively contributes to the existing RangeBonus attribute.",
|
description: "Additively contributes to the existing RangeBonus attribute.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["RangeBonus"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing = ctx.session.contains(ctx.pieceId, "RangeBonus")
|
const existing = ctx.session.contains(ctx.pieceId, "RangeBonus")
|
||||||
? ctx.session.get(ctx.pieceId, "RangeBonus")
|
? ctx.session.get(ctx.pieceId, "RangeBonus")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { ChessAttrKey } from "../../schema.js";
|
||||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
|
@ -13,6 +14,10 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "Multiply Attribute",
|
label: "Multiply Attribute",
|
||||||
description: "Multiplies an existing attribute value by the provided factor.",
|
description: "Multiplies an existing attribute value by the provided factor.",
|
||||||
paramsSchema: schema,
|
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 {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||||
if (existing === undefined) {
|
if (existing === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "On Capture",
|
label: "On Capture",
|
||||||
description: "Seeds OnCaptureHooks entries consumed during capture events.",
|
description: "Seeds OnCaptureHooks entries consumed during capture events.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["OnCaptureHooks"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing =
|
const existing =
|
||||||
(ctx.session.get(ctx.pieceId, "OnCaptureHooks") as
|
(ctx.session.get(ctx.pieceId, "OnCaptureHooks") as
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "On Damaged",
|
label: "On Damaged",
|
||||||
description: "Seeds OnDamagedHooks entries consumed during damage events.",
|
description: "Seeds OnDamagedHooks entries consumed during damage events.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["OnDamagedHooks"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing =
|
const existing =
|
||||||
(ctx.session.get(ctx.pieceId, "OnDamagedHooks") as
|
(ctx.session.get(ctx.pieceId, "OnDamagedHooks") as
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
description:
|
description:
|
||||||
"Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.",
|
"Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["OnTurnStartHooks"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing =
|
const existing =
|
||||||
(ctx.session.get(ctx.pieceId, "OnTurnStartHooks") as
|
(ctx.session.get(ctx.pieceId, "OnTurnStartHooks") as
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "Override Promotion",
|
label: "Override Promotion",
|
||||||
description: "Write PromotionOverride directly to enforce a promotion target.",
|
description: "Write PromotionOverride directly to enforce a promotion target.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["PromotionOverride"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const target: PieceType = params.target;
|
const target: PieceType = params.target;
|
||||||
ctx.session.insert(ctx.pieceId, "PromotionOverride", target);
|
ctx.session.insert(ctx.pieceId, "PromotionOverride", target);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "Reflect Damage",
|
label: "Reflect Damage",
|
||||||
description: "Seed reflected-damage percentage for deferred damage-pipeline wiring.",
|
description: "Seed reflected-damage percentage for deferred damage-pipeline wiring.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["ReflectDamagePercent"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
ctx.session.insert(ctx.pieceId, "ReflectDamagePercent", params.percentage);
|
ctx.session.insert(ctx.pieceId, "ReflectDamagePercent", params.percentage);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "Seed Attribute",
|
label: "Seed Attribute",
|
||||||
description: "Seeds a fact on the target piece, overwriting existing value.",
|
description: "Seeds a fact on the target piece, overwriting existing value.",
|
||||||
paramsSchema: schema,
|
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 {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
if (!isChessAttrKey(params.attr)) {
|
if (!isChessAttrKey(params.attr)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const descriptor: EffectPrimitive<Params> = {
|
||||||
label: "Set Capture Flag",
|
label: "Set Capture Flag",
|
||||||
description: "Bitwise-ORs one capture flag into CaptureFlags.",
|
description: "Bitwise-ORs one capture flag into CaptureFlags.",
|
||||||
paramsSchema: schema,
|
paramsSchema: schema,
|
||||||
|
seedsAttrs: ["CaptureFlags"],
|
||||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
const existing = ctx.session.get(ctx.pieceId, "CaptureFlags");
|
const existing = ctx.session.get(ctx.pieceId, "CaptureFlags");
|
||||||
const baseFlags = existing === undefined ? 0 : existing;
|
const baseFlags = existing === undefined ? 0 : existing;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { EntityId, Session } from "@paratype/rete";
|
import type { EntityId, Session } from "@paratype/rete";
|
||||||
import type { ZodType } from "zod";
|
import type { ZodType } from "zod";
|
||||||
import type { ChessEngine } from "../../engine.js";
|
import type { ChessEngine } from "../../engine.js";
|
||||||
|
import type { ChessAttrKey } from "../../schema.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T3 primitive ids (ADR-2).
|
* T3 primitive ids (ADR-2).
|
||||||
|
|
@ -57,6 +58,25 @@ export interface PrimitiveApplyContext {
|
||||||
*
|
*
|
||||||
* `childPrimitives` is provided by primitives that own nested primitive lists
|
* `childPrimitives` is provided by primitives that own nested primitive lists
|
||||||
* (e.g. trigger/conditional primitives) so validators can walk the tree.
|
* (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> {
|
export interface EffectPrimitive<Params = unknown> {
|
||||||
readonly kind: PrimitiveKind;
|
readonly kind: PrimitiveKind;
|
||||||
|
|
@ -66,6 +86,8 @@ export interface EffectPrimitive<Params = unknown> {
|
||||||
readonly apply: (ctx: PrimitiveApplyContext, params: Params) => void;
|
readonly apply: (ctx: PrimitiveApplyContext, params: Params) => void;
|
||||||
readonly maxDepth?: number;
|
readonly maxDepth?: number;
|
||||||
readonly childPrimitives?: (params: Params) => EffectPrimitiveNode[];
|
readonly childPrimitives?: (params: Params) => EffectPrimitiveNode[];
|
||||||
|
readonly seedsAttrs?: readonly ChessAttrKey[];
|
||||||
|
readonly seedsAttrsFor?: (params: unknown) => readonly ChessAttrKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Session };
|
export type { Session };
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
import { PRESET_REGISTRY } from "./registry.js";
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
import type { GameResult } from "../engine.js";
|
import type { GameResult } from "../engine.js";
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
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
|
/** Starting HP for every piece. Future work: make this per-type so
|
||||||
* pawns have 1 HP and queens have 3, etc. */
|
* pawns have 1 HP and queens have 3, etc. */
|
||||||
|
|
@ -150,12 +151,17 @@ PRESET_REGISTRY.register({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lethal. Retract all effective piece attrs ourselves (core +
|
// Lethal. Retract all effective piece attrs ourselves (core +
|
||||||
// whatever other presets declared) so nothing is left behind.
|
// whatever other presets declared) AND every primitive-seeded
|
||||||
// Returning died:true tells upstream callers (capture path) that
|
// attr (Q4.6 — HpBonus, ReflectDamagePercent, AuraSpec, trigger
|
||||||
// the attacker should advance.
|
// 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) {
|
for (const attr of engine.effectivePieceAttrs) {
|
||||||
if (session.contains(target, attr)) session.retract(target, attr);
|
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 };
|
return { consume: true, died: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@
|
||||||
*/
|
*/
|
||||||
import type { Session, EntityId } from "@paratype/rete";
|
import type { Session, EntityId } from "@paratype/rete";
|
||||||
import type { PieceType, PieceColor, Square } from "../schema.js";
|
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 { PROMOTION_PIECES } from "../schema.js";
|
||||||
import { rankOf } from "../coord.js";
|
import { rankOf } from "../coord.js";
|
||||||
import type { LegalMove } from "./types.js";
|
import type { LegalMove } from "./types.js";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue