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
*Stateshape declarations and theirread*Statehydrators (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_asbody-swap pipeline (despawn old host with_silentRemove, spawn replacement, transplant identity flags, rebuild affix/ability arrays, fire bossonPhaseChanged). - 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:
volatile→volatile_crystal,regenerating→mineral_vein,reflective_burst→volatile_crystal,phasing→comet_fragment,summoner→ 3×scrap_pilecluster,hardened→drone_wreck,gravity_well→magnetar_pulse.
READS FROM
../core/types—AffixInstance,BossArena,EnemyEntity,GameState,WorldState,ShipStatetype contracts.../enemies/spawner—GameMaster.spawnEnemyfor anchor spawns,respawn_asbody replacement, andsummonerminion ring.../core/state— sharedshipsingleton (used for distance gating, damage application, and as the spawner’s player reference).../../data/bosses—BOSS_DEFSto look up the active boss’sonPhaseChangedafter arespawn_asswap.../boss/encounter—getSharedBossVfxKitpassed intoonPhaseChanged.../abilities—createAbilityInstanceso a swapped-in phase respects each ability’s configuredstartDelay.../vfx/particles—Particles.addfor every affix’s visual cue (auras, swirls, bursts, glints).../telemetry/collector—telemetry.recordDirectorPhasefor proc and interaction counters../palette—AFFIX_VFX_PALETTEcolors 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) viarequireArena, which throws if absent.game._activeBossDefId— read insideswapHostBodyto dispatch the boss def’sonPhaseChanged.- Per-host
AffixInstance.state— every hook reads and re-writes its own typed slots through theread*Statehydrators. - Per-host
host.affixeslist — scanned bygetInstance/hostHasAffixfor self lookup and co-presence checks. world.enemies— scanned bygatedHooks(siblinggateGroupId) andperiodicInvulnHooks(cancelAnchorTypeIdpresence).
PUSHES TO
ship.hpand player-damage pipeline viadamagePlayerViaAdapter— sources taggedaffix:burning_aura,affix:volatile,affix:volatile_reflective_chain,affix:reflective_burst, withhitAngleandhpDamageMultleftundefined.- Prop spawns via
spawnPropAtViaAdapter— drops listed under OWNS; the adapter returns a boolean (pool-cap fail-silent) which is forwarded into telemetry as1or0. world.enemies— anchor spawns viaspawnAnchor(sets_isBossAnchor = true,sharesHealthWithBoss = false,isBoss = false,affixes = []),respawn_asreplacement bodies, andsummonerminions (rawGameMaster.spawnEnemy— minions do not inherit affixes).hostmutations:host.hp(regen, summoner-on-regenerating heal burst),host.alive = false+host.hp = 0+host._silentRemove = true(oldrespawn_asbody),host.phaseIndex(set on the replacement after a swap),host.affixes(rebuilt on respawn),host.abilities(rebuilt on respawn).- Per-host
AffixInstance.stateslots — hydrated defaults, accumulators, cycle timers,triggeredone-shots, mutated thresholds (e.g.summonerhalves a co-residentreflective_burst.threshold). - Visual layer —
Particles.addcalls 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 includeaffix_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 theaffix_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 fromswapHostBodywhen 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, andrunDamageFilterChainall live inengine/affixes/index.ts. Runtime only exports hook bags that the dispatcher invokes. - Does not statically import
engine/combat/damage. The import pathdamage → artifacts → … → affixes/index → data/affixes → runtimewould close a circular ES module init loop and crash at load. Player damage is reached through the late-boundsetAffixDamagePlayeradapter wired during engine boot. - Does not statically import
engine/world/props. Prop drops route through the late-boundsetAffixSpawnPropAtadapter for the same cycle-breaking reason. - Does not touch raw arena coordinates. Anchor placement always goes through
arena.ringPoints(count, frac)viacardinalPointsso spawn layout honors arena shape. - Does not catch errors from a missing affix instance.
getInstancethrows 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. Thetriggeredflag in state is the one-shot guard; subsequent updates return early. - Does not let minions inherit affixes —
summonercallsGameMaster.spawnEnemydirectly without affix payload. - Does not let anchors carry affixes, share boss HP, or be flagged as bosses —
spawnAnchorzeroes those slots and sets_isBossAnchor. - Does not emit death VFX or fire boss-end signals for the old body in
respawn_as—_silentRemove = trueplusalive = falseroute through the silent reaper path. - Does not run the next
respawn_asthreshold on the same frame as a swap — the new body’sprevHpFracis reseeded so the first post-swap update cannot immediately re-cross. - Does not actually reflect projectiles in
reflectiveHooks. The hook is reserved; it touchesstate.reflectFractiononly so a misspelled key crashes loudly when a real reflector is wired. - Does not throw on adapter no-ops.
damagePlayerViaAdapterandspawnPropAtViaAdapteronly throw if invoked before wiring; once wired they pass through whatever the adapter does (the prop spawner’sfalsereturn 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 * Nor limited to interaction triggers.
Signals
boss_anchor_destroyed— produced downstream when an anchor flagged with_isBossAnchor(set byspawnAnchor) dies; the damage pipeline routes that flag instead ofenemy_kill.boss_body_kill/boss_kill— explicitly suppressed for the discarded body inrespawn_asby setting_silentRemove,alive=false,hp=0,_dying=false,_frozenForLag=falsebefore swapping.- Boss
onPhaseChanged(replacement, newPhaseIndex, world, arena, sharedBossVfxKit)— fired fromswapHostBodywhen_activeBossDefIdresolves to a def that provides the hook;newPhaseIndexis 1-based (state.phaseIndex + 1because the caller increments after the call). host.phaseIndexstamp — set tostate.phaseIndex + 1on the replacement body so the spawn-profilephasetrigger 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 *Hooksobject is the value attached to its matchingAffixDefindata/affixes. The dispatcher inengine/affixes/index.tsreadsdef.onSpawn/def.onUpdate/def.filterIncomingDamage/def.onDeathon each iteration:applyAffixesOnSpawn(host, game, world)— invoked from the enemy-spawn path; walks the host’s affixes in descending priority and calls eachonSpawn.applyAffixesOnUpdate(host, dt, game, world)— invoked from the per-frame enemy update; walks the same priority order and calls eachonUpdate.applyAffixesOnDeath(host, game, world)— invoked from the enemy-death path before the body is reaped; walks the same priority order and calls eachonDeath.runDamageFilterChain(host, dmg, game, world)— called from the damage pipeline; threadsdmgthrough eachfilterIncomingDamagein descending priority and short-circuits at0.
- 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 ofAffixDefs by looking uphost.affixes[i].defIdinAFFIX_REGISTRYand sorting descending bypriority. The host array is small (typically≤3) so a per-call sort is acceptable; the originalhost.affixesorder is preserved for inspection.runDamageFilterChainthreads the damage value through the filters in that order and returns0immediately on the first short-circuit. The*Hooksbodies 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/damageorengine/world/propswould close circular ES init loops throughaffixes/index→data/affixes→runtime. Boot must callsetAffixDamagePlayerandsetAffixSpawnPropAtbefore 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*Stateshape and aread*State(inst)hydrator that defaults missing slots, type-guards numeric/string params vianumParam/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 topalette.tsconstants. - Affix × affix interactions use
hostHasAffix(host, defId)as the gate (non-throwing co-presence check) thengetInstance+ the sibling’sread*Stateto fetch the other affix’s hydrated state. The runtime never assumes ordering between the pair — either hook can be the lead. Interaction telemetry usesaffix_interaction:*keys; high-frequency interactions (per-hit, per-frame) are sampled withMath.random() < dtto ~once per second. - Affix × prop crossovers attach to
onDeath. Each affix drops a thematically and palette-matched prop viaspawnPropAtViaAdapter; the boolean return (pool-cap fail-silent) is forwarded into telemetry as1or0.summonermulti-spawns threescrap_pileprops in a ring around the death point with independent fail-silent counting. respawn_asis 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 viaGameMaster.spawnEnemy, transplant the flags, rebuildaffixesandabilitiesfrom the phase’snextAffixIds/nextAbilityIds, stampphaseIndex, then fire the active boss def’sonPhaseChanged. Therespawn_asinstance on the replacement is reconstructed with the originalphasesconfig plus the post-incrementphaseIndexand a reseededprevHpFracso the next threshold can fire on a later frame.- Boss-flavored affixes (
shielded,shielded_respawn,gated,respawn_as,periodic_invuln,reflective,armored) assume aBossArenaplus boss-bar plumbing;requireArenathrows 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 helpershipDistSq. Anchors are flagged with_isBossAnchorand stripped of HP-share, boss flag, and affix payload byspawnAnchor. - Visual cadence is throttled per affix: passive auras emit on a small accumulator (e.g.
burning_auraevery0.2s,gravity_wellevery0.25s halved to0.125s during a co-resident phasing window), low-frequency cues useMath.random() < dt * N, and burst hooks emit a single fan of particles at the trigger frame. - The
gravity_wellswirl uses tangential plus inward-biased velocity (-sin(ang) * tangent+-cos(ang) * inwardon the x axis,cos(ang) * tangent+-sin(ang) * inwardon the y axis) so particles read as orbital infall rather than radial blow-out.