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)— extendsXP_THRESHOLDSon 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 therarityMultcurve (1.0 → 2.0)._LUCK_BIAS— per-tier coefficients that bias the rarity roll based onship.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 byrarityMult._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 permanentModifiers.addentries with source keylevel_up:<modId>:<stat>. Captures pre-pick HP/Shield max, runsModifiers.recalc(ship, ship._base), then bumps currenthp/shieldby the gained delta so ahpMaxupgrade also heals by the gained amount._cumulativeModValue— sumsgetModifierValue(eff, i) * rarityMultoveri = 1..levelfor card display._describeModifierUpgrade/_describeWeaponUpgrade/_describeNewWeapon— card-description builders._findLowestWeaponIndex— used by Forge Strike (shooting-starlowest_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. RewardChoiceinterface andBanishCategoryunion;banishKeyForChoicehelper.
READS FROM
../core/types—GameStateshape (xp,level,xpToNext,enemyDifficultyLevel,rewardQueue,upgradeCounts,modifierTotals,weaponsAcquired,tracking,banishedKeys,artifacts,rerolls,banishes,refuels,runDef.context.upgradePool,runDef.context.weaponPool,time).../core/signals—Sig.fire('level_up', …)emitted on each level-up.../core/modifiers—Modifiers.addandModifiers.recalc. Required because directship[stat]mutation gets wiped byrecalc()whenever any timed artifact modifier expires.../../data/modifiers—MODIFIER_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 byrarityMult).../../data/weapons—WEAPONS,WEAPON_MAP,getWeaponStatAtLevel,getEffectiveLevel,getSteppedStatAtLevel,resolveWeaponRarity,WeaponCoreSpec,SteppedStat. Filters legendary and disabled entries out of chest pulls.../../data/artifacts—ARTIFACT_MAP,getTierValuesAt(used for the sympathetic-resonancebonusLevelslookup)../artifacts—grantArtifact,hasArtifact,getArtifactTier,setArtifactFlash../merge-system—findMergeCandidates,mergeWeapons,MergeCandidate.../rendering/draw-artifact-banners—pushArtifactBannerfor cascade visual confirmation.../player/states—applyExclusiveStatefor the Star Power shooting-star buff.
PUSHES TO
game.level,game.enemyDifficultyLevel,game.xp,game.xpToNext— updated inlevelUp,update,reset.game.rewardQueue—levelUpenqueues{ 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 belowmodDef.maxLevel) and feeds card display levels.Modifierstable viaModifiers.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-starweapons/lowest_weapon). Weapon-level math is capped atMAX_WEAPON_LEVEL.ship.hp,ship.shield,ship.hpMax,ship.shieldMax— settled byModifiers.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 onweaponreward.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-stargrant_*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/getEffectiveLevelfromdata/weaponsbut never authors curves. - Define modifier curves.
getModifierValueand theModifierEffectshape are owned bydata/modifiers. - Roll chest rarity.
resolveWeaponChestUpgradeis given the rarity by the caller and just applies theCHEST_RARITY_INCREMENTdelta. - 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.
generateWeaponChoicesis 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.
banishKeyForChoicereturnsnullforgrant_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 insideLevelingSystem.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 viawhile (this.checkLevelUp(game))and refreshesxpToNext.LevelingSystem.checkLevelUp(game)/LevelingSystem.levelUp(game)— granular variants of the same loop.LevelingSystem.getProgress(game)— normalized 0–1 fill for the XP bar; readsXP_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. Setslevel = 0,xp = 0,xpToNext = XP_THRESHOLDS[1] = 50.addXP(amount, game, ship?)— adds XP and callsLevelingSystem.update(game).xpForLevel(level)— extends and returnsXP_THRESHOLDS[level]. Used by tests and HUD.applyReward(choice, ship, game)— applies anyRewardChoice(the only path that mutates ship/game from a card pick).applyEventRewardUpgrades(upgradeIds, game, ship)— Event Reward artifact entry point; routes each id through_applyModifierPickexactly like a level-up.resolveWeaponChestUpgrade(currentLevel, chestRarity)— fractional weapon-level bump from chest, capped atMAX_WEAPON_LEVEL. Unknown rarities fall back to the common increment of0.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 onchoice.artifactIsLevelUp.describeNewWeapon(def)— re-export of the internal new-weapon description helper.
Pattern notes
_applyModifierPickis the single source of truth for every modifier rank, hit byapplyReward('modifier'), the shooting-starship_upgradespath, andapplyEventRewardUpgrades. The reason the code refuses to mutateship[stat]directly is documented in the long header comment:Modifiers.recalc()resets stats back toship._baseand 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 throughModifiers.add.- Math intent of every level-up pick is additive per rank (data comments read “+X% per rank”) and that’s how
Modifiers.recalcmaterializes them. The legacy direct-mutation path was compounding, which differed from intent. - HP/Shield current bump: pre-state captured before
recalc, thenship.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_upgradecase inapplyReward: the chosen weapon levels up byMath.max(1, Math.round(rarityMult))— common/uncommon/rare give +1, epic gives +2, legendary gives +2. Then the Sympathetic Resonance cascade runs: ifhasArtifact('sympathetic_resonance')is true, every other owned weapon gainsgetTierValuesAt(sr, tier).bonusLevels | 0levels (also capped atMAX_WEAPON_LEVEL),pushArtifactBanner('sympathetic_resonance', game.time)fires, andsetArtifactFlash('sympathetic_resonance', 0.3)triggers the icon highlight. Cascade math stacks brokenly with the shooting-starweaponsreward, which adds another +1 to every weapon on top.getModifierValue(eff, level)is called in three distinct places: (1) inside_applyModifierPickto compute the per-rank stat deltaval = getModifierValue(eff, currentLvl) * rarityMultforModifiers.add; (2) inside_cumulativeModValueto derive the “current value” stripe shown on first-pick cards; (3) inside_describeModifierUpgradeto render the bold injected number on upgrade-pick cards. All three paths multiply byrarityMultso the displayed value matches what gets applied._rollRarityruns 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 = 20is the universal weapon-level ceiling. Every site that mutatesweapon.levelclamps viaMath.min(MAX_WEAPON_LEVEL, …): weapon-upgrade card pick, sympathetic-resonance cascade, shooting-starweapons(each weapon +1), Forge Strike (lowest weapon +3),resolveWeaponChestUpgrade. Level-up card display also clampsnextLvlso 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.35to decide weapon vs modifier, falling back to the other pool if exhausted. Merge cards take first priority but are capped atceil(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 whosedamageTagorsecondaryDamageTagmatches. The exception isdamage_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 vianewModsOfferedso 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_refuelreturnnulland are never banishable. - XP table edges:
_BASE_THRESHOLDS[0] = 0soresetproduces a genuinely empty bar at level 0. First level-up atXP_THRESHOLDS[1] = 50._ensureThresholdis idempotent and grows the array in place on demand.