You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

294 lines
9.9 KiB
Go

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