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 dotHeavy := enemyTemplateHasPeriodicDoT(tmpl) 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 } if dotHeavy { // Slightly shorter center time (less DoT stacks) but keep band wide enough vs baseline noise. targetSecEff *= 0.93 lowSec = targetSecEff * (1.0 - tol) highSec = targetSecEff * (1.0 + tol) // Extra duration slack: DoT makes med-of-med duration jumpy across cells. halfW := (highSec - lowSec) * 0.5 * 1.12 mid := (lowSec + highSec) * 0.5 lowSec = mid - halfW highSec = mid + halfW hpLowGrid = math.Max(0.10, hpLowGrid-0.14) hpHighGrid = math.Min(1.0, hpHighGrid+0.06) fmt.Printf("# dot: burn/poison — targetSec×0.93, dur band +12%%, HP band widened\n") } 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, dotHeavy, ) 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 }