diff --git a/backend/cmd/balanceall/grid.go b/backend/cmd/balanceall/grid.go new file mode 100644 index 0000000..08d2e54 --- /dev/null +++ b/backend/cmd/balanceall/grid.go @@ -0,0 +1,340 @@ +package main + +import ( + "fmt" + "hash/fnv" + "math" + "math/rand" + "sort" + "time" + + "github.com/denisovdennis/autohero/internal/game" + "github.com/denisovdennis/autohero/internal/model" +) + +// gridScenario: fair fight heroLv == enemyLv at each tier, × gear variants (median + rolled). +type gridScenario struct { + heroLv int + enemyLv int + gearIdx int +} + +func hashGridScenario(et model.EnemyType, sc gridScenario) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(fmt.Sprintf("%s|%d|%d|%d", et, sc.heroLv, sc.enemyLv, sc.gearIdx))) + return h.Sum64() +} + +// gridScenariosForTemplate builds heroLv == enemyLv for each level in [MinLevel..MaxLevel] × gearMods. +func gridScenariosForTemplate(t model.Enemy, gearMods int) []gridScenario { + if gearMods < 2 { + gearMods = 4 + } + minL := t.MinLevel + maxL := t.MaxLevel + if minL <= 0 || maxL < minL { + lvl := (t.MinLevel + t.MaxLevel) / 2 + if lvl < 1 { + lvl = 1 + } + if t.BaseLevel > 0 { + lvl = t.BaseLevel + } + out := make([]gridScenario, 0, gearMods) + for g := 0; g < gearMods; g++ { + out = append(out, gridScenario{heroLv: lvl, enemyLv: lvl, gearIdx: g}) + } + return out + } + out := make([]gridScenario, 0, (maxL-minL+1)*gearMods) + for lv := minL; lv <= maxL; lv++ { + for g := 0; g < gearMods; g++ { + out = append(out, gridScenario{heroLv: lv, enemyLv: lv, gearIdx: g}) + } + } + return out +} + +func buildBaseHeroGrid(sc gridScenario) *model.Hero { + if sc.gearIdx == 0 { + return game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearMedian, nil) + } + seed := int64(500_000 + sc.heroLv*10_000 + sc.enemyLv*100 + sc.gearIdx*17) + rng := rand.New(rand.NewSource(seed)) + return game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearRolled, rng) +} + +type gridScenResult struct { + medianWinSec float64 + medianHeroHp float64 + winRate float64 +} + +func runOneGridScenario(tmpl model.Enemy, base *model.Hero, enemyLv int, n int, seedBase int64, h uint64) gridScenResult { + var winDur []time.Duration + var winHpPct []float64 + wins := 0 + for i := 0; i < n; i++ { + rand.Seed(seedBase + int64(i)*1_000_003 + int64(h)) + hero := game.CloneHeroForCombatSim(base) + if hero.HP <= 0 { + hero.HP = hero.MaxHP + } + maxH := hero.MaxHP + if maxH <= 0 { + maxH = 1 + } + e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv) + survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{ + TickRate: 100 * time.Millisecond, + MaxSteps: game.CombatSimMaxStepsLong, + }) + if survived { + wins++ + winDur = append(winDur, elapsed) + winHpPct = append(winHpPct, float64(hero.HP)/float64(maxH)) + } + } + wr := float64(wins) / float64(n) + if len(winDur) == 0 { + return gridScenResult{winRate: wr} + } + sort.Slice(winDur, func(i, j int) bool { return winDur[i] < winDur[j] }) + sort.Float64s(winHpPct) + med := winDur[len(winDur)/2] + medHp := winHpPct[len(winHpPct)/2] + return gridScenResult{ + medianWinSec: med.Seconds(), + medianHeroHp: medHp, + winRate: wr, + } +} + +type gridAggResult struct { + medOfMedDur float64 + medOfMedHp float64 + minWinRate float64 + medWinRate float64 + minMedDur float64 + maxMedDur float64 + minMedHp float64 + maxMedHp float64 +} + +func medianFloat(xs []float64) float64 { + if len(xs) == 0 { + return 0 + } + sort.Float64s(xs) + return xs[len(xs)/2] +} + +func aggregateGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale, atkScale float64, n int, seedBase int64) gridAggResult { + t := scaleEnemyForSim(tmpl, hpScale, atkScale) + var medDurs []float64 + var medHps []float64 + var winRates []float64 + minWR := 1.0 + minDur := 1e12 + maxDur := 0.0 + minHp := 1.0 + maxHp := 0.0 + for _, sc := range scenarios { + base := buildBaseHeroGrid(sc) + h := hashGridScenario(et, sc) + r := runOneGridScenario(t, base, sc.enemyLv, n, seedBase, h) + winRates = append(winRates, r.winRate) + if r.winRate < minWR { + minWR = r.winRate + } + if r.medianWinSec > 0 { + medDurs = append(medDurs, r.medianWinSec) + medHps = append(medHps, r.medianHeroHp) + if r.medianWinSec < minDur { + minDur = r.medianWinSec + } + if r.medianWinSec > maxDur { + maxDur = r.medianWinSec + } + if r.medianHeroHp < minHp { + minHp = r.medianHeroHp + } + if r.medianHeroHp > maxHp { + maxHp = r.medianHeroHp + } + } + } + return gridAggResult{ + medOfMedDur: medianFloat(medDurs), + medOfMedHp: medianFloat(medHps), + minWinRate: minWR, + medWinRate: medianFloat(winRates), + minMedDur: minDur, + maxMedDur: maxDur, + minMedHp: minHp, + maxMedHp: maxHp, + } +} + +func findHPScaleForAggDurationGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, n int, seedBase int64, lowSec, highSec float64, minMedWin float64) float64 { + r0 := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, n, seedBase) + if r0.medOfMedDur >= lowSec && r0.medOfMedDur <= highSec && r0.medWinRate >= minMedWin { + return 1.0 + } + lo, hi := 0.04, 4.0 + best := 1.0 + for iter := 0; iter < 44; iter++ { + mid := (lo + hi) / 2 + r := aggregateGrid(tmpl, et, scenarios, mid, 1.0, n, seedBase) + if r.medWinRate < minMedWin*0.5 { + hi = mid + continue + } + if r.medOfMedDur == 0 { + hi = mid + continue + } + if r.medOfMedDur < lowSec { + lo = mid + } else if r.medOfMedDur > highSec { + hi = mid + } else { + return mid + } + best = mid + if hi-lo < 0.012 { + break + } + } + r := aggregateGrid(tmpl, et, scenarios, best, 1.0, n, seedBase) + if r.medOfMedDur > 0 && r.medOfMedDur >= lowSec && r.medOfMedDur <= highSec && r.medWinRate >= minMedWin { + return best + } + return -1 +} + +func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 { + r0 := aggregateGrid(tmpl, et, scenarios, hpScale, 1.0, n, seedBase) + if r0.medWinRate < minMedWin { + return -1 + } + if r0.medOfMedHp >= hpLow && r0.medOfMedHp <= hpHigh { + return 1.0 + } + var lo, hi float64 + if r0.medOfMedHp < hpLow { + lo, hi = 0.08, 1.0 + } else { + lo, hi = 1.0, 12.0 + } + best := 1.0 + for iter := 0; iter < 56; iter++ { + mid := (lo + hi) / 2 + r := aggregateGrid(tmpl, et, scenarios, hpScale, mid, n, seedBase) + if r.medWinRate < minMedWin*0.5 { + hi = mid + best = mid + continue + } + if r.medOfMedHp < hpLow { + hi = mid + } else if r.medOfMedHp > hpHigh { + lo = mid + } else { + return mid + } + best = mid + if hi-lo < 0.002 { + break + } + } + r := aggregateGrid(tmpl, et, scenarios, hpScale, best, n, seedBase) + if r.medWinRate >= minMedWin && r.medOfMedHp >= hpLow && r.medOfMedHp <= hpHigh { + return best + } + return -1 +} + +func ensurePositiveMinWinGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale, atkScale *float64, n int, seedBase int64) { + for round := 0; round < 35; round++ { + a := aggregateGrid(tmpl, et, scenarios, *hpScale, *atkScale, n, seedBase) + if a.minWinRate > 0 { + return + } + *atkScale *= 0.98 + *hpScale *= 1.012 + if *atkScale < 0.15 { + return + } + } +} + +// balanceArchetypeGrid runs grid balance for one enemy type; returns ok=false if skipped/failed. +func balanceArchetypeGrid( + tmpl model.Enemy, + et model.EnemyType, + scenarios []gridScenario, + iterations int, + typeSeed int64, + lowSec, highSec float64, + targetSec float64, + hpLow, hpHigh float64, + minWinRate float64, + refinePasses int, +) (hpScale, atkScale float64, final gridAggResult, ok bool) { + if len(scenarios) == 0 { + return 0, 0, gridAggResult{}, false + } + n := iterations + if n < 20 { + n = 20 + } + seedBase := typeSeed + + hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinRate) + if hpScale < 0 { + return 0, 0, gridAggResult{}, false + } + atkScale = findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate) + if atkScale < 0 { + return 0, 0, gridAggResult{}, false + } + + for pass := 0; pass < refinePasses; pass++ { + a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase) + if a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec { + break + } + if a.medOfMedDur > 0 { + corr := targetSec / a.medOfMedDur + hpScale *= corr + if hpScale < 0.03 || hpScale > 6 { + break + } + if atk2 := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 { + atkScale = atk2 + } + } + } + + ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) + if hp2 := findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinRate); hp2 > 0 { + hpScale = hp2 + } + if atk2 := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 { + atkScale = atk2 + } + ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) + + final = aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase) + return hpScale, atkScale, final, true +} + +func printGridSQL(tmpl model.Enemy, et model.EnemyType, hpScale, atkScale float64) { + 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) +} diff --git a/backend/cmd/balanceall/main.go b/backend/cmd/balanceall/main.go new file mode 100644 index 0000000..1dc7199 --- /dev/null +++ b/backend/cmd/balanceall/main.go @@ -0,0 +1,503 @@ +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", 60, "grid: low-tier center hero HP%% on wins when -tiered-targets; else flat center") + heroHpMidHigh = flag.Float64("hero-hp-mid-high", 20, "grid: high-tier center hero HP%% on wins when -tiered-targets") + heroHpPP = flag.Float64("hero-hp-pp", 7, "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.35, "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") + } + var hpLowGrid, hpHighGrid float64 + if *gridMode { + hpMid := *heroHpMid / 100.0 + pp := *heroHpPP / 100.0 + hpLowGrid = hpMid - pp + hpHighGrid = hpMid + pp + if hpLowGrid < 0 { + hpLowGrid = 0 + } + if hpHighGrid > 1 { + hpHighGrid = 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)) + lowSec := *targetSec * (1.0 - tol) + highSec := *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) + fmt.Printf("# duration: med-of-meds in [%.1fs, %.1fs] | hero HP%% on wins: [%.1f%%, %.1f%%] (center %.1f%% ±%.1f pp)\n", + lowSec, highSec, 100*hpLowGrid, 100*hpHighGrid, *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) + + hpScale, atkScale, gfinal, ok := balanceArchetypeGrid( + tmpl, et, scenarios, *iterations, typeSeed, + lowSec, highSec, *targetSec, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses, + ) + if !ok { + fmt.Printf("## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n", et) + continue + } + fmt.Printf("## %s (grid)\n", et) + fmt.Printf(" hpScale=%.4f atkScale=%.4f | medOfMed(win)=%.1fs medOfMed(heroHp%%)=%.1f%% medWin=%.1f%% minWin=%.1f%%\n", + hpScale, atkScale, gfinal.medOfMedDur, 100*gfinal.medOfMedHp, 100*gfinal.medWinRate, 100*gfinal.minWinRate) + fmt.Printf(" per-cell median ranges: dur [%.1fs, %.1fs] heroHp%% [%.1f%%, %.1f%%]\n", + gfinal.minMedDur, gfinal.maxMedDur, 100*gfinal.minMedHp, 100*gfinal.maxMedHp) + if *printSQL { + printGridSQL(tmpl, et, hpScale, atkScale) + } + fmt.Println() + continue + } + + lvl := (tmpl.MinLevel + tmpl.MaxLevel) / 2 + if lvl < 1 { + lvl = 1 + } + + // --- Phase 1: HP scale for fight duration (atk = 1) --- + hpScale := findHPScaleForDuration(tmpl, lvl, *iterations, typeSeed, lowTarget, highTarget) + if hpScale < 0 { + fmt.Printf("## %s (hero L%d vs enemy L%d) — SKIP: cannot place median duration in band at atk=1\n\n", et, lvl, lvl) + continue + } + + // --- Phase 2: raise enemy attack until median hero HP%% on win <= maxHpFrac --- + atkScale, usedHpFrac := findAtkScaleForHeroHp(tmpl, lvl, hpScale, *iterations, typeSeed, maxHpFrac, *minWinRate) + if atkScale < 0 { + fmt.Printf("## %s (hero L%d vs enemy L%d) — SKIP: cannot reach hero HP constraint (win rate collapse)\n\n", et, lvl, lvl) + continue + } + if usedHpFrac > maxHpFrac+1e-6 { + fmt.Printf("# NOTE %s: relaxed hero HP target to %.1f%% (DoT / elite pressure)\n", et, usedHpFrac*100) + } + + final := runSeries(tmpl, lvl, hpScale, atkScale, *iterations, typeSeed) + fmt.Printf("## %s (hero L%d vs enemy L%d)\n", et, lvl, lvl) + fmt.Printf(" hpScale=%.4f atkScale=%.4f | winRate=%.1f%% median(win)=%s medHeroHp%%=%.1f%% p90(win)=%s\n", + hpScale, atkScale, 100*final.winRate, final.medianWin.Round(time.Millisecond), 100*final.medianHeroHpPctWin, final.p90Win.Round(time.Millisecond)) + + if *printSQL { + newHP := max(1, int(math.Round(float64(tmpl.MaxHP)*hpScale))) + newHPL := tmpl.HPPerLevel * hpScale + newAtk := max(1, int(math.Round(float64(tmpl.Attack)*atkScale))) + newAtkL := tmpl.AttackPerLevel * atkScale + fmt.Printf("UPDATE enemies SET hp = %d, max_hp = %d, hp_per_level = %.4f, attack = %d, attack_per_level = %.4f WHERE type = '%s';\n", + newHP, newHP, newHPL, newAtk, newAtkL, et) + } + fmt.Println() + } +} + +// findHPScaleForDuration returns hpScale >= 0, or -1 if impossible (with atkScale=1). +func findHPScaleForDuration(tmpl model.Enemy, lvl, n int, typeSeed int64, lowTarget, highTarget time.Duration) float64 { + r0 := runSeries(tmpl, lvl, 1.0, 1.0, n, typeSeed) + if r0.winRate < 0.05 { + return -1 + } + if r0.medianWin > 0 && r0.medianWin >= lowTarget && r0.medianWin <= highTarget { + return 1.0 + } + lo, hi := 0.04, 3.5 + bestHP := 1.0 + for iter := 0; iter < 36; iter++ { + mid := (lo + hi) / 2 + r := runSeries(tmpl, lvl, mid, 1.0, n, typeSeed) + if r.winRate < 0.05 { + hi = mid + bestHP = mid + continue + } + medWin := r.medianWin + if medWin == 0 { + hi = mid + bestHP = mid + continue + } + if medWin < lowTarget { + lo = mid + } else if medWin > highTarget { + hi = mid + } else { + return mid + } + bestHP = mid + if hi-lo < 0.015 { + break + } + } + // verify best effort + r := runSeries(tmpl, lvl, bestHP, 1.0, n, typeSeed) + if r.medianWin > 0 && r.medianWin >= lowTarget && r.medianWin <= highTarget { + return bestHP + } + return -1 +} + +// findAtkScaleForHeroHp raises attack until median hero HP%% on wins <= target frac. +// Tries maxHpFrac, then 0.65, 0.70 for burn/DoT-heavy elites. Returns (atkScale, usedFrac). +func findAtkScaleForHeroHp(tmpl model.Enemy, lvl int, hpScale float64, n int, typeSeed int64, maxHpFrac, minWin float64) (float64, float64) { + candidates := []float64{maxHpFrac} + for _, extra := range []float64{0.65, 0.70, 0.75} { + if extra > maxHpFrac+1e-6 { + candidates = append(candidates, extra) + } + } + for _, frac := range candidates { + a := findAtkScaleForHeroHpOnce(tmpl, lvl, hpScale, n, typeSeed, frac, minWin) + if a >= 0 { + return a, frac + } + } + return -1, maxHpFrac +} + +func findAtkScaleForHeroHpOnce(tmpl model.Enemy, lvl int, hpScale float64, n int, typeSeed int64, maxHpFrac, minWin float64) float64 { + r1 := runSeries(tmpl, lvl, hpScale, 1.0, n, typeSeed) + if r1.winRate < minWin { + return -1 + } + if r1.medianHeroHpPctWin <= maxHpFrac { + return 1.0 + } + lo, hi := 1.0, 8.0 + best := 1.0 + for iter := 0; iter < 48; iter++ { + mid := (lo + hi) / 2 + r := runSeries(tmpl, lvl, hpScale, mid, n, typeSeed) + if r.winRate < minWin { + hi = mid + continue + } + if r.medianHeroHpPctWin <= maxHpFrac { + best = mid + hi = mid + } else { + lo = mid + } + if hi-lo < 0.003 { + break + } + } + rFinal := runSeries(tmpl, lvl, hpScale, best, n, typeSeed) + if rFinal.winRate < minWin { + return -1 + } + if rFinal.medianHeroHpPctWin > maxHpFrac { + for extra := 1; extra <= 40; extra++ { + a := best * (1.0 + 0.025*float64(extra)) + if a > 12 { + break + } + r := runSeries(tmpl, lvl, hpScale, a, n, typeSeed) + if r.winRate < minWin { + break + } + best = a + if r.medianHeroHpPctWin <= maxHpFrac { + return best + } + } + } + rCheck := runSeries(tmpl, lvl, hpScale, best, n, typeSeed) + if rCheck.medianHeroHpPctWin <= maxHpFrac && rCheck.winRate >= minWin { + return best + } + return -1 +} + +func hashString(s string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(s)) + return h.Sum64() +} + +type seriesResult struct { + winRate float64 + medianAll time.Duration + medianWin time.Duration + p90Win time.Duration + medianHeroHpPctWin float64 // median of hero.HP/hero.MaxHP among winning fights (0 if no wins) +} + +func runSeries(base model.Enemy, level int, hpScale, atkScale float64, n int, seed int64) seriesResult { + t := scaleEnemyForSim(base, hpScale, atkScale) + enemy := game.BuildEnemyInstanceForLevel(t, level) + + var wins int + allDur := make([]time.Duration, 0, n) + winDur := make([]time.Duration, 0, n) + winHeroHpPct := make([]float64, 0, n) + + for i := 0; i < n; i++ { + rand.Seed(seed + int64(i)*1_000_003) + hero := game.CloneHeroForCombatSim(game.NewReferenceHeroForBalance(level, game.ReferenceGearMedian, nil)) + if hero.HP <= 0 { + hero.HP = hero.MaxHP + } + maxH := hero.MaxHP + if maxH <= 0 { + maxH = 1 + } + e := enemy + survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{ + TickRate: 100 * time.Millisecond, + MaxSteps: game.CombatSimMaxStepsLong, + }) + allDur = append(allDur, elapsed) + if survived { + wins++ + winDur = append(winDur, elapsed) + winHeroHpPct = append(winHeroHpPct, float64(hero.HP)/float64(maxH)) + } + } + + sort.Slice(allDur, func(i, j int) bool { return allDur[i] < allDur[j] }) + medAll := allDur[len(allDur)/2] + + var medWin, p90Win time.Duration + var medHeroHp float64 + if len(winDur) > 0 { + sort.Slice(winDur, func(i, j int) bool { return winDur[i] < winDur[j] }) + medWin = winDur[len(winDur)/2] + p90i := int(0.9 * float64(len(winDur)-1)) + if p90i < 0 { + p90i = 0 + } + p90Win = winDur[p90i] + } + if len(winHeroHpPct) > 0 { + sort.Float64s(winHeroHpPct) + medHeroHp = winHeroHpPct[len(winHeroHpPct)/2] + } + + return seriesResult{ + winRate: float64(wins) / float64(n), + medianAll: medAll, + medianWin: medWin, + p90Win: p90Win, + medianHeroHpPctWin: medHeroHp, + } +} + +func scaleEnemyForSim(t model.Enemy, hpScale, atkScale float64) model.Enemy { + out := t + out.MaxHP = max(1, int(math.Round(float64(out.MaxHP)*hpScale))) + out.HP = out.MaxHP + out.HPPerLevel *= hpScale + out.Attack = max(1, int(math.Round(float64(out.Attack)*atkScale))) + out.AttackPerLevel *= atkScale + return out +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func trimName32(s string) string { + const max = 32 + s = strings.TrimSpace(s) + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-1]) + "…" +} + +// 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 +} diff --git a/backend/cmd/balanceall/overlay.go b/backend/cmd/balanceall/overlay.go new file mode 100644 index 0000000..a275bf7 --- /dev/null +++ b/backend/cmd/balanceall/overlay.go @@ -0,0 +1,151 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/denisovdennis/autohero/internal/model" +) + +// enemyPartial mirrors model.Enemy with pointer fields so JSON omits mean "keep DB value". +type enemyPartial struct { + ID *int64 `json:"id"` + Type *string `json:"type"` + Name *string `json:"name"` + HP *int `json:"hp"` + MaxHP *int `json:"maxHp"` + Attack *int `json:"attack"` + Defense *int `json:"defense"` + Speed *float64 `json:"speed"` + CritChance *float64 `json:"critChance"` + MinLevel *int `json:"minLevel"` + MaxLevel *int `json:"maxLevel"` + BaseLevel *int `json:"baseLevel"` + LevelVariance *float64 `json:"levelVariance"` + MaxHeroLevelDiff *int `json:"maxHeroLevelDiff"` + HPPerLevel *float64 `json:"hpPerLevel"` + AttackPerLevel *float64 `json:"attackPerLevel"` + DefensePerLevel *float64 `json:"defensePerLevel"` + XPPerLevel *float64 `json:"xpPerLevel"` + GoldPerLevel *float64 `json:"goldPerLevel"` + Level *int `json:"level"` + XPReward *int64 `json:"xpReward"` + GoldReward *int64 `json:"goldReward"` + SpecialAbilities *[]model.SpecialAbility `json:"specialAbilities"` + IsElite *bool `json:"isElite"` +} + +// applyEnemyOverlayJSON reads a JSON object keyed by enemy type (string), merges each partial onto templates. +// Unknown keys log a warning and are skipped. Keys for types not present in templates log a warning. +func applyEnemyOverlayJSON(path string, templates map[model.EnemyType]model.Enemy) (map[model.EnemyType]model.Enemy, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read overlay %q: %w", path, err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse overlay JSON: %w", err) + } + out := make(map[model.EnemyType]model.Enemy, len(templates)) + for k, v := range templates { + out[k] = v + } + for typeKey, rawMsg := range raw { + et := model.EnemyType(typeKey) + base, ok := out[et] + if !ok { + fmt.Fprintf(os.Stderr, "balanceall overlay: skip unknown type %q (not in loaded templates)\n", typeKey) + continue + } + var p enemyPartial + if err := json.Unmarshal(rawMsg, &p); err != nil { + return nil, fmt.Errorf("overlay %q: %w", typeKey, err) + } + mergeEnemyPartial(&base, &p) + out[et] = base + } + return out, nil +} + +func mergeEnemyPartial(dst *model.Enemy, p *enemyPartial) { + if p.ID != nil { + dst.ID = *p.ID + } + if p.Type != nil { + dst.Type = model.EnemyType(*p.Type) + } + if p.Name != nil { + dst.Name = *p.Name + } + if p.HP != nil { + dst.HP = *p.HP + } + if p.MaxHP != nil { + dst.MaxHP = *p.MaxHP + } + if p.Attack != nil { + dst.Attack = *p.Attack + } + if p.Defense != nil { + dst.Defense = *p.Defense + } + if p.Speed != nil { + dst.Speed = *p.Speed + } + if p.CritChance != nil { + dst.CritChance = *p.CritChance + } + if p.MinLevel != nil { + dst.MinLevel = *p.MinLevel + } + if p.MaxLevel != nil { + dst.MaxLevel = *p.MaxLevel + } + if p.BaseLevel != nil { + dst.BaseLevel = *p.BaseLevel + } + if p.LevelVariance != nil { + dst.LevelVariance = *p.LevelVariance + } + if p.MaxHeroLevelDiff != nil { + dst.MaxHeroLevelDiff = *p.MaxHeroLevelDiff + } + if p.HPPerLevel != nil { + dst.HPPerLevel = *p.HPPerLevel + } + if p.AttackPerLevel != nil { + dst.AttackPerLevel = *p.AttackPerLevel + } + if p.DefensePerLevel != nil { + dst.DefensePerLevel = *p.DefensePerLevel + } + if p.XPPerLevel != nil { + dst.XPPerLevel = *p.XPPerLevel + } + if p.GoldPerLevel != nil { + dst.GoldPerLevel = *p.GoldPerLevel + } + if p.Level != nil { + dst.Level = *p.Level + } + if p.XPReward != nil { + dst.XPReward = *p.XPReward + } + if p.GoldReward != nil { + dst.GoldReward = *p.GoldReward + } + if p.SpecialAbilities != nil { + dst.SpecialAbilities = *p.SpecialAbilities + } + if p.IsElite != nil { + dst.IsElite = *p.IsElite + } + // If only one of hp/maxHp was overridden, keep them aligned for template rows. + if p.MaxHP != nil && p.HP == nil { + dst.HP = dst.MaxHP + } + if p.HP != nil && p.MaxHP == nil { + dst.MaxHP = dst.HP + } +} diff --git a/backend/cmd/balancesim/main.go b/backend/cmd/balancesim/main.go new file mode 100644 index 0000000..5a07b0b --- /dev/null +++ b/backend/cmd/balancesim/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "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 ( + listHeroes = flag.Bool("list-heroes", false, "list heroes from DB and exit (use -filter for name; numeric filter matches id/telegram)") + listEnemies = flag.Bool("list-enemies", false, "list enemy archetypes from DB and exit") + filter = flag.String("filter", "", "optional substring filter for -list-heroes / -list-enemies") + listLimit = flag.Int("limit", 50, "max rows for -list-heroes / -list-enemies") + + heroID = flag.Int64("hero-id", 0, "existing hero id in DB (optional)") + heroLevel = flag.Int("hero-level", 1, "reference hero level when hero-id is not provided") + enemyType = flag.String("enemy-type", "", "enemy archetype type (required)") + enemyLevel = flag.Int("enemy-level", 0, "enemy instance level (0 = catalog midpoint (min_level+max_level)/2 for this archetype)") + iterations = flag.Int("iterations", 50, "number of simulation runs") + seed = flag.Int64("seed", time.Now().UnixNano(), "rng seed") + delayMs = flag.Int64("delay-ms", 0, "wall-clock delay between simulation events (0 = instant)") + ) + flag.Parse() + + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + log.Fatal("DATABASE_URL 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) + } + model.SetEnemyTemplates(templates) + + if *listHeroes { + if *listLimit <= 0 || *listLimit > 200 { + log.Fatal("limit must be 1..200") + } + hs := storage.NewHeroStore(pool, nil) + heroes, err := hs.ListHeroesFiltered(ctx, *listLimit, 0, *filter) + if err != nil { + log.Fatalf("list heroes: %v", err) + } + fmt.Printf("# heroes (filter=%q) count=%d\n", *filter, len(heroes)) + fmt.Printf("%-10s %-36s %6s %10s %10s\n", "id", "name", "level", "telegramId", "state") + for _, h := range heroes { + fmt.Printf("%-10d %-36s %6d %10d %10s\n", h.ID, trimName(h.Name), h.Level, h.TelegramID, h.State) + } + return + } + + if *listEnemies { + if *listLimit <= 0 || *listLimit > 500 { + log.Fatal("limit must be 1..500") + } + type row struct { + typ model.EnemyType + name string + tmpl model.Enemy + } + var rows []row + for t, e := range templates { + rows = append(rows, row{typ: t, name: e.Name, tmpl: e}) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].typ < rows[j].typ }) + f := strings.TrimSpace(strings.ToLower(*filter)) + fmt.Printf("# enemy archetypes from DB (filter=%q)\n", *filter) + fmt.Printf("%-22s %-32s %6s %6s %6s %5s\n", "type (-enemy-type)", "name", "minLv", "maxLv", "baseLv", "elite") + printed := 0 + for _, r := range rows { + if f != "" { + if !strings.Contains(strings.ToLower(string(r.typ)), f) && + !strings.Contains(strings.ToLower(r.name), f) { + continue + } + } + if printed >= *listLimit { + break + } + e := r.tmpl + fmt.Printf("%-22s %-32s %6d %6d %6d %5v\n", + r.typ, trimName(r.name), e.MinLevel, e.MaxLevel, e.BaseLevel, e.IsElite) + printed++ + } + if f != "" { + fmt.Printf("# printed %d rows (limit=%d)\n", printed, *listLimit) + } + return + } + + if *enemyType == "" { + log.Fatal("enemy-type is required (or use -list-heroes / -list-enemies)") + } + if *iterations <= 0 { + log.Fatal("iterations must be > 0") + } + + tmpl, ok := templates[model.EnemyType(*enemyType)] + if !ok { + log.Fatalf("enemy type not found: %s", *enemyType) + } + + var baseHero *model.Hero + if *heroID > 0 { + hs := storage.NewHeroStore(pool, nil) + h, getErr := hs.GetByID(ctx, *heroID) + if getErr != nil { + log.Fatalf("load hero by id: %v", getErr) + } + if h == nil { + log.Fatalf("hero not found: %d", *heroID) + } + baseHero = h + } else { + baseHero = game.NewReferenceHeroForBalance(*heroLevel, game.ReferenceGearMedian, nil) + } + + heroLv := *heroLevel + if *heroID > 0 { + heroLv = baseHero.Level + } + instanceLv := *enemyLevel + if instanceLv <= 0 { + instanceLv = defaultSimEnemyLevel(tmpl) + } + + rand.Seed(*seed) + + var wins int + var total time.Duration + durations := make([]time.Duration, 0, *iterations) + + for i := 0; i < *iterations; i++ { + hero := game.CloneHeroForCombatSim(baseHero) + if hero.HP <= 0 { + hero.HP = hero.MaxHP + } + enemy := game.BuildEnemyInstanceForLevel(tmpl, instanceLv) + survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, game.CombatSimDeterministicStart, game.CombatSimOptions{ + TickRate: 100 * time.Millisecond, + WallClockDelay: time.Duration(*delayMs) * time.Millisecond, + MaxSteps: game.CombatSimMaxStepsLong, + }) + if survived { + wins++ + } + total += elapsed + durations = append(durations, elapsed) + } + + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + median := durations[len(durations)/2] + fmt.Printf("heroLevel=%d enemyInstanceLevel=%d enemy=%s iterations=%d wins=%d winRate=%.2f%%\n", + heroLv, instanceLv, *enemyType, *iterations, wins, 100*float64(wins)/float64(*iterations)) + fmt.Printf("mean=%s median=%s wallDelayMs=%d\n", (time.Duration(int64(total) / int64(*iterations))).String(), median.String(), *delayMs) +} + +// defaultSimEnemyLevel picks a representative level for balance sim when -enemy-level is omitted: +// midpoint of the archetype level band from DB (e.g. Lightning Titan 25–35 → 30). +func defaultSimEnemyLevel(t model.Enemy) int { + if t.MinLevel > 0 && t.MaxLevel >= t.MinLevel { + return (t.MinLevel + t.MaxLevel) / 2 + } + if t.BaseLevel > 0 { + return t.BaseLevel + } + return 1 +} + +func trimName(s string) string { + const max = 34 + s = strings.TrimSpace(s) + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-1]) + "…" +} diff --git a/backend/internal/game/enemy_templates_testdata_test.go b/backend/internal/game/enemy_templates_testdata_test.go new file mode 100644 index 0000000..8ea0482 --- /dev/null +++ b/backend/internal/game/enemy_templates_testdata_test.go @@ -0,0 +1,45 @@ +package game + +import "github.com/denisovdennis/autohero/internal/model" + +func ensureTestEnemyTemplates() { + if len(model.EnemyTemplates) > 0 { + return + } + model.SetEnemyTemplates(map[model.EnemyType]model.Enemy{ + model.EnemyWolf: { + Type: model.EnemyWolf, + Name: "Forest Wolf", + MaxHP: 40, + HP: 40, + Attack: 8, + Defense: 2, + Speed: 1.2, + BaseLevel: 1, + LevelVariance: 0.3, + MaxHeroLevelDiff: 5, + HPPerLevel: 5, + AttackPerLevel: 1.5, + DefensePerLevel: 1.0, + XPReward: 10, + GoldReward: 5, + }, + model.EnemyBoar: { + Type: model.EnemyBoar, + Name: "Wild Boar", + MaxHP: 60, + HP: 60, + Attack: 10, + Defense: 5, + Speed: 0.9, + BaseLevel: 3, + LevelVariance: 0.3, + MaxHeroLevelDiff: 5, + HPPerLevel: 6, + AttackPerLevel: 1.8, + DefensePerLevel: 1.3, + XPReward: 14, + GoldReward: 8, + }, + }) +} diff --git a/backend/internal/game/test_main_test.go b/backend/internal/game/test_main_test.go new file mode 100644 index 0000000..368a619 --- /dev/null +++ b/backend/internal/game/test_main_test.go @@ -0,0 +1,11 @@ +package game + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + ensureTestEnemyTemplates() + os.Exit(m.Run()) +} diff --git a/backend/migrations/000002_drop_public_schema_migrations.sql b/backend/migrations/000002_drop_public_schema_migrations.sql new file mode 100644 index 0000000..b54889f --- /dev/null +++ b/backend/migrations/000002_drop_public_schema_migrations.sql @@ -0,0 +1,2 @@ +-- Tracking lives in infra.schema_migrations; remove duplicate table left from 000001_init dump. +DROP TABLE IF EXISTS public.schema_migrations; diff --git a/docs/balanceall.md b/docs/balanceall.md new file mode 100644 index 0000000..7e7f9cc --- /dev/null +++ b/docs/balanceall.md @@ -0,0 +1,144 @@ +# balanceall — CLI баланса монстров + +Утилита `backend/cmd/balanceall` прогоняет **архетипы монстров** из **PostgreSQL** (таблица `enemies`) через то же ядро боя, что онлайн/оффлайн (`game.ResolveCombatToEndWithDuration`), на **референсном герое** (`game.NewReferenceHeroForBalance`). + +## Режим по умолчанию: сетка (`-grid`, по умолчанию `true`) + +Для каждого архетипа строится сетка сценариев: + +- **Уровни:** для каждого `L` от `min_level` до `max_level` включительно — бой **герой L** против **экземпляра монстра L** (`heroLv == enemyLv`), честный тир. +- **Шмот:** `-gear-variants` профилей (по умолчанию **4**): один с **медианным** шмотом, остальные с **rolled** ilvl (как в дропе). + +Агрегированные цели (как «медиана медиан» по ячейкам): + +1. **Длительность** — медиана по ячейкам от **медиан длительности победных** боёв попадает в полосу + `[targetSec × (1 − tolerancePct/100), targetSec × (1 + tolerancePct/100)]` (по умолчанию при 330 с и **10%** — примерно **297–363 с**). +2. **HP героя** — медиана по ячейкам от **медиан доли HP после победы** попадает в полосу + **`hero-hp-mid` ± `hero-hp-pp` процентных пунктов** (по умолчанию **60% ± 7 п.п.** → **53–67%**). + +После подбора выполняется смягчение атаки, пока в худшей ячейке есть хотя бы одна победа в выборке (как в прежнем отдельном прототипе сетки). + +Логика вынесена в `backend/cmd/balanceall/grid.go`. + +## Режим legacy (`-grid=false`) + +Один сценарий на архетип: + +- Герой и монстр: уровень = середина полосы `(min_level + max_level) / 2`, только **медианный** шмот. +- **Длительность** — медиана длительности побед в полосе по `-tolerance-pct`. +- **Давление** — медиана оставшегося HP **не выше** `-max-hero-hp-pct-on-win` (при необходимости ослабление до 65/70/75%). + +## Подключение к БД + +Нужен DSN (без БД утилита не запускается): + +- переменная окружения **`DATABASE_URL`**, или +- флаг **`-dsn`** (перекрывает env). + +Шаблоны подгружаются через `storage.ContentStore.LoadEnemyTemplates` (как в `balancesim`). + +## Запуск + +Из каталога модуля Go: + +```bash +cd backend +set DATABASE_URL=postgres://... +go run ./cmd/balanceall [флаги] +``` + +Сборка: + +```bash +go build -o balanceall ./cmd/balanceall +./balanceall -iterations 200 +``` + +## Область прогона + +- **Все архетипы из БД** — по умолчанию: строки из `enemies` в порядке `ORDER BY min_level, type` (как `ListEnemyRows`). +- **Один монстр по `enemies.id`** — `-enemy-id ` (удобно после `go run ./cmd/balanceall -list-enemies`). +- **Один архетип по строке `type`** — `-enemy-type ` (в таблице `enemies` это строка вроде `wolf`, не catalog id `enemy.wolf_forest`). + +Нельзя одновременно задавать `-enemy-id` и `-enemy-type`. + +## JSON-оверлей (`-config`) + +Флаг **`-config path.json`** задаёт файл с объектом верхнего уровня: **ключи — строки `type`**, как в таблице `enemies`. Значение — объект с **любым подмножеством** полей шаблона монстра (имена полей как в JSON у `model.Enemy`: `maxHp`, `attack`, `hpPerLevel`, `specialAbilities`, …). + +После загрузки из БД данные из файла **накладываются в памяти**: указанное в JSON поле заменяет значение из БД; отсутствующие в JSON поля не трогаются. + +Неизвестный ключ верхнего уровня (тип, которого нет среди загруженных шаблонов) пропускается с предупреждением в stderr. + +Пример: + +```json +{ + "wolf": { + "attack": 12, + "attackPerLevel": 1.1 + }, + "demon_fire": { + "maxHp": 800, + "hpPerLevel": 45 + } +} +``` + +Если в оверлее задан только один из пары `hp` / `maxHp`, второй выравнивается под него для согласованности шаблона. + +## Флаги + +| Флаг | По умолчанию | Смысл | +|------|----------------|--------| +| `-dsn` | `""` | Postgres DSN; если пусто — берётся `DATABASE_URL`. | +| `-enemy-id` | 0 | Только строка с этим `enemies.id`. | +| `-enemy-type` | `""` | Только архетип с этим `type`. | +| `-config` | `""` | Путь к JSON: частичные шаблоны по ключу `type`, поверх БД. | +| `-grid` | `true` | Сетка уровней × шмот; `false` — legacy (один уровень, медианный шмот). | +| `-gear-variants` | 4 | Режим сетки: число профилей шмота на уровень (1 median + N−1 rolled). | +| `-hero-hp-mid` | 60 | Режим сетки: центр полосы HP героя на победах (%). | +| `-hero-hp-pp` | 7 | Режим сетки: ±п.п. вокруг `-hero-hp-mid`. | +| `-refine` | 2 | Режим сетки: проходы подгонки длительности после атаки. | +| `-iterations` | 120 | Число боёв **на ячейку сетки** (grid) или на архетип (legacy). Рекомендуется ≥ 120–200. | +| `-seed` | `20260331` | База RNG; на архетип добавляется хеш `type`. | +| `-target-sec` | 330 | Центр полосы медианы длительности побед (секунды). | +| `-tolerance-pct` | 10 | Полоса вокруг центра; при 330 и 10% → **297–363 с**. | +| `-max-hero-hp-pct-on-win` | 60 | Только **legacy**: верхняя граница медианы HP героя после побед (%). | +| `-min-win-rate` | 0.35 | Legacy: планка винрейта при накрутке атаки. Сетка: планка **медианного** винрейта по ячейкам. | +| `-sql` | `true` | Печатать предлагаемые `UPDATE enemies ...`. | +| `-list-enemies` | false | Список архетипов из БД (с колонкой `id`) и выход. | +| `-filter` | `""` | Подстрока для `-list-enemies`. | +| `-limit` | 50 | Максимум строк для `-list-enemies` (1–500). | + +Примеры: + +```bash +go run ./cmd/balanceall -iterations 200 + +go run ./cmd/balanceall -enemy-type wolf -iterations 200 + +# Старый алгоритм (один уровень — середина полосы) +go run ./cmd/balanceall -grid=false -enemy-type wolf + +go run ./cmd/balanceall -list-enemies +go run ./cmd/balanceall -sql=false +``` + +## Вывод + +- **Сетка:** для каждого типа — baseline по текущему шаблону, затем `hpScale`/`atkScale`, агрегаты `medOfMed(duration)`, `medOfMed(heroHp%)`, диапазоны по ячейкам, при `-sql` — `UPDATE`. +- **Legacy:** как раньше — одна строка метрик и `UPDATE`. + +## Связь с репозиторием + +- Загрузка из БД: `internal/storage/content_store.go` (`LoadEnemyTemplates`, `ListEnemyRows`). +- Сетка: `cmd/balanceall/grid.go`. +- Одиночная симуляция: `cmd/balancesim` + `DATABASE_URL`. +- Краткий снимок для контента: `docs/monster-catalog-balanced-v1.md`. + +## Ограничения + +- Сетка не моделирует все пары (герой L5 vs монстр L1): для одной кривой в БД агрегаты по «честному» тиру (`hero == enemy`) устойчивее. +- Элиты с сильным DoT могут требовать ослабления целей или точечной настройки. +- Вывод SQL — предложение; источник правды в продакшене — таблица `enemies` после миграций и reload. diff --git a/docs/monster-catalog-balanced-v1.md b/docs/monster-catalog-balanced-v1.md new file mode 100644 index 0000000..64bb544 --- /dev/null +++ b/docs/monster-catalog-balanced-v1.md @@ -0,0 +1,43 @@ +# Monster Catalog (Balanced v1) + +This document is the reference snapshot for monster balance after the combat-system rewrite. + +Source of truth for runtime values remains PostgreSQL (`enemies` table). This file is a readable, versioned reference for future balancing and regressions. + +## Fields + +- `enemyType`: DB archetype key (`enemies.type`). +- `catalogId`: content-contract ID from `docs/specification-content-catalog.md`. +- `baseLevel`: archetype baseline level in DB. +- `levelVariance`: spawn variability around base level (`+/-` percent). +- `maxHeroLevelDiff`: absolute clamp versus hero level. +- `baseStats`: `maxHp/attack/defense/speed/critChance` stored on archetype row. +- `perLevel`: per-level progression values from DB. +- `abilities`: special abilities from DB. + +## Snapshot Template + +Fill this table from DB after each balancing pass. + +| enemyType | catalogId | baseLevel | levelVariance | maxHeroLevelDiff | baseStats (hp/atk/def/spd/crit) | perLevel (hp/atk/def/xp/gold) | abilities | +|---|---|---:|---:|---:|---|---|---| +| wolf | enemy.wolf_forest | - | - | - | - | - | - | +| boar | enemy.boar_wild | - | - | - | - | - | - | +| zombie | enemy.zombie_rotting | - | - | - | - | - | - | +| spider | enemy.spider_cave | - | - | - | - | - | - | +| orc | enemy.orc_warrior | - | - | - | - | - | - | +| skeleton_archer | enemy.skeleton_archer | - | - | - | - | - | - | +| battle_lizard | enemy.lizard_battle | - | - | - | - | - | - | +| fire_demon | enemy.demon_fire | - | - | - | - | - | - | +| ice_guardian | enemy.guard_ice | - | - | - | - | - | - | +| skeleton_king | enemy.skeleton_king | - | - | - | - | - | - | +| water_element | enemy.element_water | - | - | - | - | - | - | +| forest_warden | enemy.guard_forest | - | - | - | - | - | - | +| lightning_titan | enemy.titan_lightning | - | - | - | - | - | - | + +## Export Workflow (recommended) + +1. Run balance simulations and update `enemies` rows. +2. Export current rows into a CSV/JSON report. +3. Paste the finalized values into this document. +4. Commit both DB migration/change and this snapshot in one PR.