|
|
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)
|
|
|
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))
|
|
|
|
|
|
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, tmplMap, 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 := 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)
|
|
|
|
|
|
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 enemy slugs to balance: one row by -enemy-id, one by -enemy-type, or all (DB order).
|
|
|
func archetypeOrder(ctx context.Context, templates map[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType string) ([]string, 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
|
|
|
}
|
|
|
if _, ok := templates[r.Type]; !ok {
|
|
|
return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type)
|
|
|
}
|
|
|
return []string{r.Type}, nil
|
|
|
}
|
|
|
return nil, fmt.Errorf("no enemy row with id %d", enemyID)
|
|
|
}
|
|
|
if enemyType != "" {
|
|
|
if _, ok := templates[enemyType]; !ok {
|
|
|
return nil, fmt.Errorf("enemy type not found: %s", enemyType)
|
|
|
}
|
|
|
return []string{enemyType}, nil
|
|
|
}
|
|
|
cs := storage.NewContentStore(pool)
|
|
|
dbRows, err := cs.ListEnemyRows(ctx)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
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
|
|
|
}
|