package main import ( "fmt" "hash/fnv" "math" "math/rand" "sort" "time" "github.com/denisovdennis/autohero/internal/constants" "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" ) // enemyTemplateHasPeriodicDoT is true if the template applies poison/burn DoT in combat // (longer fights stack more periodic damage — conflicts with naive hp×atk grid balance). func enemyTemplateHasPeriodicDoT(t model.Enemy) bool { for _, a := range t.SpecialAbilities { switch a { case model.AbilityBurn, model.AbilityPoison: return true } } return false } // gridScenario: fair fight heroLv == enemyLv at each tier, × gear variants (median + rolled). type gridScenario struct { heroLv int enemyLv int gearIdx int } func hashGridScenario(et string, sc gridScenario) uint64 { h := fnv.New64a() _, _ = h.Write([]byte(fmt.Sprintf("%s|%d|%d|%d", et, sc.heroLv, sc.enemyLv, sc.gearIdx))) return h.Sum64() } // gridScenariosForTemplate builds heroLv == enemyLv for each level in [MinLevel..MaxLevel] × gearMods. func gridScenariosForTemplate(t model.Enemy, gearMods int) []gridScenario { if gearMods < 2 { gearMods = 4 } minL := t.MinLevel maxL := t.MaxLevel if minL <= 0 || maxL < minL { lvl := (t.MinLevel + t.MaxLevel) / 2 if lvl < 1 { lvl = 1 } if t.BaseLevel > 0 { lvl = t.BaseLevel } out := make([]gridScenario, 0, gearMods) for g := 0; g < gearMods; g++ { out = append(out, gridScenario{heroLv: lvl, enemyLv: lvl, gearIdx: g}) } return out } out := make([]gridScenario, 0, (maxL-minL+1)*gearMods) for lv := minL; lv <= maxL; lv++ { for g := 0; g < gearMods; g++ { out = append(out, gridScenario{heroLv: lv, enemyLv: lv, gearIdx: g}) } } return out } func buildBaseHeroGrid(sc gridScenario) *model.Hero { if sc.gearIdx == 0 { return game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearMedian, nil) } seed := int64(500_000 + sc.heroLv*10_000 + sc.enemyLv*100 + sc.gearIdx*17) rng := rand.New(rand.NewSource(seed)) return game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearRolled, rng) } type gridScenResult struct { medianWinSec float64 medianHeroHp float64 winRate float64 } func runOneGridScenario(tmpl model.Enemy, base *model.Hero, enemyLv int, n int, seedBase int64, h uint64) gridScenResult { var winDur []time.Duration var winHpPct []float64 wins := 0 for i := 0; i < n; i++ { rand.Seed(seedBase + int64(i)*1_000_003 + int64(h)) hero := game.CloneHeroForCombatSim(base) if hero.HP <= 0 { hero.HP = hero.MaxHP } maxH := hero.MaxHP if maxH <= 0 { maxH = 1 } e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv, nil) survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{ TickRate: 100 * time.Millisecond, MaxSteps: constants.CombatSimMaxStepsLong, }) if survived { wins++ winDur = append(winDur, elapsed) winHpPct = append(winHpPct, float64(hero.HP)/float64(maxH)) } } wr := float64(wins) / float64(n) if len(winDur) == 0 { return gridScenResult{winRate: wr} } sort.Slice(winDur, func(i, j int) bool { return winDur[i] < winDur[j] }) sort.Float64s(winHpPct) med := winDur[len(winDur)/2] medHp := winHpPct[len(winHpPct)/2] return gridScenResult{ medianWinSec: med.Seconds(), medianHeroHp: medHp, winRate: wr, } } type gridAggResult struct { medOfMedDur float64 medOfMedHp float64 minWinRate float64 medWinRate float64 minMedDur float64 maxMedDur float64 minMedHp float64 maxMedHp float64 } func medianFloat(xs []float64) float64 { if len(xs) == 0 { return 0 } sort.Float64s(xs) return xs[len(xs)/2] } func aggregateGrid(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale, atkScale float64, n int, seedBase int64) gridAggResult { t := scaleEnemyForSim(tmpl, hpScale, atkScale) var medDurs []float64 var medHps []float64 var winRates []float64 minWR := 1.0 minDur := 1e12 maxDur := 0.0 minHp := 1.0 maxHp := 0.0 for _, sc := range scenarios { base := buildBaseHeroGrid(sc) h := hashGridScenario(et, sc) r := runOneGridScenario(t, base, sc.enemyLv, n, seedBase, h) winRates = append(winRates, r.winRate) if r.winRate < minWR { minWR = r.winRate } if r.medianWinSec > 0 { medDurs = append(medDurs, r.medianWinSec) medHps = append(medHps, r.medianHeroHp) if r.medianWinSec < minDur { minDur = r.medianWinSec } if r.medianWinSec > maxDur { maxDur = r.medianWinSec } if r.medianHeroHp < minHp { minHp = r.medianHeroHp } if r.medianHeroHp > maxHp { maxHp = r.medianHeroHp } } } return gridAggResult{ medOfMedDur: medianFloat(medDurs), medOfMedHp: medianFloat(medHps), minWinRate: minWR, medWinRate: medianFloat(winRates), minMedDur: minDur, maxMedDur: maxDur, minMedHp: minHp, maxMedHp: maxHp, } } func findHPScaleForAggDurationGrid(tmpl model.Enemy, et string, scenarios []gridScenario, n int, seedBase int64, lowSec, highSec float64, minMedWin float64) float64 { r0 := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, n, seedBase) if r0.medOfMedDur >= lowSec && r0.medOfMedDur <= highSec && r0.medWinRate >= minMedWin { return 1.0 } lo, hi := 0.04, 28.0 best := 1.0 for iter := 0; iter < 44; iter++ { mid := (lo + hi) / 2 r := aggregateGrid(tmpl, et, scenarios, mid, 1.0, n, seedBase) if r.medWinRate < minMedWin*0.5 { hi = mid continue } if r.medOfMedDur == 0 { hi = mid continue } if r.medOfMedDur < lowSec { lo = mid } else if r.medOfMedDur > highSec { hi = mid } else { return mid } best = mid if hi-lo < 0.012 { break } } r := aggregateGrid(tmpl, et, scenarios, best, 1.0, n, seedBase) if r.medOfMedDur > 0 && r.medOfMedDur >= lowSec && r.medOfMedDur <= highSec && r.medWinRate >= minMedWin { return best } // Fallback: duration vs hpScale can be non-monotone for some ability mixes; coarse scan. durTol := 2.5 for s := 1; s <= 48; s++ { try := 0.06 + float64(s)*0.58 if try > 28.5 { break } r2 := aggregateGrid(tmpl, et, scenarios, try, 1.0, n, seedBase) if r2.medWinRate < minMedWin*0.55 { continue } if r2.medOfMedDur > 0 && r2.medOfMedDur >= lowSec-durTol && r2.medOfMedDur <= highSec+durTol { return try } } // DoT / non-monotone duration: linear scan (binary search can miss a valid island). for s := 1; s <= 55; s++ { try := 0.12 + float64(s)*0.16 if try > 9.2 { break } r2 := aggregateGrid(tmpl, et, scenarios, try, 1.0, n, seedBase) if r2.medWinRate < minMedWin*0.5 { continue } if r2.medOfMedDur > 0 && r2.medOfMedDur >= lowSec && r2.medOfMedDur <= highSec { return try } } return -1 } func findAtkScaleForAggHeroHpGridRelaxed(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64, dotHeavy bool) float64 { a := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minMedWin) if a >= 0 { return a } extra := 0.10 if dotHeavy { extra = 0.16 } hpLo2 := hpLow - extra if hpLo2 < 0.12 { hpLo2 = 0.12 } hpHi2 := hpHigh + 0.02 if dotHeavy { hpHi2 = hpHigh + 0.06 } return findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLo2, hpHi2, minMedWin) } func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 { r0 := aggregateGrid(tmpl, et, scenarios, hpScale, 1.0, n, seedBase) // Median win rate across cells can dip when HP is scaled for long fights; do not abort too early. earlyFrac := 0.55 if minMedWin < 0.24 { earlyFrac = 0.35 // DoT / relaxed gate: allow ~10–15% median cell win rate through to binary search } if r0.medWinRate < minMedWin*earlyFrac { return -1 } if r0.medOfMedHp >= hpLow && r0.medOfMedHp <= hpHigh { return 1.0 } var lo, hi float64 if r0.medOfMedHp < hpLow { lo, hi = 0.02, 1.0 } else { lo, hi = 1.0, 22.0 } best := 1.0 for iter := 0; iter < 56; iter++ { mid := (lo + hi) / 2 r := aggregateGrid(tmpl, et, scenarios, hpScale, mid, n, seedBase) binReject := minMedWin * 0.5 if minMedWin < 0.24 { binReject = minMedWin * 0.38 } if r.medWinRate < binReject { hi = mid best = mid continue } if r.medOfMedHp < hpLow { hi = mid } else if r.medOfMedHp > hpHigh { lo = mid } else { return mid } best = mid if hi-lo < 0.002 { break } } r := aggregateGrid(tmpl, et, scenarios, hpScale, best, n, seedBase) if r.medWinRate >= minMedWin && r.medOfMedHp >= hpLow && r.medOfMedHp <= hpHigh { return best } // Fallback: medOfMedHp vs atk is not always monotone (DoT, stuns); scan a coarse grid. hpTol := 0.03 for step := 0; step < 22; step++ { try := 0.02 + float64(step)*0.28 if try > 8.5 { break } r2 := aggregateGrid(tmpl, et, scenarios, hpScale, try, n, seedBase) if r2.medWinRate < minMedWin { continue } if r2.medOfMedHp >= hpLow-hpTol && r2.medOfMedHp <= hpHigh+hpTol { return try } } return -1 } func ensurePositiveMinWinGrid(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale, atkScale *float64, n int, seedBase int64) { for round := 0; round < 35; round++ { a := aggregateGrid(tmpl, et, scenarios, *hpScale, *atkScale, n, seedBase) if a.minWinRate > 0 { return } *atkScale *= 0.98 *hpScale *= 1.012 if *atkScale < 0.15 { return } } } // balanceArchetypeGrid runs grid balance for one enemy type; returns ok=false if skipped/failed. // dotHeavy: burn/poison — shorter target duration band (set in caller) and looser final duration/atk checks. func balanceArchetypeGrid( tmpl model.Enemy, et string, scenarios []gridScenario, iterations int, typeSeed int64, lowSec, highSec float64, targetSec float64, hpLow, hpHigh float64, minWinRate float64, refinePasses int, dotHeavy bool, ) (hpScale, atkScale float64, final gridAggResult, ok bool) { if len(scenarios) == 0 { return 0, 0, gridAggResult{}, false } n := iterations if n < 20 { n = 20 } seedBase := typeSeed maxRounds := 6 + refinePasses*2 if maxRounds < 10 { maxRounds = 10 } minWinGate := minWinRate if dotHeavy { // Grid cells with burn vary wildly; median-of-medians win rate can sit ~12–20%. minWinGate = minWinRate * 0.52 } hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinGate) if hpScale < 0 { hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-18, highSec+18, minWinGate*0.80) } if hpScale < 0 { pad := 40.0 if dotHeavy { pad = 55 } hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-pad, highSec+pad, minWinGate*0.72) } if hpScale < 0 { return 0, 0, gridAggResult{}, false } atkScale = findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy) if atkScale < 0 { return 0, 0, gridAggResult{}, false } for round := 0; round < maxRounds; round++ { a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase) durOk := a.medOfMedDur > 0 && a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec hpOk := a.medOfMedHp >= hpLow && a.medOfMedHp <= hpHigh if durOk && hpOk && a.medWinRate >= minWinGate { break } if a.medOfMedDur > 0 && !durOk { corr := targetSec / a.medOfMedDur hpScale *= corr if hpScale < 0.02 || hpScale > 28 { break } } if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 { atkScale = atk2 } else { break } ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) } ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 { atkScale = atk2 } ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) for fix := 0; fix < 10; fix++ { a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase) if a.medOfMedDur <= 0 { break } if a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec { break } hpScale *= targetSec / a.medOfMedDur if hpScale < 0.02 || hpScale > 28 { break } if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 { atkScale = atk2 } ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) } final = aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase) if final.medWinRate < minWinGate { return 0, 0, gridAggResult{}, false } minAtk := 0.22 if dotHeavy { minAtk = 0.18 } if atkScale < minAtk { return 0, 0, gridAggResult{}, false } durLowChk, durHighChk := lowSec, highSec if dotHeavy { durLowChk -= 25 durHighChk += 35 } if final.medOfMedDur > 0 && (final.medOfMedDur < durLowChk || final.medOfMedDur > durHighChk) { return 0, 0, gridAggResult{}, false } return hpScale, atkScale, final, true } func printGridSQL(tmpl model.Enemy, et string, hpScale, atkScale float64) { newHP := max(1, int(math.Round(float64(tmpl.MaxHP)*hpScale))) newHPL := tmpl.HPPerLevel * hpScale newAtk := max(1, int(math.Round(float64(tmpl.Attack)*atkScale))) newAtkL := tmpl.AttackPerLevel * atkScale fmt.Printf("UPDATE enemies SET hp = %d, max_hp = %d, hp_per_level = %.4f, attack = %d, attack_per_level = %.4f WHERE type = '%s';\n", newHP, newHP, newHPL, newAtk, newAtkL, et) }