package main import ( "context" "flag" "fmt" "hash/fnv" "log" "math" "math/rand" "os" "sort" "strconv" "strings" "time" "github.com/denisovdennis/autohero/internal/constants" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" ) func main() { var ( dsnFlag = flag.String("dsn", "", "Postgres DSN (default: DATABASE_URL env)") listEnemies = flag.Bool("list-enemies", false, "print enemy rows from DB and exit") listArchetypes = flag.Bool("list-archetypes", false, "print distinct enemies.archetype values from DB and exit") filter = flag.String("filter", "", "optional substring filter for -list-enemies (id/type/name/archetype)") 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 template slug (enemies.type); ignored if -enemy-id is set") enemyArchetypeFlag = flag.String("enemy-archetype", "", "if set without -enemy-id/-enemy-type, include all DB rows with this enemies.archetype; with -enemy-type, must match template archetype") 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") adjustEnemies = flag.Bool("adjust-enemies", true, "when false, only simulate and report metrics; do not tune or print UPDATE SQL") printSQL = flag.Bool("sql", true, "print suggested UPDATE statements") gearCheck = flag.Bool("gear-check", false, "compare baseline (common) vs max (legendary) reference gear per level; exits non-zero on violations") gearCheckMaxSpeedupPct = flag.Float64("gear-check-max-speedup-pct", 20, "max allowed kill speedup with max gear vs baseline (percent); duration max >= baseline * (1 - pct/100)") gearCheckMaxHeroHpPct = flag.Float64("gear-check-max-hero-hp-pct", 75, "max median hero HP%% on wins with max gear (0-100)") gearCheckLevelMin = flag.Int("gear-check-level-min", 0, "gear-check: min hero/enemy level inclusive (0 = use template min_level)") gearCheckLevelMax = flag.Int("gear-check-level-max", 0, "gear-check: max hero/enemy level inclusive (0 = use template max_level)") gearCheckStrict = flag.Bool("gear-check-strict", false, "gear-check: baseline must get wins (SKIP → FAIL) so vacuous passes are impossible") gearBase = flag.String("gear-base", "db", "gear catalog source before overlay: db (gear/equipment_items) or code (embedded defaults only)") gearOverlay = flag.String("gear-overlay", "", "JSON file: partial GearFamily patches keyed by item name or \"slot:name\"; merged over catalog for simulation only") gearPrintSQL = flag.Bool("gear-print-sql", false, "print SQL UPDATEs for gear/equipment_items from patched catalog; requires -gear-overlay; then exit") ) 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() if err := tuning.ReloadNow(ctx, nil, storage.NewRuntimeConfigStore(pool)); err != nil { log.Printf("# balanceall: runtime_config not loaded (%v); using code defaults only", err) } cs := storage.NewContentStore(pool) enemies, err := cs.LoadEnemyTemplates(ctx) if err != nil { log.Fatalf("load enemies: %v", err) } tmplMap := game.EnemyTemplatesFromSlice(enemies) if p := strings.TrimSpace(*configJSON); p != "" { tmplMap, err = applyEnemyOverlayJSON(p, tmplMap) if err != nil { log.Fatalf("config overlay: %v", err) } } model.SetEnemyTemplates(game.EnemySliceFromMap(tmplMap)) gearFamilies, err := loadGearCatalog(ctx, cs, strings.TrimSpace(*gearBase)) if err != nil { log.Fatalf("load gear catalog: %v", err) } if p := strings.TrimSpace(*gearOverlay); p != "" { gearFamilies, err = applyGearOverlayJSON(p, gearFamilies) if err != nil { log.Fatalf("gear-overlay: %v", err) } } model.SetGearCatalog(gearFamilies) if *gearPrintSQL { if strings.TrimSpace(*gearOverlay) == "" { log.Fatal("-gear-print-sql requires -gear-overlay") } keys, err := listOverlayKeys(strings.TrimSpace(*gearOverlay)) if err != nil { log.Fatalf("gear overlay keys: %v", err) } printGearOverlayMigrationSQL(gearFamilies, keys) return } if *listArchetypes { dbRows, err := cs.ListEnemyRows(ctx) if err != nil { log.Fatalf("list enemies: %v", err) } seen := make(map[string]struct{}) var list []string for _, r := range dbRows { a := strings.TrimSpace(r.Archetype) if a == "" { continue } if _, ok := seen[a]; ok { continue } seen[a] = struct{}{} list = append(list, a) } sort.Strings(list) fmt.Printf("# balanceall: distinct enemies.archetype (%d)\n", len(list)) for _, a := range list { fmt.Println(a) } return } 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 %-12s %-22s %-32s %6s %6s %6s %5s\n", "id", "archetype", "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) && !strings.Contains(strings.ToLower(r.Archetype), f) { continue } } if printed >= *listLimit { break } fmt.Printf("%-8d %-12s %-22s %-32s %6d %6d %6d %5v\n", r.ID, trimName12(r.Archetype), 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 !*gearCheck && *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, tmplMap, pool, *enemyIDFlag, strings.TrimSpace(*enemyTypeFlag), strings.TrimSpace(*enemyArchetypeFlag)) if err != nil { log.Fatal(err) } overlayNote := "" if strings.TrimSpace(*configJSON) != "" { overlayNote = fmt.Sprintf(" | overlay=%q", strings.TrimSpace(*configJSON)) } if *gearCheck { if *gearCheckMaxHeroHpPct <= 0 || *gearCheckMaxHeroHpPct > 100 { log.Fatal("gear-check-max-hero-hp-pct must be in (0,100]") } if *gearCheckMaxSpeedupPct < 0 || *gearCheckMaxSpeedupPct >= 100 { log.Fatal("gear-check-max-speedup-pct must be in [0,100)") } if *gearCheckLevelMin > 0 && *gearCheckLevelMax > 0 && *gearCheckLevelMin > *gearCheckLevelMax { log.Fatal("gear-check-level-min must be <= gear-check-level-max") } totalFail := 0 fmt.Printf("# balanceall: gear-check | iterations/cell=%d%s\n", *iterations, overlayNote) if len(order) > 0 { switch { case len(order) <= 10: fmt.Printf("# templates (%d): %s\n", len(order), strings.Join(order, ", ")) default: fmt.Printf("# templates (%d): %s … +%d more\n", len(order), strings.Join(order[:8], ", "), len(order)-8) } } lvlNote := "template level band" if *gearCheckLevelMin > 0 || *gearCheckLevelMax > 0 { lvlNote = fmt.Sprintf("levels [%s..%s] ∩ template band", gearCheckLevelLabel(*gearCheckLevelMin), gearCheckLevelLabel(*gearCheckLevelMax)) } fmt.Printf("# levels: %s\n", lvlNote) fmt.Printf("# baseline = Iron Sword + Chainmail (common); max = Soul Reaver + Crown of Eternity (legendary); ilvl=hero level (from DB catalog)\n") if *gearCheckStrict { fmt.Printf("# strict: baseline must win (no vacuous SKIP)\n") } fmt.Printf("# rules: dur (max) >= baseline * (1 - %.1f/100); median hero HP%% (max) <= %.1f%%\n\n", *gearCheckMaxSpeedupPct, *gearCheckMaxHeroHpPct) for _, et := range order { tmpl := tmplMap[et] typeSeed := *seed + int64(hashString(string(et))) scenarios := gearCheckScenariosForTemplate(tmpl, *gearCheckLevelMin, *gearCheckLevelMax) if len(scenarios) == 0 { fmt.Printf("## %s (0 level cells — empty intersection with level clamp)\n", et) if *gearCheckStrict { totalFail++ fmt.Println(" FAIL — strict mode requires at least one level cell") } else { fmt.Println(" SKIP — no levels to simulate") } fmt.Println() continue } fail, lines := runGearCheck(tmpl, et, scenarios, *iterations, typeSeed, *gearCheckMaxSpeedupPct, *gearCheckMaxHeroHpPct, *gearCheckStrict) totalFail += fail fmt.Printf("## %s (%d level cells)\n", et, len(scenarios)) for _, ln := range lines { fmt.Println(ln) } if fail == 0 { fmt.Printf(" OK — all level cells pass\n") } else { fmt.Printf(" %d failing level cell(s)\n", fail) } fmt.Println() } if totalFail > 0 { fmt.Printf("# GEAR-CHECK: FAILED (%d violation(s))\n", totalFail) os.Exit(1) } fmt.Printf("# GEAR-CHECK: PASSED\n") return } 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) if *gridMode { fmt.Printf("# balanceall: grid mode | iterations/cell=%d%s\n", *iterations, overlayNote) if !*adjustEnemies { fmt.Printf("# adjust-enemies=false: report metrics only (no tuning, no SQL)\n") } 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)) if !*adjustEnemies { fmt.Printf("# adjust-enemies=false: report metrics only (no tuning, no SQL)\n\n") } } for _, et := range order { tmpl := tmplMap[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) if !*adjustEnemies { fmt.Printf("## %s (grid, no-adjust)\n", et) fmt.Printf(" medOfMedDur=%.1fs medOfMed(heroHp%%)=%.1f%% medWin=%.1f%% minWin=%.1f%%\n", base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate) fmt.Printf(" per-cell median ranges: dur [%.1fs, %.1fs] heroHp%% [%.1f%%, %.1f%%]\n", base.minMedDur, base.maxMedDur, 100*base.minMedHp, 100*base.maxMedHp) fmt.Println() continue } 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 } if !*adjustEnemies { final := runSeries(tmpl, lvl, 1.0, 1.0, *iterations, typeSeed) fmt.Printf("## %s (hero L%d vs enemy L%d, no-adjust)\n", et, lvl, lvl) fmt.Printf(" atkScale=1.0000 hpScale=1.0000 | winRate=%.1f%% median(win)=%s medHeroHp%%=%.1f%% p90(win)=%s\n", 100*final.winRate, final.medianWin.Round(time.Millisecond), 100*final.medianHeroHpPctWin, final.p90Win.Round(time.Millisecond)) fmt.Println() continue } // --- 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: constants.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 gearCheckLevelLabel(v int) string { if v <= 0 { return "auto" } return strconv.Itoa(v) } func trimName12(s string) string { const max = 12 s = strings.TrimSpace(s) runes := []rune(s) if len(runes) <= max { return s } return string(runes[:max-1]) + "…" } 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 enemy template slugs (enemies.type) to run: one row by -enemy-id, // one by -enemy-type, all rows matching -enemy-archetype, or all rows in DB order. func archetypeOrder(ctx context.Context, templates map[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType, enemyArchetype string) ([]string, error) { cs := storage.NewContentStore(pool) dbRows, err := cs.ListEnemyRows(ctx) if err != nil { return nil, err } if enemyID > 0 { for _, r := range dbRows { if r.ID != enemyID { continue } if _, ok := templates[r.Type]; !ok { return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type) } if enemyArchetype != "" && r.Archetype != enemyArchetype { return nil, fmt.Errorf("enemy id %d has archetype %q, want %q", enemyID, r.Archetype, enemyArchetype) } return []string{r.Type}, nil } return nil, fmt.Errorf("no enemy row with id %d", enemyID) } if enemyType != "" { t, ok := templates[enemyType] if !ok { return nil, fmt.Errorf("enemy type not found: %s", enemyType) } if enemyArchetype != "" && t.Archetype != enemyArchetype { return nil, fmt.Errorf("enemy-type %q has archetype %q, not %q", enemyType, t.Archetype, enemyArchetype) } return []string{enemyType}, nil } if enemyArchetype != "" { out := make([]string, 0) for _, r := range dbRows { if r.Archetype != enemyArchetype { continue } if _, ok := templates[r.Type]; !ok { continue } out = append(out, r.Type) } if len(out) == 0 { return nil, fmt.Errorf("no enemy rows with archetype %q", enemyArchetype) } return out, nil } out := make([]string, 0, len(dbRows)) for _, r := range dbRows { if _, ok := templates[r.Type]; !ok { continue } out = append(out, r.Type) } if len(out) == 0 { return nil, fmt.Errorf("no enemy rows in database") } return out, nil }