artifacts.ts
PURPOSE
Owns the per-run runtime for Alien Artifacts: tracks the player’s active artifact set, grants and tier-levels artifacts, ticks per-frame state for “weapon-like” artifacts (companion droid, force field, crate buster deferred pulse, echo generator), fans out signal handlers for triggered artifacts (Soul Leech, Reactive Shield, Crate Buster, Battering Ram, T-Bone Shockwave, Event Healer), rolls reward-card choices, exposes render-state snapshots for the bridge, and pipes artifact unlock + telemetry events out to the persistent stores.
Three artifact categories drive the dispatch shape: unique (custom triggered effects), stat (direct ship-stat modifiers), and weapon (fire on a timer like a weapon, no slot cost, no horizontal scaling).
OWNS
ArtifactInstance—{ id, tier }shape exported for callers.tieris the runtime tier index 0-4 (common, uncommon, rare, epic, legendary). Every artifact is granted at common and laddered up in-run;ARTIFACT_TIER_MAX(= 4) is the legendary cap._active: ArtifactInstance[]— the player’s active artifacts this run, module-level._map: Map<string, number>— id → index in_activefor O(1) lookup._st: Record<string, number>— flat mutable bag for per-artifact per-frame state. Keyed by artifact-specific names (ff_phase,ff_orbX,ff_fieldTimer,companion_angle,droid_phase,droid_fieldRadius,droid_fieldTimer,droid_fieldTickTimer,crateBuster_secondPulseTimerand its X/Y/pctClose/pctFar/radius,echo_lastWeaponCount,tbone_beamTimerand its X/Y/dir/range/width,ps_pulseTimerand its X/Y/radius). Single object, no per-artifact allocations._artifactFlash: Record<string, number>— HUD flash timers (set to 0.3 on trigger, decayed each frame intickArtifacts). HUD reads viagetArtifactFlash(id).- Cached
Siglistener refs (_onEnemyKill,_onShieldHit,_onCrateBreak,_onTboneHit,_onEventComplete) soteardownArtifactscan detach exactly whatinitArtifactsattached. SPEED_BLEED—{ heavy: 0.95, medium: 0.85, light: 0.50 }. Mirrorscollision-resolver.tsso Battering Ram can undo the collision-time speed bleed precisely.DROID_FIELD_RADIUS = 120,DROID_FIELD_DURATION = 5.0,DROID_FIELD_TICK = 0.2,DROID_FLY_SPEED = 450— Companion Droid tuning.ARTIFACT_TIER_NAMES_ARR = ['common', 'uncommon', 'rare', 'epic', 'legendary']— local rarity-name table for reward cards (kept local to avoid importing the const assertion).- Lifecycle:
initArtifacts(),teardownArtifacts(),grantArtifact(id, g?, s?). Grant either creates a new instance at tier 0 or unapplies/re-applies stat effects at the next tier, re-registersEffectEngineentries with the new tier’s tier-values, records best-tier for end-of-run flush, and detects legendary unlocks via_maybeRecordLegendaryUnlock. - Per-frame:
tickArtifacts(dt)and its per-artifact branches_tickCompanionDroid,_tickCrateBuster,_tickEchoGenerator,_tickForceField. - Signal handlers:
_handleEnemyKill(Soul Leech ghost spawn),_handleShieldHit(Reactive Shield aura + legendary chain lightning),_handleCrateBreak(Crate Buster pulse + queued second pulse for legendarydoublePulse),_handleTboneHit(Battering Ram speed restore + bonus damage; T-Bone Shockwavetesla_lineprojectile spawn),_handleEventComplete(Event Healer heal + legendary invuln). - Helpers:
_initArtifactState,_applyStatEffects,_unapplyStatEffects,_applyFlatBonus,_removeFlatBonus,_recordBestTier,_maybeRecordLegendaryUnlock,_aoeRing,_haloFlash,_crateBusterDamageAndVfx. - Reward generation:
ArtifactRollModeunion ('any' | 'new_only' | 'upgrade_only'),canRollArtifactUpgrade(),countUpgradeableArtifacts(),countOwnedArtifacts(),countAvailableNewArtifacts(),rollArtifactChoices(count, mode). - Queries / render-state snapshots:
getArtifactFlash,setArtifactFlash,hasArtifact,getArtifactTier,getActiveArtifacts,getCompanionDroidPos,getDroidBeamState,getTboneBeamState,getKnockbackPulseState,getForceFieldState,getWeaponKnockback,notifyKnockback,getWeaponStunDuration.
READS FROM
../core/signals—Sig.on/offandSignalContext. Subscribes toenemy_kill,shield_hit,crate_break,tbone_hit,event_completeat priority 50.../core/state— module-levelgame,ship,worldsingletons (aliased_moduleGame,_moduleShip).grantArtifactaccepts optional overrides for non-singleton call sites.../core/types—GameState,ShipState.../../data/artifacts—ARTIFACT_MAP,ARTIFACT_DEFS,ARTIFACT_TIER_COLOR_BY_IDX,ARTIFACT_TIER_MAX,getTierValuesAt,getTierLabelAt,getArtifactFlatBonus,getArtifactFlatBonusValueAt,ArtifactDef. All tier-tuned numbers and copy come from data.../core/modifiers—Modifiers.add/Modifiers.removeBySourcefor the universal flat-bonus passive and any other stat-mode artifact effect that needs proper recalc semantics. Source key shape isartifact:<id>:flatbonus.../core/set-pool—acquireSet()for thehitsset on spawnedplayerBullets(Soul Leech ghosts, T-Bone tesla line).../core/spatial-grid—enemyGrid.query(x, y, radius)for every range check (droid cluster centroid, droid zap, force-field expel/repel/legendary-explode, Soul Leech target finding, Reactive Shield aura + chain target search, Crate Buster pulse, T-Bone average-HP sampling, Battering Ram bonus damage).../combat/damage—damageEnemy(e, dmg, game, ship, world)for every artifact-dealt hit.../effects/effect-engine—EffectEngine.register(def.effects, 'artifact:<id>', tierValues)andunregister(...)on grant / level up. Lets data-driven artifact effects flow through the same engine as weapons and modifiers.../effects/custom-handlers—tickFlameZones,clearFlameZones,tickDelayedAoEs,clearDelayedAoEs.tickArtifactsdrives the flame-zone and delayed-AoE update loops; teardown clears both.../vfx/particles—Particles(hex bursts, rawadd) andDmgNumbersfor all spark, ember, wisp, and trail effects.../vfx/sonar-rings—SonarRings.shockwavefor deployment/zap/repel/expire rings.../vfx/explosion-fx—ExplosionFX.haloRingandExplosionFX.bigfor halo flashes and legendary chain-lightning impacts.../rendering/draw-artifact-banners—clearArtifactBanners()(called from init + teardown to drop stale banners between runs).../telemetry/collector—telemetry.recordArtifactEvent(id, kind, tier, value?, count?)forpick,droid_deploy,droid_zap,echo_burst,ff_deploy,soul_ghost,reactive_aura,reactive_chain,crate_pulse,ram_restore,ram_bonus,tbone_beam,event_heal,event_invuln.../../stores/artifactUnlocksStore—useArtifactUnlocksStore.getState().isUnlocked(id)inside_maybeRecordLegendaryUnlock. Synchronous Zustand read; wrapped in try/catch for headless/test contexts../leveling— typeRewardChoiceonly (no runtime dependency;leveling.applyRewardis the caller ofgrantArtifact, not the other direction).
PUSHES TO
_activeand_map— owned mutable run state;initArtifactsclears both,grantArtifactappends or in-place levels,teardownArtifactsclears._st— per-artifact per-frame state bag (see OWNS)._artifactFlash[id]— set to 0.3 on every trigger (grant, droid deploy, zap, echo burst, force-field deploy, force-field repel, force-field expire, crate pulse, ram restore, ram bonus, soul ghost spawn, reactive aura, t-bone beam, event heal, personal-space knockback). HUD reads these and they decay overdteach frame.gm.artifacts(serializable mirror) — kept in sync with_activeon every grant/level-up.gm.upgradeCounts['more_projectiles']— Echo Generator stat path adjusts this by the artifact’s per-tierextraProjectiles(and togglesecho_double_chance/echo_triple_chanceon grant/unapply).gm.tracking.artifactsCollected— pushed on every grant (one entry per grant call).gm.tracking.artifactBestTier[id]— monotonically increased on grant; bridge flushes to the persistent unlocks store at run end.gm.newlyUnlockedArtifactIds— appended when an artifact reaches legendary AND isn’t already unlocked as a starter (deduped against the store). Bridge flushes;RunStatsScreenrenders a reveal section.Modifierstable —_applyFlatBonus/_removeFlatBonusadd/strip per-artifact passive entries keyedartifact:<id>:flatbonus; the stat / mode / value all come fromdata/artifacts.EffectEngine—register('artifact:<id>', def.effects, tierValues)on grant;unregister+ re-registeron level-up so the engine sees the new tier’s tier-values.world.playerBullets— Soul Leech pushesghostarch bullets (with target id and homing); T-Bone Shockwave pushes atesla_linearch bullet with_behaviors: ['tesla_line']and full ball/spread/cooldown fields._moduleShip— Battering Ram rewritesship.vx/ship.vyto restore speed after collision bleed; Force Field setsship.invulnerable = trueand refreshesship.invulnTimer = 0.1while the player is inside the field; Event Healer setsship.hp = min(hpMax, hp + hpMax * healPct), and on legendary setsinvulnerable+invulnTimer = v.invulnTime. Personal Space stuns from_handleTboneHit-adjacent paths sete._stunTimer.- Enemy state — droid zap and force-field-legendary explode call
damageEnemy; droid zap setse._stunTimerat legendary; force-field expel/repel rewritese.x/e.y/e.vx/e.vy. - VFX pipelines (
Particles,SonarRings,ExplosionFX) — many calls per artifact for trail, deploy, zap, repel, expire, halo, chain arc, ember, wisp, smoke, ice burst. - Telemetry —
telemetry.recordArtifactEventon grant and every triggered effect. custom-handlerszones —tickFlameZonesandtickDelayedAoEsare driven fromtickArtifacts;clearFlameZonesandclearDelayedAoEsfrom teardown.clearArtifactBanners()is called from init and teardown.
DOES NOT
- Define artifact stats, tier curves, descriptions, copy, or rarity colors. Everything tier-specific is read through
getTierValuesAt(def, tier)fromdata/artifacts. - Draw artifact sprites or HUD icons. The bridge calls
getCompanionDroidPos,getDroidBeamState,getForceFieldState,getTboneBeamState,getKnockbackPulseStateand renders from those snapshots; HUD readsgetArtifactFlashand renders. - Spawn artifact pickups. Bridge spawns and routes collection;
grantArtifactis the entry point once the pickup is collected. - Decide which artifacts appear in the reward pool beyond the per-run whitelist.
rollArtifactChoicesrespectsgm.runDef.context.artifactPool(if set),gm.banishedKeys, and theArtifactRollMode, but the underlying pool comes fromARTIFACT_DEFS. - Handle weapon-knockback math.
getWeaponKnockbackprefersship._knockbackForce(set byEffectEngine’smodify_stat) and only falls back to the Personal Space tier value for legacy compatibility. Same pattern forgetWeaponStunDurationreadingship._knockbackStun. - Modify the persistent unlocks store directly. Both legendary detection and best-tier tracking write to game-state buckets the bridge flushes at run end.
- Render or own the artifact reward card text styling —
rollArtifactChoicesbuildsRewardChoicedata only, and deliberately does NOT forwarddef.description(the tier-specific sentence fromgetTierLabelAt(def, nextTier)is the authoritative card text). - Spawn flame zones.
lingering_flamesartifact andfire_trailweapon both callspawnFlameZoneincustom-handlers; this module only ticks/clears viatickFlameZones/clearFlameZones.
Signals
Subscribes (all attached in initArtifacts at priority 50, detached in teardownArtifacts):
enemy_kill→_handleEnemyKill— Soul Leech ghost spawn.ctx.num1/ctx.num2is the kill position. Picks up tov.ghostCount(or 2 for legendarychainGhost) nearest living enemies within 400px and spawns one homing ghost bullet per target, cycling targets if there are fewer enemies than ghosts.shield_hit→_handleShieldHit— Reactive Shield aura damages all enemies withinv.auraRadiusof the ship; at legendary (chainJump), each hit arcs once to a nearest within 80px not already in the aura set.crate_break→_handleCrateBreak— Crate Buster pulse with distance falloff (v.dmgPctClose→v.dmgPctFar) and, at legendary (doublePulse), schedules a second 1.5× radius / 1.5× damage pulse 0.3s later via_st.crateBuster_secondPulseTimer.tbone_hit→_handleTboneHit— Battering Ram speed-restore (usesSPEED_BLEED[shipClass]to compute exact restore mult so the ship retainsv.speedRetainof pre-collision speed) plus legendary bonus % HP damage. Also spawns the T-Bone Shockwavetesla_linebullet (predicate:ctx.str1 === 'tbone').event_complete→_handleEventComplete— Event Healer healshpMax * v.healPct; legendary sets invuln forv.invulnTime.
Does not fire any signals itself.
Entry points
initArtifacts()— called from bridge at run start (afterSig.clear()). Resets_active/_map/_st/_artifactFlash, clears artifact banners, and attaches all five signal listeners.teardownArtifacts()— called from bridge at run end. Detaches all listeners, clears module state, and callsclearFlameZones()+clearDelayedAoEs()+clearArtifactBanners().grantArtifact(id, g?, s?)— called fromleveling.applyRewardfor picked artifact cards, from bridge for starting artifacts (runDef.context.startingArtifactIdandrunDef.startingArtifacts— looped tier+1 times for pre-leveled grants), from playground forplayground.grantArtifact(id, t), and from pickup-collection paths. Idempotent on level cap (no-op when already atARTIFACT_TIER_MAX).tickArtifacts(dt)— called from bridge each frame, after enemy updates and before particles. Order:tickFlameZones(dt)→tickDelayedAoEs(dt)→ decay_artifactFlash→ decaytbone_beamTimer/ps_pulseTimer→ per-instance switch overinst.idto_tickCompanionDroid,_tickCrateBuster,_tickEchoGenerator,_tickForceField.rollArtifactChoices(count, mode = 'any')— called from bridge to build the artifact event card pool.'upgrade_only'may return fewer thancountcards when the player has fewer upgradeable artifacts than requested; callers must handle the short/empty case.canRollArtifactUpgrade(),countUpgradeableArtifacts(),countOwnedArtifacts(),countAvailableNewArtifacts()— bridge uses these to decide whether to swap an artifact event to a weapon event, and to gate “two-card” event variants.hasArtifact(id),getArtifactTier(id),getActiveArtifacts()— read-only queries used by the bridge, leveling, and the playground REPL.getArtifactFlash(id)/setArtifactFlash(id, duration)— HUD reads flash timers; the EffectEngine’sflash_artifactaction calls the setter.getCompanionDroidPos(),getDroidBeamState(),getForceFieldState(),getTboneBeamState(),getKnockbackPulseState()— bridge render snapshots. Each returnsnullwhen the artifact is absent or not in the relevant phase.getWeaponKnockback(),getWeaponStunDuration()— called bycombat/damage.damageEnemyon every weapon hit.notifyKnockback(x, y, force)— called bydamage.tsafter applying knockback, to set the Personal Space pulse-ring VFX (_st.ps_pulseTimer = 0.15, radius =force * 0.8).
Pattern notes
- Switch-on-id dispatch with shared flat state. Every per-frame branch (
_tickCompanionDroid,_tickCrateBuster,_tickEchoGenerator,_tickForceField) and every signal handler is a_map.has(id)guard followed bygetTierValuesAt(ARTIFACT_MAP[id], inst.tier)to pull a tier-tunedvrecord. Behavior in code, numbers in data. - No per-artifact object allocations. All per-artifact state goes into one module-level
_stkeyed by string. The naming convention is<shortprefix>_<field>(ff_*,droid_*,companion_*,crateBuster_*,echo_*,tbone_*,ps_*). - Phase machines for weapon-like artifacts. Companion Droid runs phases 0–4 (idle/orbit → fly → deploy → field/zap → return). Force Field runs phases 0–2 (cooldown → orb-flight → field-active). Both use field-radius and timer fields in
_stand emit clean SonarRings + Particles at each phase transition. - Level-up = unapply old tier, mutate
inst.tier, apply new tier. Stat effects (Echo Generator’smore_projectilescount, the universal flat-bonus modifier) are stripped and re-added so they pick up the new tier’s value cleanly.EffectEngineentries are unregistered + re-registered with new tier-values for the same reason. - Legendary tier is a
tier >= ARTIFACT_TIER_MAXcheck, not a separate codepath. Each per-frame branch and signal handler checkstier >= ARTIFACT_TIER_MAX(or the equivalent tier-value flag likev.doublePulse,v.chainJump,v.chainGhost,v.invulnTime,v.bonusPct,v.explodePct,v.stunDuration) to add the legendary slice. - Stat-mode artifacts go through
Modifiers, not direct ship-stat writes._applyFlatBonusalways usesModifiers.add(eid, stat, mode, value, 0, 0, 'artifact:<id>:flatbonus'). Directship[stat]mutation would get wiped on the nextModifiers.recalc. - Reward-card description split.
def.descriptionis intentionally NOT forwarded onto theRewardChoice— the tier-specific sentence fromgetTierLabelAt(def, nextTier)is the single authoritative card text (a fix for the previous two-conflicting-descriptions bug). - Knockback / stun dual-path.
getWeaponKnockback/getWeaponStunDurationprefership._knockbackForce/ship._knockbackStun(set by the EffectEngine’smodify_stat) and fall back to the artifact’s tier value. Lets data-driven artifacts and the legacy Personal Space path coexist. - Crate Buster second pulse is deferred via
_st, not viasetTimeout. Frame-accurate, pauseable, and falls undertickArtifacts(dt)for free. _handleEnemyKillreads kill position fromctx.num1/ctx.num2._handleTboneHitdiscriminates the T-Bone Shockwave branch onctx.str1 === 'tbone'. Signal context is a flatnum1/num2/num3/num4/str1payload by convention.- Telegraphy of legendary unlocks goes through
gm.newlyUnlockedArtifactIds._maybeRecordLegendaryUnlockis the single funnel and dedupes against the persistent store; bridge handles the flush. Edge case: pre-seeded legendary starting artifacts also flag, because the run-stats reveal section treats first-time unlocks at any moment as new. tickArtifactsalways drivestickFlameZonesandtickDelayedAoEsfirst. Both live ineffects/custom-handlersand are clear-on-teardown. Spawning into either list is done by the artifact’s signal/tick branch (e.g.lingering_flamescallsspawnFlameZone); the artifact module just owns the per-frame tick.