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.

789 lines
28 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"
"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
}