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