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