huge combat update and wipe

master
Denis Ranneft 1 month ago
parent 8ecb3981ac
commit b6eb68bf11

@ -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)
}

@ -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
}

@ -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
}
}

@ -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 2535 → 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]) + "…"
}

@ -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,
},
})
}

@ -0,0 +1,11 @@
package game
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
ensureTestEnemyTemplates()
os.Exit(m.Run())
}

@ -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;

@ -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%** — примерно **297363 с**).
2. **HP героя** — медиана по ячейкам от **медиан доли HP после победы** попадает в полосу
**`hero-hp-mid` ± `hero-hp-pp` процентных пунктов** (по умолчанию **60% ± 7 п.п.****5367%**).
После подбора выполняется смягчение атаки, пока в худшей ячейке есть хотя бы одна победа в выборке (как в прежнем отдельном прототипе сетки).
Логика вынесена в `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 <id>` (удобно после `go run ./cmd/balanceall -list-enemies`).
- **Один архетип по строке `type`** — `-enemy-type <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 + N1 rolled). |
| `-hero-hp-mid` | 60 | Режим сетки: центр полосы HP героя на победах (%). |
| `-hero-hp-pp` | 7 | Режим сетки: ±п.п. вокруг `-hero-hp-mid`. |
| `-refine` | 2 | Режим сетки: проходы подгонки длительности после атаки. |
| `-iterations` | 120 | Число боёв **на ячейку сетки** (grid) или на архетип (legacy). Рекомендуется ≥ 120200. |
| `-seed` | `20260331` | База RNG; на архетип добавляется хеш `type`. |
| `-target-sec` | 330 | Центр полосы медианы длительности побед (секунды). |
| `-tolerance-pct` | 10 | Полоса вокруг центра; при 330 и 10% → **297363 с**. |
| `-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` (1500). |
Примеры:
```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.

@ -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.
Loading…
Cancel
Save