package main import ( "context" "flag" "fmt" "log" "math" "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" "github.com/denisovdennis/autohero/internal/tuning" ) func main() { var ( dsnFlag = flag.String("dsn", "", "Postgres DSN (default: DATABASE_URL env)") iterPerLevel = flag.Int("iterations-per-level", 40, "Monte Carlo iterations per hero level (higher = slower, smoother)") seed = flag.Int64("seed", 20260331, "RNG seed base") restSec = flag.Float64("rest-sec", 400, "seconds of rest after each fight") accountLoss = flag.Bool("account-losses", false, "if true, XP rate uses all fights; else wins-only expectation") gearStr = flag.String("gear", "median", "median|rolled reference gear") typesFilter = flag.String("types", "", "comma-separated enemy types to include in SQL output (empty = all)") maxHeroLevel = flag.Int("max-level", 49, "max hero level step simulated (L→L+1 up to this L); use lower if high levels never win") targetW1 = flag.Float64("target-weeks-1-10", 1, "target wall-clock weeks for level-ups 1→10") targetW2 = flag.Float64("target-weeks-10-20", 3, "target weeks for 10→20") targetW3 = flag.Float64("target-weeks-20-30", 6, "target weeks for 20→30") targetW4 = flag.Float64("target-weeks-30-40", 10, "target weeks for 30→40") targetW5 = flag.Float64("target-weeks-40-50", 20, "target weeks for 40→50") weekDur = flag.Duration("week", 7*24*time.Hour, "duration of one target week (default 7d)") runReport = flag.Bool("report", true, "print band durations vs targets (baseline DB xp_reward)") optTypes = flag.Bool("optimize-types", false, "optimize per-row xp_reward (each enemies.type); no global multiplier") optBands = flag.Bool("optimize-bands", false, "optimize five content-tier multipliers (TemplateProgressionBand), not per-type") eliteMul = flag.Float64("elite-scale", 1, "multiplier on is_elite rows (applied with per-type / per-band scales)") optIters = flag.Int("optimize-iters", 100, "coordinate-descent passes per optimize round (-optimize-types / -optimize-bands)") optRounds = flag.Int("optimize-rounds", 8, "for -optimize-types: repeat from last scaled values until -target-max-rel-err or max rounds") enforceTierXP = flag.Bool("enforce-tier-xp", true, "for -optimize-types: xp_reward non-decreasing with min/max level tier (strict up when tier rises)") targetMaxRelErr = flag.Float64("target-max-rel-err", 0.18, "stop optimize rounds when max relative error on bands+total is below this (e.g. 0.18 = 18%)") printSQL = flag.Bool("sql", true, "print suggested UPDATE enemies SET xp_reward=...") sqlAll = flag.Bool("sql-all", true, "emit UPDATE for every enemy row (not only changed)") ) flag.Parse() dsn := strings.TrimSpace(*dsnFlag) if dsn == "" { dsn = os.Getenv("DATABASE_URL") } if dsn == "" { log.Fatal("DATABASE_URL or -dsn is required") } if *optTypes && *optBands { log.Fatal("use only one of -optimize-types or -optimize-bands") } ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) if err != nil { log.Fatalf("open db: %v", err) } defer pool.Close() rcStore := storage.NewRuntimeConfigStore(pool) if err := tuning.ReloadNow(ctx, nil, rcStore); err != nil { log.Fatalf("load runtime config: %v", err) } cs := storage.NewContentStore(pool) baseTemplates, err := cs.LoadEnemyTemplates(ctx) if err != nil { log.Fatalf("load enemies: %v", err) } if len(baseTemplates) == 0 { log.Fatal("no enemy templates in database") } gear := game.ReferenceGearMedian if strings.EqualFold(strings.TrimSpace(*gearStr), "rolled") { gear = game.ReferenceGearRolled } maxL := *maxHeroLevel if maxL < 1 { maxL = 1 } if maxL > 49 { maxL = 49 } params := game.ProgressionSimParams{ IterationsPerLevel: *iterPerLevel, Seed: *seed, RestAfterCombat: time.Duration(*restSec * float64(time.Second)), Gear: gear, AccountLosses: *accountLoss, MinHeroLevel: 1, MaxHeroLevelInclusive: maxL, } fullTargets := [5]time.Duration{ time.Duration(*targetW1 * float64(*weekDur)), time.Duration(*targetW2 * float64(*weekDur)), time.Duration(*targetW3 * float64(*weekDur)), time.Duration(*targetW4 * float64(*weekDur)), time.Duration(*targetW5 * float64(*weekDur)), } targets := game.ProratedBandTargets(maxL, fullTargets) totalTarget := game.SumBandTargets(targets) fmt.Printf("# xpprogsim: maxHeroLevel=%d | prorated target sum=%s (full 1→50 would be %s)\n", maxL, totalTarget.Round(time.Second), (fullTargets[0]+fullTargets[1]+fullTargets[2]+fullTargets[3]+fullTargets[4]).Round(time.Second)) typeFilter := parseTypesFilter(*typesFilter) if *runReport { res, err := game.SimulateProgressionBands(params, game.CloneEnemyTemplates(game.EnemyTemplatesFromSlice(baseTemplates))) if err != nil { log.Fatalf("simulate: %v", err) } printReport("baseline (DB xp_reward)", res, targets, totalTarget) } if *optTypes { baseRound := game.CloneEnemyTemplates(game.EnemyTemplatesFromSlice(baseTemplates)) iters := *optIters var lastScaled map[string]model.Enemy var lastRes game.ProgressionBandResult var lastSq float64 var lastPerType map[string]float64 for round := 0; round < *optRounds; round++ { var scaled map[string]model.Enemy lastPerType, scaled, lastRes, lastSq = game.OptimizePerTypeScales(baseRound, params, targets, *eliteMul, iters, *enforceTierXP) lastScaled = scaled maxRel := game.MaxRelativeErrorVsTargets(lastRes.BandDurations, targets, lastRes.Total, totalTarget) fmt.Printf("\n# optimize-types round %d/%d: iters=%d sqErr=%.6f maxRelErr=%.2f%% enforceTier=%v\n", round+1, *optRounds, iters, lastSq, 100*maxRel, *enforceTierXP) if !math.IsInf(lastRes.TotalSec, 1) && maxRel <= *targetMaxRelErr { fmt.Printf("# stopped: max relative error <= %.0f%%\n", 100*(*targetMaxRelErr)) break } baseRound = game.CloneEnemyTemplates(scaled) iters += *optIters / 3 if iters > 400 { iters = 400 } } if lastPerType != nil { printPerTypeMultipliers(lastPerType) } printReport("after per-type optimization (final)", lastRes, targets, totalTarget) if *printSQL && lastScaled != nil { if *sqlAll { printSQLAll(lastScaled, typeFilter) } else { printSQLDiff(game.EnemyTemplatesFromSlice(baseTemplates), lastScaled, typeFilter) } } } if *optBands { scales, res, sqErr := game.OptimizeBandScales(game.EnemyTemplatesFromSlice(baseTemplates), params, targets, 1, *eliteMul, *optIters) fmt.Printf("\n# optimize-bands: per-band scales [%v] sqErr=%.6f\n", formatFloats(scales[:]), sqErr) printReport("after band-tier optimization", res, targets, totalTarget) spec := game.XPRewardScaleSpec{Global: 1, Elite: *eliteMul, PerBand: scales} scaled := game.ApplyXPRewardScaleSpec(game.EnemyTemplatesFromSlice(baseTemplates), spec) if *printSQL { printSQLDiff(game.EnemyTemplatesFromSlice(baseTemplates), scaled, typeFilter) } } if !*runReport && !*optTypes && !*optBands { log.Fatal("nothing to do: enable -report and/or -optimize-types and/or -optimize-bands") } } func parseTypesFilter(s string) map[string]bool { s = strings.TrimSpace(s) if s == "" { return nil } out := make(map[string]bool) for _, p := range strings.Split(s, ",") { p = strings.TrimSpace(p) if p == "" { continue } out[p] = true } return out } func printPerTypeMultipliers(m map[string]float64) { keys := make([]string, 0, len(m)) for t := range m { keys = append(keys, t) } sort.Strings(keys) fmt.Print("# multipliers vs DB xp_reward: ") for i, ks := range keys { if i > 0 { fmt.Print(" ") } fmt.Printf("%s=%.4f", ks, m[ks]) } fmt.Println() } func printReport(title string, res game.ProgressionBandResult, targets [5]time.Duration, totalTarget time.Duration) { fmt.Printf("\n## %s\n", title) labels := []string{"1→10", "10→20", "20→30", "30→40", "40→50"} errs := game.BandErrors(res.BandDurations, targets) for i := 0; i < 5; i++ { fmt.Printf(" band %s: sim=%s target=%s rel_err=%.2f%%\n", labels[i], res.BandDurations[i].Round(time.Second), targets[i].Round(time.Second), 100*errs[i]) } fmt.Printf(" TOTAL: sim=%s target=%s rel_err=%.2f%%\n", res.Total.Round(time.Second), totalTarget.Round(time.Second), 100*(float64(res.Total)/float64(totalTarget)-1)) if math.IsInf(res.TotalSec, 1) { fmt.Println(" NOTE: total time hit +Inf (some hero levels had zero XP rate — raise balance, use -account-losses, or -max-level).") } else { minWR := 1.0 for _, w := range res.WinRates { if w < minWR { minWR = w } } if minWR < 0.05 && len(res.WinRates) > 0 { fmt.Printf(" NOTE: min MC win rate across levels=%.0f%% — progression may be unrealistic at high levels.\n", 100*minWR) } } } func printSQLDiff(base, scaled map[string]model.Enemy, filter map[string]bool) { fmt.Println() for typ, b := range base { if filter != nil && !filter[typ] { continue } s, ok := scaled[typ] if !ok { continue } if s.XPReward == b.XPReward { continue } t := strings.ReplaceAll(typ, "'", "''") fmt.Printf("UPDATE public.enemies SET xp_reward = %d WHERE type = '%s';\n", s.XPReward, t) } } // printSQLAll emits UPDATE for every row in scaled (optionally filtered by type), tier order. func printSQLAll(scaled map[string]model.Enemy, filter map[string]bool) { fmt.Println() order := game.SortEnemyTypesByLevelTier(scaled) for _, typ := range order { if filter != nil && !filter[typ] { continue } s, ok := scaled[typ] if !ok { continue } t := strings.ReplaceAll(typ, "'", "''") fmt.Printf("UPDATE public.enemies SET xp_reward = %d WHERE type = '%s';\n", s.XPReward, t) } } func formatFloats(v []float64) string { var b strings.Builder b.WriteByte('[') for i, x := range v { if i > 0 { b.WriteString(", ") } fmt.Fprintf(&b, "%.4f", x) } b.WriteByte(']') return b.String() }