engine/effects/effect-engine
PURPOSE
Central runtime for the unified effect pipeline. Owns the per-run registry of effect instances, multiplexes bus-signal listeners across all instances that watch the same signal, evaluates triggers (signal, aura, timer, counter, run_start, custom_tick) and dispatches their conditions and actions, decays cooldowns and tracks charges, and manages the full lifecycle from clear → register → init → per-frame tick → clear.
OWNS
- Module-private effect-instance list — every
EffectInstanceregistered for the current run, each holding a reference to itsEffectDef, the owner tag, the$varvalue dict, cooldown remaining, charges used, counter accumulator, timer accumulator, aura-active flag, and enabled kill switch. - Indexed dispatch tables built from that list:
- signal name → instance array (
_bySignal), - aura instance array (
_auras), - timer instance array (
_timers), - counter signal name → instance array (
_counters), - custom-tick instance array (
_tickHandlers).
- signal name → instance array (
- Listener-ref map (
_listenerRefs) keyed by signal name with__counter__and__timer_reset__prefixes for the three subscription classes against the same underlying signal, used atcleartime to unsubscribe cleanly. - Cached references to the current run’s
GameState,ShipState, andWorldState, set on everytickand read by signal handlers, counter listeners, and timer-reset listeners between frames. - Initialized flag (
_initialized) that gates mid-run auto-subscription inregisterand decides whetherrun_starteffects fire immediately. - The
EffectEnginepublic object — the entire external API. - The
_createInstance,_snapshotCtx,_tryExecute,_ensureSignalListener,_ensureCounterListener,_ensureTimerResetListener, and_removeAuraModifiersinternal helpers. - The signal-context snapshot taken before each dispatch — a frozen copy of
Sig._ctxfields (name,uid1,uid2,num1,num2,str1) that guards against the shared mutable bus context being overwritten by nested signals fired from within an action. - Per-fire telemetry — a
[EFFECT]console.logline listing the owner, fire count, resolved action params, and trigger name, plus a per-owner counter onwindow.__effectTriggerCountsfor gauntlet metric collection.
READS FROM
../core/signalsforSig(subscribe / unsubscribe / context inspection) andSignalListenertype.../core/modifiersforModifiers._modsByTarget(per-target bucket),Modifiers._mods(master list), andModifiers._dirtyset — all read by_removeAuraModifiersfor aura-tag teardown.../core/typesfor theGameState,ShipState,WorldStateshapes used by the public surface and cached refs../typesforEffectDef,EffectInstance,SignalSnapshot../conditionsforevaluateAllConditions, called before any action dispatch../actionsforexecuteAllActions, called when conditions pass../custom-registryforCustomTickHandlers— name-keyed registry consulted by the per-framecustom_tickdispatcher../resolve-paramsforresolveNumber, used to resolve$varcooldowns, counter thresholds, timer durations, and numericcustom_tickparams.../rendering/draw-artifact-bannersforpushArtifactBanner, invoked on activation whenshowBanneris set and the owner is taggedartifact:.- The
Sig._ctxshared-context object every time a signal handler enters_snapshotCtx. - The cached
game.timefield read bypushArtifactBanner. EffectInstance.def.charges,def.cooldown,def.showBanner,def.actions,def.trigger.type,def.trigger.signal,def.trigger.auraInterval,def.trigger.timerDuration,def.trigger.timerRepeat,def.trigger.timerResetSignal,def.trigger.counterSignal,def.trigger.counterThreshold,def.trigger.counterResets.ShipState.eid(via untyped cast) for aura-modifier source-tag teardown.window(optionally) and its__effectTriggerCountsmap for the global trigger counter.
PUSHES TO
Sig.on/Sig.off— subscribes one listener per unique signal name on priority band 50 (the effects band) for each of the three subscription classes; unsubscribes the same listener refs atcleartime, stripping the__counter__/__timer_reset__prefixes when keying off../conditions::evaluateAllConditions— called from_tryExecutebefore any action dispatch and from the aura tick path before state-transition decisions../actions::executeAllActions— called from_tryExecutewhen all conditions pass and from the aura tick path when entering / refreshing the active branch.Modifiers._modsByTargetandModifiers._mods— direct bucket splice and master-list splice from_removeAuraModifiersto drop every modifier whosesourceequals the instance owner.Modifiers._dirty— adds the affected entity id after every aura-modifier removal.EffectInstance.cooldownRemaining,chargesUsed,timerAccum,counterAccum,auraActive,enabled— mutated in place across_tryExecute, the aura tick, the timer tick, the counter listener, and the timer-reset listener.pushArtifactBanner— pushes a banner entry keyed off theartifact:<id>owner tag for any fire whose definition opts in viashowBanner.console.log— one[EFFECT]line per fire with resolved action params and trigger name.window.__effectTriggerCounts[owner]— bumps the per-owner counter (lazy-creates the map) for gauntlet metrics.
DOES NOT
- Does not define any effect content, trigger / condition / action vocabularies, or tier values — those live in
./types,./conditions,./actions, and the data layer. - Does not implement any condition or action — every gating check delegates to
evaluateAllConditionsand every action runs throughexecuteAllActionsor the namedcustom_tickhandler. - Does not own the custom action registry — only consumes
CustomTickHandlersfrom./custom-registry. - Does not own the signal bus or the signal context shape — only subscribes to
Sigand snapshots its existing context fields. - Does not own enemy status, flame zones, delayed AoEs, or modifier-add logic — those are reached through
./actionsand the modifiers module. - Does not surgically remove single instances on
unregister— drops every matching instance and rebuilds every index from scratch, accepting the rebuild cost in exchange for simpler bookkeeping. - Does not snapshot the
Sig._ctxper-instance inside the listener loop — the snapshot is taken once at the top of each handler, before any dispatch, and reused for every instance keyed to that signal. - Does not separately handle the
custom_tickaction in the normal action-dispatch path —executeAllActionstreats it as a no-op while the engine’s own tick loop walks_tickHandlersand invokes the resolved handler with dt. - Does not validate
EffectDefshape at registration — malformed defs surface as runtime errors during evaluation. - Does not throttle the
[EFFECT]telemetry log — every fire emits one line; banner throttling lives downstream in the artifact-banner module. - Does not retain the cached game / ship / world across
clear— those references are nulled so post-run handlers cannot accidentally see stale state.
Signals
Subscribes to:
- Every unique signal name across all registered
signal-trigger instances — one bus listener per name on priority 50, multiplexed across the per-signal instance array via_bySignal. - Every unique signal name across all registered
counter-trigger instances — separate listener per name (keyed__counter__<name>in the ref map) that increments per-instancecounterAccumand fires_tryExecuteonce the resolved threshold is reached, resetting unlesscounterResets === false. - Every distinct
timerResetSignalacross registeredtimer-trigger instances — separate listener per name (keyed__timer_reset__<name>) that zerostimerAccumon every matching timer and tears down its aura modifiers ifauraActive.
Emits: No signals of its own — re-emission of bus signals is the emit_signal action’s responsibility and runs through ./actions, not this file.
Context handling: Each subscribed listener calls _snapshotCtx once before iterating the instance list; the resulting SignalSnapshot is reused for every _tryExecute call in the loop. Actions that fire nested signals therefore cannot corrupt the outer event’s view of Sig._ctx.
Entry points
EffectEngine.register(defs, owner, values)— appends each def’s instance to_instances, indexes by trigger type, auto-subscribes the matching bus listener when called mid-run afterinit, firesrun_startimmediately if the engine is already running and the cached state is primed, and registers the instance into_tickHandlerswhen any action iscustom_tick.EffectEngine.unregister(owner)— splices every instance owned by the tag out of_instances, runs_removeAuraModifiersfor any withauraActive, then clears and rebuilds every index from the remaining instances (does not unsubscribe bus listeners — those persist untilclear).EffectEngine.init()— wires up one listener per_bySignalkey, one per_counterskey, one per distincttimerResetSignalacross_timers, flips_initializedtrue, then fires everyrun_startinstance once against the cached state.EffectEngine.tick(dt, game, ship, world)— sets the cached state refs, decayscooldownRemainingon every instance, ticks the aura interval / state-transition / scaled-refresh branches, ticks one-shot and repeating timers with$var-resolved durations, and dispatches everycustom_tickaction by name with resolved numeric params.EffectEngine.clear()— strips listener-ref prefixes, callsSig.offfor each entry, empties every index and the instance list, nulls the cached state refs, and resets_initialized.EffectEngine.getInstances()— read-only view of_instancesfor diagnostics and tests.EffectEngine.isInitialized()— bridge-side flag query used byregisterRunEffectsand teardown.
Pattern notes
- The full lifecycle is
clear→register(many) →init→tickper frame →clearat run end. Mid-runregistercalls that arrive afterinitauto-subscribe their new signal / counter listeners and firerun_startimmediately, supporting in-mission artifact pickups. - The shared
Sig._ctxobject is snapshotted once per listener entry before any dispatch. Without that snapshot, an action that itself fires a signal would overwrite the outer handler’s view of the original event — the file comment flags this as the bug the snapshot exists to prevent. - One bus listener per unique signal name. The listener’s body iterates the per-signal instance array and calls
_tryExecuteon each, so the cost of an additional instance is one array element, not oneSig.oncall. - Listener refs are keyed off the underlying signal name with
__counter__and__timer_reset__prefixes so the three subscription classes can coexist against the same signal and still be unsubscribed individually atcleartime. _tryExecutegates onenabled,cooldownRemaining > 0, exhaustedcharges(charges >= 0 && chargesUsed >= charges), andevaluateAllConditions. On success it runsexecuteAllActions, logs telemetry, bumpschargesUsed, setscooldownRemainingfrom a literal or$var-resolved value, and pushes the activation banner whenshowBanneris set and the owner is taggedartifact:.- Auras tick on
auraInterval(default0.5s) rather than every frame. Three branches: conditions pass + not active → execute actions and mark active; conditions fail + active → strip modifiers by source tag and mark inactive; conditions pass + still active → tear down and re-execute, so anymodify_stat_scaledaction recomputes its magnitude each interval. - Timers accumulate
dt, resolve their duration through$varsubstitution, fire once at threshold, and either reset (whentimerRepeat) or disable themselves. A matchingtimerResetSignalzeros the accumulator and removes any active aura modifiers attached to the instance. - Counters increment a per-instance
counterAccuminside their dedicated listener, resolve the threshold via$varsubstitution per check, fire_tryExecutewhen reached, and reset the accumulator unlesscounterResets === false. - The
custom_tickaction type is a no-op inexecuteAllActions. Per-frame work runs fromtickby walking_tickHandlers, looking up the named handler inCustomTickHandlers, resolving each non-handlerparam throughresolveNumber(booleans coerced to 0 / 1, non-numeric params silently skipped), and invoking the handler withdt, the resolved params, and the cached state. unregisterrebuilds every index from scratch instead of surgically deleting entries. The instance counts a single run accumulates are small enough that the rebuild cost is preferable to the bookkeeping required for surgical removal._removeAuraModifierswalks the per-target modifier bucket in reverse, splices every modifier whosesourcematches the instance owner, mirrors the deletion intoModifiers._mods, and marks the entity dirty. The target id is read off the cached ship via an untypedeidcast and defaults to0if the ship is missing.- The activation banner is dev-curated — opt-in via
showBanneron the definition and only fires for owners taggedartifact:<id>. High-frequency procs, auras, andrun_starteffects can register banners without spamming the screen because designers leave the flag off. - Telemetry has two channels: a
[EFFECT]console.logline per fire that lists the owner, fire count, resolved action params (with$varsubstitution applied), and trigger name; andwindow.__effectTriggerCounts[owner]bumped per fire for gauntlet metric collection. The window write is guarded by atypeof window !== 'undefined'check so the engine still runs in non-browser test environments. registeronly auto-subscribessignalandcounterlisteners mid-run —timerresets and the timer-reset listener subscriptions are batched ininit, so a new timer-trigger instance registered afterinitwill tick correctly but its reset-signal listener will not be wired until the nextclear→initcycle.