package game import ( "fmt" "math" "math/rand" "sort" "time" "github.com/denisovdennis/autohero/internal/constants" "github.com/denisovdennis/autohero/internal/model" ) // DefaultProgressionBandTargets are wall-clock budgets per plan: sum of time for // level-ups L→L+1 within each band (1→10, 10→20, …, 40→50). var DefaultProgressionBandTargets = [5]time.Duration{ 1 * 7 * 24 * time.Hour, 3 * 7 * 24 * time.Hour, 6 * 7 * 24 * time.Hour, 10 * 7 * 24 * time.Hour, 20 * 7 * 24 * time.Hour, } // Hero level L means the step L→L+1 is in the band that contains L (1→10 = L 1..9, …). var progressionBandLevelStart = [...]int{1, 10, 20, 30, 40} var progressionBandLevelEnd = [...]int{9, 19, 29, 39, 49} // SimulatedLevelCountInBand returns how many level-up steps in band bandIdx are included // when simulating hero levels 1..maxHeroLevel (inclusive upper step). func SimulatedLevelCountInBand(bandIdx int, maxHeroLevel int) int { if bandIdx < 0 || bandIdx > 4 { return 0 } lo := progressionBandLevelStart[bandIdx] hi := progressionBandLevelEnd[bandIdx] if maxHeroLevel < lo { return 0 } upper := hi if maxHeroLevel < upper { upper = maxHeroLevel } return upper - lo + 1 } func fullLevelCountInBand(bandIdx int) int { return progressionBandLevelEnd[bandIdx] - progressionBandLevelStart[bandIdx] + 1 } // ProratedBandTargets scales each band target by the fraction of level steps simulated // when maxHeroLevel is below 49 (partial run, e.g. -max-level 29). func ProratedBandTargets(maxHeroLevel int, full [5]time.Duration) [5]time.Duration { var out [5]time.Duration for i := 0; i < 5; i++ { c := SimulatedLevelCountInBand(i, maxHeroLevel) if c <= 0 { continue } denom := fullLevelCountInBand(i) out[i] = time.Duration(float64(full[i]) * float64(c) / float64(denom)) } return out } // SumBandTargets sums per-band targets (e.g. prorated). func SumBandTargets(t [5]time.Duration) time.Duration { var s time.Duration for _, d := range t { s += d } return s } // ProgressionSimParams configures long-run XP / time estimation. type ProgressionSimParams struct { // IterationsPerLevel is Monte Carlo samples per hero level (1..49). IterationsPerLevel int // Seed drives RNG for enemy pick and combat rolls (via rand.Seed per iteration). Seed int64 // RestAfterCombat is added to each fight duration (post-battle downtime). RestAfterCombat time.Duration // Gear is ReferenceGearMedian or ReferenceGearRolled. Gear ReferenceGearProfile // AccountLosses: if true, XP rate = sum(xp)/sum(cycle) over all fights; if false, wins-only ratio. AccountLosses bool // MinHeroLevel and MaxHeroLevelInclusive bound the simulated level-ups (default 1 and 49). MinHeroLevel, MaxHeroLevelInclusive int } func (p ProgressionSimParams) normalized() ProgressionSimParams { out := p if out.IterationsPerLevel < 1 { out.IterationsPerLevel = 80 } if out.RestAfterCombat < 0 { out.RestAfterCombat = 0 } if out.MinHeroLevel < 1 { out.MinHeroLevel = 1 } if out.MaxHeroLevelInclusive < out.MinHeroLevel { out.MaxHeroLevelInclusive = 49 } if out.MaxHeroLevelInclusive > 49 { out.MaxHeroLevelInclusive = 49 } return out } // ProgressionBandResult holds simulated time sums and diagnostics. type ProgressionBandResult struct { BandDurations [5]time.Duration Total time.Duration // TotalSec is the sum of per-level times in seconds (may be +Inf if some levels never award XP). TotalSec float64 // Per-level seconds (hero level L → L+1), index L-1. LevelUpSec []float64 WinRates []float64 // per hero level, fraction of wins in MC iterations } // EnemyTemplatesFromSlice indexes DB rows by Slug for balance tooling. func EnemyTemplatesFromSlice(templates []model.Enemy) map[string]model.Enemy { m := make(map[string]model.Enemy, len(templates)) for _, e := range templates { if e.Slug != "" { m[e.Slug] = e } } return m } // EnemySliceFromMap converts a slug-keyed map to a slice for SetEnemyTemplates. func EnemySliceFromMap(m map[string]model.Enemy) []model.Enemy { out := make([]model.Enemy, 0, len(m)) for _, e := range m { out = append(out, e) } return out } // CloneEnemyTemplates returns a shallow copy of the map with copied Enemy values (keys = slug). func CloneEnemyTemplates(src map[string]model.Enemy) map[string]model.Enemy { if src == nil { return nil } out := make(map[string]model.Enemy, len(src)) for k, v := range src { cp := v if v.SpecialAbilities != nil { cp.SpecialAbilities = append([]model.SpecialAbility(nil), v.SpecialAbilities...) } out[k] = cp } return out } // TemplateProgressionBand maps an enemy template to a band index 0..4 using the // midpoint of [min_level..max_level] (or base level) for content-tier grouping. func TemplateProgressionBand(t model.Enemy) int { mid := t.BaseLevel if t.MinLevel > 0 && t.MaxLevel >= t.MinLevel { mid = (t.MinLevel + t.MaxLevel) / 2 } if mid <= 10 { return 0 } if mid <= 20 { return 1 } if mid <= 30 { return 2 } if mid <= 40 { return 3 } return 4 } // XPRewardScaleSpec defines multipliers applied to template.XPReward (before instance scaling). type XPRewardScaleSpec struct { Global float64 // Elite multiplies xp_reward on templates with IsElite (in addition to Global). Elite float64 // PerType multipliers by enemy slug; missing keys default to 1. PerType map[string]float64 // PerBand scales template by TemplateProgressionBand; length 5, values default to 1. PerBand [5]float64 } func (s XPRewardScaleSpec) effectiveType(slug string) float64 { if s.PerType != nil { if v, ok := s.PerType[slug]; ok && v > 0 { return v } } return 1 } func (s XPRewardScaleSpec) effectiveBand(band int) float64 { if band < 0 || band > 4 { return 1 } v := s.PerBand[band] if v <= 0 { return 1 } return v } func (s XPRewardScaleSpec) eliteMul(e model.Enemy) float64 { m := s.Elite if m <= 0 { m = 1 } if e.IsElite { return m } return 1 } // ApplyXPRewardScaleSpec returns cloned templates with scaled XPReward (rounded, min 1). func ApplyXPRewardScaleSpec(base map[string]model.Enemy, spec XPRewardScaleSpec) map[string]model.Enemy { out := CloneEnemyTemplates(base) g := spec.Global if g <= 0 { g = 1 } for slug, e := range out { band := TemplateProgressionBand(e) mult := g * spec.effectiveType(slug) * spec.effectiveBand(band) * spec.eliteMul(e) x := float64(e.XPReward) * mult if x < 1 { x = 1 } e.XPReward = int64(math.Round(x)) out[slug] = e } return out } // WithEnemyTemplates sets global enemy templates for the duration of fn, then restores. func WithEnemyTemplates(templates map[string]model.Enemy, fn func()) { prev := CloneEnemyTemplates(EnemyTemplatesFromSlice(model.EnemyTemplates)) model.SetEnemyTemplates(EnemySliceFromMap(templates)) defer func() { model.SetEnemyTemplates(EnemySliceFromMap(prev)) }() fn() } // SimulateProgressionBands runs Monte Carlo time estimates per level-up and sums bands. func SimulateProgressionBands(params ProgressionSimParams, templates map[string]model.Enemy) (ProgressionBandResult, error) { p := params.normalized() if len(templates) == 0 { return ProgressionBandResult{}, fmt.Errorf("empty enemy templates") } nLevels := p.MaxHeroLevelInclusive - p.MinHeroLevel + 1 levelUpSec := make([]float64, nLevels) winRates := make([]float64, nLevels) // Accumulate seconds in float64 so +Inf from zero-win levels does not cast to 0 Duration. var bandSec [5]float64 WithEnemyTemplates(templates, func() { for idx, L := range levelRange(p.MinHeroLevel, p.MaxHeroLevelInclusive) { sec, wr := estimateLevelUpSeconds(L, p) levelUpSec[idx] = sec winRates[idx] = wr bi := bandIndexForHeroLevel(L) if bi >= 0 { bandSec[bi] += sec } } }) var bandAcc [5]time.Duration for i := range bandSec { bandAcc[i] = secondsToDuration(bandSec[i]) } var totalSec float64 for _, s := range bandSec { totalSec += s } total := secondsToDuration(totalSec) return ProgressionBandResult{ BandDurations: bandAcc, Total: total, TotalSec: totalSec, LevelUpSec: levelUpSec, WinRates: winRates, }, nil } func levelRange(minL, maxL int) []int { out := make([]int, 0, maxL-minL+1) for L := minL; L <= maxL; L++ { out = append(out, L) } return out } // bandIndexForHeroLevel returns which progression band hero level L belongs to when leveling L→L+1. // secondsToDuration converts simulated seconds to a Duration. +Inf maps to max duration // (impossible-to-finish level); NaN maps to 0. func secondsToDuration(sec float64) time.Duration { if math.IsNaN(sec) { return 0 } if math.IsInf(sec, 1) { return time.Duration(1<<63 - 1) } if math.IsInf(sec, -1) { return 0 } maxF := float64((1<<63 - 1) / int64(time.Second)) if sec >= maxF { return time.Duration(1<<63 - 1) } if sec <= 0 { return 0 } return time.Duration(sec * float64(time.Second)) } func bandIndexForHeroLevel(L int) int { switch { case L >= 1 && L <= 9: return 0 case L >= 10 && L <= 19: return 1 case L >= 20 && L <= 29: return 2 case L >= 30 && L <= 39: return 3 case L >= 40 && L <= 49: return 4 default: return -1 } } func estimateLevelUpSeconds(heroLevel int, p ProgressionSimParams) (seconds float64, winRate float64) { xpNeed := float64(model.XPToNextLevel(heroLevel)) if xpNeed <= 0 { return 0, 1 } n := p.IterationsPerLevel var sumCycle float64 var sumXP float64 var sumCycleWin float64 var sumXPWin float64 wins := 0 for i := 0; i < n; i++ { seed := p.Seed + int64(heroLevel)*1_000_003 + int64(i)*97_981 rand.Seed(seed) var gearRng *rand.Rand if p.Gear == ReferenceGearRolled { gearRng = rand.New(rand.NewSource(seed + 42)) } baseHero := NewReferenceHeroForBalance(heroLevel, p.Gear, gearRng) hero := CloneHeroForCombatSim(baseHero) pickRNG := rand.New(rand.NewSource(seed + 11_111)) enemy := PickEnemyForLevelWithRNG(heroLevel, pickRNG, hero) survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{ TickRate: 100 * time.Millisecond, MaxSteps: constants.CombatSimMaxStepsLong, }) cycle := elapsed.Seconds() + p.RestAfterCombat.Seconds() xp := float64(enemy.XPReward) if !survived { xp = 0 } else { wins++ } sumCycle += cycle sumXP += xp if survived { sumCycleWin += cycle sumXPWin += float64(enemy.XPReward) } } winRate = float64(wins) / float64(n) var xpPerSec float64 if p.AccountLosses { if sumCycle > 0 { xpPerSec = sumXP / sumCycle } } else { if sumCycleWin > 0 && sumXPWin > 0 { xpPerSec = sumXPWin / sumCycleWin } } if xpPerSec <= 0 { return math.Inf(1), winRate } return xpNeed / xpPerSec, winRate } // BandErrors returns relative errors (sim/target - 1) per band; targets must be > 0. func BandErrors(sim, targets [5]time.Duration) [5]float64 { var e [5]float64 for i := range sim { if targets[i] <= 0 { e[i] = 0 continue } e[i] = float64(sim[i])/float64(targets[i]) - 1 } return e } // SquaredErrorSum returns sum of squared relative band errors. func SquaredErrorSum(sim, targets [5]time.Duration) float64 { var s float64 for i := range sim { if targets[i] <= 0 { continue } r := float64(sim[i])/float64(targets[i]) - 1 s += r * r } return s } // EnemyLevelTierMid is a single sort key from catalog level band (higher = later content). // Uses midpoint of [min_level..max_level] when set; otherwise base_level. func EnemyLevelTierMid(e model.Enemy) int { if e.MinLevel > 0 && e.MaxLevel >= e.MinLevel { return (e.MinLevel + e.MaxLevel) / 2 } bl := e.BaseLevel if bl <= 0 { bl = 1 } return bl } // SortEnemyTypesByLevelTier sorts by ascending EnemyLevelTierMid, then slug. func SortEnemyTypesByLevelTier(m map[string]model.Enemy) []string { types := make([]string, 0, len(m)) for t := range m { types = append(types, t) } sort.Slice(types, func(i, j int) bool { mi := EnemyLevelTierMid(m[types[i]]) mj := EnemyLevelTierMid(m[types[j]]) if mi != mj { return mi < mj } return types[i] < types[j] }) return types } // EnforceMonotonicXPRewardByTier ensures non-decreasing xp_reward with level tier; when tier // strictly increases, xp_reward must strictly increase (>= previous + 1). func EnforceMonotonicXPRewardByTier(templates map[string]model.Enemy) map[string]model.Enemy { out := CloneEnemyTemplates(templates) order := SortEnemyTypesByLevelTier(out) prevMid := -1 var prevXP int64 first := true for _, typ := range order { e := out[typ] mid := EnemyLevelTierMid(e) x := e.XPReward if x < 1 { x = 1 } if !first { if mid > prevMid { if x <= prevXP { x = prevXP + 1 } } else { if x < prevXP { x = prevXP } } } e.XPReward = x out[typ] = e prevXP = x prevMid = mid first = false } return out } // MaxRelativeErrorVsTargets returns max |sim/target-1| over bands with target>0, plus total error entry. func MaxRelativeErrorVsTargets(sim [5]time.Duration, targets [5]time.Duration, totalSim, totalTarget time.Duration) float64 { maxE := 0.0 for i := range sim { if targets[i] <= 0 { continue } e := math.Abs(float64(sim[i])/float64(targets[i]) - 1) if e > maxE { maxE = e } } if totalTarget > 0 { e := math.Abs(float64(totalSim)/float64(totalTarget) - 1) if e > maxE { maxE = e } } return maxE } func clonePerTypeMap(m map[string]float64) map[string]float64 { out := make(map[string]float64, len(m)) for k, v := range m { out[k] = v } return out } // OptimizePerTypeScales adjusts each enemy row's xp_reward via PerType multipliers (Global fixed at 1). // Elite templates still use elite multiplier from spec. Resulting XPReward are integers (rounded). // If enforceMonotonic is true, xp_reward is non-decreasing with EnemyLevelTierMid (strictly up when tier rises). func OptimizePerTypeScales( base map[string]model.Enemy, params ProgressionSimParams, targets [5]time.Duration, elite float64, maxIters int, enforceMonotonic bool, ) (map[string]float64, map[string]model.Enemy, ProgressionBandResult, float64) { if maxIters < 1 { maxIters = 120 } types := SortEnemyTypesByLevelTier(base) perType := make(map[string]float64, len(types)) for _, t := range types { perType[t] = 1 } applyAndSim := func(spec XPRewardScaleSpec) (ProgressionBandResult, error) { tmpl := ApplyXPRewardScaleSpec(base, spec) if enforceMonotonic { tmpl = EnforceMonotonicXPRewardByTier(tmpl) } return SimulateProgressionBands(params, tmpl) } bestSpec := XPRewardScaleSpec{Global: 1, Elite: elite, PerType: perType, PerBand: [5]float64{1, 1, 1, 1, 1}} res, _ := applyAndSim(bestSpec) bestErr := SquaredErrorSum(res.BandDurations, targets) // Wide factors so integer xp_reward can move (DB often has 1–3); include coarse steps to approach targets. factors := []float64{8, 4, 2, 1.5, 1.25, 1.1, 1.05, 1.02, 1.01, 0.99, 0.98, 0.95, 0.9, 0.75, 0.5, 0.25} for iter := 0; iter < maxIters; iter++ { improved := false for _, typ := range types { for _, f := range factors { cand := clonePerTypeMap(perType) next := cand[typ] * f if next < 0.05 || next > 200 { continue } cand[typ] = next spec := XPRewardScaleSpec{Global: 1, Elite: elite, PerType: cand, PerBand: [5]float64{1, 1, 1, 1, 1}} r, err := applyAndSim(spec) if err != nil { continue } errVal := SquaredErrorSum(r.BandDurations, targets) if errVal < bestErr { bestErr = errVal perType = cand res = r improved = true } } } if !improved { break } } finalSpec := XPRewardScaleSpec{Global: 1, Elite: elite, PerType: perType, PerBand: [5]float64{1, 1, 1, 1, 1}} scaled := ApplyXPRewardScaleSpec(base, finalSpec) if enforceMonotonic { scaled = EnforceMonotonicXPRewardByTier(scaled) } res, _ = SimulateProgressionBands(params, scaled) return perType, scaled, res, SquaredErrorSum(res.BandDurations, targets) } // OptimizeBandScales searches PerBand multipliers to minimize squared relative error vs targets. // global and elite are fixed; only PerBand[5] is optimized (coordinate descent). func OptimizeBandScales( base map[string]model.Enemy, params ProgressionSimParams, targets [5]time.Duration, global, elite float64, maxIters int, ) ([5]float64, ProgressionBandResult, float64) { if maxIters < 1 { maxIters = 120 } scales := [5]float64{1, 1, 1, 1, 1} bestSpec := XPRewardScaleSpec{Global: global, Elite: elite, PerBand: scales} templates := ApplyXPRewardScaleSpec(base, bestSpec) res, _ := SimulateProgressionBands(params, templates) bestErr := SquaredErrorSum(res.BandDurations, targets) step := 0.08 for iter := 0; iter < maxIters; iter++ { improved := false for g := 0; g < 5; g++ { for _, mult := range []float64{1 + step, 1 - step, 1 + step/2, 1 - step/2} { if mult <= 0.05 { continue } cand := scales cand[g] *= mult if cand[g] < 0.05 || cand[g] > 200 { continue } spec := XPRewardScaleSpec{Global: global, Elite: elite, PerBand: cand} tmpl := ApplyXPRewardScaleSpec(base, spec) r, err := SimulateProgressionBands(params, tmpl) if err != nil { continue } errVal := SquaredErrorSum(r.BandDurations, targets) if errVal < bestErr { bestErr = errVal scales = cand res = r improved = true } } } if !improved { step *= 0.5 if step < 0.005 { break } } } finalSpec := XPRewardScaleSpec{Global: global, Elite: elite, PerBand: scales} templates = ApplyXPRewardScaleSpec(base, finalSpec) res, _ = SimulateProgressionBands(params, templates) return scales, res, SquaredErrorSum(res.BandDurations, targets) }