diff --git a/_datafiles/config.yaml b/_datafiles/config.yaml index 1375863d3..1e125bcf8 100755 --- a/_datafiles/config.yaml +++ b/_datafiles/config.yaml @@ -359,6 +359,84 @@ GamePlay: # If false, players can form and join parties from anywhere in the world. SameRoomOnly: false + # Progression settings + # Controls all character progression formulas: stat gains, HP/Mana, XP curve, and level-up rewards. + # Visit /admin/progression for a live visual editor with charts. + Progression: + # --- Stat gain formula --- + # racial_value(level) = floor(base * BaseModFactor * (level-1)^BaseModExponent) + # + floor(NaturalGainsModFactor * level^NaturalGainsExponent) + # + # - BaseModFactor - + # Scales how much a racial base stat contributes to growth per level. + # Higher values cause strong-base races to pull further ahead of weak-base races. + BaseModFactor: 0.3333333334 + # - BaseModExponent - + # Shape of the base-scaled component. 1.0 = linear. <1.0 = diminishing returns. + # >1.0 = accelerating returns. Valid range: 0.1 to 5.0. + BaseModExponent: 1.0 + # - NaturalGainsModFactor - + # Flat gains every character receives per level regardless of race. + # Higher values raise the floor for weak-base races. + NaturalGainsModFactor: 0.5 + # - NaturalGainsExponent - + # Shape of the flat gains component. 1.0 = linear. <1.0 = diminishing returns. + # >1.0 = accelerating. Valid range: 0.1 to 5.0. + NaturalGainsExponent: 1.0 + # --- HP formula: HealthMax = HPBase + level*HPPerLevel + Vitality_adj*HPPerVitality + mods --- + HPBase: 5 + HPPerLevel: 1.0 + HPPerVitality: 4.0 + # --- Mana formula: ManaMax = ManaBase + level*ManaPerLevel + Mysticism_adj*ManaPerMysticism + mods --- + ManaBase: 4 + ManaPerLevel: 1.0 + ManaPerMysticism: 3.0 + # --- Level-up rewards --- + # Points awarded to the player on each qualifying level. + # EveryNLevels controls how often a qualifying level occurs: + # 1 = every level, 2 = every other level, 3 = every 3rd level, etc. + TrainingPointsPerLevel: 1 + TrainingPointsEveryNLevels: 1 + StatPointsPerLevel: 1 + StatPointsEveryNLevels: 1 + # --- XP curve --- + # XP_to_level(L) = (XPBase + L^XPLevelPower * XPLevelFactor * XPBase) * TNLScale + # - XPBase - + # Flat XP cost component. Scales the entire curve up or down. + XPBase: 1000 + # - XPLevelFactor - + # Multiplier on the level-based component. Higher = more XP required per level. + XPLevelFactor: 0.75 + # - XPLevelPower - + # Exponent controlling curve steepness. 2.0 = quadratic. 1.5 = gentler. 3.0 = steeper. + # Valid range: 0.1 to 5.0. + XPLevelPower: 2.0 + # - MaxLevel - + # Soft display cap for admin charts. Does not enforce a hard level cap. + MaxLevel: 100 + # --- Stat value compression --- + # Once a stat's Value reaches StatCapThreshold, further gains are compressed: + # ValueAdj = StatCapAnchor + round((Value - StatCapAnchor)^StatCapExponent * StatCapScale) + # - StatCapThreshold - + # The Value at which compression begins. Default 105. + StatCapThreshold: 105 + # - StatCapAnchor - + # The value the compression is anchored to. Overage = Value - StatCapAnchor. Default 100. + StatCapAnchor: 100 + # - StatCapExponent - + # Curve shape above the cap. 0.5 = sqrt (strong compression, default). + # 1.0 = no compression (linear). Above 1.0 = reduced compression (1.5 = gentle, 2.0 = very gentle). + # 0.25 = very aggressive compression. Valid range: 0.01 to 4.0. + StatCapExponent: 0.5 + # - StatCapScale - + # Multiplier applied after the exponent. Default 2.0. + StatCapScale: 2.0 + # - StatCapExemptBonus - + # When true, only the racial portion of a stat is compressed. Training points and + # equipment/buff mods are added on top of the compressed racial value at full value. + # Default false (original behaviour: all sources compressed together). + StatCapExemptBonus: false + ################################################################################ # # INTEGRATIONS diff --git a/_datafiles/html/admin/config.html b/_datafiles/html/admin/config.html index 3c5d79f38..fcbb4941d 100644 --- a/_datafiles/html/admin/config.html +++ b/_datafiles/html/admin/config.html @@ -259,6 +259,29 @@

Pending Changes

'GamePlay.Combat.CritMultMax': 'Maximum critical hit damage multiplier when attacker has full Perception advantage over the defender.', 'GamePlay.Combat.DodgeChanceMin': 'Minimum chance to dodge an incoming hit (percent). Applied when defender has no Perception advantage over the attacker.', 'GamePlay.Combat.DodgeChanceMax': 'Maximum chance to dodge an incoming hit (percent) when defender has full Perception advantage over the attacker.', + 'GamePlay.Progression.BaseModFactor': 'Scales how much a racial base stat contributes to growth per level. Higher values cause strong-base races to diverge more from weak-base races.', + 'GamePlay.Progression.BaseModExponent': 'Curve shape for the racial component. 1.0 = linear. Less than 1.0 = diminishing returns at high levels. Greater than 1.0 = accelerating. Valid range: 0.1 to 5.0.', + 'GamePlay.Progression.NaturalGainsModFactor': 'Flat stat gains every character receives per level regardless of race. Higher values raise the floor for weak-base races.', + 'GamePlay.Progression.NaturalGainsExponent': 'Curve shape for the flat gains component. 1.0 = linear. Less than 1.0 = diminishing returns. Valid range: 0.1 to 5.0.', + 'GamePlay.Progression.HPBase': 'Flat HP added to every character independent of level or stats.', + 'GamePlay.Progression.HPPerLevel': 'HP gained per character level.', + 'GamePlay.Progression.HPPerVitality': 'HP gained per point of adjusted Vitality.', + 'GamePlay.Progression.ManaBase': 'Flat mana added to every character independent of level or stats.', + 'GamePlay.Progression.ManaPerLevel': 'Mana gained per character level.', + 'GamePlay.Progression.ManaPerMysticism': 'Mana gained per point of adjusted Mysticism.', + 'GamePlay.Progression.TrainingPointsPerLevel': 'Training points awarded on each qualifying level-up.', + 'GamePlay.Progression.TrainingPointsEveryNLevels': 'How often a training point qualifying level occurs. 1 = every level, 2 = every other level, 3 = every 3rd level, etc.', + 'GamePlay.Progression.StatPointsPerLevel': 'Stat points awarded on each qualifying level-up.', + 'GamePlay.Progression.StatPointsEveryNLevels': 'How often a stat point qualifying level occurs. 1 = every level, 2 = every other level, 3 = every 3rd level, etc.', + 'GamePlay.Progression.XPBase': 'Flat XP component. Scales the entire XP curve up or down.', + 'GamePlay.Progression.XPLevelFactor': 'Multiplier on the level-based XP component. Higher = more XP required per level.', + 'GamePlay.Progression.XPLevelPower': 'Exponent controlling XP curve steepness. 2.0 = quadratic, 1.5 = gentler, 3.0 = steeper. Valid range: 0.1 to 5.0.', + 'GamePlay.Progression.MaxLevel': 'Soft display cap used in admin charts. Does not enforce a hard level cap.', + 'GamePlay.Progression.StatCapThreshold': 'The stat Value at which compression begins. Below this, ValueAdj equals Value.', + 'GamePlay.Progression.StatCapAnchor': 'The value the compression formula is anchored to. Overage = Value minus Anchor.', + 'GamePlay.Progression.StatCapExponent': 'Compression curve shape above the cap. 0.5 = sqrt (strong compression, default). 1.0 = no compression. Above 1.0 = reduced compression (1.5 = gentle, 2.0 = very gentle). Valid range: 0.01 to 4.0.', + 'GamePlay.Progression.StatCapScale': 'Multiplier applied after the exponent in the compression formula. Default 2.0.', + 'GamePlay.Progression.StatCapExemptBonus': 'When true, only the racial portion of a stat is compressed. Training points and equipment or buff mods are added on top at full value, ensuring deliberate investment always pays off.', 'GamePlay.PVP.Enabled': 'PVP mode. Options: enabled (everywhere), disabled (nowhere), limited (PVP-flagged rooms only).', 'GamePlay.PVP.MinimumLevel': 'Minimum level required to participate in PVP. Only applies when PVP is enabled or limited.', 'GamePlay.XPScale': 'Global XP multiplier as a percentage. 100 = normal.', diff --git a/_datafiles/html/admin/progression.html b/_datafiles/html/admin/progression.html new file mode 100644 index 000000000..dffd0ef69 --- /dev/null +++ b/_datafiles/html/admin/progression.html @@ -0,0 +1,985 @@ +{{template "header" .}} + + + +

Character Progression

+

Adjust progression formulas and see live charts. Click Save to apply changes to the server.

+ +
+ +
+ These settings affect all characters on the server, including existing ones. + Changes take effect immediately on the next stat recalculation and cannot be automatically reversed. + Raising values mid-game will make existing characters stronger than intended; lowering them may make + high-level characters feel weaker overnight. Test changes on a development server before applying + them to a live world. +
+
+ +
+ +
+ + +
+ +
+ + +
+ Base Mod Factor ? + + 0.3333 +
+ +
+ Base Mod Exponent ? + + 1.00 +
+ +
+ Natural Gains Factor ? + + 0.50 +
+ +
+ Natural Gains Exponent ? + + 1.00 +
+ +
+ Cap Threshold ? + +
+
+ Cap Anchor ? + +
+
+ Cap Exponent ? + + 0.50 +
+
+ Cap Scale ? + + 2.00 +
+
+ Exempt Training & Mods ? + +
+
+ +
+ + +
+ HP Base ? + +
+
+ HP per Level ? + + 1.00 +
+
+ HP per Vitality ? + + 4.00 +
+ +
+ Mana Base ? + +
+
+ Mana per Level ? + + 1.00 +
+
+ Mana per Mysticism ? + + 3.00 +
+
+ +
+ +
+ XP Base ? + +
+
+ XP Level Factor ? + + 0.75 +
+
+ XP Level Power ? + + 2.00 +
+
+ Max Level (chart range) ? + + 100 +
+
+ +
+ +
+ +
+
Training Points ?
+
+ Give + + point(s) every + + level(s) +
+
+
+ +
+
Stat Points ?
+
+ Give + + point(s) every + + level(s) +
+
+
+ +
+
+ + +
+ + +
+ +
+
+ Stat Gains per Level + Solid lines = raw Value (racial only, no training). Dashed = ValueAdj after cap compression. When “Exempt Training & Mods” is on, dashed shows the compressed racial floor — training points land on top of it uncapped. +
+
+ +
+
Base 15 (strong)
+
Base 10 (average)
+
Base 5 (weak)
+
+ +  Dashed = effective (ValueAdj) +
+
+
+
+ +
+
+ HP & Mana at Level + Example character: Vitality 10, Mysticism 8, no training or equipment. +
+
+ +
+
HP
+
Mana
+
+
+
+ +
+
+ XP Required per Level + XP delta needed to gain each level (TNLScale 1.0). +
+
+ +
+
XP per level
+
+
+
+ +
+
+ XP Cumulative + Total XP required to reach each level (TNLScale 1.0). +
+
+ +
+
Cumulative XP
+
+
+
+ +
+
+ + + +
+ + + +{{template "footer" .}} diff --git a/internal/characters/character.go b/internal/characters/character.go index 96fa9e369..4386971f1 100644 --- a/internal/characters/character.go +++ b/internal/characters/character.go @@ -1330,8 +1330,13 @@ func (c *Character) XPTL(lvl int) int { if lvl < 1 { lvl = 1 } - fLvl := float64(lvl) - return int(float32(1000+(fLvl*(fLvl*.75)*1000)) * c.TNLScale) + cfg := configs.GetProgressionConfig() + base := float64(cfg.XPBase) + xp := (base + math.Pow(float64(lvl), float64(cfg.XPLevelPower))*float64(cfg.XPLevelFactor)*base) * float64(c.TNLScale) + if xp > math.MaxInt64 { + return math.MaxInt64 + } + return int(xp) } // Returns the actual xp in regards to the current level/next level @@ -1359,8 +1364,14 @@ func (c *Character) LevelUp() (bool, stats.Statistics) { var statsBefore stats.Statistics = c.Stats c.Level++ - c.TrainingPoints++ - c.StatPoints++ + + cfgProg := configs.GetProgressionConfig() + if int(cfgProg.TrainingPointsEveryNLevels) <= 1 || c.Level%int(cfgProg.TrainingPointsEveryNLevels) == 0 { + c.TrainingPoints += int(cfgProg.TrainingPointsPerLevel) + } + if int(cfgProg.StatPointsEveryNLevels) <= 1 || c.Level%int(cfgProg.StatPointsEveryNLevels) == 0 { + c.StatPoints += int(cfgProg.StatPointsPerLevel) + } c.Validate() @@ -1476,15 +1487,18 @@ func (c *Character) RecalculateStats() { // Set HP/MP maxes // This relies on the above stats so has to be calculated afterwards - c.HealthMax.Mods = 5 + - c.StatMod(string(statmods.HealthMax)) + // Any sort of spell buffs etc. are just direct modifiers - c.Level + // For every level you get 1 hp - c.Stats.Vitality.ValueAdj*4 // for every vitality you get 3hp - - c.ManaMax.Mods = 4 + - c.StatMod(string(statmods.ManaMax)) + // Any sort of spell buffs etc. are just direct modifiers - c.Level + // For every level you get 1 mp - c.Stats.Mysticism.ValueAdj*3 // for every Mysticism you get 2mp + cfgProg := configs.GetProgressionConfig() + c.HealthMax.NoCap = true + c.HealthMax.Mods = int(cfgProg.HPBase) + + c.StatMod(string(statmods.HealthMax)) + + int(float64(c.Level)*float64(cfgProg.HPPerLevel)) + + int(float64(c.Stats.Vitality.ValueAdj)*float64(cfgProg.HPPerVitality)) + + c.ManaMax.NoCap = true + c.ManaMax.Mods = int(cfgProg.ManaBase) + + c.StatMod(string(statmods.ManaMax)) + + int(float64(c.Level)*float64(cfgProg.ManaPerLevel)) + + int(float64(c.Stats.Mysticism.ValueAdj)*float64(cfgProg.ManaPerMysticism)) // Set max action points c.ActionPointsMax.Mods = 200 // hard coded for now diff --git a/internal/configs/config.gameplay.go b/internal/configs/config.gameplay.go index ac454c088..89814e886 100644 --- a/internal/configs/config.gameplay.go +++ b/internal/configs/config.gameplay.go @@ -11,6 +11,8 @@ type GamePlay struct { Death GameplayDeath `yaml:"Death"` // Party settings Party GameplayParty `yaml:"Party"` + // Progression settings + Progression ProgressionConfig `yaml:"Progression"` LivesStart ConfigInt `yaml:"LivesStart"` // Starting permadeath lives LivesMax ConfigInt `yaml:"LivesMax"` // Maximum permadeath lives @@ -177,6 +179,8 @@ func (g *GamePlay) Validate() { g.Combat.validate() + g.Progression.Validate() + } func (c *CombatConfig) validate() { diff --git a/internal/configs/config.progression.go b/internal/configs/config.progression.go new file mode 100644 index 000000000..2f281f7ea --- /dev/null +++ b/internal/configs/config.progression.go @@ -0,0 +1,155 @@ +package configs + +type ProgressionConfig struct { + // Stat gain formula: GainsForLevel + // racial_value = floor(base * BaseModFactor * (level-1)^BaseModExponent) + // + floor(NaturalGainsModFactor * level^NaturalGainsExponent) + // + // BaseModFactor controls how much a racial base stat matters at high levels. + // Higher values cause races with strong bases to diverge more from weak-base races. + BaseModFactor ConfigFloat `yaml:"BaseModFactor"` + // BaseModExponent controls the shape of the base-scaled component. + // 1.0 = linear growth, <1.0 = diminishing returns, >1.0 = accelerating returns. + BaseModExponent ConfigFloat `yaml:"BaseModExponent"` + // NaturalGainsModFactor controls the universal flat gains every character receives + // per level, regardless of race. Higher values raise the floor for weak-base races. + NaturalGainsModFactor ConfigFloat `yaml:"NaturalGainsModFactor"` + // NaturalGainsExponent controls the shape of the flat gains component. + // 1.0 = linear, <1.0 = diminishing returns, >1.0 = accelerating. + NaturalGainsExponent ConfigFloat `yaml:"NaturalGainsExponent"` + + // HP formula: HealthMax = HPBase + level*HPPerLevel + Vitality_adj*HPPerVitality + mods + HPBase ConfigInt `yaml:"HPBase"` + HPPerLevel ConfigFloat `yaml:"HPPerLevel"` + HPPerVitality ConfigFloat `yaml:"HPPerVitality"` + + // Mana formula: ManaMax = ManaBase + level*ManaPerLevel + Mysticism_adj*ManaPerMysticism + mods + ManaBase ConfigInt `yaml:"ManaBase"` + ManaPerLevel ConfigFloat `yaml:"ManaPerLevel"` + ManaPerMysticism ConfigFloat `yaml:"ManaPerMysticism"` + + // Points awarded to the player on each level-up. + TrainingPointsPerLevel ConfigInt `yaml:"TrainingPointsPerLevel"` + TrainingPointsEveryNLevels ConfigInt `yaml:"TrainingPointsEveryNLevels"` + StatPointsPerLevel ConfigInt `yaml:"StatPointsPerLevel"` + StatPointsEveryNLevels ConfigInt `yaml:"StatPointsEveryNLevels"` + + // XP curve: XP_to_level(L) = (XPBase + L^XPLevelPower * XPLevelFactor * XPBase) * TNLScale + // XPBase is the flat XP cost at level 1. + XPBase ConfigInt `yaml:"XPBase"` + // XPLevelFactor scales how fast the curve rises. Higher = more XP per level. + XPLevelFactor ConfigFloat `yaml:"XPLevelFactor"` + // XPLevelPower is the exponent. 2.0 = quadratic, 1.5 = gentler, 3.0 = steeper. + XPLevelPower ConfigFloat `yaml:"XPLevelPower"` + + // MaxLevel is the soft display cap used in admin charts and any future level-cap enforcement. + MaxLevel ConfigInt `yaml:"MaxLevel"` + + // Stat value compression (applied in Recalculate). + // Once a stat's Value reaches StatCapThreshold, further gains are compressed: + // ValueAdj = StatCapAnchor + round((Value-StatCapAnchor)^StatCapExponent * StatCapScale) + // StatCapThreshold: the Value at which compression begins. Default 105. + StatCapThreshold ConfigInt `yaml:"StatCapThreshold"` + // StatCapAnchor: the value the compression formula is anchored to. Default 100. + // Overage is measured from this point: overage = Value - StatCapAnchor. + StatCapAnchor ConfigInt `yaml:"StatCapAnchor"` + // StatCapExponent: controls how aggressively gains are compressed above the threshold. + // 0.5 = sqrt (default, strong compression), 1.0 = linear pass-through (no compression), + // 0.25 = very aggressive. Valid range: 0.01 to 1.0. + StatCapExponent ConfigFloat `yaml:"StatCapExponent"` + // StatCapScale: multiplier applied after the exponent. Default 2.0. + StatCapScale ConfigFloat `yaml:"StatCapScale"` + // StatCapExemptBonus: when true, only the racial portion of a stat is compressed. + // Training points and equipment/buff mods are added on top of the compressed racial + // value without being subject to the cap. This ensures deliberate investment always + // pays off fully. Default false (original behaviour: everything compressed together). + StatCapExemptBonus ConfigBool `yaml:"StatCapExemptBonus"` +} + +func (p *ProgressionConfig) Validate() { + if p.BaseModFactor <= 0 { + p.BaseModFactor = 0.3333333334 + } + if p.BaseModExponent < 0.1 || p.BaseModExponent > 5.0 { + p.BaseModExponent = 1.0 + } + if p.NaturalGainsModFactor <= 0 { + p.NaturalGainsModFactor = 0.5 + } + if p.NaturalGainsExponent < 0.1 || p.NaturalGainsExponent > 5.0 { + p.NaturalGainsExponent = 1.0 + } + + if p.HPBase < 0 { + p.HPBase = 5 + } + if p.HPPerLevel <= 0 { + p.HPPerLevel = 1.0 + } + if p.HPPerVitality <= 0 { + p.HPPerVitality = 4.0 + } + + if p.ManaBase < 0 { + p.ManaBase = 4 + } + if p.ManaPerLevel <= 0 { + p.ManaPerLevel = 1.0 + } + if p.ManaPerMysticism <= 0 { + p.ManaPerMysticism = 3.0 + } + + if p.TrainingPointsPerLevel < 0 { + p.TrainingPointsPerLevel = 1 + } + if p.TrainingPointsEveryNLevels < 1 { + p.TrainingPointsEveryNLevels = 1 + } + if p.StatPointsPerLevel < 0 { + p.StatPointsPerLevel = 1 + } + if p.StatPointsEveryNLevels < 1 { + p.StatPointsEveryNLevels = 1 + } + + if p.XPBase < 1 { + p.XPBase = 1000 + } + if p.XPLevelFactor <= 0 { + p.XPLevelFactor = 0.75 + } + if p.XPLevelPower < 0.1 || p.XPLevelPower > 5.0 { + p.XPLevelPower = 2.0 + } + + if p.MaxLevel < 10 { + p.MaxLevel = 100 + } + + if p.StatCapThreshold < 1 { + p.StatCapThreshold = 105 + } + if p.StatCapAnchor < 0 { + p.StatCapAnchor = 100 + } + if p.StatCapAnchor >= p.StatCapThreshold { + p.StatCapAnchor = p.StatCapThreshold - 1 + } + if p.StatCapExponent <= 0 || p.StatCapExponent > 1.0 { + p.StatCapExponent = 0.5 + } + if p.StatCapScale <= 0 { + p.StatCapScale = 2.0 + } +} + +func GetProgressionConfig() ProgressionConfig { + configDataLock.RLock() + defer configDataLock.RUnlock() + + if !configData.validated { + configData.Validate() + } + return configData.GamePlay.Progression +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 3dc245039..0a882f02c 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -1,10 +1,9 @@ package stats -import "math" +import ( + "math" -const ( - BaseModFactor = 0.3333333334 // How much of a scaling to aply to levels before multiplying by racial stat - NaturalGainsModFactor = 0.5 // Free stats gained per level modded by this. + "github.com/GoMudEngine/GoMud/internal/configs" ) type Statistics struct { @@ -19,12 +18,13 @@ type Statistics struct { // When saving to a file, we don't need to write all the properties that we calculate. // Just keep track of "Training" because that's not calculated. type StatInfo struct { - Training int `yaml:"training,omitempty"` // How much it's been trained with Training Points spending - Value int `yaml:"-"` // Final calculated value - ValueAdj int `yaml:"-"` // Final calculated value (Adjusted) - Racial int `yaml:"-"` // Value provided by racial benefits - Base int `yaml:"base,omitempty"` // Base stat value - Mods int `yaml:"-"` // How much it's modded by equipment, spells, etc. + Training int `yaml:"training,omitempty"` // How much it's been trained with Training Points spending + Value int `yaml:"-"` // Final calculated value + ValueAdj int `yaml:"-"` // Final calculated value (Adjusted) + Racial int `yaml:"-"` // Value provided by racial benefits + Base int `yaml:"base,omitempty"` // Base stat value + Mods int `yaml:"-"` // How much it's modded by equipment, spells, etc. + NoCap bool `yaml:"-"` // When true, skip the stat cap compression in Recalculate } func (si *StatInfo) SetMod(mod ...int) { @@ -38,25 +38,50 @@ func (si *StatInfo) SetMod(mod ...int) { } } +// GainsForLevel returns the racial stat value at the given level, using the +// configured progression formula: +// +// racial = floor(base * BaseModFactor * (level-1)^BaseModExponent) +// + floor(NaturalGainsModFactor * level^NaturalGainsExponent) func (si *StatInfo) GainsForLevel(level int) int { if level < 1 { level = 1 } - levelScale := float64(level-1) * BaseModFactor - basePoints := int(levelScale * float64(si.Base)) + cfg := configs.GetProgressionConfig() - // every x levels we get natural gains - freeStatPoints := int(float64(level) * NaturalGainsModFactor) + basePoints := int(math.Pow(float64(level-1), float64(cfg.BaseModExponent)) * + float64(cfg.BaseModFactor) * float64(si.Base)) - return basePoints + freeStatPoints + freePoints := int(math.Pow(float64(level), float64(cfg.NaturalGainsExponent)) * + float64(cfg.NaturalGainsModFactor)) + + return basePoints + freePoints } func (si *StatInfo) Recalculate(level int) { si.Racial = si.GainsForLevel(level) si.Value = si.Racial + si.Training + si.Mods si.ValueAdj = si.Value - if si.ValueAdj >= 105 { - overage := si.ValueAdj - 100 - si.ValueAdj = 100 + int(math.Round(math.Sqrt(float64(overage))*2)) + if si.NoCap { + return + } + cfg := configs.GetProgressionConfig() + if bool(cfg.StatCapExemptBonus) { + // Compress only the racial portion; training and mods are added uncapped. + compressedRacial := si.Racial + if si.Racial >= int(cfg.StatCapThreshold) { + overage := si.Racial - int(cfg.StatCapAnchor) + if overage < 0 { + overage = 0 + } + compressedRacial = int(cfg.StatCapAnchor) + int(math.Round(math.Pow(float64(overage), float64(cfg.StatCapExponent))*float64(cfg.StatCapScale))) + } + si.ValueAdj = compressedRacial + si.Training + si.Mods + } else if si.ValueAdj >= int(cfg.StatCapThreshold) { + overage := si.ValueAdj - int(cfg.StatCapAnchor) + if overage < 0 { + overage = 0 + } + si.ValueAdj = int(cfg.StatCapAnchor) + int(math.Round(math.Pow(float64(overage), float64(cfg.StatCapExponent))*float64(cfg.StatCapScale))) } } diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index 99e14b103..8bc4635be 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -4,10 +4,44 @@ import ( "math" "testing" + "github.com/GoMudEngine/GoMud/internal/configs" "github.com/stretchr/testify/assert" ) +// defaultProgression sets all ProgressionConfig fields to their documented +// defaults so tests run independently of any loaded config file. +func defaultProgression() { + _ = configs.SetVal("GamePlay.Progression.BaseModFactor", "0.3333333334") + _ = configs.SetVal("GamePlay.Progression.BaseModExponent", "1.0") + _ = configs.SetVal("GamePlay.Progression.NaturalGainsModFactor", "0.5") + _ = configs.SetVal("GamePlay.Progression.NaturalGainsExponent", "1.0") + _ = configs.SetVal("GamePlay.Progression.HPBase", "5") + _ = configs.SetVal("GamePlay.Progression.HPPerLevel", "1.0") + _ = configs.SetVal("GamePlay.Progression.HPPerVitality", "4.0") + _ = configs.SetVal("GamePlay.Progression.ManaBase", "4") + _ = configs.SetVal("GamePlay.Progression.ManaPerLevel", "1.0") + _ = configs.SetVal("GamePlay.Progression.ManaPerMysticism", "3.0") + _ = configs.SetVal("GamePlay.Progression.TrainingPointsPerLevel", "1") + _ = configs.SetVal("GamePlay.Progression.TrainingPointsEveryNLevels", "1") + _ = configs.SetVal("GamePlay.Progression.StatPointsPerLevel", "1") + _ = configs.SetVal("GamePlay.Progression.StatPointsEveryNLevels", "1") + _ = configs.SetVal("GamePlay.Progression.XPBase", "1000") + _ = configs.SetVal("GamePlay.Progression.XPLevelFactor", "0.75") + _ = configs.SetVal("GamePlay.Progression.XPLevelPower", "2.0") + _ = configs.SetVal("GamePlay.Progression.MaxLevel", "100") + _ = configs.SetVal("GamePlay.Progression.StatCapThreshold", "105") + _ = configs.SetVal("GamePlay.Progression.StatCapAnchor", "100") + _ = configs.SetVal("GamePlay.Progression.StatCapExponent", "0.5") + _ = configs.SetVal("GamePlay.Progression.StatCapScale", "2.0") + _ = configs.SetVal("GamePlay.Progression.StatCapExemptBonus", "false") +} + func TestStatInfo_GainsForLevel(t *testing.T) { + defaultProgression() + + // With exponent=1.0 (linear), the formula reduces to the previous constants: + // basePoints = int((level-1) * 0.3333 * base) + // freePoints = int(level * 0.5) tests := []struct { name string base int @@ -18,9 +52,9 @@ func TestStatInfo_GainsForLevel(t *testing.T) { {name: "level 1 base 0", base: 0, level: 1, expected: 0}, {name: "level 0 clamps to 1", base: 10, level: 0, expected: 0}, {name: "negative level clamps to 1", base: 10, level: -5, expected: 0}, - // level 2, base 10: levelScale=(2-1)*BaseModFactor=0.333, basePoints=int(0.333*10)=3, free=int(2*0.5)=1 => 4 + // level 2, base 10: basePoints=int(1*0.3333*10)=3, free=int(2*0.5)=1 => 4 {name: "level 2 base 10", base: 10, level: 2, expected: 4}, - // level 10, base 10: levelScale=9*0.333=3.0, basePoints=int(3.0*10)=30, free=int(10*0.5)=5 => 35 + // level 10, base 10: basePoints=int(9*0.3333333334*10)=int(30.0)=30, free=int(10*0.5)=5 => 35 {name: "level 10 base 10", base: 10, level: 10, expected: 35}, // level 10, base 0: basePoints=0, free=5 => 5 {name: "level 10 base 0", base: 0, level: 10, expected: 5}, @@ -35,6 +69,7 @@ func TestStatInfo_GainsForLevel(t *testing.T) { } func TestStatInfo_Recalculate_BelowCap(t *testing.T) { + defaultProgression() si := StatInfo{Base: 10, Training: 5} si.Recalculate(10) @@ -45,6 +80,7 @@ func TestStatInfo_Recalculate_BelowCap(t *testing.T) { } func TestStatInfo_Recalculate_WithMods(t *testing.T) { + defaultProgression() si := StatInfo{Base: 10, Training: 3} si.SetMod(7) si.Recalculate(5) @@ -53,27 +89,36 @@ func TestStatInfo_Recalculate_WithMods(t *testing.T) { } func TestStatInfo_Recalculate_AboveCap(t *testing.T) { - // Force a Value above 105 by using high training + defaultProgression() + // Force a Value above the cap threshold by using high training. + // With defaults: threshold=105, anchor=100, exponent=0.5, scale=2.0 + // Value = 200, overage = 200-100 = 100, ValueAdj = 100 + round(sqrt(100)*2) = 120 si := StatInfo{Base: 10, Training: 200} si.Recalculate(1) // Value = Racial + 200; Racial at level 1 = 0, so Value = 200 assert.Equal(t, 200, si.Value) - // ValueAdj formula: 100 + round(sqrt(overage) * 2), overage = 200 - 100 = 100 - expectedAdj := 100 + int(math.Round(math.Sqrt(100)*2)) + // Use the actual config defaults to compute expected ValueAdj. + cfg := configs.GetProgressionConfig() + overage := 200 - int(cfg.StatCapAnchor) + expectedAdj := int(cfg.StatCapAnchor) + int(math.Round(math.Pow(float64(overage), float64(cfg.StatCapExponent))*float64(cfg.StatCapScale))) assert.Equal(t, expectedAdj, si.ValueAdj) assert.True(t, si.ValueAdj < si.Value, "ValueAdj should be less than Value when above cap") } func TestStatInfo_Recalculate_ExactlyAtCap(t *testing.T) { - // Value == 105 triggers the cap branch (>= 105) + defaultProgression() + // Value == threshold triggers the cap branch. + // With defaults: threshold=105, anchor=100, exponent=0.5, scale=2.0 + // Value=105, overage=5, ValueAdj = 100 + round(sqrt(5)*2) = 100 + round(4.47) = 104 si := StatInfo{Base: 0, Training: 105} si.Recalculate(1) assert.Equal(t, 105, si.Value) - overage := 105 - 100 - expectedAdj := 100 + int(math.Round(math.Sqrt(float64(overage))*2)) + cfg := configs.GetProgressionConfig() + overage := 105 - int(cfg.StatCapAnchor) + expectedAdj := int(cfg.StatCapAnchor) + int(math.Round(math.Pow(float64(overage), float64(cfg.StatCapExponent))*float64(cfg.StatCapScale))) assert.Equal(t, expectedAdj, si.ValueAdj) } diff --git a/internal/usercommands/experience.go b/internal/usercommands/experience.go index 1406cdd80..606afc554 100644 --- a/internal/usercommands/experience.go +++ b/internal/usercommands/experience.go @@ -90,15 +90,30 @@ func Experience(rest string, user *users.UserRecord, room *rooms.Room, flags eve stats := []string{`str`, `spd`, `smt`, `vit`, `mys`, `per`} - oldG := map[string]int{ - `str`: mockChar.Stats.Strength.GainsForLevel(startLevel - 1), - `spd`: mockChar.Stats.Speed.GainsForLevel(startLevel - 1), - `smt`: mockChar.Stats.Smarts.GainsForLevel(startLevel - 1), - `vit`: mockChar.Stats.Vitality.GainsForLevel(startLevel - 1), - `mys`: mockChar.Stats.Mysticism.GainsForLevel(startLevel - 1), - `per`: mockChar.Stats.Perception.GainsForLevel(startLevel - 1), + // adjForLevel returns the effective (ValueAdj) stat value at a given level + // for the mock character, using Recalculate so the cap is applied. + adjForLevel := func(lvl int) map[string]int { + if lvl < 1 { + lvl = 1 + } + mockChar.Stats.Strength.Recalculate(lvl) + mockChar.Stats.Speed.Recalculate(lvl) + mockChar.Stats.Smarts.Recalculate(lvl) + mockChar.Stats.Vitality.Recalculate(lvl) + mockChar.Stats.Mysticism.Recalculate(lvl) + mockChar.Stats.Perception.Recalculate(lvl) + return map[string]int{ + `str`: mockChar.Stats.Strength.ValueAdj, + `spd`: mockChar.Stats.Speed.ValueAdj, + `smt`: mockChar.Stats.Smarts.ValueAdj, + `vit`: mockChar.Stats.Vitality.ValueAdj, + `mys`: mockChar.Stats.Mysticism.ValueAdj, + `per`: mockChar.Stats.Perception.ValueAdj, + } } + oldG := adjForLevel(startLevel - 1) + newG := map[string]int{} totalG := map[string]int{} for _, stat := range stats { @@ -107,14 +122,7 @@ func Experience(rest string, user *users.UserRecord, room *rooms.Room, flags eve for i := startLevel; i <= endLevel; i++ { - newG = map[string]int{ - `str`: mockChar.Stats.Strength.GainsForLevel(i), - `spd`: mockChar.Stats.Speed.GainsForLevel(i), - `smt`: mockChar.Stats.Smarts.GainsForLevel(i), - `vit`: mockChar.Stats.Vitality.GainsForLevel(i), - `mys`: mockChar.Stats.Mysticism.GainsForLevel(i), - `per`: mockChar.Stats.Perception.GainsForLevel(i), - } + newG = adjForLevel(i) tnlXP := mockChar.XPTL(i) - mockChar.XPTL(i-1) diff --git a/internal/users/userrecord.go b/internal/users/userrecord.go index 30d57d978..48197ecaa 100644 --- a/internal/users/userrecord.go +++ b/internal/users/userrecord.go @@ -208,6 +208,9 @@ func (u *UserRecord) GrantXP(amt int, source string) { Scale: xpScale, }) + tpBefore := u.Character.TrainingPoints + spBefore := u.Character.StatPoints + if newLevel, statsDelta := u.Character.LevelUp(); newLevel { c := configs.GetGamePlayConfig() @@ -243,10 +246,14 @@ func (u *UserRecord) GrantXP(amt int, source string) { levelUpEvent.StatsDelta.Mysticism.Value += statsDelta.Mysticism.Value levelUpEvent.StatsDelta.Perception.Value += statsDelta.Perception.Value - levelUpEvent.TrainingPoints += 1 - levelUpEvent.StatPoints += 1 + // Snapshot before the next LevelUp call so we can measure what was actually granted. + tpBefore = u.Character.TrainingPoints + spBefore = u.Character.StatPoints newLevel, statsDelta = u.Character.LevelUp() + + levelUpEvent.TrainingPoints += u.Character.TrainingPoints - tpBefore + levelUpEvent.StatPoints += u.Character.StatPoints - spBefore } if u.Character.ExtraLives > int(c.LivesMax) { diff --git a/internal/web/admin_items.go b/internal/web/admin_items.go index 9f6034a83..2ff6a6ad3 100644 --- a/internal/web/admin_items.go +++ b/internal/web/admin_items.go @@ -20,6 +20,7 @@ var pageWritePermissions = map[string]string{ "/admin/color-aliases": "color-aliases.write", "/admin/colorpatterns": "colorpatterns.write", "/admin/config": "config.write", + "/admin/progression": "config.write", "/admin/conversations": "conversations.write", "/admin/gametime": "gametime.write", "/admin/items": "items.write", diff --git a/internal/web/admin_nav.go b/internal/web/admin_nav.go index 3ff2861c7..e46983d2e 100644 --- a/internal/web/admin_nav.go +++ b/internal/web/admin_nav.go @@ -48,6 +48,13 @@ func buildAdminNav() []WebNavItem { {Label: "API Docs", Target: "/admin/races-api"}, }, }, + { + Name: "Progression", + Target: "/admin/progression", + SubItems: []WebNavSub{ + {Label: "View / Edit", Target: "/admin/progression"}, + }, + }, }, }, diff --git a/internal/web/admin_progression.go b/internal/web/admin_progression.go new file mode 100644 index 000000000..c363ef9bd --- /dev/null +++ b/internal/web/admin_progression.go @@ -0,0 +1,7 @@ +package web + +import "net/http" + +func adminProgression(w http.ResponseWriter, r *http.Request) { + serveAdminTemplate(w, r, "progression.html", nil) +} diff --git a/internal/web/admin_routes.go b/internal/web/admin_routes.go index e7eccdfb4..5e43b9e91 100644 --- a/internal/web/admin_routes.go +++ b/internal/web/admin_routes.go @@ -14,6 +14,7 @@ func registerAdminRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /admin/https/", doBasicAuth(RunWithMUDLocked(httpsIndex))) mux.HandleFunc("GET /admin/config", doBasicAuth(RunWithMUDLocked(adminConfig))) mux.HandleFunc("GET /admin/config-api", doBasicAuth(RunWithMUDLocked(adminConfigAPI))) + mux.HandleFunc("GET /admin/progression", doBasicAuth(RunWithMUDLocked(adminProgression))) mux.HandleFunc("GET /admin/items", doBasicAuth(RunWithMUDLocked(adminItems))) mux.HandleFunc("GET /admin/items-rank-weapons", doBasicAuth(RunWithMUDLocked(adminItemsRankWeapons))) mux.HandleFunc("GET /admin/items-rank-armor", doBasicAuth(RunWithMUDLocked(adminItemsRankArmor))) diff --git a/internal/web/api_routes.go b/internal/web/api_routes.go index fe917a161..8b651f82b 100644 --- a/internal/web/api_routes.go +++ b/internal/web/api_routes.go @@ -29,6 +29,9 @@ func registerAdminAPIRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /admin/api/v1/config", doBasicAuth(RunWithMUDLocked(apiV1GetConfig))) mux.HandleFunc("PATCH /admin/api/v1/config", doBasicAuth(RequirePermission("config.write", RunWithMUDLocked(RunInTestMode(apiV1PatchConfig))))) + // Progression preview + mux.HandleFunc("GET /admin/api/v1/progression/preview", doBasicAuth(RunWithMUDLocked(apiV1GetProgressionPreview))) + // StatMods mux.HandleFunc("GET /admin/api/v1/statmods", doBasicAuth(RunWithMUDLocked(apiV1GetStatMods))) diff --git a/internal/web/api_v1_progression.go b/internal/web/api_v1_progression.go new file mode 100644 index 000000000..fd3e5747c --- /dev/null +++ b/internal/web/api_v1_progression.go @@ -0,0 +1,250 @@ +package web + +import ( + "math" + "net/http" + "strconv" + + "github.com/GoMudEngine/GoMud/internal/configs" +) + +type progressionPreviewData struct { + Levels []int `json:"levels"` + StatGains map[string][]int `json:"stat_gains"` + StatGainsAdj map[string][]int `json:"stat_gains_adj"` + HP []int `json:"hp"` + Mana []int `json:"mana"` + XPPerLevel []int `json:"xp_per_level"` + XPCumulative []int `json:"xp_cumulative"` +} + +// GET /admin/api/v1/progression/preview +// +// Accepts optional query parameters matching every ProgressionConfig field. +// When a parameter is present it overrides the live config for this preview +// computation only — nothing is saved. Returns precomputed chart series so the +// admin page can render accurate graphs without duplicating the formula in JS. +func apiV1GetProgressionPreview(w http.ResponseWriter, r *http.Request) { + cfg := configs.GetProgressionConfig() + + // Override config from query params when provided. + q := r.URL.Query() + if v := q.Get("BaseModFactor"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.BaseModFactor = configs.ConfigFloat(f) + } + } + if v := q.Get("BaseModExponent"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.BaseModExponent = configs.ConfigFloat(f) + } + } + if v := q.Get("NaturalGainsModFactor"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.NaturalGainsModFactor = configs.ConfigFloat(f) + } + } + if v := q.Get("NaturalGainsExponent"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.NaturalGainsExponent = configs.ConfigFloat(f) + } + } + if v := q.Get("HPBase"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + cfg.HPBase = configs.ConfigInt(i) + } + } + if v := q.Get("HPPerLevel"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.HPPerLevel = configs.ConfigFloat(f) + } + } + if v := q.Get("HPPerVitality"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.HPPerVitality = configs.ConfigFloat(f) + } + } + if v := q.Get("ManaBase"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + cfg.ManaBase = configs.ConfigInt(i) + } + } + if v := q.Get("ManaPerLevel"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.ManaPerLevel = configs.ConfigFloat(f) + } + } + if v := q.Get("ManaPerMysticism"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.ManaPerMysticism = configs.ConfigFloat(f) + } + } + if v := q.Get("XPBase"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + cfg.XPBase = configs.ConfigInt(i) + } + } + if v := q.Get("XPLevelFactor"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.XPLevelFactor = configs.ConfigFloat(f) + } + } + if v := q.Get("XPLevelPower"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.XPLevelPower = configs.ConfigFloat(f) + } + } + if v := q.Get("MaxLevel"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + cfg.MaxLevel = configs.ConfigInt(i) + } + } + if v := q.Get("StatCapThreshold"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + cfg.StatCapThreshold = configs.ConfigInt(i) + } + } + if v := q.Get("StatCapAnchor"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + cfg.StatCapAnchor = configs.ConfigInt(i) + } + } + if v := q.Get("StatCapExponent"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.StatCapExponent = configs.ConfigFloat(f) + } + } + if v := q.Get("StatCapScale"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + cfg.StatCapScale = configs.ConfigFloat(f) + } + } + if v := q.Get("StatCapExemptBonus"); v != "" { + cfg.StatCapExemptBonus = configs.ConfigBool(v == "true" || v == "1") + } + + cfg.Validate() + + maxLevel := int(cfg.MaxLevel) + + // Downsample to at most 100 points for chart rendering. + // When MaxLevel > 100 we pick evenly-spaced levels so the chart stays readable. + const maxPoints = 100 + var chartLevels []int + if maxLevel <= maxPoints { + chartLevels = make([]int, maxLevel) + for i := range chartLevels { + chartLevels[i] = i + 1 + } + } else { + chartLevels = make([]int, maxPoints) + for i := range chartLevels { + // Evenly space from level 1 to maxLevel inclusive. + chartLevels[i] = 1 + int(math.Round(float64(i)*(float64(maxLevel-1)/float64(maxPoints-1)))) + } + } + n := len(chartLevels) + levels := chartLevels + + // Stat gains: three representative racial base values plus their compressed (ValueAdj) counterparts. + // base5 = weak race, base10 = average, base15 = strong. + statBases := map[string]int{"base5": 5, "base10": 10, "base15": 15} + statGains := make(map[string][]int, len(statBases)) + statGainsAdj := make(map[string][]int, len(statBases)) + for label, base := range statBases { + series := make([]int, n) + seriesAdj := make([]int, n) + for i, lvl := range levels { + v := gainsForLevelWithCfg(lvl, base, cfg) + series[i] = v + seriesAdj[i] = applyCapWithCfg(v, cfg) + } + statGains[label] = series + statGainsAdj[label] = seriesAdj + } + + // HP and Mana: representative character — Vitality=10, Mysticism=8, no training mods. + const exampleVitality = 10 + const exampleMysticism = 8 + hp := make([]int, n) + mana := make([]int, n) + for i, lvl := range levels { + hp[i] = int(cfg.HPBase) + + int(float64(lvl)*float64(cfg.HPPerLevel)) + + int(float64(exampleVitality)*float64(cfg.HPPerVitality)) + mana[i] = int(cfg.ManaBase) + + int(float64(lvl)*float64(cfg.ManaPerLevel)) + + int(float64(exampleMysticism)*float64(cfg.ManaPerMysticism)) + } + + // XP curve (TNLScale = 1.0 for display purposes). + xpPerLevel := make([]int, n) + xpCumulative := make([]int, n) + for i, lvl := range levels { + xpForLevel := xpTLWithCfg(lvl, cfg) + xpPrev := 0 + if lvl > 1 { + xpPrev = xpTLWithCfg(lvl-1, cfg) + } + delta := xpForLevel - xpPrev + if lvl == 1 { + delta = 0 + } + xpPerLevel[i] = delta + if i == 0 { + xpCumulative[i] = delta + } else { + xpCumulative[i] = xpCumulative[i-1] + delta + } + } + + writeJSON(w, http.StatusOK, APIResponse[progressionPreviewData]{ + Success: true, + Data: progressionPreviewData{ + Levels: levels, + StatGains: statGains, + StatGainsAdj: statGainsAdj, + HP: hp, + Mana: mana, + XPPerLevel: xpPerLevel, + XPCumulative: xpCumulative, + }, + }) +} + +// gainsForLevelWithCfg mirrors stats.StatInfo.GainsForLevel using a local cfg +// snapshot so the preview does not touch global config. +func gainsForLevelWithCfg(level, base int, cfg configs.ProgressionConfig) int { + if level < 1 { + level = 1 + } + basePoints := int(math.Pow(float64(level-1), float64(cfg.BaseModExponent)) * + float64(cfg.BaseModFactor) * float64(base)) + freePoints := int(math.Pow(float64(level), float64(cfg.NaturalGainsExponent)) * + float64(cfg.NaturalGainsModFactor)) + return basePoints + freePoints +} + +// applyCapWithCfg mirrors stats.StatInfo.Recalculate's compression step using a +// local cfg snapshot so the preview does not touch global config. +// value is treated as racial-only (no training/mods) matching the chart series. +func applyCapWithCfg(value int, cfg configs.ProgressionConfig) int { + if value < int(cfg.StatCapThreshold) { + return value + } + overage := value - int(cfg.StatCapAnchor) + if overage < 0 { + overage = 0 + } + return int(cfg.StatCapAnchor) + int(math.Round(math.Pow(float64(overage), float64(cfg.StatCapExponent))*float64(cfg.StatCapScale))) +} + +// xpTLWithCfg mirrors Character.XPTL using a local cfg snapshot (TNLScale=1.0). +func xpTLWithCfg(lvl int, cfg configs.ProgressionConfig) int { + if lvl < 1 { + lvl = 1 + } + base := float64(cfg.XPBase) + xp := base + math.Pow(float64(lvl), float64(cfg.XPLevelPower))*float64(cfg.XPLevelFactor)*base + return int(xp) +}