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 ininst.def.actionsin order.ACTION_MAP—Record<string, ActionFn>mapping action type string to implementation function. The authoritative built-in action registry.ActionFntype — 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).
- Stat / modifier:
READS FROM
./types—ActionDef,EffectInstance,SignalSnapshot(action shape, effect instance state, signal context).../core/types—GameState,ShipState,WorldState../resolve-params—resolveNumber,resolveString,resolveAllParamsfor value/expression resolution againstinst.values../custom-registry—CustomHandlerslookup table for thecustomaction type.../../data/artifacts/index—ARTIFACT_MAPfor artifact color/audio metadata used byflash_artifact.- Ship state fields (via
as anycasts):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 fieldsx,y,hp,hpMax,vx,vy,_id,eid,_isPackLeader. - Game state:
game.upgradeCounts(formodify_upgrade_count). SignalSnapshotfields:num1,num2,uid1used as coordinate or target-id source whencenter === 'signal'ororiginX === 'signal'.- Effect instance:
inst.values(resolved parameter bindings),inst.owner(default modifier source, parsed for artifact id when prefixedartifact:),inst.def.actions(iteration target forexecuteAllActions).
PUSHES TO
../core/modifiers—Modifiers.add(...)for stat changes; direct mutation ofModifiers._modsByTarget,Modifiers._mods,Modifiers._dirtyfor source-scoped removal inremove_modifiersandmodify_stat_scaled.../core/signals—Sig.fire('player_healed', ...)after a successful heal, andSig.fire(signal, ...)fromemit_signal.../combat/damage—damageEnemy(enemy, dmg, game, ship, world)for every enemy hit bydamage_aoe../enemy-status—applyStatus(enemy, type, duration, value, maxStacks)fromapply_enemy_statusandapply_status_aoe.../vfx/particles—Particles.burstHex(...),Particles.starBurst(...).../vfx/sonar-rings—SonarRings.spawn(...)for ring VFX.../vfx/player-glow—PlayerGlow.flash(r, g, b)for hull-color flash.../vfx/juice—Juice.fire(def.procAudioCue)for per-artifact audio cues (opt-in viaprocAudioCueon artifact def, tick 68 / directive t66).../world/artifacts—setArtifactFlash(artifactId, 0.5)to drive the HUD icon scale-pop.world.playerBullets— pushes new bullet objects fromspawn_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).vyinapply_knockback. - Weapon mutations:
(w as any)._empowerMult,(w as any)._empowerShotsinempower_weapon. - Game mutations:
game.upgradeCounts[upgradeId]increment inmodify_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 theactCustomTickcomment). - 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.paramsshape — missing values fall back via??defaults; unknown numeric fields silently default to 0. - Does not handle
custom_tick—actCustomTickis 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
resolveAllParamsis not called here despite being imported). - Does not target enemies by id outside of
apply_enemy_status(which usessnap.uid1); other AoE actions ignore signal target id and only consumesnap.num1/snap.num2as coordinates. - Does not knock back pack leaders —
apply_knockbackskips any enemy with_isPackLeader. - Does not heal past max —
healclamps tohpMax,heal_shieldclamps toshieldMax; theplayer_healedsignal only fires when the clamped delta is positive. - Does not produce a return value — all actions are
voidand communicate exclusively via mutation and signal emission.
Signals
Fires:
player_healed— emitted byhealaftership.hpclamps tohpMax, only when actual heal delta is> 0. Payload:(amount=actualHeal, 0, 0, 0, '').- Any signal string —
emit_signalfires whatever name resolves fromaction.params.signal, withnum1/num2/str1taken from action params when present, falling back to the incomingsnap.num1/snap.num2when omitted.
Reads (via SignalSnapshot snap):
snap.num1,snap.num2— used as world coordinates when an action’scenter(ororiginXfor projectiles) param is'signal'. Falls back toship.x/ship.yotherwise. Consumed bydamage_aoe,apply_knockback,apply_status_aoe,spawn_projectile.snap.uid1— enemy entity id, used byapply_enemy_statusto find the target enemy (matched againste._idore.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. ThrowsUnknown action type: <type>whenACTION_MAP[action.type]is missing.executeAllActions(inst, snap, game, ship, world)— iteratesinst.def.actionsand 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
actFoofunction with theActionFnsignature and registering it inACTION_MAP. No other file needs to change for the dispatcher to pick it up (the type string must also be allowed by theActionDefschema in./types). - New custom behaviors (one-off logic that doesn’t warrant a registry entry) are added to
CustomHandlersin./custom-registry; thecustomaction delegates with all params auto-resolved to numbers (booleans are coerced to0/1, non-numeric values are dropped).
Pattern notes
- Dispatcher table over switch:
ACTION_MAPis aRecord<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/resolveStringagainstinst.valuesper 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) andspawn_projectileuse acenter(ororiginX) string param. Value'signal'readssnap.num1/snap.num2; anything else (default'ship') readsship.x/ship.y. - Squared-distance loop: AoE actions compare
dx*dx + dy*dy <= radius*radiusto skip asqrtper enemy. Onlydamage_aoecallsMath.sqrt— and only when linear falloff is requested. - Linear falloff in
damage_aoe: whenparams.falloff === 'linear'andparams.falloffFar !== undefined, damage is linearly interpolated fromdamageat center tofalloffFarat the radius edge.hpPctmode is honored for both ends — the edge value is also treated as a fraction ofhpMax. The interpolation isdmg + (farDmg - dmg) * twheret = min(1, dist / radius). flash_artifactis owner-aware: the function recovers an artifact id by stripping theartifact:prefix frominst.owner(falls back toinst.ownerraw). 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, andPlayerGlow.flashwith the bright color parsed from#RRGGBB. Colors come fromARTIFACT_MAP[id].colors, defaulting to#ffffff/#88aaffwhen missing.modify_stat_scaledself-replaces: before adding the new modifier, the function scansModifiers._modsByTarget.get(eid)and removes any prior entry matching the samesourceandstat. 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_modifiersis source-scoped: walks the entity’s modifier bucket in reverse and splices out every modifier whosesourcematchesparams.source ?? inst.owner. Mirrored splice fromModifiers._mods, then marksModifiers._dirty.add(eid)so cached aggregates rebuild.spawn_projectileangle modes:spread === 360distributescountshots evenly around the circle with a small random offset (Math.random() * 0.3);spread > 0randomizes each shot within a ±spread/2degree cone of the base angle;spread === 0(default) fanscountshots around the base angle at fixed 0.15-rad spacing. Base angle is derived fromatan2(ship.vy, ship.vx), falling back toship.anglewhen velocity is zero. Bullets are pushed directly ontoworld.playerBullets; the shape includespierceCount,homing,blastRadius,weaponId(default'_artifact'), andarch(default'projectile').vfxis a switch ontype: six recognised types —sonar_ring,particles,starburst,dual_ring,player_glow,burst_and_ring. Unknown types are silently ignored.player_glowparses a#RRGGBBcolor into byte triplet; everything else passes colors through as strings.empower_weapontarget modes:randompicks one weapon index;slot_0always uses weapon 0;allwrites_empowerMultand_empowerShotsonto every weapon and returns early. Any unrecognisedtargetvalue falls through withweaponIdx = -1and writes nothing.apply_enemy_statusis single-target only: it requiressnap.uid1and stops at the first matching enemy. There is no AoE variant in this function; bulk status goes throughapply_status_aoe.actRestoreSpeedis a documented placeholder: the body is empty with a comment that complex ram restoration uses acustomhandler instead. The registry slot exists sorestore_speeddoesn’t crash.actCustomTickis intentional dead code in the dispatcher:custom_tickeffects are ticked elsewhere (inEffectEngine.tick()). The no-op exists so any accidental dispatch is safe.- Cast-heavy access to ship/enemy state: the file uses
(ship as any).Xand(e as any).Xthroughout to reach fields not on the canonicalShipState/ enemy types. This is deliberate — the action layer is one of the few places that needs to read engine-internal fields likeeid,_base,_id,_isPackLeaderwithout 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. healmode list:'flat'(default — add rawamount),'percentMax'(healhpMax * amount),'percentMissing'(heal(hpMax - hp) * amount).heal_shieldsupports only'flat'and'percentMax'.- Modifier mode strings:
modify_stataccepts'flat' | 'percent' | 'set';modify_stat_scaledaccepts only'flat' | 'percent'(no'set'). - Stacking on modifiers:
modify_statpasses{ stackType: stacking, maxStacks }toModifiers.add, wherestackingis'independent'(default) or'refresh'.modify_stat_scaleddoes not pass stacking — it self-replaces by source instead.