You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

438 lines
12 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

package main
import (
"fmt"
"hash/fnv"
"math"
"math/rand"
"sort"
"time"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
)
// gridScenario: fair fight heroLv == enemyLv at each tier, × gear variants (median + rolled).
type gridScenario struct {
heroLv int
enemyLv int
gearIdx int
}
func hashGridScenario(et model.EnemyType, 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 model.EnemyType, 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 model.EnemyType, 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 model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 {
a := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minMedWin)
if a >= 0 {
return a
}
hpLo2 := hpLow - 0.10
if hpLo2 < 0.15 {
hpLo2 = 0.15
}
return findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLo2, hpHigh+0.02, minMedWin)
}
func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, 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.
if r0.medWinRate < minMedWin*0.55 {
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)
if r.medWinRate < minMedWin*0.5 {
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 model.EnemyType, 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.
func balanceArchetypeGrid(
tmpl model.Enemy,
et model.EnemyType,
scenarios []gridScenario,
iterations int,
typeSeed int64,
lowSec, highSec float64,
targetSec float64,
hpLow, hpHigh float64,
minWinRate float64,
refinePasses int,
) (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
}
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinRate)
if hpScale < 0 {
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-18, highSec+18, minWinRate*0.80)
}
if hpScale < 0 {
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-40, highSec+40, minWinRate*0.72)
}
if hpScale < 0 {
return 0, 0, gridAggResult{}, false
}
atkScale = findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate)
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 >= minWinRate {
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, minWinRate); 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, minWinRate); 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, minWinRate); atk2 > 0 {
atkScale = atk2
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
}
final = aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase)
if final.medWinRate < minWinRate {
return 0, 0, gridAggResult{}, false
}
if atkScale < 0.22 {
return 0, 0, gridAggResult{}, false
}
if final.medOfMedDur > 0 && (final.medOfMedDur < lowSec || final.medOfMedDur > highSec) {
return 0, 0, gridAggResult{}, false
}
return hpScale, atkScale, final, true
}
func printGridSQL(tmpl model.Enemy, et model.EnemyType, 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)
}