leveling.ts

PURPOSE

Owns the run-time progression loop: XP-to-level conversion, reward-card pool generation for level-ups, weapon-cache pulls, and shooting-star pulls, and the canonical application of every reward type (weapon, weapon_upgrade, weapon_merge, modifier, artifact, shooting_star, legacy upgrade). Also exposes resolveWeaponChestUpgrade for fractional weapon-chest gains and applyEventRewardUpgrades for the Event Reward artifact path.

OWNS

  • XP_THRESHOLDS — dynamically-growing XP threshold table. Hand-tuned base table for levels 0–20; beyond level 20 each level’s cost compounds at 12% over the previous level’s cost.
  • _BASE_THRESHOLDS — the hand-tuned 0–20 ladder. Levels 1–10 are a flat linear ramp (50, 100, 150, … 500). Levels 11–20 ramp from cost 575 to 3200.
  • _ensureThreshold(level) — extends XP_THRESHOLDS on demand using the 12% compounding rule.
  • MAX_WEAPON_LEVEL = 20 — hard cap for all weapon level math (cards, chest upgrades, sympathetic-resonance cascade, shooting-star levelers, Forge Strike).
  • RARITY_ROLL_TABLE — five-tier rarity distribution for every level-up choice (common 50 / uncommon 30 / rare 15 / epic 4 / legendary 1) and the rarityMult curve (1.0 → 2.0).
  • _LUCK_BIAS — per-tier coefficients that bias the rarity roll based on ship.luck * (1 + ship.luckMult).
  • _rollRarity(ship?) — weighted rarity roll. Multiplies each non-common weight by (1 + effectiveLuck * _LUCK_BIAS[i]) and samples; returns { rarity, mult }. Falls back to common on numeric edge cases.
  • CHEST_RARITY_INCREMENT — fractional weapon-level deltas for chest pulls (common 0.20 → legendary 1.00).
  • _NEW_MODIFIER_DESC / _UPGRADE_MODIFIER_TEMPLATE — strings for first-pick vs upgrade-pick card copy. Upgrade template carries a {n} slot that gets filled with the value from the chosen stat at next level scaled by rarityMult.
  • _SHOOTING_STAR_CARDS — display metadata (name, icon, description) for the eight shooting-star categories.
  • _FLAT_AS_PERCENT_STATS — set of flat-mode stats that render as percent on cards (xpGainMult, luckMult).
  • _applyModifierPick — single source of truth for translating a modifier choice into permanent Modifiers.add entries with source key level_up:<modId>:<stat>. Captures pre-pick HP/Shield max, runs Modifiers.recalc(ship, ship._base), then bumps current hp/shield by the gained delta so a hpMax upgrade also heals by the gained amount.
  • _cumulativeModValue — sums getModifierValue(eff, i) * rarityMult over i = 1..level for card display.
  • _describeModifierUpgrade / _describeWeaponUpgrade / _describeNewWeapon — card-description builders.
  • _findLowestWeaponIndex — used by Forge Strike (shooting-star lowest_weapon).
  • _weaponFamilyIcon — emoji-icon fallback by weapon family.
  • LevelingSystem — public service object: checkLevelUp, levelUp, update, getProgress, generateRewardChoices, generateWeaponChoices, reset.
  • Compatibility wrappers: xpForLevel, addXP, generateRewardChoices, applyReward.
  • RewardChoice interface and BanishCategory union; banishKeyForChoice helper.

READS FROM

  • ../core/typesGameState shape (xp, level, xpToNext, enemyDifficultyLevel, rewardQueue, upgradeCounts, modifierTotals, weaponsAcquired, tracking, banishedKeys, artifacts, rerolls, banishes, refuels, runDef.context.upgradePool, runDef.context.weaponPool, time).
  • ../core/signalsSig.fire('level_up', …) emitted on each level-up.
  • ../core/modifiersModifiers.add and Modifiers.recalc. Required because direct ship[stat] mutation gets wiped by recalc() whenever any timed artifact modifier expires.
  • ../../data/modifiersMODIFIER_TYPES, MODIFIER_TYPE_MAP, getModifierValue, ModifierTypeDef, ModifierEffect. getModifierValue(eff, level) is the per-level magnitude used for both stat math and the value shown on the card (the latter additionally multiplied by rarityMult).
  • ../../data/weaponsWEAPONS, WEAPON_MAP, getWeaponStatAtLevel, getEffectiveLevel, getSteppedStatAtLevel, resolveWeaponRarity, WeaponCoreSpec, SteppedStat. Filters legendary and disabled entries out of chest pulls.
  • ../../data/artifactsARTIFACT_MAP, getTierValuesAt (used for the sympathetic-resonance bonusLevels lookup).
  • ./artifactsgrantArtifact, hasArtifact, getArtifactTier, setArtifactFlash.
  • ./merge-systemfindMergeCandidates, mergeWeapons, MergeCandidate.
  • ../rendering/draw-artifact-bannerspushArtifactBanner for cascade visual confirmation.
  • ../player/statesapplyExclusiveState for the Star Power shooting-star buff.

PUSHES TO

  • game.level, game.enemyDifficultyLevel, game.xp, game.xpToNext — updated in levelUp, update, reset.
  • game.rewardQueuelevelUp enqueues { type: 'level_up', level }.
  • game.modifierTotals[modId] — accumulated post-rarity stat total per modifier id; the card UI reads this to draw the “current value” stripe.
  • game.upgradeCounts[modId] — rank tracker for each owned modifier; gates re-offers (must stay below modDef.maxLevel) and feeds card display levels.
  • Modifiers table via Modifiers.add(eid, stat, mode, val, 0, 0, 'level_up:<modId>:<stat>')duration: 0 = permanent for the run. Independent stacking, so each rank is its own entry.
  • ship.weapons — pushed/levelled (weapon, weapon_upgrade, weapon_merge, shooting-star weapons / lowest_weapon). Weapon-level math is capped at MAX_WEAPON_LEVEL.
  • ship.hp, ship.shield, ship.hpMax, ship.shieldMax — settled by Modifiers.recalc(ship, ship._base), then current values are bumped by the gained delta so HP/Shield max picks heal by exactly the gained amount (not a full heal).
  • game.weaponsAcquired — incremented on weapon reward.
  • game.tracking.weaponsFound / game.tracking.upgradesChosen — telemetry arrays (entries like ${id}_lv, merge_${aId}_${bId}, shooting_star_${cat}, event_reward_${id}).
  • game.rerolls, game.banishes, (game as any).refuels — incremented on the corresponding shooting-star grant_* categories.
  • Sig.fire('level_up', 0, 0, game.level, 0, '') — signal emitted on every level-up for effect/audio/UI listeners.

DOES NOT

  • Render UI. Cards are data-only; rendering lives elsewhere.
  • Compute weapon stats. Reads getWeaponStatAtLevel / getEffectiveLevel from data/weapons but never authors curves.
  • Define modifier curves. getModifierValue and the ModifierEffect shape are owned by data/modifiers.
  • Roll chest rarity. resolveWeaponChestUpgrade is given the rarity by the caller and just applies the CHEST_RARITY_INCREMENT delta.
  • Apply weapon levels from the level-up card pool to anything except the chosen weapon (except via sympathetic-resonance cascade — see below).
  • Distribute weapon caches via the modifier pool. generateWeaponChoices is strictly weapons and has no modifier fallback; if fewer non-owned, non-legendary, non-banished, in-pool weapons exist than requested it returns fewer cards.
  • Surface legendary weapons from chests. Legendaries are merge-only; the chest filter rejects isLegendary.
  • Cap modifier slots. modifierSlotsFree = 999 — players can hold every modifier type simultaneously.
  • Cap level. Thresholds extend infinitely via _ensureThreshold.
  • Handle the actual merge math; that lives in merge-system.
  • Decide whether shooting-star refills are banishable. banishKeyForChoice returns null for grant_reroll / grant_banish / grant_refuel, so they can never be removed from the pool.

Signals

Fires:

  • Sig.fire('level_up', 0, 0, game.level, 0, '') — emitted once per level-up inside LevelingSystem.levelUp. Consumers (audio, VFX, HUD, effect system) react.

Does not subscribe to any signals.

Entry points

  • LevelingSystem.update(game) — called from the game tick. Drains all pending level-ups via while (this.checkLevelUp(game)) and refreshes xpToNext.
  • LevelingSystem.checkLevelUp(game) / LevelingSystem.levelUp(game) — granular variants of the same loop.
  • LevelingSystem.getProgress(game) — normalized 0–1 fill for the XP bar; reads XP_THRESHOLDS[level] and [level+1].
  • LevelingSystem.generateRewardChoices(game, count, ship) — builds the level-up card pool.
  • LevelingSystem.generateWeaponChoices(game, ship, count) — builds the weapon-cache card pool (NEW weapons only, no modifier fallback).
  • LevelingSystem.reset(game) — run start. Sets level = 0, xp = 0, xpToNext = XP_THRESHOLDS[1] = 50.
  • addXP(amount, game, ship?) — adds XP and calls LevelingSystem.update(game).
  • xpForLevel(level) — extends and returns XP_THRESHOLDS[level]. Used by tests and HUD.
  • applyReward(choice, ship, game) — applies any RewardChoice (the only path that mutates ship/game from a card pick).
  • applyEventRewardUpgrades(upgradeIds, game, ship) — Event Reward artifact entry point; routes each id through _applyModifierPick exactly like a level-up.
  • resolveWeaponChestUpgrade(currentLevel, chestRarity) — fractional weapon-level bump from chest, capped at MAX_WEAPON_LEVEL. Unknown rarities fall back to the common increment of 0.20.
  • generateShootingStarChoices(game, ship) — builds up to 2 shooting-star cards from the eligible set, after dropping banished categories.
  • banishKeyForChoice(choice) — stable banish-key builder. Distinguishes “weapon (new from chest)” from “weapon_upgrade”, and distinguishes “artifact_new” from “artifact_upgrade” based on choice.artifactIsLevelUp.
  • describeNewWeapon(def) — re-export of the internal new-weapon description helper.

Pattern notes

  • _applyModifierPick is the single source of truth for every modifier rank, hit by applyReward('modifier'), the shooting-star ship_upgrades path, and applyEventRewardUpgrades. The reason the code refuses to mutate ship[stat] directly is documented in the long header comment: Modifiers.recalc() resets stats back to ship._base and re-applies registered entries whenever a timed artifact modifier expires. Direct mutations get silently wiped on the first artifact cycle, so every persistent stat change must go through Modifiers.add.
  • Math intent of every level-up pick is additive per rank (data comments read “+X% per rank”) and that’s how Modifiers.recalc materializes them. The legacy direct-mutation path was compounding, which differed from intent.
  • HP/Shield current bump: pre-state captured before recalc, then ship.hp += (ship.hpMax - prevHpMax) and the analogous shield line. This prevents the “free full heal exploit” while still rewarding the pick with current-value gain equal to the max-value gain.
  • weapon_upgrade case in applyReward: the chosen weapon levels up by Math.max(1, Math.round(rarityMult)) — common/uncommon/rare give +1, epic gives +2, legendary gives +2. Then the Sympathetic Resonance cascade runs: if hasArtifact('sympathetic_resonance') is true, every other owned weapon gains getTierValuesAt(sr, tier).bonusLevels | 0 levels (also capped at MAX_WEAPON_LEVEL), pushArtifactBanner('sympathetic_resonance', game.time) fires, and setArtifactFlash('sympathetic_resonance', 0.3) triggers the icon highlight. Cascade math stacks brokenly with the shooting-star weapons reward, which adds another +1 to every weapon on top.
  • getModifierValue(eff, level) is called in three distinct places: (1) inside _applyModifierPick to compute the per-rank stat delta val = getModifierValue(eff, currentLvl) * rarityMult for Modifiers.add; (2) inside _cumulativeModValue to derive the “current value” stripe shown on first-pick cards; (3) inside _describeModifierUpgrade to render the bold injected number on upgrade-pick cards. All three paths multiply by rarityMult so the displayed value matches what gets applied.
  • _rollRarity runs independently per card. Higher rarity multiplies the modifier delta and adds bonus weapon levels — it is not a pool-wide draw. Luck biases the non-common weights only; the common-pool weight is never starved because the bias coefficient for index 0 (_LUCK_BIAS[0] = 0) is zero.
  • MAX_WEAPON_LEVEL = 20 is the universal weapon-level ceiling. Every site that mutates weapon.level clamps via Math.min(MAX_WEAPON_LEVEL, …): weapon-upgrade card pick, sympathetic-resonance cascade, shooting-star weapons (each weapon +1), Forge Strike (lowest weapon +3), resolveWeaponChestUpgrade. Level-up card display also clamps nextLvl so the card text never advertises a level higher than 20.
  • Reward pool composition uses two shuffled sub-pools (modifiers and weapon upgrades) plus an optional merge pool that gets prepended. Per slot the picker rolls WEAPON_CHANCE = 0.35 to decide weapon vs modifier, falling back to the other pool if exhausted. Merge cards take first priority but are capped at ceil(count / 2). A padding pass fills any remaining slots from unused entries without re-using ids.
  • damage_* modifier gating: only surfaces a damage_<tag> modifier when the ship currently owns a weapon whose damageTag or secondaryDamageTag matches. The exception is damage_all, which is always offerable as long as the ship has at least one weapon.
  • New modifiers are gated by modifierSlotsFree (effectively unlimited at 999) and tracked per pull via newModsOffered so a single pull never offers more NEW modifiers than free slots.
  • Banish keys: weapon|<id> (chest new), weapon_upgrade|<id> (level-up card), weapon_merge|<legendaryId>, modifier|<id>, artifact_new|<id>, artifact_upgrade|<id>, shooting_star|<category>. grant_reroll, grant_banish, grant_refuel return null and are never banishable.
  • XP table edges: _BASE_THRESHOLDS[0] = 0 so reset produces a genuinely empty bar at level 0. First level-up at XP_THRESHOLDS[1] = 50. _ensureThreshold is idempotent and grows the array in place on demand.