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. tier is 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 _active for 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_secondPulseTimer and its X/Y/pctClose/pctFar/radius, echo_lastWeaponCount, tbone_beamTimer and its X/Y/dir/range/width, ps_pulseTimer and 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 in tickArtifacts). HUD reads via getArtifactFlash(id).
  • Cached Sig listener refs (_onEnemyKill, _onShieldHit, _onCrateBreak, _onTboneHit, _onEventComplete) so teardownArtifacts can detach exactly what initArtifacts attached.
  • SPEED_BLEED{ heavy: 0.95, medium: 0.85, light: 0.50 }. Mirrors collision-resolver.ts so 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-registers EffectEngine entries 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 legendary doublePulse), _handleTboneHit (Battering Ram speed restore + bonus damage; T-Bone Shockwave tesla_line projectile spawn), _handleEventComplete (Event Healer heal + legendary invuln).
  • Helpers: _initArtifactState, _applyStatEffects, _unapplyStatEffects, _applyFlatBonus, _removeFlatBonus, _recordBestTier, _maybeRecordLegendaryUnlock, _aoeRing, _haloFlash, _crateBusterDamageAndVfx.
  • Reward generation: ArtifactRollMode union ('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/signalsSig.on/off and SignalContext. Subscribes to enemy_kill, shield_hit, crate_break, tbone_hit, event_complete at priority 50.
  • ../core/state — module-level game, ship, world singletons (aliased _moduleGame, _moduleShip). grantArtifact accepts optional overrides for non-singleton call sites.
  • ../core/typesGameState, ShipState.
  • ../../data/artifactsARTIFACT_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/modifiersModifiers.add / Modifiers.removeBySource for the universal flat-bonus passive and any other stat-mode artifact effect that needs proper recalc semantics. Source key shape is artifact:<id>:flatbonus.
  • ../core/set-poolacquireSet() for the hits set on spawned playerBullets (Soul Leech ghosts, T-Bone tesla line).
  • ../core/spatial-gridenemyGrid.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/damagedamageEnemy(e, dmg, game, ship, world) for every artifact-dealt hit.
  • ../effects/effect-engineEffectEngine.register(def.effects, 'artifact:<id>', tierValues) and unregister(...) on grant / level up. Lets data-driven artifact effects flow through the same engine as weapons and modifiers.
  • ../effects/custom-handlerstickFlameZones, clearFlameZones, tickDelayedAoEs, clearDelayedAoEs. tickArtifacts drives the flame-zone and delayed-AoE update loops; teardown clears both.
  • ../vfx/particlesParticles (hex bursts, raw add) and DmgNumbers for all spark, ember, wisp, and trail effects.
  • ../vfx/sonar-ringsSonarRings.shockwave for deployment/zap/repel/expire rings.
  • ../vfx/explosion-fxExplosionFX.haloRing and ExplosionFX.big for halo flashes and legendary chain-lightning impacts.
  • ../rendering/draw-artifact-bannersclearArtifactBanners() (called from init + teardown to drop stale banners between runs).
  • ../telemetry/collectortelemetry.recordArtifactEvent(id, kind, tier, value?, count?) for pick, 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/artifactUnlocksStoreuseArtifactUnlocksStore.getState().isUnlocked(id) inside _maybeRecordLegendaryUnlock. Synchronous Zustand read; wrapped in try/catch for headless/test contexts.
  • ./leveling — type RewardChoice only (no runtime dependency; leveling.applyReward is the caller of grantArtifact, not the other direction).

PUSHES TO

  • _active and _map — owned mutable run state; initArtifacts clears both, grantArtifact appends or in-place levels, teardownArtifacts clears.
  • _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 over dt each frame.
  • gm.artifacts (serializable mirror) — kept in sync with _active on every grant/level-up.
  • gm.upgradeCounts['more_projectiles'] — Echo Generator stat path adjusts this by the artifact’s per-tier extraProjectiles (and toggles echo_double_chance / echo_triple_chance on 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; RunStatsScreen renders a reveal section.
  • Modifiers table — _applyFlatBonus/_removeFlatBonus add/strip per-artifact passive entries keyed artifact:<id>:flatbonus; the stat / mode / value all come from data/artifacts.
  • EffectEngineregister('artifact:<id>', def.effects, tierValues) on grant; unregister + re-register on level-up so the engine sees the new tier’s tier-values.
  • world.playerBullets — Soul Leech pushes ghost arch bullets (with target id and homing); T-Bone Shockwave pushes a tesla_line arch bullet with _behaviors: ['tesla_line'] and full ball/spread/cooldown fields.
  • _moduleShip — Battering Ram rewrites ship.vx/ship.vy to restore speed after collision bleed; Force Field sets ship.invulnerable = true and refreshes ship.invulnTimer = 0.1 while the player is inside the field; Event Healer sets ship.hp = min(hpMax, hp + hpMax * healPct), and on legendary sets invulnerable + invulnTimer = v.invulnTime. Personal Space stuns from _handleTboneHit-adjacent paths set e._stunTimer.
  • Enemy state — droid zap and force-field-legendary explode call damageEnemy; droid zap sets e._stunTimer at legendary; force-field expel/repel rewrites e.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.recordArtifactEvent on grant and every triggered effect.
  • custom-handlers zones — tickFlameZones and tickDelayedAoEs are driven from tickArtifacts; clearFlameZones and clearDelayedAoEs from 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) from data/artifacts.
  • Draw artifact sprites or HUD icons. The bridge calls getCompanionDroidPos, getDroidBeamState, getForceFieldState, getTboneBeamState, getKnockbackPulseState and renders from those snapshots; HUD reads getArtifactFlash and renders.
  • Spawn artifact pickups. Bridge spawns and routes collection; grantArtifact is the entry point once the pickup is collected.
  • Decide which artifacts appear in the reward pool beyond the per-run whitelist. rollArtifactChoices respects gm.runDef.context.artifactPool (if set), gm.banishedKeys, and the ArtifactRollMode, but the underlying pool comes from ARTIFACT_DEFS.
  • Handle weapon-knockback math. getWeaponKnockback prefers ship._knockbackForce (set by EffectEngine’s modify_stat) and only falls back to the Personal Space tier value for legacy compatibility. Same pattern for getWeaponStunDuration reading ship._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 — rollArtifactChoices builds RewardChoice data only, and deliberately does NOT forward def.description (the tier-specific sentence from getTierLabelAt(def, nextTier) is the authoritative card text).
  • Spawn flame zones. lingering_flames artifact and fire_trail weapon both call spawnFlameZone in custom-handlers; this module only ticks/clears via tickFlameZones/clearFlameZones.

Signals

Subscribes (all attached in initArtifacts at priority 50, detached in teardownArtifacts):

  • enemy_kill_handleEnemyKill — Soul Leech ghost spawn. ctx.num1/ctx.num2 is the kill position. Picks up to v.ghostCount (or 2 for legendary chainGhost) 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 within v.auraRadius of 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.dmgPctClosev.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 (uses SPEED_BLEED[shipClass] to compute exact restore mult so the ship retains v.speedRetain of pre-collision speed) plus legendary bonus % HP damage. Also spawns the T-Bone Shockwave tesla_line bullet (predicate: ctx.str1 === 'tbone').
  • event_complete_handleEventComplete — Event Healer heals hpMax * v.healPct; legendary sets invuln for v.invulnTime.

Does not fire any signals itself.

Entry points

  • initArtifacts() — called from bridge at run start (after Sig.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 calls clearFlameZones() + clearDelayedAoEs() + clearArtifactBanners().
  • grantArtifact(id, g?, s?) — called from leveling.applyReward for picked artifact cards, from bridge for starting artifacts (runDef.context.startingArtifactId and runDef.startingArtifacts — looped tier+1 times for pre-leveled grants), from playground for playground.grantArtifact(id, t), and from pickup-collection paths. Idempotent on level cap (no-op when already at ARTIFACT_TIER_MAX).
  • tickArtifacts(dt) — called from bridge each frame, after enemy updates and before particles. Order: tickFlameZones(dt)tickDelayedAoEs(dt) → decay _artifactFlash → decay tbone_beamTimer / ps_pulseTimer → per-instance switch over inst.id to _tickCompanionDroid, _tickCrateBuster, _tickEchoGenerator, _tickForceField.
  • rollArtifactChoices(count, mode = 'any') — called from bridge to build the artifact event card pool. 'upgrade_only' may return fewer than count cards 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’s flash_artifact action calls the setter.
  • getCompanionDroidPos(), getDroidBeamState(), getForceFieldState(), getTboneBeamState(), getKnockbackPulseState() — bridge render snapshots. Each returns null when the artifact is absent or not in the relevant phase.
  • getWeaponKnockback(), getWeaponStunDuration() — called by combat/damage.damageEnemy on every weapon hit.
  • notifyKnockback(x, y, force) — called by damage.ts after 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 by getTierValuesAt(ARTIFACT_MAP[id], inst.tier) to pull a tier-tuned v record. Behavior in code, numbers in data.
  • No per-artifact object allocations. All per-artifact state goes into one module-level _st keyed 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 _st and emit clean SonarRings + Particles at each phase transition.
  • Level-up = unapply old tier, mutate inst.tier, apply new tier. Stat effects (Echo Generator’s more_projectiles count, the universal flat-bonus modifier) are stripped and re-added so they pick up the new tier’s value cleanly. EffectEngine entries are unregistered + re-registered with new tier-values for the same reason.
  • Legendary tier is a tier >= ARTIFACT_TIER_MAX check, not a separate codepath. Each per-frame branch and signal handler checks tier >= ARTIFACT_TIER_MAX (or the equivalent tier-value flag like v.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. _applyFlatBonus always uses Modifiers.add(eid, stat, mode, value, 0, 0, 'artifact:<id>:flatbonus'). Direct ship[stat] mutation would get wiped on the next Modifiers.recalc.
  • Reward-card description split. def.description is intentionally NOT forwarded onto the RewardChoice — the tier-specific sentence from getTierLabelAt(def, nextTier) is the single authoritative card text (a fix for the previous two-conflicting-descriptions bug).
  • Knockback / stun dual-path. getWeaponKnockback / getWeaponStunDuration prefer ship._knockbackForce / ship._knockbackStun (set by the EffectEngine’s modify_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 via setTimeout. Frame-accurate, pauseable, and falls under tickArtifacts(dt) for free.
  • _handleEnemyKill reads kill position from ctx.num1/ctx.num2. _handleTboneHit discriminates the T-Bone Shockwave branch on ctx.str1 === 'tbone'. Signal context is a flat num1/num2/num3/num4/str1 payload by convention.
  • Telegraphy of legendary unlocks goes through gm.newlyUnlockedArtifactIds. _maybeRecordLegendaryUnlock is 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.
  • tickArtifacts always drives tickFlameZones and tickDelayedAoEs first. Both live in effects/custom-handlers and are clear-on-teardown. Spawning into either list is done by the artifact’s signal/tick branch (e.g. lingering_flames calls spawnFlameZone); the artifact module just owns the per-frame tick.