From bda06fba64d0d65ac09b9b1bcde1b7a64f2bf31a Mon Sep 17 00:00:00 2001
From: Volte6 <143822+Volte6@users.noreply.github.com>
Date: Fri, 29 May 2026 00:22:53 -0700
Subject: [PATCH] Configurable level and stat/experience gain charts
---
_datafiles/config.yaml | 78 ++
_datafiles/html/admin/config.html | 23 +
_datafiles/html/admin/progression.html | 985 +++++++++++++++++++++++++
internal/characters/character.go | 40 +-
internal/configs/config.gameplay.go | 4 +
internal/configs/config.progression.go | 155 ++++
internal/stats/stats.go | 61 +-
internal/stats/stats_test.go | 61 +-
internal/usercommands/experience.go | 38 +-
internal/users/userrecord.go | 11 +-
internal/web/admin_items.go | 1 +
internal/web/admin_nav.go | 7 +
internal/web/admin_progression.go | 7 +
internal/web/admin_routes.go | 1 +
internal/web/api_routes.go | 3 +
internal/web/api_v1_progression.go | 250 +++++++
16 files changed, 1669 insertions(+), 56 deletions(-)
create mode 100644 _datafiles/html/admin/progression.html
create mode 100644 internal/configs/config.progression.go
create mode 100644 internal/web/admin_progression.go
create mode 100644 internal/web/api_v1_progression.go
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.
+
+
+
+
+
+
+
+
+
+
+
+
Stat Gains
+
+
+ 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 & Mana
+
+
+ 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 Curve
+
+ XP Base ?
+
+
+
+ XP Level Factor ?
+
+ 0.75
+
+
+ XP Level Power ?
+
+ 2.00
+
+
+ Max Level (chart range) ?
+
+ 100
+
+
+
+
+
Level-Up Rewards
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
Restore all defaults?
+
This will reset every progression setting to its original default value and save immediately to the server. All custom values will be lost and the change will affect all characters at once.
+
+
+
+
+
+
+
+
+
+
+
+{{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)
+}