|
|
package main
|
|
|
|
|
|
import (
|
|
|
"fmt"
|
|
|
"hash/fnv"
|
|
|
"math"
|
|
|
"math/rand"
|
|
|
"sort"
|
|
|
"time"
|
|
|
|
|
|
"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)
|
|
|
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
|
|
|
TickRate: 100 * time.Millisecond,
|
|
|
MaxSteps: game.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)
|
|
|
}
|