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.

201 lines
5.9 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

package main
import (
"context"
"flag"
"fmt"
"log"
"math/rand"
"os"
"sort"
"strings"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"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 {
slug string
name string
tmpl model.Enemy
}
var rows []row
for _, e := range templates {
rows = append(rows, row{slug: e.Slug, name: e.Name, tmpl: e})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].slug < rows[j].slug })
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(r.slug), 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.slug, 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 := model.EnemyBySlug(*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, nil)
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
WallClockDelay: time.Duration(*delayMs) * time.Millisecond,
MaxSteps: constants.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]) + "…"
}