engine/affixes/runtime.ts

PURPOSE

Holds the per-affix lifecycle hook bodies (onSpawn, onUpdate, filterIncomingDamage, onDeath) for every affix declared in §3.2 of the affix spec. One exported *Hooks bag per affix; each is wired into the affix registry through data/affixes. The sibling dispatcher in engine/affixes/index.ts walks the host’s affix list in priority order and invokes the hooks defined here.

State lives on the per-host AffixInstance.state and is cast to a strict shape inside each hook so the rest of the engine sees a typed contract. Hooks are deliberately small; placement and shared cross-cutting work (arena ring points, anchor flagging, damage adapters) are factored into module-local helpers.

OWNS

  • The hook-bag exports: shieldedHooks, shieldedRespawnHooks, gatedHooks, respawnAsHooks, periodicInvulnHooks, reflectiveHooks, armoredHooks, burningAuraHooks, volatileHooks, regeneratingHooks, reflectiveBurstHooks, phasingHooks, summonerHooks, hardenedHooks, gravityWellHooks.
  • The late-bound damage adapter (setAffixDamagePlayer, internal _damagePlayer, damagePlayerViaAdapter).
  • The late-bound prop-spawn adapter (setAffixSpawnPropAt, internal _spawnPropAt, spawnPropAtViaAdapter).
  • Default tunables for boss-flavored affixes: SHIELDED_DEFAULT_ANCHOR_COUNT, SHIELDED_DEFAULT_ANCHOR_TYPE, SHIELDED_ANCHOR_RING_FRAC (0.42), SHIELDED_RESPAWN_DEFAULT_INTERVAL, ARMORED_DEFAULT_MULT.
  • Per-affix *State shape declarations and their read*State hydrators (defaulting + type-guarding numeric/string slots).
  • Module-local helpers: getInstance, hostHasAffix, numParam, strParam, cardinalPoints, spawnAnchor, isAlive, requireArena, shipDistSq, swapHostBody, hardenedDamageMult, phasingIsActive, anyShieldedAnchorAlive, spawnShieldedAnchors, periodicInvulnAnchorPresent.
  • The respawn_as body-swap pipeline (despawn old host with _silentRemove, spawn replacement, transplant identity flags, rebuild affix/ability arrays, fire boss onPhaseChanged).
  • The affix × affix interaction matrix: volatile+reflective_burst, regenerating+phasing, regenerating+hardened, phasing+hardened, summoner+volatile, summoner+gravity_well, summoner+regenerating, summoner+phasing, summoner+reflective_burst, reflective_burst+gravity_well, gravity_well+phasing.
  • Affix × prop death drops: volatilevolatile_crystal, regeneratingmineral_vein, reflective_burstvolatile_crystal, phasingcomet_fragment, summoner → 3× scrap_pile cluster, hardeneddrone_wreck, gravity_wellmagnetar_pulse.

READS FROM

  • ../core/typesAffixInstance, BossArena, EnemyEntity, GameState, WorldState, ShipState type contracts.
  • ../enemies/spawnerGameMaster.spawnEnemy for anchor spawns, respawn_as body replacement, and summoner minion ring.
  • ../core/state — shared ship singleton (used for distance gating, damage application, and as the spawner’s player reference).
  • ../../data/bossesBOSS_DEFS to look up the active boss’s onPhaseChanged after a respawn_as swap.
  • ../boss/encountergetSharedBossVfxKit passed into onPhaseChanged.
  • ../abilitiescreateAbilityInstance so a swapped-in phase respects each ability’s configured startDelay.
  • ../vfx/particlesParticles.add for every affix’s visual cue (auras, swirls, bursts, glints).
  • ../telemetry/collectortelemetry.recordDirectorPhase for proc and interaction counters.
  • ./paletteAFFIX_VFX_PALETTE colors plus default tunables: BURNING_AURA_DEFAULT_RADIUS, BURNING_AURA_DEFAULT_DPS, VOLATILE_DEFAULT_RADIUS, VOLATILE_DEFAULT_DAMAGE, REGENERATING_DEFAULT_FRAC_PER_SEC, REFLECTIVE_BURST_DEFAULT_THRESHOLD, REFLECTIVE_BURST_DEFAULT_RADIUS, REFLECTIVE_BURST_DEFAULT_DAMAGE, PHASING_DEFAULT_WINDOW_DURATION, PHASING_DEFAULT_CYCLE_INTERVAL, SUMMONER_DEFAULT_HP_THRESHOLD_FRAC, SUMMONER_DEFAULT_MINION_TYPE_ID, SUMMONER_DEFAULT_MINION_COUNT, SUMMONER_DEFAULT_SPAWN_DISTANCE_PX, HARDENED_DEFAULT_MAX_REDUCTION, HARDENED_DEFAULT_RAMP_DURATION.
  • game.bossArena — required for boss-flavored hooks (shielded, shielded_respawn) via requireArena, which throws if absent.
  • game._activeBossDefId — read inside swapHostBody to dispatch the boss def’s onPhaseChanged.
  • Per-host AffixInstance.state — every hook reads and re-writes its own typed slots through the read*State hydrators.
  • Per-host host.affixes list — scanned by getInstance / hostHasAffix for self lookup and co-presence checks.
  • world.enemies — scanned by gatedHooks (sibling gateGroupId) and periodicInvulnHooks (cancelAnchorTypeId presence).

PUSHES TO

  • ship.hp and player-damage pipeline via damagePlayerViaAdapter — sources tagged affix:burning_aura, affix:volatile, affix:volatile_reflective_chain, affix:reflective_burst, with hitAngle and hpDamageMult left undefined.
  • Prop spawns via spawnPropAtViaAdapter — drops listed under OWNS; the adapter returns a boolean (pool-cap fail-silent) which is forwarded into telemetry as 1 or 0.
  • world.enemies — anchor spawns via spawnAnchor (sets _isBossAnchor = true, sharesHealthWithBoss = false, isBoss = false, affixes = []), respawn_as replacement bodies, and summoner minions (raw GameMaster.spawnEnemy — minions do not inherit affixes).
  • host mutations: host.hp (regen, summoner-on-regenerating heal burst), host.alive = false + host.hp = 0 + host._silentRemove = true (old respawn_as body), host.phaseIndex (set on the replacement after a swap), host.affixes (rebuilt on respawn), host.abilities (rebuilt on respawn).
  • Per-host AffixInstance.state slots — hydrated defaults, accumulators, cycle timers, triggered one-shots, mutated thresholds (e.g. summoner halves a co-resident reflective_burst.threshold).
  • Visual layer — Particles.add calls for every affix’s signature look (burning sparks, volatile starburst, regenerating green sparks, reflective purple ring, phasing blue haze, summoner red bursts plus interaction overlays, hardened silver glints, gravity-well violet swirl with tangential plus inward bias).
  • Telemetry stream via telemetry.recordDirectorPhase — phase keys include affix_proc:burning_aura, affix_proc:volatile, affix_proc:volatile_drops_crystal, affix_proc:reflective_burst, affix_proc:phasing_blocked, affix_proc:phasing_drops_comet, affix_proc:summoner, affix_proc:summoner_drops_scrap_cluster, affix_proc:regenerating_drops_mineral, affix_proc:reflective_burst_drops_volatile, affix_proc:hardened_drops_drone, affix_proc:gravity_well_drops_magnetar, and the affix_interaction:* keys (volatile_reflective_chain, regen_phasing_sealed, regen_hardened_turtle, phasing_hardens, summoner_volatile_pretell, summoner_gravity_clump, summoner_regen_heal_burst, summoner_phasing_panic_blink, summoner_reflective_cohesion_loss, reflective_gravity_amp, gravity_phasing_dense_swirl).
  • Boss-level dispatch — def.onPhaseChanged(replacement, 1-based phaseIndex, world, bossArena, sharedBossVfxKit) fired from swapHostBody when the active boss def supplies the hook.

DOES NOT

  • Does not own the registry, lookup, or priority sort. AFFIX_REGISTRY, registerAffix, getAffix, sortedDefs, applyAffixesOnSpawn, applyAffixesOnUpdate, applyAffixesOnDeath, and runDamageFilterChain all live in engine/affixes/index.ts. Runtime only exports hook bags that the dispatcher invokes.
  • Does not statically import engine/combat/damage. The import path damage → artifacts → … → affixes/index → data/affixes → runtime would close a circular ES module init loop and crash at load. Player damage is reached through the late-bound setAffixDamagePlayer adapter wired during engine boot.
  • Does not statically import engine/world/props. Prop drops route through the late-bound setAffixSpawnPropAt adapter for the same cycle-breaking reason.
  • Does not touch raw arena coordinates. Anchor placement always goes through arena.ringPoints(count, frac) via cardinalPoints so spawn layout honors arena shape.
  • Does not catch errors from a missing affix instance. getInstance throws if the dispatcher fed the hook a host that lacks the named affix — treated as a wiring bug, not a runtime branch.
  • Does not heal hosts whose hp >= hpMax (regen short-circuits).
  • Does not re-fire summoner. The triggered flag in state is the one-shot guard; subsequent updates return early.
  • Does not let minions inherit affixes — summoner calls GameMaster.spawnEnemy directly without affix payload.
  • Does not let anchors carry affixes, share boss HP, or be flagged as bosses — spawnAnchor zeroes those slots and sets _isBossAnchor.
  • Does not emit death VFX or fire boss-end signals for the old body in respawn_as_silentRemove = true plus alive = false route through the silent reaper path.
  • Does not run the next respawn_as threshold on the same frame as a swap — the new body’s prevHpFrac is reseeded so the first post-swap update cannot immediately re-cross.
  • Does not actually reflect projectiles in reflectiveHooks. The hook is reserved; it touches state.reflectFraction only so a misspelled key crashes loudly when a real reflector is wired.
  • Does not throw on adapter no-ops. damagePlayerViaAdapter and spawnPropAtViaAdapter only throw if invoked before wiring; once wired they pass through whatever the adapter does (the prop spawner’s false return is treated as pool-cap fail-silent).
  • Does not record per-frame telemetry for high-frequency events (regen heal, hardened ramp, gravity-well swirl). Sampling is throttled via Math.random() < dt * N or limited to interaction triggers.

Signals

  • boss_anchor_destroyed — produced downstream when an anchor flagged with _isBossAnchor (set by spawnAnchor) dies; the damage pipeline routes that flag instead of enemy_kill.
  • boss_body_kill / boss_kill — explicitly suppressed for the discarded body in respawn_as by setting _silentRemove, alive=false, hp=0, _dying=false, _frozenForLag=false before swapping.
  • Boss onPhaseChanged(replacement, newPhaseIndex, world, arena, sharedBossVfxKit) — fired from swapHostBody when _activeBossDefId resolves to a def that provides the hook; newPhaseIndex is 1-based (state.phaseIndex + 1 because the caller increments after the call).
  • host.phaseIndex stamp — set to state.phaseIndex + 1 on the replacement body so the spawn-profile phase trigger has a typed counter to read.
  • Telemetry counters via recordDirectorPhase — full list under PUSHES TO; consumed by the cloud director for tuning.

Entry points

  • setAffixDamagePlayer(fn) — called once from the bridge boot path to bind the player-damage adapter. Calling any hook that damages the player before wiring throws 'Affix runtime called damagePlayer before setAffixDamagePlayer was wired'.
  • setAffixSpawnPropAt(fn) — called once from the bridge boot path to bind the prop-spawn adapter. Calling any hook that drops a prop before wiring throws 'Affix runtime called spawnPropAt before setAffixSpawnPropAt was wired'.
  • Hook bags — each export const *Hooks object is the value attached to its matching AffixDef in data/affixes. The dispatcher in engine/affixes/index.ts reads def.onSpawn / def.onUpdate / def.filterIncomingDamage / def.onDeath on each iteration:
    • applyAffixesOnSpawn(host, game, world) — invoked from the enemy-spawn path; walks the host’s affixes in descending priority and calls each onSpawn.
    • applyAffixesOnUpdate(host, dt, game, world) — invoked from the per-frame enemy update; walks the same priority order and calls each onUpdate.
    • applyAffixesOnDeath(host, game, world) — invoked from the enemy-death path before the body is reaped; walks the same priority order and calls each onDeath.
    • runDamageFilterChain(host, dmg, game, world) — called from the damage pipeline; threads dmg through each filterIncomingDamage in descending priority and short-circuits at 0.
  • Hook signatures supplied by this file:
    • onSpawn(host, game, world)shielded, shielded_respawn.
    • onUpdate(host, dt, game, world)shielded_respawn, respawn_as, periodic_invuln, burning_aura, regenerating, phasing, summoner, hardened, gravity_well.
    • filterIncomingDamage(host, dmg, game, world)shielded, shielded_respawn, gated, periodic_invuln, reflective, armored, reflective_burst, phasing, hardened.
    • onDeath(host, game, world)volatile, regenerating, reflective_burst, phasing, summoner, hardened, gravity_well.

Pattern notes

  • Priority-ordered hook dispatch lives in engine/affixes/index.ts. sortedDefs(host) builds a fresh array of AffixDefs by looking up host.affixes[i].defId in AFFIX_REGISTRY and sorting descending by priority. The host array is small (typically ≤3) so a per-call sort is acceptable; the original host.affixes order is preserved for inspection. runDamageFilterChain threads the damage value through the filters in that order and returns 0 immediately on the first short-circuit. The *Hooks bodies in this file rely on that ordering implicitly: short-circuit filters (shielded, shielded_respawn, gated, periodic_invuln, phasing) sit above multiplier filters (armored, hardened) so a zero kills the chain before reductions waste work.
  • Late-bound adapters are the canonical cycle-break pattern in this module. Static imports of engine/combat/damage or engine/world/props would close circular ES init loops through affixes/indexdata/affixesruntime. Boot must call setAffixDamagePlayer and setAffixSpawnPropAt before any hook fires; both unset adapters throw rather than silently no-op.
  • State is owned by the per-host AffixInstance, never by the module. Each affix has a *State shape and a read*State(inst) hydrator that defaults missing slots, type-guards numeric/string params via numParam / strParam (which throw on type mismatch — bad data table, not runtime fallback), and casts to the typed shape on return. Tunables that don’t come from data fall back to palette.ts constants.
  • Affix × affix interactions use hostHasAffix(host, defId) as the gate (non-throwing co-presence check) then getInstance + the sibling’s read*State to fetch the other affix’s hydrated state. The runtime never assumes ordering between the pair — either hook can be the lead. Interaction telemetry uses affix_interaction:* keys; high-frequency interactions (per-hit, per-frame) are sampled with Math.random() < dt to ~once per second.
  • Affix × prop crossovers attach to onDeath. Each affix drops a thematically and palette-matched prop via spawnPropAtViaAdapter; the boolean return (pool-cap fail-silent) is forwarded into telemetry as 1 or 0. summoner multi-spawns three scrap_pile props in a ring around the death point with independent fail-silent counting.
  • respawn_as is the only hook that mutates host identity. The pattern: snapshot identity flags (hp, hpMax, displayName, barColor, isBoss, sharesHealthWithBoss, untargetable), mark the old body silent-removed, spawn the new body via GameMaster.spawnEnemy, transplant the flags, rebuild affixes and abilities from the phase’s nextAffixIds / nextAbilityIds, stamp phaseIndex, then fire the active boss def’s onPhaseChanged. The respawn_as instance on the replacement is reconstructed with the original phases config plus the post-increment phaseIndex and a reseeded prevHpFrac so the next threshold can fire on a later frame.
  • Boss-flavored affixes (shielded, shielded_respawn, gated, respawn_as, periodic_invuln, reflective, armored) assume a BossArena plus boss-bar plumbing; requireArena throws if invoked without one. World-roaming elite affixes (burning_aura, volatile, regenerating, reflective_burst, phasing, summoner, hardened, gravity_well) operate on roving elites with no arena, gating on player proximity via the squared-distance helper shipDistSq. Anchors are flagged with _isBossAnchor and stripped of HP-share, boss flag, and affix payload by spawnAnchor.
  • Visual cadence is throttled per affix: passive auras emit on a small accumulator (e.g. burning_aura every 0.2s, gravity_well every 0.25s halved to 0.125s during a co-resident phasing window), low-frequency cues use Math.random() < dt * N, and burst hooks emit a single fan of particles at the trigger frame.
  • The gravity_well swirl uses tangential plus inward-biased velocity (-sin(ang) * tangent + -cos(ang) * inward on the x axis, cos(ang) * tangent + -sin(ang) * inward on the y axis) so particles read as orbital infall rather than radial blow-out.