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 AffixDef interface: id, priority, and the four optional hooks onSpawn(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; throws Affix id collision: <id> already registered if the id is already taken (silent overwrites are treated as a bug).
  • getAffix(id) — strict lookup; throws Unknown 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 fresh AffixDef[] from host.affixes[i].defId and sorts it descending by priority. Sorts a copy on every iteration so the original host.affixes mutation order stays stable for inspection.
  • The four dispatch entry points: applyAffixesOnSpawn, applyAffixesOnUpdate, applyAffixesOnDeath, runDamageFilterChain. Each early-returns when host.affixes.length === 0.
  • The idempotent boot loader initAffixRegistry() that walks AFFIX_DEFS from data/affixes and calls registerAffix for each entry.

READS FROM

  • ../core/types — type-only import of EnemyEntity, GameState, WorldState for the hook signatures.
  • ../../data/affixes — value import of AFFIX_DEFS used by initAffixRegistry. The import lives at the bottom of the file because the chain damage.ts → affixes/index → data/affixes → runtime → abilities/patterns → damage is a closed ES module init cycle; the bottom placement plus the runtime Array.isArray guard inside initAffixRegistry lets the cycle resolve.
  • Per-host host.affixes[i].defId — read by sortedDefs to look up each AffixDef in the registry.
  • host.affixes.length — read by every dispatch entry point as the zero-affix early-return guard.
  • def.priority — read by sortedDefs for the descending sort comparator.
  • def.onSpawn / def.onUpdate / def.onDeath / def.filterIncomingDamage — read per-iteration; missing hooks are skipped.

PUSHES TO

  • The AFFIX_REGISTRY map — inserts via registerAffix, read by getAffix and sortedDefs everywhere 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 matching AffixDef field. Side effects (mutations to host, 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 — runDamageFilterChain returns the surviving dmg value (or 0 on 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 via initAffixRegistry.
  • Does not own the hook bodies. Every onSpawn / onUpdate / filterIncomingDamage / onDeath behaviour lives in runtime.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 === 0 short-circuit in the damage filter chain. A filterIncomingDamage hook returning NaN, 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 === 0 short-circuits the chain.
  • Does not catch hook exceptions. A throw from any hook propagates out of the dispatch loop.
  • Does not memoise sortedDefs results. A fresh array is allocated and sorted on every dispatch call (host arrays are ≤3 in practice).
  • Does not run at module-load time. initAffixRegistry must be called explicitly from bridge.ts at engine boot; cold-start import paths that bypass the bridge (e.g. data-only tests) see AFFIX_DEFS as the cycle’s unbound sentinel and initAffixRegistry no-ops on the Array.isArray guard.
  • Does not re-initialise an already-populated registry. initAffixRegistry returns early when Object.keys(AFFIX_REGISTRY).length > 0.
  • Does not unregister or replace defs. There is no unregisterAffix and registerAffix throws on collision.
  • Does not invoke hooks on hosts with no affixes — every entry point short-circuits on host.affixes.length === 0 before allocating sortedDefs.
  • Does not enforce hook signatures at runtime. The AffixDef shape is a TypeScript contract; the dispatcher reads def.onX directly 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 by initAffixRegistry per catalog row; available for ad-hoc registration in tests.
  • getAffix(id: string): AffixDef — registry lookup with unknown-id throw. Used internally by sortedDefs; 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 after host.affixes is populated. Walks the host’s affixes in descending priority and invokes each present onSpawn.
  • applyAffixesOnUpdate(host, dt, game, world) — called from the per-frame enemy update. Same priority walk, invokes each present onUpdate with the frame dt.
  • applyAffixesOnDeath(host, game, world) — called from the enemy-death path before the body is reaped. Same priority walk, invokes each present onDeath.
  • runDamageFilterChain(host, dmg, game, world): number — called from the damage pipeline before final application. Threads dmg through each present filterIncomingDamage in descending priority; returns 0 immediately on the first hook that returns 0, otherwise returns the value emitted by the last filter. Returns the original dmg unchanged when host.affixes.length === 0.
  • initAffixRegistry(): void — boot-time idempotent catalog load. Called once from bridge.ts. No-ops if AFFIX_DEFS is not yet bound (cycle path) or if the registry already has entries.
  • AFFIX_REGISTRY — exported registry map. Read by roll.ts and consumers that need to introspect the loaded catalog.
  • AffixDef — exported interface; the contract every entry in data/affixes implements.

Pattern notes

  • Priority is the only ordering signal. sortedDefs sorts descending by def.priority on every dispatch; the convention is that short-circuiting filters (the boss-flavoured shielded, shielded_respawn, gated, periodic_invuln, and phasing) 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 on host.affixes is 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 === 0 halts 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 by Array.isArray inside initAffixRegistry to survive the documented import cycle through damage → artifacts → … → affixes/index → data/affixes → runtime → abilities/patterns → damage. Production always boots through bridge.ts, so the registry is populated before any hook fires; tests that import data/affixes in isolation can call initAffixRegistry themselves 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 new AffixDef row in data/affixes with hook references into runtime.ts.

3 items under this folder.