engine/affixes/index.ts
PURPOSE
Owns the affix registry, the AffixDef contract, and the per-host lifecycle dispatch loops that drive every affix on every enemy. Affixes are pure data: data/affixes.ts declares the catalog, runtime.ts carries the hook bodies, and this module is the seam between them — it holds the global lookup map, registers defs at boot, builds a per-call priority-ordered view of a host’s affixes, and walks that view for spawn / update / death dispatch and for the incoming-damage filter chain. Hooks are dispatched in descending priority order; a filterIncomingDamage hook returning 0 short-circuits the chain.
OWNS
- The
AffixDefinterface:id,priority, and the four optional hooksonSpawn(host, game, world),onUpdate(host, dt, game, world),filterIncomingDamage(host, dmg, game, world): number,onDeath(host, game, world). - The global mutable
AFFIX_REGISTRY: Record<string, AffixDef>keyed by affix id. registerAffix(def)— inserts into the registry; throwsAffix id collision: <id> already registeredif the id is already taken (silent overwrites are treated as a bug).getAffix(id)— strict lookup; throwsUnknown affix id: <id>on miss (unknown id is a data-table typo, not a recoverable runtime branch).- The private
sortedDefs(host)helper that materialises a freshAffixDef[]fromhost.affixes[i].defIdand sorts it descending bypriority. Sorts a copy on every iteration so the originalhost.affixesmutation order stays stable for inspection. - The four dispatch entry points:
applyAffixesOnSpawn,applyAffixesOnUpdate,applyAffixesOnDeath,runDamageFilterChain. Each early-returns whenhost.affixes.length === 0. - The idempotent boot loader
initAffixRegistry()that walksAFFIX_DEFSfromdata/affixesand callsregisterAffixfor each entry.
READS FROM
../core/types— type-only import ofEnemyEntity,GameState,WorldStatefor the hook signatures.../../data/affixes— value import ofAFFIX_DEFSused byinitAffixRegistry. The import lives at the bottom of the file because the chaindamage.ts → affixes/index → data/affixes → runtime → abilities/patterns → damageis a closed ES module init cycle; the bottom placement plus the runtimeArray.isArrayguard insideinitAffixRegistrylets the cycle resolve.- Per-host
host.affixes[i].defId— read bysortedDefsto look up eachAffixDefin the registry. host.affixes.length— read by every dispatch entry point as the zero-affix early-return guard.def.priority— read bysortedDefsfor the descending sort comparator.def.onSpawn/def.onUpdate/def.onDeath/def.filterIncomingDamage— read per-iteration; missing hooks are skipped.
PUSHES TO
- The
AFFIX_REGISTRYmap — inserts viaregisterAffix, read bygetAffixandsortedDefseverywhere downstream. - Per-affix hook bodies in
runtime.ts— invoked with(host, game, world),(host, dt, game, world),(host, dmg, game, world), or(host, game, world)per the matchingAffixDeffield. Side effects (mutations tohost, particle pushes, telemetry, anchor and minion spawns, prop drops, player damage) are entirely the hooks’ responsibility — this module only orchestrates the call sequence. - Damage pipeline —
runDamageFilterChainreturns the survivingdmgvalue (or0on short-circuit) to its caller; nothing here applies damage itself.
DOES NOT
- Does not own or register the affix catalog content. Catalog rows live in
data/affixes; this module only consumes them viainitAffixRegistry. - Does not own the hook bodies. Every
onSpawn/onUpdate/filterIncomingDamage/onDeathbehaviour lives inruntime.ts. The dispatcher is body-agnostic. - Does not pick, roll, or distribute affixes onto enemies. Sampling lives in
engine/affixes/roll.ts; the spawner decides whether to roll. - Does not validate hook return values beyond the
=== 0short-circuit in the damage filter chain. AfilterIncomingDamagehook returningNaN, a negative number, or a value larger than the input is passed through. - Does not skip later filters when an earlier filter increases damage — only
current === 0short-circuits the chain. - Does not catch hook exceptions. A throw from any hook propagates out of the dispatch loop.
- Does not memoise
sortedDefsresults. A fresh array is allocated and sorted on every dispatch call (host arrays are≤3in practice). - Does not run at module-load time.
initAffixRegistrymust be called explicitly frombridge.tsat engine boot; cold-start import paths that bypass the bridge (e.g. data-only tests) seeAFFIX_DEFSas the cycle’s unbound sentinel andinitAffixRegistryno-ops on theArray.isArrayguard. - Does not re-initialise an already-populated registry.
initAffixRegistryreturns early whenObject.keys(AFFIX_REGISTRY).length > 0. - Does not unregister or replace defs. There is no
unregisterAffixandregisterAffixthrows on collision. - Does not invoke hooks on hosts with no affixes — every entry point short-circuits on
host.affixes.length === 0before allocatingsortedDefs. - Does not enforce hook signatures at runtime. The
AffixDefshape is a TypeScript contract; the dispatcher readsdef.onXdirectly and calls it if present.
Signals
None. This module is a synchronous dispatcher; it neither emits nor subscribes to any engine signal. All cross-system effects are downstream of the hook bodies in runtime.ts.
Entry points
registerAffix(def: AffixDef): void— registry insert with collision throw. Called byinitAffixRegistryper catalog row; available for ad-hoc registration in tests.getAffix(id: string): AffixDef— registry lookup with unknown-id throw. Used internally bysortedDefs; exported for callers that need to read priority or hook presence from a known id.applyAffixesOnSpawn(host, game, world)— called from the enemy-spawn path afterhost.affixesis populated. Walks the host’s affixes in descending priority and invokes each presentonSpawn.applyAffixesOnUpdate(host, dt, game, world)— called from the per-frame enemy update. Same priority walk, invokes each presentonUpdatewith the framedt.applyAffixesOnDeath(host, game, world)— called from the enemy-death path before the body is reaped. Same priority walk, invokes each presentonDeath.runDamageFilterChain(host, dmg, game, world): number— called from the damage pipeline before final application. Threadsdmgthrough each presentfilterIncomingDamagein descending priority; returns0immediately on the first hook that returns0, otherwise returns the value emitted by the last filter. Returns the originaldmgunchanged whenhost.affixes.length === 0.initAffixRegistry(): void— boot-time idempotent catalog load. Called once frombridge.ts. No-ops ifAFFIX_DEFSis not yet bound (cycle path) or if the registry already has entries.AFFIX_REGISTRY— exported registry map. Read byroll.tsand consumers that need to introspect the loaded catalog.AffixDef— exported interface; the contract every entry indata/affixesimplements.
Pattern notes
- Priority is the only ordering signal.
sortedDefssorts descending bydef.priorityon every dispatch; the convention is that short-circuiting filters (the boss-flavouredshielded,shielded_respawn,gated,periodic_invuln, andphasing) sit at higher priorities than multiplier filters (armored,hardened) so a zero kills the chain before reductions are wasted. - A fresh sort per call is intentional: host affix lists are tiny (
≤3), the cost is negligible, and the input order onhost.affixesis preserved for inspection and debugging. Memoising would require invalidation hooks the runtime does not currently need. - The damage filter chain’s short-circuit is exact-zero only. Hooks must return
0(not< 0, not a near-zero float) to stop the chain. The contract is unidirectional: hooks may reduce, hold, or increase the value, but only=== 0halts dispatch. - Spawn / update / death dispatch is fire-and-forget — none of the three accumulate return values, and a throw inside any hook escapes the loop and aborts later hooks for that host on that tick.
- The catalog import (
AFFIX_DEFS) is deliberately placed at file bottom and guarded byArray.isArrayinsideinitAffixRegistryto survive the documented import cycle throughdamage → artifacts → … → affixes/index → data/affixes → runtime → abilities/patterns → damage. Production always boots throughbridge.ts, so the registry is populated before any hook fires; tests that importdata/affixesin isolation can callinitAffixRegistrythemselves once the cycle has resolved. - Collision and unknown-id are hard errors by design. The data-table contract guarantees globally unique ids, and bosses spawn with a fixed affix id list, so a missing or duplicated id is always a wiring bug — not a condition this module tries to recover from.
- The dispatcher is intentionally hook-body-blind. It does not know about anchors, body swaps, prop drops, particles, telemetry, or boss arenas; those live entirely in
runtime.ts. Adding a new affix never requires a change here — only a newAffixDefrow indata/affixeswith hook references intoruntime.ts.