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.

576 lines
19 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 (
"context"
"flag"
"fmt"
"hash/fnv"
"log"
"math"
"math/rand"
"os"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
func main() {
var (
dsnFlag = flag.String("dsn", "", "Postgres DSN (default: DATABASE_URL env)")
listEnemies = flag.Bool("list-enemies", false, "print enemy archetypes from DB and exit")
filter = flag.String("filter", "", "optional substring filter for -list-enemies (id/type/name)")
listLimit = flag.Int("limit", 50, "max rows for -list-enemies")
enemyIDFlag = flag.Int64("enemy-id", 0, "if set, balance only this enemies.id row")
enemyTypeFlag = flag.String("enemy-type", "", "if set, balance only this archetype (type string); ignored if -enemy-id is set")
configJSON = flag.String("config", "", "optional JSON file: partial enemy objects keyed by type string, merged over DB templates")
gridMode = flag.Bool("grid", true, "equal-level × gear grid (median + rolled); false = legacy single midpoint + median gear only")
gearVariants = flag.Int("gear-variants", 4, "grid mode: median + (N-1) rolled ilvl profiles per level")
heroHpMid = flag.Float64("hero-hp-mid", 66, "grid: low-tier center hero HP%% on wins when -tiered-targets; else flat center")
heroHpMidHigh = flag.Float64("hero-hp-mid-high", 26, "grid: high-tier center hero HP%% when -tiered-targets")
heroHpPP = flag.Float64("hero-hp-pp", 6, "grid mode: ±percentage points around hero-hp-mid (center varies when tiered)")
refinePasses = flag.Int("refine", 2, "grid mode: duration/atk refinement passes")
iterations = flag.Int("iterations", 120, "runs per archetype (grid: per scenario cell)")
seed = flag.Int64("seed", 20260331, "base rng seed")
targetSec = flag.Float64("target-sec", 330, "grid: low-tier center median duration (sec) when -tiered-targets; else flat center")
targetSecHigh = flag.Float64("target-sec-high", 660, "grid: high-tier center median duration (sec) when -tiered-targets")
tieredTargets = flag.Bool("tiered-targets", true, "grid: interpolate target duration and hero HP%% center from low tier to high tier by archetype level band")
tierLevelMin = flag.Int("tier-level-min", 1, "grid tiered: global min level for normalization (catalog)")
tierLevelMax = flag.Int("tier-level-max", 35, "grid tiered: global max level for normalization (catalog)")
tierGamma = flag.Float64("tier-gamma", 1.5, "grid tiered: exponent for nonlinear interpolation (1=linear)")
tolerancePct = flag.Float64("tolerance-pct", 10, "deviation from target (percent); 10%% with target 330 → band [297s,363s]")
maxHeroHpPct = flag.Float64("max-hero-hp-pct-on-win", 60, "legacy mode: median hero HP%% after victory must be <= this (0-100)")
minWinRate = flag.Float64("min-win-rate", 0.28, "stop raising enemy attack if win rate falls below this (legacy); grid: median win rate floor")
printSQL = flag.Bool("sql", true, "print suggested UPDATE statements")
)
flag.Parse()
if *enemyIDFlag != 0 && strings.TrimSpace(*enemyTypeFlag) != "" {
log.Fatal("use only one of -enemy-id or -enemy-type")
}
dsn := strings.TrimSpace(*dsnFlag)
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
log.Fatal("DATABASE_URL or -dsn is required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer pool.Close()
cs := storage.NewContentStore(pool)
templates, err := cs.LoadEnemyTemplates(ctx)
if err != nil {
log.Fatalf("load enemies: %v", err)
}
if p := strings.TrimSpace(*configJSON); p != "" {
templates, err = applyEnemyOverlayJSON(p, templates)
if err != nil {
log.Fatalf("config overlay: %v", err)
}
}
model.SetEnemyTemplates(templates)
if *listEnemies {
if *listLimit <= 0 || *listLimit > 500 {
log.Fatal("limit must be 1..500")
}
f := strings.TrimSpace(strings.ToLower(*filter))
dbRows, err := cs.ListEnemyRows(ctx)
if err != nil {
log.Fatalf("list enemies: %v", err)
}
fmt.Printf("# balanceall enemies from DB (filter=%q)\n", *filter)
fmt.Printf("%-8s %-22s %-32s %6s %6s %6s %5s\n", "id", "type", "name", "minLv", "maxLv", "baseLv", "elite")
printed := 0
for _, r := range dbRows {
idStr := fmt.Sprintf("%d", r.ID)
if f != "" {
if !strings.Contains(strings.ToLower(idStr), f) &&
!strings.Contains(strings.ToLower(r.Type), f) &&
!strings.Contains(strings.ToLower(r.Name), f) {
continue
}
}
if printed >= *listLimit {
break
}
fmt.Printf("%-8d %-22s %-32s %6d %6d %6d %5v\n",
r.ID, r.Type, trimName32(r.Name), r.MinLevel, r.MaxLevel, r.BaseLevel, r.IsElite)
printed++
}
if f != "" {
fmt.Printf("# printed %d rows (limit=%d)\n", printed, *listLimit)
}
return
}
if *iterations < 40 {
log.Fatal("iterations should be at least 40")
}
if *gearVariants < 2 {
log.Fatal("gear-variants must be at least 2")
}
if *gridMode && *tieredTargets && *tierLevelMax <= *tierLevelMin {
log.Fatal("tier-level-max must be > tier-level-min")
}
var hpLowGridFlat, hpHighGridFlat float64
if *gridMode && !*tieredTargets {
hpMid := *heroHpMid / 100.0
pp := *heroHpPP / 100.0
hpLowGridFlat = hpMid - pp
hpHighGridFlat = hpMid + pp
if hpLowGridFlat < 0 {
hpLowGridFlat = 0
}
if hpHighGridFlat > 1 {
hpHighGridFlat = 1
}
}
maxHpFrac := *maxHeroHpPct / 100.0
if !*gridMode && (maxHpFrac <= 0 || maxHpFrac > 1) {
log.Fatal("max-hero-hp-pct-on-win must be in (0,100]")
}
order, err := archetypeOrder(ctx, templates, pool, *enemyIDFlag, strings.TrimSpace(*enemyTypeFlag))
if err != nil {
log.Fatal(err)
}
target := time.Duration(*targetSec * float64(time.Second))
tol := *tolerancePct / 100.0
lowTarget := time.Duration(float64(target) * (1.0 - tol))
highTarget := time.Duration(float64(target) * (1.0 + tol))
legacyLowSec := *targetSec * (1.0 - tol)
legacyHighSec := *targetSec * (1.0 + tol)
overlayNote := ""
if strings.TrimSpace(*configJSON) != "" {
overlayNote = fmt.Sprintf(" | overlay=%q", strings.TrimSpace(*configJSON))
}
if *gridMode {
fmt.Printf("# balanceall: grid mode | iterations/cell=%d%s\n", *iterations, overlayNote)
if *tieredTargets {
fmt.Printf("# tiered: duration center %.0fs → %.0fs | hero HP center %.0f%% → %.0f%% (level mid in [%d,%d], gamma=%.2f); ±%.1f%% dur / ±%.1f pp HP\n",
*targetSec, *targetSecHigh, *heroHpMid, *heroHpMidHigh, *tierLevelMin, *tierLevelMax, *tierGamma, *tolerancePct, *heroHpPP)
} else {
fmt.Printf("# duration: med-of-meds in [%.1fs, %.1fs] | hero HP%% on wins: [%.1f%%, %.1f%%] (center %.1f%% ±%.1f pp)\n",
legacyLowSec, legacyHighSec, 100*hpLowGridFlat, 100*hpHighGridFlat, *heroHpMid, *heroHpPP)
}
fmt.Printf("# levels: heroLv==enemyLv for each L in [min_level..max_level]; gear: %d variants\n\n", *gearVariants)
} else {
fmt.Printf("# balanceall: legacy mode | iterations=%d%s | phase1: HP→duration | phase2: atk→median hero HP on win <= %.1f%%\n# duration band=[%s,%s] min win rate %.0f%%\n\n",
*iterations, overlayNote, *maxHeroHpPct, lowTarget.Round(time.Millisecond), highTarget.Round(time.Millisecond), 100*(*minWinRate))
}
for _, et := range order {
tmpl := templates[et]
typeSeed := *seed + int64(hashString(string(et)))
if *gridMode {
scenarios := gridScenariosForTemplate(tmpl, *gearVariants)
base := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, *iterations, typeSeed)
fmt.Printf("# baseline %s (grid %d cells): medOfMedDur=%.1fs medOfMedHp=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64
if *tieredTargets {
norm := archetypeTierNorm(tmpl, *tierLevelMin, *tierLevelMax)
tPow := math.Pow(norm, *tierGamma)
targetSecEff = *targetSec + (*targetSecHigh-*targetSec)*tPow
heroHpMidEff := *heroHpMid + (*heroHpMidHigh-*heroHpMid)*tPow
lowSec = targetSecEff * (1.0 - tol)
highSec = targetSecEff * (1.0 + tol)
hpMid := heroHpMidEff / 100.0
ppPts := *heroHpPP
if norm > 0.35 {
// Elites with DoT/stun: median hero HP on wins is harder to fit in a tight band.
ppPts += (norm - 0.35) * 45
}
pp := ppPts / 100.0
// Minimum remaining HP floor: 60% for early archetypes (norm<0.25), then linearly toward 20% at norm=1.
var floorHp float64
if norm < 0.25 {
floorHp = 0.60
} else {
u := (norm - 0.25) / 0.75
if u > 1 {
u = 1
}
// Steeper than linear 60%→20% so DoT-heavy mid-tier elites can still hit the HP band.
floorHp = math.Max(0.20, 0.60-0.65*u)
}
hpLowGrid = hpMid - pp
if hpLowGrid < floorHp {
hpLowGrid = floorHp
}
hpHighGrid = hpMid + pp
if hpLowGrid < 0 {
hpLowGrid = 0
}
if hpHighGrid > 1 {
hpHighGrid = 1
}
fmt.Printf("# tier: norm=%.3f curve=%.3f | targetSec=%.1fs heroHpMid=%.1f%% | bands dur [%.1f,%.1f] hp [%.1f%%,%.1f%%] (±%.1f pp)\n",
norm, tPow, targetSecEff, heroHpMidEff, lowSec, highSec, 100*hpLowGrid, 100*hpHighGrid, ppPts)
} else {
targetSecEff = *targetSec
lowSec = legacyLowSec
highSec = legacyHighSec
hpLowGrid = hpLowGridFlat
hpHighGrid = hpHighGridFlat
}
hpScale, atkScale, gfinal, ok := balanceArchetypeGrid(
tmpl, et, scenarios, *iterations, typeSeed,
lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses,
)
if !ok {
fmt.Printf("## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n", et)
continue
}
fmt.Printf("## %s (grid)\n", et)
fmt.Printf(" hpScale=%.4f atkScale=%.4f | medOfMed(win)=%.1fs medOfMed(heroHp%%)=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
hpScale, atkScale, gfinal.medOfMedDur, 100*gfinal.medOfMedHp, 100*gfinal.medWinRate, 100*gfinal.minWinRate)
fmt.Printf(" per-cell median ranges: dur [%.1fs, %.1fs] heroHp%% [%.1f%%, %.1f%%]\n",
gfinal.minMedDur, gfinal.maxMedDur, 100*gfinal.minMedHp, 100*gfinal.maxMedHp)
if *printSQL {
printGridSQL(tmpl, et, hpScale, atkScale)
}
fmt.Println()
continue
}
lvl := (tmpl.MinLevel + tmpl.MaxLevel) / 2
if lvl < 1 {
lvl = 1
}
// --- Phase 1: HP scale for fight duration (atk = 1) ---
hpScale := findHPScaleForDuration(tmpl, lvl, *iterations, typeSeed, lowTarget, highTarget)
if hpScale < 0 {
fmt.Printf("## %s (hero L%d vs enemy L%d) — SKIP: cannot place median duration in band at atk=1\n\n", et, lvl, lvl)
continue
}
// --- Phase 2: raise enemy attack until median hero HP%% on win <= maxHpFrac ---
atkScale, usedHpFrac := findAtkScaleForHeroHp(tmpl, lvl, hpScale, *iterations, typeSeed, maxHpFrac, *minWinRate)
if atkScale < 0 {
fmt.Printf("## %s (hero L%d vs enemy L%d) — SKIP: cannot reach hero HP constraint (win rate collapse)\n\n", et, lvl, lvl)
continue
}
if usedHpFrac > maxHpFrac+1e-6 {
fmt.Printf("# NOTE %s: relaxed hero HP target to %.1f%% (DoT / elite pressure)\n", et, usedHpFrac*100)
}
final := runSeries(tmpl, lvl, hpScale, atkScale, *iterations, typeSeed)
fmt.Printf("## %s (hero L%d vs enemy L%d)\n", et, lvl, lvl)
fmt.Printf(" hpScale=%.4f atkScale=%.4f | winRate=%.1f%% median(win)=%s medHeroHp%%=%.1f%% p90(win)=%s\n",
hpScale, atkScale, 100*final.winRate, final.medianWin.Round(time.Millisecond), 100*final.medianHeroHpPctWin, final.p90Win.Round(time.Millisecond))
if *printSQL {
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)
}
fmt.Println()
}
}
// findHPScaleForDuration returns hpScale >= 0, or -1 if impossible (with atkScale=1).
func findHPScaleForDuration(tmpl model.Enemy, lvl, n int, typeSeed int64, lowTarget, highTarget time.Duration) float64 {
r0 := runSeries(tmpl, lvl, 1.0, 1.0, n, typeSeed)
if r0.winRate < 0.05 {
return -1
}
if r0.medianWin > 0 && r0.medianWin >= lowTarget && r0.medianWin <= highTarget {
return 1.0
}
lo, hi := 0.04, 3.5
bestHP := 1.0
for iter := 0; iter < 36; iter++ {
mid := (lo + hi) / 2
r := runSeries(tmpl, lvl, mid, 1.0, n, typeSeed)
if r.winRate < 0.05 {
hi = mid
bestHP = mid
continue
}
medWin := r.medianWin
if medWin == 0 {
hi = mid
bestHP = mid
continue
}
if medWin < lowTarget {
lo = mid
} else if medWin > highTarget {
hi = mid
} else {
return mid
}
bestHP = mid
if hi-lo < 0.015 {
break
}
}
// verify best effort
r := runSeries(tmpl, lvl, bestHP, 1.0, n, typeSeed)
if r.medianWin > 0 && r.medianWin >= lowTarget && r.medianWin <= highTarget {
return bestHP
}
return -1
}
// findAtkScaleForHeroHp raises attack until median hero HP%% on wins <= target frac.
// Tries maxHpFrac, then 0.65, 0.70 for burn/DoT-heavy elites. Returns (atkScale, usedFrac).
func findAtkScaleForHeroHp(tmpl model.Enemy, lvl int, hpScale float64, n int, typeSeed int64, maxHpFrac, minWin float64) (float64, float64) {
candidates := []float64{maxHpFrac}
for _, extra := range []float64{0.65, 0.70, 0.75} {
if extra > maxHpFrac+1e-6 {
candidates = append(candidates, extra)
}
}
for _, frac := range candidates {
a := findAtkScaleForHeroHpOnce(tmpl, lvl, hpScale, n, typeSeed, frac, minWin)
if a >= 0 {
return a, frac
}
}
return -1, maxHpFrac
}
func findAtkScaleForHeroHpOnce(tmpl model.Enemy, lvl int, hpScale float64, n int, typeSeed int64, maxHpFrac, minWin float64) float64 {
r1 := runSeries(tmpl, lvl, hpScale, 1.0, n, typeSeed)
if r1.winRate < minWin {
return -1
}
if r1.medianHeroHpPctWin <= maxHpFrac {
return 1.0
}
lo, hi := 1.0, 8.0
best := 1.0
for iter := 0; iter < 48; iter++ {
mid := (lo + hi) / 2
r := runSeries(tmpl, lvl, hpScale, mid, n, typeSeed)
if r.winRate < minWin {
hi = mid
continue
}
if r.medianHeroHpPctWin <= maxHpFrac {
best = mid
hi = mid
} else {
lo = mid
}
if hi-lo < 0.003 {
break
}
}
rFinal := runSeries(tmpl, lvl, hpScale, best, n, typeSeed)
if rFinal.winRate < minWin {
return -1
}
if rFinal.medianHeroHpPctWin > maxHpFrac {
for extra := 1; extra <= 40; extra++ {
a := best * (1.0 + 0.025*float64(extra))
if a > 12 {
break
}
r := runSeries(tmpl, lvl, hpScale, a, n, typeSeed)
if r.winRate < minWin {
break
}
best = a
if r.medianHeroHpPctWin <= maxHpFrac {
return best
}
}
}
rCheck := runSeries(tmpl, lvl, hpScale, best, n, typeSeed)
if rCheck.medianHeroHpPctWin <= maxHpFrac && rCheck.winRate >= minWin {
return best
}
return -1
}
func hashString(s string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(s))
return h.Sum64()
}
type seriesResult struct {
winRate float64
medianAll time.Duration
medianWin time.Duration
p90Win time.Duration
medianHeroHpPctWin float64 // median of hero.HP/hero.MaxHP among winning fights (0 if no wins)
}
func runSeries(base model.Enemy, level int, hpScale, atkScale float64, n int, seed int64) seriesResult {
t := scaleEnemyForSim(base, hpScale, atkScale)
enemy := game.BuildEnemyInstanceForLevel(t, level)
var wins int
allDur := make([]time.Duration, 0, n)
winDur := make([]time.Duration, 0, n)
winHeroHpPct := make([]float64, 0, n)
for i := 0; i < n; i++ {
rand.Seed(seed + int64(i)*1_000_003)
hero := game.CloneHeroForCombatSim(game.NewReferenceHeroForBalance(level, game.ReferenceGearMedian, nil))
if hero.HP <= 0 {
hero.HP = hero.MaxHP
}
maxH := hero.MaxHP
if maxH <= 0 {
maxH = 1
}
e := enemy
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
})
allDur = append(allDur, elapsed)
if survived {
wins++
winDur = append(winDur, elapsed)
winHeroHpPct = append(winHeroHpPct, float64(hero.HP)/float64(maxH))
}
}
sort.Slice(allDur, func(i, j int) bool { return allDur[i] < allDur[j] })
medAll := allDur[len(allDur)/2]
var medWin, p90Win time.Duration
var medHeroHp float64
if len(winDur) > 0 {
sort.Slice(winDur, func(i, j int) bool { return winDur[i] < winDur[j] })
medWin = winDur[len(winDur)/2]
p90i := int(0.9 * float64(len(winDur)-1))
if p90i < 0 {
p90i = 0
}
p90Win = winDur[p90i]
}
if len(winHeroHpPct) > 0 {
sort.Float64s(winHeroHpPct)
medHeroHp = winHeroHpPct[len(winHeroHpPct)/2]
}
return seriesResult{
winRate: float64(wins) / float64(n),
medianAll: medAll,
medianWin: medWin,
p90Win: p90Win,
medianHeroHpPctWin: medHeroHp,
}
}
func scaleEnemyForSim(t model.Enemy, hpScale, atkScale float64) model.Enemy {
out := t
out.MaxHP = max(1, int(math.Round(float64(out.MaxHP)*hpScale)))
out.HP = out.MaxHP
out.HPPerLevel *= hpScale
out.Attack = max(1, int(math.Round(float64(out.Attack)*atkScale)))
out.AttackPerLevel *= atkScale
return out
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func trimName32(s string) string {
const max = 32
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}
// archetypeTierNorm maps the midpoint of [MinLevel, MaxLevel] to [0,1] using global catalog bounds (e.g. 1..35).
func archetypeTierNorm(t model.Enemy, globalMin, globalMax int) float64 {
if globalMax <= globalMin {
return 0
}
mid := float64(t.MinLevel+t.MaxLevel) / 2
n := (mid - float64(globalMin)) / float64(globalMax-globalMin)
if n < 0 {
return 0
}
if n > 1 {
return 1
}
return n
}
// archetypeOrder returns which archetypes to balance: one row by -enemy-id, one by -enemy-type, or all (DB order).
func archetypeOrder(ctx context.Context, templates map[model.EnemyType]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType string) ([]model.EnemyType, error) {
if enemyID > 0 {
cs := storage.NewContentStore(pool)
rows, err := cs.ListEnemyRows(ctx)
if err != nil {
return nil, err
}
for _, r := range rows {
if r.ID != enemyID {
continue
}
et := model.EnemyType(r.Type)
if _, ok := templates[et]; !ok {
return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type)
}
return []model.EnemyType{et}, nil
}
return nil, fmt.Errorf("no enemy row with id %d", enemyID)
}
if enemyType != "" {
et := model.EnemyType(enemyType)
if _, ok := templates[et]; !ok {
return nil, fmt.Errorf("enemy type not found: %s", enemyType)
}
return []model.EnemyType{et}, nil
}
cs := storage.NewContentStore(pool)
dbRows, err := cs.ListEnemyRows(ctx)
if err != nil {
return nil, err
}
out := make([]model.EnemyType, 0, len(dbRows))
for _, r := range dbRows {
et := model.EnemyType(r.Type)
if _, ok := templates[et]; !ok {
continue
}
out = append(out, et)
}
if len(out) == 0 {
return nil, fmt.Errorf("no enemy rows in database")
}
return out, nil
}