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 clearregisterinit → per-frame tickclear.

OWNS

  • Module-private effect-instance list — every EffectInstance registered for the current run, each holding a reference to its EffectDef, the owner tag, the $var value 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).
  • 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 at clear time to unsubscribe cleanly.
  • Cached references to the current run’s GameState, ShipState, and WorldState, set on every tick and read by signal handlers, counter listeners, and timer-reset listeners between frames.
  • Initialized flag (_initialized) that gates mid-run auto-subscription in register and decides whether run_start effects fire immediately.
  • The EffectEngine public object — the entire external API.
  • The _createInstance, _snapshotCtx, _tryExecute, _ensureSignalListener, _ensureCounterListener, _ensureTimerResetListener, and _removeAuraModifiers internal helpers.
  • The signal-context snapshot taken before each dispatch — a frozen copy of Sig._ctx fields (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.log line listing the owner, fire count, resolved action params, and trigger name, plus a per-owner counter on window.__effectTriggerCounts for gauntlet metric collection.

READS FROM

  • ../core/signals for Sig (subscribe / unsubscribe / context inspection) and SignalListener type.
  • ../core/modifiers for Modifiers._modsByTarget (per-target bucket), Modifiers._mods (master list), and Modifiers._dirty set — all read by _removeAuraModifiers for aura-tag teardown.
  • ../core/types for the GameState, ShipState, WorldState shapes used by the public surface and cached refs.
  • ./types for EffectDef, EffectInstance, SignalSnapshot.
  • ./conditions for evaluateAllConditions, called before any action dispatch.
  • ./actions for executeAllActions, called when conditions pass.
  • ./custom-registry for CustomTickHandlers — name-keyed registry consulted by the per-frame custom_tick dispatcher.
  • ./resolve-params for resolveNumber, used to resolve $var cooldowns, counter thresholds, timer durations, and numeric custom_tick params.
  • ../rendering/draw-artifact-banners for pushArtifactBanner, invoked on activation when showBanner is set and the owner is tagged artifact:.
  • The Sig._ctx shared-context object every time a signal handler enters _snapshotCtx.
  • The cached game.time field read by pushArtifactBanner.
  • 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 __effectTriggerCounts map 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 at clear time, stripping the __counter__ / __timer_reset__ prefixes when keying off.
  • ./conditions::evaluateAllConditions — called from _tryExecute before any action dispatch and from the aura tick path before state-transition decisions.
  • ./actions::executeAllActions — called from _tryExecute when all conditions pass and from the aura tick path when entering / refreshing the active branch.
  • Modifiers._modsByTarget and Modifiers._mods — direct bucket splice and master-list splice from _removeAuraModifiers to drop every modifier whose source equals 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 the artifact:<id> owner tag for any fire whose definition opts in via showBanner.
  • 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 evaluateAllConditions and every action runs through executeAllActions or the named custom_tick handler.
  • Does not own the custom action registry — only consumes CustomTickHandlers from ./custom-registry.
  • Does not own the signal bus or the signal context shape — only subscribes to Sig and snapshots its existing context fields.
  • Does not own enemy status, flame zones, delayed AoEs, or modifier-add logic — those are reached through ./actions and 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._ctx per-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_tick action in the normal action-dispatch path — executeAllActions treats it as a no-op while the engine’s own tick loop walks _tickHandlers and invokes the resolved handler with dt.
  • Does not validate EffectDef shape 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-instance counterAccum and fires _tryExecute once the resolved threshold is reached, resetting unless counterResets === false.
  • Every distinct timerResetSignal across registered timer-trigger instances — separate listener per name (keyed __timer_reset__<name>) that zeros timerAccum on every matching timer and tears down its aura modifiers if auraActive.

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 after init, fires run_start immediately if the engine is already running and the cached state is primed, and registers the instance into _tickHandlers when any action is custom_tick.
  • EffectEngine.unregister(owner) — splices every instance owned by the tag out of _instances, runs _removeAuraModifiers for any with auraActive, then clears and rebuilds every index from the remaining instances (does not unsubscribe bus listeners — those persist until clear).
  • EffectEngine.init() — wires up one listener per _bySignal key, one per _counters key, one per distinct timerResetSignal across _timers, flips _initialized true, then fires every run_start instance once against the cached state.
  • EffectEngine.tick(dt, game, ship, world) — sets the cached state refs, decays cooldownRemaining on every instance, ticks the aura interval / state-transition / scaled-refresh branches, ticks one-shot and repeating timers with $var-resolved durations, and dispatches every custom_tick action by name with resolved numeric params.
  • EffectEngine.clear() — strips listener-ref prefixes, calls Sig.off for each entry, empties every index and the instance list, nulls the cached state refs, and resets _initialized.
  • EffectEngine.getInstances() — read-only view of _instances for diagnostics and tests.
  • EffectEngine.isInitialized() — bridge-side flag query used by registerRunEffects and teardown.

Pattern notes

  • The full lifecycle is clearregister (many) → inittick per frame → clear at run end. Mid-run register calls that arrive after init auto-subscribe their new signal / counter listeners and fire run_start immediately, supporting in-mission artifact pickups.
  • The shared Sig._ctx object 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 _tryExecute on each, so the cost of an additional instance is one array element, not one Sig.on call.
  • 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 at clear time.
  • _tryExecute gates on enabled, cooldownRemaining > 0, exhausted charges (charges >= 0 && chargesUsed >= charges), and evaluateAllConditions. On success it runs executeAllActions, logs telemetry, bumps chargesUsed, sets cooldownRemaining from a literal or $var-resolved value, and pushes the activation banner when showBanner is set and the owner is tagged artifact:.
  • Auras tick on auraInterval (default 0.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 any modify_stat_scaled action recomputes its magnitude each interval.
  • Timers accumulate dt, resolve their duration through $var substitution, fire once at threshold, and either reset (when timerRepeat) or disable themselves. A matching timerResetSignal zeros the accumulator and removes any active aura modifiers attached to the instance.
  • Counters increment a per-instance counterAccum inside their dedicated listener, resolve the threshold via $var substitution per check, fire _tryExecute when reached, and reset the accumulator unless counterResets === false.
  • The custom_tick action type is a no-op in executeAllActions. Per-frame work runs from tick by walking _tickHandlers, looking up the named handler in CustomTickHandlers, resolving each non-handler param through resolveNumber (booleans coerced to 0 / 1, non-numeric params silently skipped), and invoking the handler with dt, the resolved params, and the cached state.
  • unregister rebuilds 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.
  • _removeAuraModifiers walks the per-target modifier bucket in reverse, splices every modifier whose source matches the instance owner, mirrors the deletion into Modifiers._mods, and marks the entity dirty. The target id is read off the cached ship via an untyped eid cast and defaults to 0 if the ship is missing.
  • The activation banner is dev-curated — opt-in via showBanner on the definition and only fires for owners tagged artifact:<id>. High-frequency procs, auras, and run_start effects can register banners without spamming the screen because designers leave the flag off.
  • Telemetry has two channels: a [EFFECT] console.log line per fire that lists the owner, fire count, resolved action params (with $var substitution applied), and trigger name; and window.__effectTriggerCounts[owner] bumped per fire for gauntlet metric collection. The window write is guarded by a typeof window !== 'undefined' check so the engine still runs in non-browser test environments.
  • register only auto-subscribes signal and counter listeners mid-run — timer resets and the timer-reset listener subscriptions are batched in init, so a new timer-trigger instance registered after init will tick correctly but its reset-signal listener will not be wired until the next clearinit cycle.