engine/effects/actions.ts

PURPOSE

Action executors for the data-driven effect system. Each function performs one action type on the world when an effect’s trigger condition fires. The dispatcher (executeAction) looks up an action by its type string in ACTION_MAP and runs it; executeAllActions iterates every action on an EffectInstance’s def.actions array. Actions never re-check trigger conditions — gating already happened upstream. The file is the registry surface: it owns the canonical list of built-in action types and their implementations, while delegating to existing systems (modifiers, damage, VFX, signals) rather than reimplementing them.

OWNS

  • executeAction(action, inst, snap, game, ship, world) — main dispatch entry, throws on unknown action type.
  • executeAllActions(inst, snap, game, ship, world) — runs every action in inst.def.actions in order.
  • ACTION_MAPRecord<string, ActionFn> mapping action type string to implementation function. The authoritative built-in action registry.
  • ActionFn type — shared signature for action implementations: (action, inst, snap, game, ship, world) => void.
  • Per-action implementations (all private to the file):
    • Stat / modifier: actModifyStat (modify_stat), actModifyStatScaled (modify_stat_scaled), actModifyUpgradeCount (modify_upgrade_count), actRemoveModifiers (remove_modifiers).
    • Health / shield: actHeal (heal), actHealShield (heal_shield).
    • Defensive buffs: actGrantInvuln (grant_invuln), actSetHeat (set_heat).
    • Damage / control: actDamageAoe (damage_aoe), actApplyKnockback (apply_knockback), actRestoreSpeed (restore_speed, placeholder no-op).
    • Spawn: actSpawnProjectile (spawn_projectile).
    • Enemy status: actApplyEnemyStatus (apply_enemy_status), actApplyStatusAoe (apply_status_aoe).
    • Weapon: actEmpowerWeapon (empower_weapon).
    • Signal / VFX: actEmitSignal (emit_signal), actFlashArtifact (flash_artifact), actVfx (vfx).
    • Extensibility: actCustom (custom), actCustomTick (custom_tick, no-op stub).

READS FROM

  • ./typesActionDef, EffectInstance, SignalSnapshot (action shape, effect instance state, signal context).
  • ../core/typesGameState, ShipState, WorldState.
  • ./resolve-paramsresolveNumber, resolveString, resolveAllParams for value/expression resolution against inst.values.
  • ./custom-registryCustomHandlers lookup table for the custom action type.
  • ../../data/artifacts/indexARTIFACT_MAP for artifact color/audio metadata used by flash_artifact.
  • Ship state fields (via as any casts): eid, hp, hpMax, shield, shieldMax, radius, heat, invulnerable, invulnTimer, vx, vy, _base.hpMax, _base.shieldMax, weapons[]._empowerMult, weapons[]._empowerShots, angle.
  • World state: world.enemies[] (read for AoE damage / status / knockback iteration), enemy fields x, y, hp, hpMax, vx, vy, _id, eid, _isPackLeader.
  • Game state: game.upgradeCounts (for modify_upgrade_count).
  • SignalSnapshot fields: num1, num2, uid1 used as coordinate or target-id source when center === 'signal' or originX === 'signal'.
  • Effect instance: inst.values (resolved parameter bindings), inst.owner (default modifier source, parsed for artifact id when prefixed artifact:), inst.def.actions (iteration target for executeAllActions).

PUSHES TO

  • ../core/modifiersModifiers.add(...) for stat changes; direct mutation of Modifiers._modsByTarget, Modifiers._mods, Modifiers._dirty for source-scoped removal in remove_modifiers and modify_stat_scaled.
  • ../core/signalsSig.fire('player_healed', ...) after a successful heal, and Sig.fire(signal, ...) from emit_signal.
  • ../combat/damagedamageEnemy(enemy, dmg, game, ship, world) for every enemy hit by damage_aoe.
  • ./enemy-statusapplyStatus(enemy, type, duration, value, maxStacks) from apply_enemy_status and apply_status_aoe.
  • ../vfx/particlesParticles.burstHex(...), Particles.starBurst(...).
  • ../vfx/sonar-ringsSonarRings.spawn(...) for ring VFX.
  • ../vfx/player-glowPlayerGlow.flash(r, g, b) for hull-color flash.
  • ../vfx/juiceJuice.fire(def.procAudioCue) for per-artifact audio cues (opt-in via procAudioCue on artifact def, tick 68 / directive t66).
  • ../world/artifactssetArtifactFlash(artifactId, 0.5) to drive the HUD icon scale-pop.
  • world.playerBullets — pushes new bullet objects from spawn_projectile.
  • Ship mutations: ship.hp, ship.shield, (ship as any).invulnerable, (ship as any).invulnTimer, (ship as any).heat.
  • Enemy mutations: (e as any).vx, (e as any).vy in apply_knockback.
  • Weapon mutations: (w as any)._empowerMult, (w as any)._empowerShots in empower_weapon.
  • Game mutations: game.upgradeCounts[upgradeId] increment in modify_upgrade_count.

DOES NOT

  • Does not evaluate trigger conditions — the comment in the file header is explicit: “Actions never check conditions — that’s already done before dispatch.”
  • Does not iterate effects, schedule ticks, or manage cooldowns — that’s EffectEngine.tick() (referenced in the actCustomTick comment).
  • Does not implement the modifier-stacking math, damage formula, status-tick logic, or particle simulation — it delegates to Modifiers, damageEnemy, applyStatus, and the VFX modules respectively.
  • Does not validate ActionDef.params shape — missing values fall back via ?? defaults; unknown numeric fields silently default to 0.
  • Does not handle custom_tickactCustomTick is an intentional no-op stub; ticking happens in the effect engine.
  • Does not resolve action parameters into a single batch — each implementation resolves only the fields it cares about (the imported resolveAllParams is not called here despite being imported).
  • Does not target enemies by id outside of apply_enemy_status (which uses snap.uid1); other AoE actions ignore signal target id and only consume snap.num1 / snap.num2 as coordinates.
  • Does not knock back pack leaders — apply_knockback skips any enemy with _isPackLeader.
  • Does not heal past max — heal clamps to hpMax, heal_shield clamps to shieldMax; the player_healed signal only fires when the clamped delta is positive.
  • Does not produce a return value — all actions are void and communicate exclusively via mutation and signal emission.

Signals

Fires:

  • player_healed — emitted by heal after ship.hp clamps to hpMax, only when actual heal delta is > 0. Payload: (amount=actualHeal, 0, 0, 0, '').
  • Any signal string — emit_signal fires whatever name resolves from action.params.signal, with num1 / num2 / str1 taken from action params when present, falling back to the incoming snap.num1 / snap.num2 when omitted.

Reads (via SignalSnapshot snap):

  • snap.num1, snap.num2 — used as world coordinates when an action’s center (or originX for projectiles) param is 'signal'. Falls back to ship.x / ship.y otherwise. Consumed by damage_aoe, apply_knockback, apply_status_aoe, spawn_projectile.
  • snap.uid1 — enemy entity id, used by apply_enemy_status to find the target enemy (matched against e._id or e.eid).

The action layer never subscribes to signals directly — EffectEngine consumes the signal stream upstream and passes a SignalSnapshot into actions through the dispatcher.

Entry points

  • executeAction(action, inst, snap, game, ship, world) — single-action dispatch. Throws Unknown action type: <type> when ACTION_MAP[action.type] is missing.
  • executeAllActions(inst, snap, game, ship, world) — iterates inst.def.actions and dispatches each in declaration order.
  • Both are called by the effect engine after a trigger fires. They are not called from data definitions, UI, or other gameplay systems.
  • New action types are added by writing a private actFoo function with the ActionFn signature and registering it in ACTION_MAP. No other file needs to change for the dispatcher to pick it up (the type string must also be allowed by the ActionDef schema in ./types).
  • New custom behaviors (one-off logic that doesn’t warrant a registry entry) are added to CustomHandlers in ./custom-registry; the custom action delegates with all params auto-resolved to numbers (booleans are coerced to 0/1, non-numeric values are dropped).

Pattern notes

  • Dispatcher table over switch: ACTION_MAP is a Record<string, ActionFn> so registration is a single line per action. Adding a new action does not touch the dispatcher.
  • Resolve-on-read: every implementation calls resolveNumber / resolveString against inst.values per field. There is no shared resolved-params struct passed in — each action picks the fields it needs and resolves them with sensible defaults via ??.
  • Center-mode pattern: AoE actions (damage_aoe, apply_knockback, apply_status_aoe) and spawn_projectile use a center (or originX) string param. Value 'signal' reads snap.num1 / snap.num2; anything else (default 'ship') reads ship.x / ship.y.
  • Squared-distance loop: AoE actions compare dx*dx + dy*dy <= radius*radius to skip a sqrt per enemy. Only damage_aoe calls Math.sqrt — and only when linear falloff is requested.
  • Linear falloff in damage_aoe: when params.falloff === 'linear' and params.falloffFar !== undefined, damage is linearly interpolated from damage at center to falloffFar at the radius edge. hpPct mode is honored for both ends — the edge value is also treated as a fraction of hpMax. The interpolation is dmg + (farDmg - dmg) * t where t = min(1, dist / radius).
  • flash_artifact is owner-aware: the function recovers an artifact id by stripping the artifact: prefix from inst.owner (falls back to inst.owner raw). It then drives four cosmetic systems in a fixed sequence — HUD scale-pop (setArtifactFlash, 0.5s), optional audio cue (Juice.fire(def.procAudioCue) only if the artifact opts in), double sonar ring (outer bright at 4.0×, inner primary at 2.0×), 18+8 particle burst, and PlayerGlow.flash with the bright color parsed from #RRGGBB. Colors come from ARTIFACT_MAP[id].colors, defaulting to #ffffff / #88aaff when missing.
  • modify_stat_scaled self-replaces: before adding the new modifier, the function scans Modifiers._modsByTarget.get(eid) and removes any prior entry matching the same source and stat. This makes it safe to call on every tick from an aura effect — only one scaled modifier per (entity, stat, source) ever exists. The scaling variable is read from one of four hardcoded sources: heat, speed (Euclidean ship velocity), hp_pct, shield_pct.
  • remove_modifiers is source-scoped: walks the entity’s modifier bucket in reverse and splices out every modifier whose source matches params.source ?? inst.owner. Mirrored splice from Modifiers._mods, then marks Modifiers._dirty.add(eid) so cached aggregates rebuild.
  • spawn_projectile angle modes: spread === 360 distributes count shots evenly around the circle with a small random offset (Math.random() * 0.3); spread > 0 randomizes each shot within a ±spread/2 degree cone of the base angle; spread === 0 (default) fans count shots around the base angle at fixed 0.15-rad spacing. Base angle is derived from atan2(ship.vy, ship.vx), falling back to ship.angle when velocity is zero. Bullets are pushed directly onto world.playerBullets; the shape includes pierceCount, homing, blastRadius, weaponId (default '_artifact'), and arch (default 'projectile').
  • vfx is a switch on type: six recognised types — sonar_ring, particles, starburst, dual_ring, player_glow, burst_and_ring. Unknown types are silently ignored. player_glow parses a #RRGGBB color into byte triplet; everything else passes colors through as strings.
  • empower_weapon target modes: random picks one weapon index; slot_0 always uses weapon 0; all writes _empowerMult and _empowerShots onto every weapon and returns early. Any unrecognised target value falls through with weaponIdx = -1 and writes nothing.
  • apply_enemy_status is single-target only: it requires snap.uid1 and stops at the first matching enemy. There is no AoE variant in this function; bulk status goes through apply_status_aoe.
  • actRestoreSpeed is a documented placeholder: the body is empty with a comment that complex ram restoration uses a custom handler instead. The registry slot exists so restore_speed doesn’t crash.
  • actCustomTick is intentional dead code in the dispatcher: custom_tick effects are ticked elsewhere (in EffectEngine.tick()). The no-op exists so any accidental dispatch is safe.
  • Cast-heavy access to ship/enemy state: the file uses (ship as any).X and (e as any).X throughout to reach fields not on the canonical ShipState / enemy types. This is deliberate — the action layer is one of the few places that needs to read engine-internal fields like eid, _base, _id, _isPackLeader without owning the typings.
  • Defaults are hardcoded constants: every parameter has a fallback baked into the action body (e.g. radius ?? 100, force ?? 0, duration ?? 1). There is no shared defaults table; tweaking a default means editing the action implementation.
  • heal mode list: 'flat' (default — add raw amount), 'percentMax' (heal hpMax * amount), 'percentMissing' (heal (hpMax - hp) * amount). heal_shield supports only 'flat' and 'percentMax'.
  • Modifier mode strings: modify_stat accepts 'flat' | 'percent' | 'set'; modify_stat_scaled accepts only 'flat' | 'percent' (no 'set').
  • Stacking on modifiers: modify_stat passes { stackType: stacking, maxStacks } to Modifiers.add, where stacking is 'independent' (default) or 'refresh'. modify_stat_scaled does not pass stacking — it self-replaces by source instead.