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