update gear

master
Denis Ranneft 1 month ago
parent 485254d6cd
commit ae6fa7bb9c

@ -10,6 +10,7 @@ import (
"math/rand" "math/rand"
"os" "os"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@ -18,18 +19,21 @@ import (
"github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
) )
func main() { func main() {
var ( var (
dsnFlag = flag.String("dsn", "", "Postgres DSN (default: DATABASE_URL env)") dsnFlag = flag.String("dsn", "", "Postgres DSN (default: DATABASE_URL env)")
listEnemies = flag.Bool("list-enemies", false, "print enemy archetypes from DB and exit") listEnemies = flag.Bool("list-enemies", false, "print enemy rows from DB and exit")
filter = flag.String("filter", "", "optional substring filter for -list-enemies (id/type/name)") listArchetypes = flag.Bool("list-archetypes", false, "print distinct enemies.archetype values from DB and exit")
listLimit = flag.Int("limit", 50, "max rows for -list-enemies") filter = flag.String("filter", "", "optional substring filter for -list-enemies (id/type/name/archetype)")
listLimit = flag.Int("limit", 50, "max rows for -list-enemies")
enemyIDFlag = flag.Int64("enemy-id", 0, "if set, balance only this enemies.id row") enemyIDFlag = flag.Int64("enemy-id", 0, "if set, balance only this enemies.id row")
enemyTypeFlag = flag.String("enemy-type", "", "if set, balance only this archetype (type string); ignored if -enemy-id is set") enemyTypeFlag = flag.String("enemy-type", "", "if set, balance only this template slug (enemies.type); ignored if -enemy-id is set")
enemyArchetypeFlag = flag.String("enemy-archetype", "", "if set without -enemy-id/-enemy-type, include all DB rows with this enemies.archetype; with -enemy-type, must match template archetype")
configJSON = flag.String("config", "", "optional JSON file: partial enemy objects keyed by type string, merged over DB templates") configJSON = flag.String("config", "", "optional JSON file: partial enemy objects keyed by type string, merged over DB templates")
@ -51,7 +55,19 @@ func main() {
tolerancePct = flag.Float64("tolerance-pct", 10, "deviation from target (percent); 10%% with target 330 → band [297s,363s]") tolerancePct = flag.Float64("tolerance-pct", 10, "deviation from target (percent); 10%% with target 330 → band [297s,363s]")
maxHeroHpPct = flag.Float64("max-hero-hp-pct-on-win", 60, "legacy mode: median hero HP%% after victory must be <= this (0-100)") maxHeroHpPct = flag.Float64("max-hero-hp-pct-on-win", 60, "legacy mode: median hero HP%% after victory must be <= this (0-100)")
minWinRate = flag.Float64("min-win-rate", 0.28, "stop raising enemy attack if win rate falls below this (legacy); grid: median win rate floor") minWinRate = flag.Float64("min-win-rate", 0.28, "stop raising enemy attack if win rate falls below this (legacy); grid: median win rate floor")
adjustEnemies = flag.Bool("adjust-enemies", true, "when false, only simulate and report metrics; do not tune or print UPDATE SQL")
printSQL = flag.Bool("sql", true, "print suggested UPDATE statements") printSQL = flag.Bool("sql", true, "print suggested UPDATE statements")
gearCheck = flag.Bool("gear-check", false, "compare baseline (common) vs max (legendary) reference gear per level; exits non-zero on violations")
gearCheckMaxSpeedupPct = flag.Float64("gear-check-max-speedup-pct", 20, "max allowed kill speedup with max gear vs baseline (percent); duration max >= baseline * (1 - pct/100)")
gearCheckMaxHeroHpPct = flag.Float64("gear-check-max-hero-hp-pct", 75, "max median hero HP%% on wins with max gear (0-100)")
gearCheckLevelMin = flag.Int("gear-check-level-min", 0, "gear-check: min hero/enemy level inclusive (0 = use template min_level)")
gearCheckLevelMax = flag.Int("gear-check-level-max", 0, "gear-check: max hero/enemy level inclusive (0 = use template max_level)")
gearCheckStrict = flag.Bool("gear-check-strict", false, "gear-check: baseline must get wins (SKIP → FAIL) so vacuous passes are impossible")
gearBase = flag.String("gear-base", "db", "gear catalog source before overlay: db (weapons/armor/equipment_items) or code (embedded defaults only)")
gearOverlay = flag.String("gear-overlay", "", "JSON file: partial GearFamily patches keyed by item name or \"slot:name\"; merged over catalog for simulation only")
gearPrintSQL = flag.Bool("gear-print-sql", false, "print SQL UPDATEs for gear/weapons/armor/equipment_items from patched catalog; requires -gear-overlay; then exit")
) )
flag.Parse() flag.Parse()
@ -72,6 +88,9 @@ func main() {
log.Fatalf("open db: %v", err) log.Fatalf("open db: %v", err)
} }
defer pool.Close() defer pool.Close()
if err := tuning.ReloadNow(ctx, nil, storage.NewRuntimeConfigStore(pool)); err != nil {
log.Printf("# balanceall: runtime_config not loaded (%v); using code defaults only", err)
}
cs := storage.NewContentStore(pool) cs := storage.NewContentStore(pool)
enemies, err := cs.LoadEnemyTemplates(ctx) enemies, err := cs.LoadEnemyTemplates(ctx)
if err != nil { if err != nil {
@ -86,6 +105,56 @@ func main() {
} }
model.SetEnemyTemplates(game.EnemySliceFromMap(tmplMap)) model.SetEnemyTemplates(game.EnemySliceFromMap(tmplMap))
gearFamilies, err := loadGearCatalog(ctx, cs, strings.TrimSpace(*gearBase))
if err != nil {
log.Fatalf("load gear catalog: %v", err)
}
if p := strings.TrimSpace(*gearOverlay); p != "" {
gearFamilies, err = applyGearOverlayJSON(p, gearFamilies)
if err != nil {
log.Fatalf("gear-overlay: %v", err)
}
}
model.SetGearCatalog(gearFamilies)
if *gearPrintSQL {
if strings.TrimSpace(*gearOverlay) == "" {
log.Fatal("-gear-print-sql requires -gear-overlay")
}
keys, err := listOverlayKeys(strings.TrimSpace(*gearOverlay))
if err != nil {
log.Fatalf("gear overlay keys: %v", err)
}
printGearOverlayMigrationSQL(gearFamilies, keys)
return
}
if *listArchetypes {
dbRows, err := cs.ListEnemyRows(ctx)
if err != nil {
log.Fatalf("list enemies: %v", err)
}
seen := make(map[string]struct{})
var list []string
for _, r := range dbRows {
a := strings.TrimSpace(r.Archetype)
if a == "" {
continue
}
if _, ok := seen[a]; ok {
continue
}
seen[a] = struct{}{}
list = append(list, a)
}
sort.Strings(list)
fmt.Printf("# balanceall: distinct enemies.archetype (%d)\n", len(list))
for _, a := range list {
fmt.Println(a)
}
return
}
if *listEnemies { if *listEnemies {
if *listLimit <= 0 || *listLimit > 500 { if *listLimit <= 0 || *listLimit > 500 {
log.Fatal("limit must be 1..500") log.Fatal("limit must be 1..500")
@ -96,22 +165,23 @@ func main() {
log.Fatalf("list enemies: %v", err) log.Fatalf("list enemies: %v", err)
} }
fmt.Printf("# balanceall enemies from DB (filter=%q)\n", *filter) fmt.Printf("# balanceall enemies from DB (filter=%q)\n", *filter)
fmt.Printf("%-8s %-22s %-32s %6s %6s %6s %5s\n", "id", "type", "name", "minLv", "maxLv", "baseLv", "elite") fmt.Printf("%-8s %-12s %-22s %-32s %6s %6s %6s %5s\n", "id", "archetype", "type", "name", "minLv", "maxLv", "baseLv", "elite")
printed := 0 printed := 0
for _, r := range dbRows { for _, r := range dbRows {
idStr := fmt.Sprintf("%d", r.ID) idStr := fmt.Sprintf("%d", r.ID)
if f != "" { if f != "" {
if !strings.Contains(strings.ToLower(idStr), f) && if !strings.Contains(strings.ToLower(idStr), f) &&
!strings.Contains(strings.ToLower(r.Type), f) && !strings.Contains(strings.ToLower(r.Type), f) &&
!strings.Contains(strings.ToLower(r.Name), f) { !strings.Contains(strings.ToLower(r.Name), f) &&
!strings.Contains(strings.ToLower(r.Archetype), f) {
continue continue
} }
} }
if printed >= *listLimit { if printed >= *listLimit {
break break
} }
fmt.Printf("%-8d %-22s %-32s %6d %6d %6d %5v\n", fmt.Printf("%-8d %-12s %-22s %-32s %6d %6d %6d %5v\n",
r.ID, r.Type, trimName32(r.Name), r.MinLevel, r.MaxLevel, r.BaseLevel, r.IsElite) r.ID, trimName12(r.Archetype), r.Type, trimName32(r.Name), r.MinLevel, r.MaxLevel, r.BaseLevel, r.IsElite)
printed++ printed++
} }
if f != "" { if f != "" {
@ -123,7 +193,7 @@ func main() {
if *iterations < 40 { if *iterations < 40 {
log.Fatal("iterations should be at least 40") log.Fatal("iterations should be at least 40")
} }
if *gearVariants < 2 { if !*gearCheck && *gearVariants < 2 {
log.Fatal("gear-variants must be at least 2") log.Fatal("gear-variants must be at least 2")
} }
if *gridMode && *tieredTargets && *tierLevelMax <= *tierLevelMin { if *gridMode && *tieredTargets && *tierLevelMax <= *tierLevelMin {
@ -147,11 +217,84 @@ func main() {
log.Fatal("max-hero-hp-pct-on-win must be in (0,100]") log.Fatal("max-hero-hp-pct-on-win must be in (0,100]")
} }
order, err := archetypeOrder(ctx, tmplMap, pool, *enemyIDFlag, strings.TrimSpace(*enemyTypeFlag)) order, err := archetypeOrder(ctx, tmplMap, pool, *enemyIDFlag, strings.TrimSpace(*enemyTypeFlag), strings.TrimSpace(*enemyArchetypeFlag))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
overlayNote := ""
if strings.TrimSpace(*configJSON) != "" {
overlayNote = fmt.Sprintf(" | overlay=%q", strings.TrimSpace(*configJSON))
}
if *gearCheck {
if *gearCheckMaxHeroHpPct <= 0 || *gearCheckMaxHeroHpPct > 100 {
log.Fatal("gear-check-max-hero-hp-pct must be in (0,100]")
}
if *gearCheckMaxSpeedupPct < 0 || *gearCheckMaxSpeedupPct >= 100 {
log.Fatal("gear-check-max-speedup-pct must be in [0,100)")
}
if *gearCheckLevelMin > 0 && *gearCheckLevelMax > 0 && *gearCheckLevelMin > *gearCheckLevelMax {
log.Fatal("gear-check-level-min must be <= gear-check-level-max")
}
totalFail := 0
fmt.Printf("# balanceall: gear-check | iterations/cell=%d%s\n", *iterations, overlayNote)
if len(order) > 0 {
switch {
case len(order) <= 10:
fmt.Printf("# templates (%d): %s\n", len(order), strings.Join(order, ", "))
default:
fmt.Printf("# templates (%d): %s … +%d more\n", len(order), strings.Join(order[:8], ", "), len(order)-8)
}
}
lvlNote := "template level band"
if *gearCheckLevelMin > 0 || *gearCheckLevelMax > 0 {
lvlNote = fmt.Sprintf("levels [%s..%s] ∩ template band",
gearCheckLevelLabel(*gearCheckLevelMin), gearCheckLevelLabel(*gearCheckLevelMax))
}
fmt.Printf("# levels: %s\n", lvlNote)
fmt.Printf("# baseline = Iron Sword + Chainmail (common); max = Soul Reaver + Crown of Eternity (legendary); ilvl=hero level (from DB catalog)\n")
if *gearCheckStrict {
fmt.Printf("# strict: baseline must win (no vacuous SKIP)\n")
}
fmt.Printf("# rules: dur (max) >= baseline * (1 - %.1f/100); median hero HP%% (max) <= %.1f%%\n\n",
*gearCheckMaxSpeedupPct, *gearCheckMaxHeroHpPct)
for _, et := range order {
tmpl := tmplMap[et]
typeSeed := *seed + int64(hashString(string(et)))
scenarios := gearCheckScenariosForTemplate(tmpl, *gearCheckLevelMin, *gearCheckLevelMax)
if len(scenarios) == 0 {
fmt.Printf("## %s (0 level cells — empty intersection with level clamp)\n", et)
if *gearCheckStrict {
totalFail++
fmt.Println(" FAIL — strict mode requires at least one level cell")
} else {
fmt.Println(" SKIP — no levels to simulate")
}
fmt.Println()
continue
}
fail, lines := runGearCheck(tmpl, et, scenarios, *iterations, typeSeed, *gearCheckMaxSpeedupPct, *gearCheckMaxHeroHpPct, *gearCheckStrict)
totalFail += fail
fmt.Printf("## %s (%d level cells)\n", et, len(scenarios))
for _, ln := range lines {
fmt.Println(ln)
}
if fail == 0 {
fmt.Printf(" OK — all level cells pass\n")
} else {
fmt.Printf(" %d failing level cell(s)\n", fail)
}
fmt.Println()
}
if totalFail > 0 {
fmt.Printf("# GEAR-CHECK: FAILED (%d violation(s))\n", totalFail)
os.Exit(1)
}
fmt.Printf("# GEAR-CHECK: PASSED\n")
return
}
target := time.Duration(*targetSec * float64(time.Second)) target := time.Duration(*targetSec * float64(time.Second))
tol := *tolerancePct / 100.0 tol := *tolerancePct / 100.0
lowTarget := time.Duration(float64(target) * (1.0 - tol)) lowTarget := time.Duration(float64(target) * (1.0 - tol))
@ -159,12 +302,11 @@ func main() {
legacyLowSec := *targetSec * (1.0 - tol) legacyLowSec := *targetSec * (1.0 - tol)
legacyHighSec := *targetSec * (1.0 + tol) legacyHighSec := *targetSec * (1.0 + tol)
overlayNote := ""
if strings.TrimSpace(*configJSON) != "" {
overlayNote = fmt.Sprintf(" | overlay=%q", strings.TrimSpace(*configJSON))
}
if *gridMode { if *gridMode {
fmt.Printf("# balanceall: grid mode | iterations/cell=%d%s\n", *iterations, overlayNote) fmt.Printf("# balanceall: grid mode | iterations/cell=%d%s\n", *iterations, overlayNote)
if !*adjustEnemies {
fmt.Printf("# adjust-enemies=false: report metrics only (no tuning, no SQL)\n")
}
if *tieredTargets { if *tieredTargets {
fmt.Printf("# tiered: duration center %.0fs → %.0fs | hero HP center %.0f%% → %.0f%% (level mid in [%d,%d], gamma=%.2f); ±%.1f%% dur / ±%.1f pp HP\n", fmt.Printf("# tiered: duration center %.0fs → %.0fs | hero HP center %.0f%% → %.0f%% (level mid in [%d,%d], gamma=%.2f); ±%.1f%% dur / ±%.1f pp HP\n",
*targetSec, *targetSecHigh, *heroHpMid, *heroHpMidHigh, *tierLevelMin, *tierLevelMax, *tierGamma, *tolerancePct, *heroHpPP) *targetSec, *targetSecHigh, *heroHpMid, *heroHpMidHigh, *tierLevelMin, *tierLevelMax, *tierGamma, *tolerancePct, *heroHpPP)
@ -176,6 +318,9 @@ func main() {
} else { } else {
fmt.Printf("# balanceall: legacy mode | iterations=%d%s | phase1: HP→duration | phase2: atk→median hero HP on win <= %.1f%%\n# duration band=[%s,%s] min win rate %.0f%%\n\n", fmt.Printf("# balanceall: legacy mode | iterations=%d%s | phase1: HP→duration | phase2: atk→median hero HP on win <= %.1f%%\n# duration band=[%s,%s] min win rate %.0f%%\n\n",
*iterations, overlayNote, *maxHeroHpPct, lowTarget.Round(time.Millisecond), highTarget.Round(time.Millisecond), 100*(*minWinRate)) *iterations, overlayNote, *maxHeroHpPct, lowTarget.Round(time.Millisecond), highTarget.Round(time.Millisecond), 100*(*minWinRate))
if !*adjustEnemies {
fmt.Printf("# adjust-enemies=false: report metrics only (no tuning, no SQL)\n\n")
}
} }
for _, et := range order { for _, et := range order {
@ -187,6 +332,15 @@ func main() {
base := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, *iterations, typeSeed) base := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, *iterations, typeSeed)
fmt.Printf("# baseline %s (grid %d cells): medOfMedDur=%.1fs medOfMedHp=%.1f%% medWin=%.1f%% minWin=%.1f%%\n", fmt.Printf("# baseline %s (grid %d cells): medOfMedDur=%.1fs medOfMedHp=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate) et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
if !*adjustEnemies {
fmt.Printf("## %s (grid, no-adjust)\n", et)
fmt.Printf(" medOfMedDur=%.1fs medOfMed(heroHp%%)=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
fmt.Printf(" per-cell median ranges: dur [%.1fs, %.1fs] heroHp%% [%.1f%%, %.1f%%]\n",
base.minMedDur, base.maxMedDur, 100*base.minMedHp, 100*base.maxMedHp)
fmt.Println()
continue
}
var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64 var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64
dotHeavy := enemyTemplateHasPeriodicDoT(tmpl) dotHeavy := enemyTemplateHasPeriodicDoT(tmpl)
@ -276,6 +430,14 @@ func main() {
if lvl < 1 { if lvl < 1 {
lvl = 1 lvl = 1
} }
if !*adjustEnemies {
final := runSeries(tmpl, lvl, 1.0, 1.0, *iterations, typeSeed)
fmt.Printf("## %s (hero L%d vs enemy L%d, no-adjust)\n", et, lvl, lvl)
fmt.Printf(" atkScale=1.0000 hpScale=1.0000 | winRate=%.1f%% median(win)=%s medHeroHp%%=%.1f%% p90(win)=%s\n",
100*final.winRate, final.medianWin.Round(time.Millisecond), 100*final.medianHeroHpPctWin, final.p90Win.Round(time.Millisecond))
fmt.Println()
continue
}
// --- Phase 1: HP scale for fight duration (atk = 1) --- // --- Phase 1: HP scale for fight duration (atk = 1) ---
hpScale := findHPScaleForDuration(tmpl, lvl, *iterations, typeSeed, lowTarget, highTarget) hpScale := findHPScaleForDuration(tmpl, lvl, *iterations, typeSeed, lowTarget, highTarget)
@ -519,6 +681,23 @@ func max(a, b int) int {
return b return b
} }
func gearCheckLevelLabel(v int) string {
if v <= 0 {
return "auto"
}
return strconv.Itoa(v)
}
func trimName12(s string) string {
const max = 12
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}
func trimName32(s string) string { func trimName32(s string) string {
const max = 32 const max = 32
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
@ -545,35 +724,54 @@ func archetypeTierNorm(t model.Enemy, globalMin, globalMax int) float64 {
return n return n
} }
// archetypeOrder returns which enemy slugs to balance: one row by -enemy-id, one by -enemy-type, or all (DB order). // archetypeOrder returns which enemy template slugs (enemies.type) to run: one row by -enemy-id,
func archetypeOrder(ctx context.Context, templates map[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType string) ([]string, error) { // one by -enemy-type, all rows matching -enemy-archetype, or all rows in DB order.
func archetypeOrder(ctx context.Context, templates map[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType, enemyArchetype string) ([]string, error) {
cs := storage.NewContentStore(pool)
dbRows, err := cs.ListEnemyRows(ctx)
if err != nil {
return nil, err
}
if enemyID > 0 { if enemyID > 0 {
cs := storage.NewContentStore(pool) for _, r := range dbRows {
rows, err := cs.ListEnemyRows(ctx)
if err != nil {
return nil, err
}
for _, r := range rows {
if r.ID != enemyID { if r.ID != enemyID {
continue continue
} }
if _, ok := templates[r.Type]; !ok { if _, ok := templates[r.Type]; !ok {
return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type) return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type)
} }
if enemyArchetype != "" && r.Archetype != enemyArchetype {
return nil, fmt.Errorf("enemy id %d has archetype %q, want %q", enemyID, r.Archetype, enemyArchetype)
}
return []string{r.Type}, nil return []string{r.Type}, nil
} }
return nil, fmt.Errorf("no enemy row with id %d", enemyID) return nil, fmt.Errorf("no enemy row with id %d", enemyID)
} }
if enemyType != "" { if enemyType != "" {
if _, ok := templates[enemyType]; !ok { t, ok := templates[enemyType]
if !ok {
return nil, fmt.Errorf("enemy type not found: %s", enemyType) return nil, fmt.Errorf("enemy type not found: %s", enemyType)
} }
if enemyArchetype != "" && t.Archetype != enemyArchetype {
return nil, fmt.Errorf("enemy-type %q has archetype %q, not %q", enemyType, t.Archetype, enemyArchetype)
}
return []string{enemyType}, nil return []string{enemyType}, nil
} }
cs := storage.NewContentStore(pool) if enemyArchetype != "" {
dbRows, err := cs.ListEnemyRows(ctx) out := make([]string, 0)
if err != nil { for _, r := range dbRows {
return nil, err if r.Archetype != enemyArchetype {
continue
}
if _, ok := templates[r.Type]; !ok {
continue
}
out = append(out, r.Type)
}
if len(out) == 0 {
return nil, fmt.Errorf("no enemy rows with archetype %q", enemyArchetype)
}
return out, nil
} }
out := make([]string, 0, len(dbRows)) out := make([]string, 0, len(dbRows))
for _, r := range dbRows { for _, r := range dbRows {

@ -12,10 +12,14 @@ import (
type ReferenceGearProfile int type ReferenceGearProfile int
const ( const (
// ReferenceGearMedian uses ilvl == hero level and common Iron Sword + Chainmail (deterministic, low noise). // ReferenceGearMedian uses ilvl == hero level and uncommon sword + mail (deterministic, low noise).
ReferenceGearMedian ReferenceGearProfile = iota ReferenceGearMedian ReferenceGearProfile = iota
// ReferenceGearRolled uses RollIlvl(level, false) per slot with rng (matches base-monster drop spread). // ReferenceGearRolled uses RollIlvl(level, false) per slot with rng (matches base-monster drop spread).
ReferenceGearRolled ReferenceGearRolled
// ReferenceGearBaseline is common sword + common medium chest at ilvl == hero level (weakest fair reference).
ReferenceGearBaseline
// ReferenceGearMax is legendary sword + legendary medium chest at ilvl == hero level (strongest fair reference).
ReferenceGearMax
) )
// NewReferenceHeroForBalance builds a hero at the given level with sword + medium chest // NewReferenceHeroForBalance builds a hero at the given level with sword + medium chest
@ -57,34 +61,63 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
aIlvl = rollIlvlForBalance(level, false, rng) aIlvl = rollIlvlForBalance(level, false, rng)
} }
// Typical mid-tier drops: uncommon sword + mail (catalog bases; slightly above 10 for elite/DoT balance targets). var wRarity, aRarity model.Rarity
const refGearBase = 12 var refGearBase int
wPrimary := model.ScalePrimary(refGearBase, wIlvl, model.RarityUncommon) switch profile {
h.Gear[model.SlotMainHand] = &model.GearItem{ case ReferenceGearBaseline:
Slot: model.SlotMainHand, wRarity, aRarity = model.RarityCommon, model.RarityCommon
FormID: "gear.form.main_hand.sword", refGearBase = 7
Name: "Steel Sword", case ReferenceGearMax:
Subtype: "sword", wRarity, aRarity = model.RarityLegendary, model.RarityLegendary
Rarity: model.RarityUncommon, refGearBase = 7
Ilvl: wIlvl, default:
BasePrimary: refGearBase, // Median, Rolled, unknown: uncommon + mid-tier ref base (legacy balance target).
PrimaryStat: wPrimary, wRarity, aRarity = model.RarityUncommon, model.RarityUncommon
StatType: "attack", refGearBase = 12
SpeedModifier: 1.0,
CritChance: 0.05,
} }
aPrimary := model.ScalePrimary(refGearBase, aIlvl, model.RarityUncommon)
h.Gear[model.SlotChest] = &model.GearItem{ if profile == ReferenceGearBaseline || profile == ReferenceGearMax {
Slot: model.SlotChest, wName, aName := "Iron Sword", "Chainmail"
FormID: "gear.form.chest.medium", if profile == ReferenceGearMax {
Name: "Reinforced Mail", wName, aName = "Soul Reaver", "Crown of Eternity"
Subtype: "medium", }
Rarity: model.RarityUncommon, if wf := model.GearFamilyByName(wName); wf != nil {
Ilvl: aIlvl, h.Gear[model.SlotMainHand] = model.NewGearItem(wf, wIlvl, wRarity)
BasePrimary: refGearBase, }
PrimaryStat: aPrimary, if af := model.GearFamilyByName(aName); af != nil {
StatType: "defense", h.Gear[model.SlotChest] = model.NewGearItem(af, aIlvl, aRarity)
SpeedModifier: 1.0, }
}
if h.Gear[model.SlotMainHand] == nil {
wPrimary := model.ScalePrimary(refGearBase, wIlvl, wRarity)
h.Gear[model.SlotMainHand] = &model.GearItem{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand.sword",
Name: "Steel Sword",
Subtype: "sword",
Rarity: wRarity,
Ilvl: wIlvl,
BasePrimary: refGearBase,
PrimaryStat: wPrimary,
StatType: "attack",
SpeedModifier: 1.0,
CritChance: 0.05,
}
}
if h.Gear[model.SlotChest] == nil {
aPrimary := model.ScalePrimary(refGearBase, aIlvl, aRarity)
h.Gear[model.SlotChest] = &model.GearItem{
Slot: model.SlotChest,
FormID: "gear.form.chest.medium",
Name: "Reinforced Mail",
Subtype: "medium",
Rarity: aRarity,
Ilvl: aIlvl,
BasePrimary: refGearBase,
PrimaryStat: aPrimary,
StatType: "defense",
SpeedModifier: 1.0,
}
} }
now := time.Now() now := time.Now()

@ -104,6 +104,23 @@ func init() {
var gearBySlot map[EquipmentSlot][]GearFamily var gearBySlot map[EquipmentSlot][]GearFamily
// GearFamilyByName returns the first catalog family with the given display name, or nil.
func GearFamilyByName(name string) *GearFamily {
for i := range GearCatalog {
if GearCatalog[i].Name == name {
return &GearCatalog[i]
}
}
return nil
}
// DefaultGearFamilies returns a snapshot of the built-in catalog (before any SetGearCatalog / DB merge).
func DefaultGearFamilies() []GearFamily {
out := make([]GearFamily, len(defaultGearCatalog))
copy(out, defaultGearCatalog)
return out
}
func SetGearCatalog(families []GearFamily) { func SetGearCatalog(families []GearFamily) {
if len(families) == 0 { if len(families) == 0 {
return return
@ -191,22 +208,22 @@ type legacyWeaponEntry struct {
var legacyWeapons = []legacyWeaponEntry{ var legacyWeapons = []legacyWeaponEntry{
// Daggers // Daggers
{Name: "Rusty Dagger", Type: "daggers", Rarity: RarityCommon, Damage: 3, Speed: 1.3, CritChance: 0.05}, {Name: "Rusty Dagger", Type: "daggers", Rarity: RarityCommon, Damage: 3, Speed: 1.3, CritChance: 0.05},
{Name: "Iron Dagger", Type: "daggers", Rarity: RarityUncommon, Damage: 5, Speed: 1.3, CritChance: 0.08}, {Name: "Iron Dagger", Type: "daggers", Rarity: RarityUncommon, Damage: 3, Speed: 1.3, CritChance: 0.08},
{Name: "Assassin's Blade", Type: "daggers", Rarity: RarityRare, Damage: 8, Speed: 1.35, CritChance: 0.20}, {Name: "Assassin's Blade", Type: "daggers", Rarity: RarityRare, Damage: 3, Speed: 1.35, CritChance: 0.20},
{Name: "Phantom Edge", Type: "daggers", Rarity: RarityEpic, Damage: 12, Speed: 1.4, CritChance: 0.25}, {Name: "Phantom Edge", Type: "daggers", Rarity: RarityEpic, Damage: 3, Speed: 1.4, CritChance: 0.25},
{Name: "Fang of the Void", Type: "daggers", Rarity: RarityLegendary, Damage: 18, Speed: 1.5, CritChance: 0.30}, {Name: "Fang of the Void", Type: "daggers", Rarity: RarityLegendary, Damage: 3, Speed: 1.5, CritChance: 0.30},
// Swords // Swords
{Name: "Iron Sword", Type: "sword", Rarity: RarityCommon, Damage: 7, Speed: 1.0, CritChance: 0.03}, {Name: "Iron Sword", Type: "sword", Rarity: RarityCommon, Damage: 7, Speed: 1.0, CritChance: 0.03},
{Name: "Steel Sword", Type: "sword", Rarity: RarityUncommon, Damage: 10, Speed: 1.0, CritChance: 0.05}, {Name: "Steel Sword", Type: "sword", Rarity: RarityUncommon, Damage: 7, Speed: 1.0, CritChance: 0.05},
{Name: "Longsword", Type: "sword", Rarity: RarityRare, Damage: 15, Speed: 1.0, CritChance: 0.08}, {Name: "Longsword", Type: "sword", Rarity: RarityRare, Damage: 7, Speed: 1.0, CritChance: 0.08},
{Name: "Excalibur", Type: "sword", Rarity: RarityEpic, Damage: 22, Speed: 1.05, CritChance: 0.10}, {Name: "Excalibur", Type: "sword", Rarity: RarityEpic, Damage: 7, Speed: 1.05, CritChance: 0.10},
{Name: "Soul Reaver", Type: "sword", Rarity: RarityLegendary, Damage: 30, Speed: 1.1, CritChance: 0.12, SpecialEffect: "lifesteal"}, {Name: "Soul Reaver", Type: "sword", Rarity: RarityLegendary, Damage: 7, Speed: 1.1, CritChance: 0.12, SpecialEffect: "lifesteal"},
// Axes // Axes
{Name: "Rusty Axe", Type: "axe", Rarity: RarityCommon, Damage: 12, Speed: 0.7, CritChance: 0.02}, {Name: "Rusty Axe", Type: "axe", Rarity: RarityCommon, Damage: 12, Speed: 0.7, CritChance: 0.02},
{Name: "Battle Axe", Type: "axe", Rarity: RarityUncommon, Damage: 18, Speed: 0.7, CritChance: 0.04}, {Name: "Battle Axe", Type: "axe", Rarity: RarityUncommon, Damage: 12, Speed: 0.7, CritChance: 0.04},
{Name: "War Axe", Type: "axe", Rarity: RarityRare, Damage: 25, Speed: 0.75, CritChance: 0.06}, {Name: "War Axe", Type: "axe", Rarity: RarityRare, Damage: 12, Speed: 0.75, CritChance: 0.06},
{Name: "Infernal Axe", Type: "axe", Rarity: RarityEpic, Damage: 35, Speed: 0.75, CritChance: 0.08}, {Name: "Infernal Axe", Type: "axe", Rarity: RarityEpic, Damage: 12, Speed: 0.75, CritChance: 0.08},
{Name: "Godslayer's Edge", Type: "axe", Rarity: RarityLegendary, Damage: 50, Speed: 0.8, CritChance: 0.10, SpecialEffect: "splash"}, {Name: "Godslayer's Edge", Type: "axe", Rarity: RarityLegendary, Damage: 12, Speed: 0.8, CritChance: 0.10, SpecialEffect: "splash"},
} }
// legacyArmorEntry holds armor catalog data for building the GearCatalog. // legacyArmorEntry holds armor catalog data for building the GearCatalog.
@ -222,28 +239,28 @@ type legacyArmorEntry struct {
} }
var legacyArmors = []legacyArmorEntry{ var legacyArmors = []legacyArmorEntry{
// Light armor // Light armor — same base Defense per class; rarity scaling via M(rarity) in ScalePrimary.
{Name: "Leather Armor", Type: "light", Rarity: RarityCommon, Defense: 3, SpeedModifier: 1.05, AgilityBonus: 3}, {Name: "Leather Armor", Type: "light", Rarity: RarityCommon, Defense: 2, SpeedModifier: 1.05, AgilityBonus: 3},
{Name: "Ranger's Vest", Type: "light", Rarity: RarityUncommon, Defense: 5, SpeedModifier: 1.08, AgilityBonus: 5}, {Name: "Ranger's Vest", Type: "light", Rarity: RarityUncommon, Defense: 2, SpeedModifier: 1.08, AgilityBonus: 5},
{Name: "Shadow Cloak", Type: "light", Rarity: RarityRare, Defense: 8, SpeedModifier: 1.10, AgilityBonus: 8, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"}, {Name: "Shadow Cloak", Type: "light", Rarity: RarityRare, Defense: 2, SpeedModifier: 1.10, AgilityBonus: 8, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"},
{Name: "Phantom Garb", Type: "light", Rarity: RarityEpic, Defense: 12, SpeedModifier: 1.12, AgilityBonus: 12, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"}, {Name: "Phantom Garb", Type: "light", Rarity: RarityEpic, Defense: 2, SpeedModifier: 1.12, AgilityBonus: 12, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"},
{Name: "Whisper of the Void", Type: "light", Rarity: RarityLegendary, Defense: 16, SpeedModifier: 1.15, AgilityBonus: 18, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"}, {Name: "Whisper of the Void", Type: "light", Rarity: RarityLegendary, Defense: 2, SpeedModifier: 1.15, AgilityBonus: 18, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"},
// Medium armor // Medium armor
{Name: "Chainmail", Type: "medium", Rarity: RarityCommon, Defense: 7, SpeedModifier: 1.0}, {Name: "Chainmail", Type: "medium", Rarity: RarityCommon, Defense: 4, SpeedModifier: 1.0},
{Name: "Reinforced Mail", Type: "medium", Rarity: RarityUncommon, Defense: 10, SpeedModifier: 1.0}, {Name: "Reinforced Mail", Type: "medium", Rarity: RarityUncommon, Defense: 4, SpeedModifier: 1.0},
{Name: "Battle Armor", Type: "medium", Rarity: RarityRare, Defense: 15, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"}, {Name: "Battle Armor", Type: "medium", Rarity: RarityRare, Defense: 4, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"},
{Name: "Royal Guard", Type: "medium", Rarity: RarityEpic, Defense: 22, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"}, {Name: "Royal Guard", Type: "medium", Rarity: RarityEpic, Defense: 4, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"},
{Name: "Crown of Eternity", Type: "medium", Rarity: RarityLegendary, Defense: 30, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"}, {Name: "Crown of Eternity", Type: "medium", Rarity: RarityLegendary, Defense: 4, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"},
// Heavy armor // Heavy armor
{Name: "Iron Plate", Type: "heavy", Rarity: RarityCommon, Defense: 14, SpeedModifier: 0.7, AgilityBonus: -3}, {Name: "Iron Plate", Type: "heavy", Rarity: RarityCommon, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -3},
{Name: "Steel Plate", Type: "heavy", Rarity: RarityUncommon, Defense: 20, SpeedModifier: 0.7, AgilityBonus: -3}, {Name: "Steel Plate", Type: "heavy", Rarity: RarityUncommon, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -3},
{Name: "Fortress Armor", Type: "heavy", Rarity: RarityRare, Defense: 28, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"}, {Name: "Fortress Armor", Type: "heavy", Rarity: RarityRare, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"},
{Name: "Dragon Scale", Type: "heavy", Rarity: RarityEpic, Defense: 38, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"}, {Name: "Dragon Scale", Type: "heavy", Rarity: RarityEpic, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"},
{Name: "Dragon Slayer", Type: "heavy", Rarity: RarityLegendary, Defense: 50, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"}, {Name: "Dragon Slayer", Type: "heavy", Rarity: RarityLegendary, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"},
// Ancient Guardian's Set // Ancient Guardian's Set
{Name: "Guardian's Plate", Type: "heavy", Rarity: RarityRare, Defense: 30, SpeedModifier: 0.7, AgilityBonus: 2, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"}, {Name: "Guardian's Plate", Type: "heavy", Rarity: RarityRare, Defense: 10, SpeedModifier: 0.7, AgilityBonus: 2, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"},
{Name: "Guardian's Bastion", Type: "heavy", Rarity: RarityEpic, Defense: 42, SpeedModifier: 0.7, AgilityBonus: 4, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"}, {Name: "Guardian's Bastion", Type: "heavy", Rarity: RarityEpic, Defense: 10, SpeedModifier: 0.7, AgilityBonus: 4, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"},
{Name: "Ancient Guardian's Aegis", Type: "heavy", Rarity: RarityLegendary, Defense: 55, SpeedModifier: 0.7, AgilityBonus: 6, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"}, {Name: "Ancient Guardian's Aegis", Type: "heavy", Rarity: RarityLegendary, Defense: 10, SpeedModifier: 0.7, AgilityBonus: 6, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"},
} }
// legacyEquipmentFamily is the template used by the old equipment system. // legacyEquipmentFamily is the template used by the old equipment system.

@ -7,33 +7,44 @@ import (
"github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/tuning"
) )
// IlvlFactor returns L(ilvl) = 1 + 0.03 * max(0, ilvl - 1) per spec section 6.4. // IlvlFactor returns L(ilvl) = pow(mult, max(0, ilvl-1)) per spec section 6.4.
func IlvlFactor(ilvl int) float64 { func IlvlFactor(ilvl int) float64 {
d := ilvl - 1 d := ilvl - 1
if d < 0 { if d < 0 {
d = 0 d = 0
} }
return 1.0 + tuning.Get().IlvlFactorSlope*float64(d) mult := tuning.Get().IlvlPerLevelMultiplier
if mult <= 0 {
mult = tuning.DefaultValues().IlvlPerLevelMultiplier
}
return math.Pow(mult, float64(d))
} }
// RarityMultiplier returns M(rarity) per spec section 6.4.2. // RarityMultiplier returns M(rarity) per spec section 6.4.2.
func RarityMultiplier(rarity Rarity) float64 { func RarityMultiplier(rarity Rarity) float64 {
switch rarity { switch rarity {
case RarityCommon: case RarityCommon:
return tuning.Get().RarityMultiplierCommon return safeMultiplier(tuning.Get().RarityMultiplierCommon, tuning.DefaultValues().RarityMultiplierCommon)
case RarityUncommon: case RarityUncommon:
return tuning.Get().RarityMultiplierUncommon return safeMultiplier(tuning.Get().RarityMultiplierUncommon, tuning.DefaultValues().RarityMultiplierUncommon)
case RarityRare: case RarityRare:
return tuning.Get().RarityMultiplierRare return safeMultiplier(tuning.Get().RarityMultiplierRare, tuning.DefaultValues().RarityMultiplierRare)
case RarityEpic: case RarityEpic:
return tuning.Get().RarityMultiplierEpic return safeMultiplier(tuning.Get().RarityMultiplierEpic, tuning.DefaultValues().RarityMultiplierEpic)
case RarityLegendary: case RarityLegendary:
return tuning.Get().RarityMultiplierLegendary return safeMultiplier(tuning.Get().RarityMultiplierLegendary, tuning.DefaultValues().RarityMultiplierLegendary)
default: default:
return 1.00 return 1.00
} }
} }
func safeMultiplier(value float64, fallback float64) float64 {
if value > 0 {
return value
}
return fallback
}
// ScalePrimary computes primaryOut = round(basePrimary * L(ilvl) * M(rarity)). // ScalePrimary computes primaryOut = round(basePrimary * L(ilvl) * M(rarity)).
func ScalePrimary(basePrimary int, ilvl int, rarity Rarity) int { func ScalePrimary(basePrimary int, ilvl int, rarity Rarity) int {
return int(math.Round(float64(basePrimary) * IlvlFactor(ilvl) * RarityMultiplier(rarity))) return int(math.Round(float64(basePrimary) * IlvlFactor(ilvl) * RarityMultiplier(rarity)))

@ -14,9 +14,9 @@ type Values struct {
EncounterCooldownBaseMs int64 `json:"encounterCooldownBaseMs"` EncounterCooldownBaseMs int64 `json:"encounterCooldownBaseMs"`
EncounterActivityBase float64 `json:"encounterActivityBase"` EncounterActivityBase float64 `json:"encounterActivityBase"`
BaseMoveSpeed float64 `json:"baseMoveSpeed"` BaseMoveSpeed float64 `json:"baseMoveSpeed"`
MovementTickRateMs int64 `json:"movementTickRateMs"` MovementTickRateMs int64 `json:"movementTickRateMs"`
PositionSyncRateMs int64 `json:"positionSyncRateMs"` PositionSyncRateMs int64 `json:"positionSyncRateMs"`
TownRestMinMs int64 `json:"townRestMinMs"` TownRestMinMs int64 `json:"townRestMinMs"`
TownRestMaxMs int64 `json:"townRestMaxMs"` TownRestMaxMs int64 `json:"townRestMaxMs"`
@ -29,12 +29,12 @@ type Values struct {
// TownNPCInteractChance: offline only — after reaching an NPC, probability of “using” services // TownNPCInteractChance: offline only — after reaching an NPC, probability of “using” services
// (buy potion, full heal, accept a quest) instead of walking past. // (buy potion, full heal, accept a quest) instead of walking past.
TownNPCInteractChance float64 `json:"townNpcInteractChance"` TownNPCInteractChance float64 `json:"townNpcInteractChance"`
TownNPCRollMinMs int64 `json:"townNpcRollMinMs"` TownNPCRollMinMs int64 `json:"townNpcRollMinMs"`
TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"`
TownNPCRetryMs int64 `json:"townNpcRetryMs"` TownNPCRetryMs int64 `json:"townNpcRetryMs"`
TownNPCPauseMs int64 `json:"townNpcPauseMs"` TownNPCPauseMs int64 `json:"townNpcPauseMs"`
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"`
// TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach). // TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach).
TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"` TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"`
// TownAfterNPCRestChance: after the NPC tour, at town center — probability of a full town rest // TownAfterNPCRestChance: after the NPC tour, at town center — probability of a full town rest
@ -58,17 +58,17 @@ type Values struct {
GoldLootScale float64 `json:"goldLootScale"` GoldLootScale float64 `json:"goldLootScale"`
// GoldDropChance is P(at least one gold line) per kill before luck; rolled first, then rarity/amount. // GoldDropChance is P(at least one gold line) per kill before luck; rolled first, then rarity/amount.
GoldDropChance float64 `json:"goldDropChance"` GoldDropChance float64 `json:"goldDropChance"`
PotionDropChance float64 `json:"potionDropChance"` PotionDropChance float64 `json:"potionDropChance"`
EquipmentDropBase float64 `json:"equipmentDropBase"` EquipmentDropBase float64 `json:"equipmentDropBase"`
GoldCommonMin int64 `json:"goldCommonMin"` GoldCommonMin int64 `json:"goldCommonMin"`
GoldCommonMax int64 `json:"goldCommonMax"` GoldCommonMax int64 `json:"goldCommonMax"`
GoldUncommonMin int64 `json:"goldUncommonMin"` GoldUncommonMin int64 `json:"goldUncommonMin"`
GoldUncommonMax int64 `json:"goldUncommonMax"` GoldUncommonMax int64 `json:"goldUncommonMax"`
GoldRareMin int64 `json:"goldRareMin"` GoldRareMin int64 `json:"goldRareMin"`
GoldRareMax int64 `json:"goldRareMax"` GoldRareMax int64 `json:"goldRareMax"`
GoldEpicMin int64 `json:"goldEpicMin"` GoldEpicMin int64 `json:"goldEpicMin"`
GoldEpicMax int64 `json:"goldEpicMax"` GoldEpicMax int64 `json:"goldEpicMax"`
GoldLegendaryMin int64 `json:"goldLegendaryMin"` GoldLegendaryMin int64 `json:"goldLegendaryMin"`
GoldLegendaryMax int64 `json:"goldLegendaryMax"` GoldLegendaryMax int64 `json:"goldLegendaryMax"`
@ -88,25 +88,25 @@ type Values struct {
// QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours).
QuestOfferRefreshHours int `json:"questOfferRefreshHours"` QuestOfferRefreshHours int `json:"questOfferRefreshHours"`
CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageScale float64 `json:"combatDamageScale"`
CombatDamageRollMin float64 `json:"combatDamageRollMin"` CombatDamageRollMin float64 `json:"combatDamageRollMin"`
CombatDamageRollMax float64 `json:"combatDamageRollMax"` CombatDamageRollMax float64 `json:"combatDamageRollMax"`
// EnemyCombatDamageScale / Roll* apply only when an enemy hits the hero (not hero→enemy). // EnemyCombatDamageScale / Roll* apply only when an enemy hits the hero (not hero→enemy).
EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"` EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"`
EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"` EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"`
EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"` EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"`
// EnemyAttackIntervalMultiplier applies only to enemy attack spacing (hero cadence unchanged). Pair with enemy damage scale for similar incoming DPS. // EnemyAttackIntervalMultiplier applies only to enemy attack spacing (hero cadence unchanged). Pair with enemy damage scale for similar incoming DPS.
EnemyAttackIntervalMultiplier float64 `json:"enemyAttackIntervalMultiplier"` EnemyAttackIntervalMultiplier float64 `json:"enemyAttackIntervalMultiplier"`
EnemyDodgeChance float64 `json:"enemyDodgeChance"` EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
HeroCritChanceCap float64 `json:"heroCritChanceCap"` HeroCritChanceCap float64 `json:"heroCritChanceCap"`
HeroBlockChancePerDefense float64 `json:"heroBlockChancePerDefense"` HeroBlockChancePerDefense float64 `json:"heroBlockChancePerDefense"`
HeroBlockChanceCap float64 `json:"heroBlockChanceCap"` HeroBlockChanceCap float64 `json:"heroBlockChanceCap"`
EnemyBurstEveryN int64 `json:"enemyBurstEveryN"` EnemyBurstEveryN int64 `json:"enemyBurstEveryN"`
EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"` EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"`
EnemyChainEveryN int64 `json:"enemyChainEveryN"` EnemyChainEveryN int64 `json:"enemyChainEveryN"`
EnemyChainMultiplier float64 `json:"enemyChainMultiplier"` EnemyChainMultiplier float64 `json:"enemyChainMultiplier"`
DebuffProcBurn float64 `json:"debuffProcBurn"` DebuffProcBurn float64 `json:"debuffProcBurn"`
DebuffProcPoison float64 `json:"debuffProcPoison"` DebuffProcPoison float64 `json:"debuffProcPoison"`
@ -115,21 +115,21 @@ type Values struct {
DebuffProcFreeze float64 `json:"debuffProcFreeze"` DebuffProcFreeze float64 `json:"debuffProcFreeze"`
DebuffProcIceSlow float64 `json:"debuffProcIceSlow"` DebuffProcIceSlow float64 `json:"debuffProcIceSlow"`
EnemyRegenDefault float64 `json:"enemyRegenDefault"` EnemyRegenDefault float64 `json:"enemyRegenDefault"`
EnemyRegenSkeletonKing float64 `json:"enemyRegenSkeletonKing"` EnemyRegenSkeletonKing float64 `json:"enemyRegenSkeletonKing"`
EnemyRegenForestWarden float64 `json:"enemyRegenForestWarden"` EnemyRegenForestWarden float64 `json:"enemyRegenForestWarden"`
EnemyRegenBattleLizard float64 `json:"enemyRegenBattleLizard"` EnemyRegenBattleLizard float64 `json:"enemyRegenBattleLizard"`
SummonCycleSeconds int64 `json:"summonCycleSeconds"` SummonCycleSeconds int64 `json:"summonCycleSeconds"`
SummonDamageDivisor int64 `json:"summonDamageDivisor"` SummonDamageDivisor int64 `json:"summonDamageDivisor"`
LuckBuffMultiplier float64 `json:"luckBuffMultiplier"` LuckBuffMultiplier float64 `json:"luckBuffMultiplier"`
MinAttackIntervalMs int64 `json:"minAttackIntervalMs"` MinAttackIntervalMs int64 `json:"minAttackIntervalMs"`
CombatPaceMultiplier int64 `json:"combatPaceMultiplier"` CombatPaceMultiplier int64 `json:"combatPaceMultiplier"`
PotionHealPercent float64 `json:"potionHealPercent"` PotionHealPercent float64 `json:"potionHealPercent"`
PotionAutoUseThreshold float64 `json:"potionAutoUseThreshold"` PotionAutoUseThreshold float64 `json:"potionAutoUseThreshold"`
ReviveHpPercent float64 `json:"reviveHpPercent"` ReviveHpPercent float64 `json:"reviveHpPercent"`
AutoReviveAfterMs int64 `json:"autoReviveAfterMs"` AutoReviveAfterMs int64 `json:"autoReviveAfterMs"`
XPCurveEarlyBase float64 `json:"xpCurveEarlyBase"` XPCurveEarlyBase float64 `json:"xpCurveEarlyBase"`
XPCurveEarlyScale float64 `json:"xpCurveEarlyScale"` XPCurveEarlyScale float64 `json:"xpCurveEarlyScale"`
@ -140,35 +140,37 @@ type Values struct {
LevelUpHPEvery int64 `json:"levelUpHpEvery"` LevelUpHPEvery int64 `json:"levelUpHpEvery"`
// LevelUpHpBase is added to MaxHP together with Constitution/6 when LevelUpHPEvery fires (spec §3.3 cadence). // LevelUpHpBase is added to MaxHP together with Constitution/6 when LevelUpHPEvery fires (spec §3.3 cadence).
LevelUpHpBase int `json:"levelUpHpBase"` LevelUpHpBase int `json:"levelUpHpBase"`
LevelUpATKEvery int64 `json:"levelUpAtkEvery"` LevelUpATKEvery int64 `json:"levelUpAtkEvery"`
LevelUpDEFEvery int64 `json:"levelUpDefEvery"` LevelUpDEFEvery int64 `json:"levelUpDefEvery"`
LevelUpSTREvery int64 `json:"levelUpStrEvery"` LevelUpSTREvery int64 `json:"levelUpStrEvery"`
LevelUpCONEvery int64 `json:"levelUpConEvery"` LevelUpCONEvery int64 `json:"levelUpConEvery"`
LevelUpAGIEvery int64 `json:"levelUpAgiEvery"` LevelUpAGIEvery int64 `json:"levelUpAgiEvery"`
LevelUpLUCKEvery int64 `json:"levelUpLuckEvery"` LevelUpLUCKEvery int64 `json:"levelUpLuckEvery"`
AgilityCoef float64 `json:"agilityCoef"` AgilityCoef float64 `json:"agilityCoef"`
MaxAttackSpeed float64 `json:"maxAttackSpeed"` MaxAttackSpeed float64 `json:"maxAttackSpeed"`
MinAttackSpeed float64 `json:"minAttackSpeed"` MinAttackSpeed float64 `json:"minAttackSpeed"`
IlvlFactorSlope float64 `json:"ilvlFactorSlope"` // IlvlFactorSlope is deprecated; kept for backward-compatible payloads.
RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"` IlvlFactorSlope float64 `json:"ilvlFactorSlope"`
RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"` IlvlPerLevelMultiplier float64 `json:"ilvlPerLevelMultiplier"`
RarityMultiplierRare float64 `json:"rarityMultiplierRare"` RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"`
RarityMultiplierEpic float64 `json:"rarityMultiplierEpic"` RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"`
RarityMultiplierLegendary float64 `json:"rarityMultiplierLegendary"` RarityMultiplierRare float64 `json:"rarityMultiplierRare"`
RollIlvlEliteBaseChance float64 `json:"rollIlvlEliteBaseChance"` RarityMultiplierEpic float64 `json:"rarityMultiplierEpic"`
RarityMultiplierLegendary float64 `json:"rarityMultiplierLegendary"`
RollIlvlEliteBaseChance float64 `json:"rollIlvlEliteBaseChance"`
RollIlvlElitePlusOneChance float64 `json:"rollIlvlElitePlusOneChance"` RollIlvlElitePlusOneChance float64 `json:"rollIlvlElitePlusOneChance"`
BuffChargePeriodMs int64 `json:"buffChargePeriodMs"` BuffChargePeriodMs int64 `json:"buffChargePeriodMs"`
FreeBuffActivationsPerPeriod int64 `json:"freeBuffActivationsPerPeriod"` FreeBuffActivationsPerPeriod int64 `json:"freeBuffActivationsPerPeriod"`
SubscriptionDurationMs int64 `json:"subscriptionDurationMs"` SubscriptionDurationMs int64 `json:"subscriptionDurationMs"`
SubscriptionWeeklyPriceRUB int64 `json:"subscriptionWeeklyPriceRub"` SubscriptionWeeklyPriceRUB int64 `json:"subscriptionWeeklyPriceRub"`
BuffRefillPriceRUB int64 `json:"buffRefillPriceRub"` BuffRefillPriceRUB int64 `json:"buffRefillPriceRub"`
ResurrectionRefillPriceRUB int64 `json:"resurrectionRefillPriceRub"` ResurrectionRefillPriceRUB int64 `json:"resurrectionRefillPriceRub"`
MaxRevivesFree int64 `json:"maxRevivesFree"` MaxRevivesFree int64 `json:"maxRevivesFree"`
MaxRevivesSubscriber int64 `json:"maxRevivesSubscriber"` MaxRevivesSubscriber int64 `json:"maxRevivesSubscriber"`
EnemyScaleBandHP float64 `json:"enemyScaleBandHp"` EnemyScaleBandHP float64 `json:"enemyScaleBandHp"`
EnemyScaleOvercapHP float64 `json:"enemyScaleOvercapHp"` EnemyScaleOvercapHP float64 `json:"enemyScaleOvercapHp"`
@ -231,26 +233,26 @@ type Values struct {
func DefaultValues() Values { func DefaultValues() Values {
return Values{ return Values{
EncounterCooldownBaseMs: 12_000, EncounterCooldownBaseMs: 12_000,
EncounterActivityBase: 0.035, EncounterActivityBase: 0.035,
BaseMoveSpeed: 2.0, BaseMoveSpeed: 2.0,
MovementTickRateMs: 500, MovementTickRateMs: 500,
PositionSyncRateMs: 10_000, PositionSyncRateMs: 10_000,
TownRestMinMs: 5 * 60 * 1000, TownRestMinMs: 5 * 60 * 1000,
TownRestMaxMs: 20 * 60 * 1000, TownRestMaxMs: 20 * 60 * 1000,
TownRestHPPerS: 0.002, TownRestHPPerS: 0.002,
TownArrivalRadius: 0.5, TownArrivalRadius: 0.5,
TownNPCVisitChance: 0.78, TownNPCVisitChance: 0.78,
TownNPCApproachChance: 1.0, TownNPCApproachChance: 1.0,
TownNPCInteractChance: 0.65, TownNPCInteractChance: 0.65,
TownNPCRollMinMs: 800, TownNPCRollMinMs: 800,
TownNPCRollMaxMs: 2600, TownNPCRollMaxMs: 2600,
TownNPCRetryMs: 450, TownNPCRetryMs: 450,
TownNPCPauseMs: 30_000, TownNPCPauseMs: 30_000,
TownNPCLogIntervalMs: 5_000, TownNPCLogIntervalMs: 5_000,
TownNPCWalkSpeed: 3.0, TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65, TownNPCStandoffWorld: 0.65,
TownAfterNPCRestChance: 0.78, TownAfterNPCRestChance: 0.78,
WanderingMerchantPromptTimeoutMs: 15_000, WanderingMerchantPromptTimeoutMs: 15_000,
MerchantCostBase: 20, MerchantCostBase: 20,
MerchantCostPerLevel: 5, MerchantCostPerLevel: 5,
@ -291,108 +293,109 @@ func DefaultValues() Values {
QuestOffersPerNPC: 2, QuestOffersPerNPC: 2,
QuestOfferRefreshHours: 2, QuestOfferRefreshHours: 2,
// combatDamageScale tracks combatPaceMultiplier: DPS ~ scale/pace, so halving pace halves scale to keep fight length. // combatDamageScale tracks combatPaceMultiplier: DPS ~ scale/pace, so halving pace halves scale to keep fight length.
CombatDamageScale: 0.216, CombatDamageScale: 0.216,
CombatDamageRollMin: 0.60, CombatDamageRollMin: 0.60,
CombatDamageRollMax: 1.10, CombatDamageRollMax: 1.10,
EnemyCombatDamageScale: DefaultEnemyCombatDamageScale, EnemyCombatDamageScale: DefaultEnemyCombatDamageScale,
EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin, EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin,
EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax, EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax,
EnemyAttackIntervalMultiplier: DefaultEnemyAttackIntervalMultiplier, EnemyAttackIntervalMultiplier: DefaultEnemyAttackIntervalMultiplier,
EnemyDodgeChance: 0.14, EnemyDodgeChance: 0.14,
EnemyCriticalMinChance: 0.10, EnemyCriticalMinChance: 0.10,
EnemyCritChanceCap: 0.20, EnemyCritChanceCap: 0.20,
HeroCritChanceCap: 0.12, HeroCritChanceCap: 0.12,
HeroBlockChancePerDefense: 0.0025, HeroBlockChancePerDefense: 0.0025,
HeroBlockChanceCap: 0.20, HeroBlockChanceCap: 0.20,
EnemyBurstEveryN: 3, EnemyBurstEveryN: 3,
EnemyBurstMultiplier: 1.5, EnemyBurstMultiplier: 1.5,
EnemyChainEveryN: 6, EnemyChainEveryN: 6,
EnemyChainMultiplier: 3.0, EnemyChainMultiplier: 3.0,
DebuffProcBurn: 0.18, DebuffProcBurn: 0.18,
DebuffProcPoison: 0.10, DebuffProcPoison: 0.10,
DebuffProcSlow: 0.25, DebuffProcSlow: 0.25,
DebuffProcStun: 0.25, DebuffProcStun: 0.25,
DebuffProcFreeze: 0.20, DebuffProcFreeze: 0.20,
DebuffProcIceSlow: 0.20, DebuffProcIceSlow: 0.20,
EnemyRegenDefault: DefaultEnemyRegenDefault, EnemyRegenDefault: DefaultEnemyRegenDefault,
EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing, EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing,
EnemyRegenForestWarden: DefaultEnemyRegenForestWarden, EnemyRegenForestWarden: DefaultEnemyRegenForestWarden,
EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard, EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard,
SummonCycleSeconds: 18, SummonCycleSeconds: 18,
SummonDamageDivisor: 10, SummonDamageDivisor: 10,
LuckBuffMultiplier: 1.75, LuckBuffMultiplier: 1.75,
MinAttackIntervalMs: 250, MinAttackIntervalMs: 250,
CombatPaceMultiplier: 14, CombatPaceMultiplier: 14,
PotionHealPercent: 0.30, PotionHealPercent: 0.30,
PotionAutoUseThreshold: 0.30, PotionAutoUseThreshold: 0.30,
ReviveHpPercent: 0.50, ReviveHpPercent: 0.50,
AutoReviveAfterMs: int64(time.Hour / time.Millisecond), AutoReviveAfterMs: int64(time.Hour / time.Millisecond),
XPCurveEarlyBase: 100, XPCurveEarlyBase: 100,
XPCurveEarlyScale: 1.5, XPCurveEarlyScale: 1.5,
XPCurveMidBase: 2947, XPCurveMidBase: 2947,
XPCurveMidScale: 1.15, XPCurveMidScale: 1.15,
XPCurveLateBase: 48232, XPCurveLateBase: 48232,
XPCurveLateScale: 1.10, XPCurveLateScale: 1.10,
LevelUpHPEvery: 4, LevelUpHPEvery: 4,
LevelUpHpBase: 10, LevelUpHpBase: 10,
LevelUpATKEvery: 3, LevelUpATKEvery: 3,
LevelUpDEFEvery: 3, LevelUpDEFEvery: 3,
LevelUpSTREvery: 2, LevelUpSTREvery: 2,
LevelUpCONEvery: 2, LevelUpCONEvery: 2,
LevelUpAGIEvery: 2, LevelUpAGIEvery: 2,
LevelUpLUCKEvery: 5, LevelUpLUCKEvery: 5,
AgilityCoef: 0.03, AgilityCoef: 0.03,
MaxAttackSpeed: 4.0, MaxAttackSpeed: 4.0,
MinAttackSpeed: 0.1, MinAttackSpeed: 0.1,
IlvlFactorSlope: 0.03, IlvlFactorSlope: 0.03,
RarityMultiplierCommon: 1.00, IlvlPerLevelMultiplier: 1.10,
RarityMultiplierUncommon: 1.12, RarityMultiplierCommon: 1.00,
RarityMultiplierRare: 1.30, RarityMultiplierUncommon: 1.0877573,
RarityMultiplierEpic: 1.52, RarityMultiplierRare: 1.1832160,
RarityMultiplierLegendary: 1.78, RarityMultiplierEpic: 1.2870518,
RollIlvlEliteBaseChance: 0.4, RarityMultiplierLegendary: 1.40,
RollIlvlElitePlusOneChance: 0.4, RollIlvlEliteBaseChance: 0.4,
BuffChargePeriodMs: 24 * 60 * 60 * 1000, RollIlvlElitePlusOneChance: 0.4,
FreeBuffActivationsPerPeriod: 2, BuffChargePeriodMs: 24 * 60 * 60 * 1000,
SubscriptionDurationMs: 7 * 24 * 60 * 60 * 1000, FreeBuffActivationsPerPeriod: 2,
SubscriptionWeeklyPriceRUB: 299, SubscriptionDurationMs: 7 * 24 * 60 * 60 * 1000,
BuffRefillPriceRUB: 50, SubscriptionWeeklyPriceRUB: 299,
ResurrectionRefillPriceRUB: 150, BuffRefillPriceRUB: 50,
MaxRevivesFree: 1, ResurrectionRefillPriceRUB: 150,
MaxRevivesSubscriber: 2, MaxRevivesFree: 1,
EnemyScaleBandHP: 0.062, MaxRevivesSubscriber: 2,
EnemyScaleOvercapHP: 0.031, EnemyScaleBandHP: 0.062,
EnemyScaleBandATK: 0.044, EnemyScaleOvercapHP: 0.031,
EnemyScaleOvercapATK: 0.024, EnemyScaleBandATK: 0.044,
EnemyScaleBandDEF: 0.038, EnemyScaleOvercapATK: 0.024,
EnemyScaleOvercapDEF: 0.020, EnemyScaleBandDEF: 0.038,
EnemyScaleBandXP: 0.05, EnemyScaleOvercapDEF: 0.020,
EnemyScaleOvercapXP: 0.03, EnemyScaleBandXP: 0.05,
EnemyScaleBandGold: 0.05, EnemyScaleOvercapXP: 0.03,
EnemyScaleOvercapGold: 0.025, EnemyScaleBandGold: 0.05,
AutoEquipThreshold: 1.03, EnemyScaleOvercapGold: 0.025,
LootHistoryLimit: 50, AutoEquipThreshold: 1.03,
LootHistoryLimit: 50,
AdventureStartChance: 0.0001,
AdventureCooldownMs: 300_000, AdventureStartChance: 0.0001,
AdventureOutDurationMs: 20_000, AdventureCooldownMs: 300_000,
AdventureWildMinMs: 560_000, AdventureOutDurationMs: 20_000,
AdventureWildMaxMs: 2_960_000, AdventureWildMinMs: 560_000,
AdventureReturnDurationMs: 20_000, AdventureWildMaxMs: 2_960_000,
AdventureDepthWorldUnits: 40.0, AdventureReturnDurationMs: 20_000,
AdventureEncounterCooldownMs: 6_000, AdventureDepthWorldUnits: 40.0,
AdventureReturnEncounterEnabled: true, AdventureEncounterCooldownMs: 6_000,
AdventureReturnWildnessMin: 0.35, AdventureReturnEncounterEnabled: true,
AdventureReturnWildnessMin: 0.35,
LowHpThreshold: 0.25,
RoadsideRestExitHp: 0.70, LowHpThreshold: 0.25,
AdventureRestTargetHp: 0.70, RoadsideRestExitHp: 0.70,
RoadsideRestMinMs: 240_000, AdventureRestTargetHp: 0.70,
RoadsideRestMaxMs: 600_000, RoadsideRestMinMs: 240_000,
RoadsideRestHpPerS: 0.003, RoadsideRestMaxMs: 600_000,
AdventureRestHpPerS: 0.004, RoadsideRestHpPerS: 0.003,
AdventureRestHpPerS: 0.004,
RoadsideRestDepthWorldUnits: 12.0,
RoadsideRestDepthWorldUnits: 12.0,
} }
} }
@ -505,4 +508,3 @@ func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) e
Set(next) Set(next)
return nil return nil
} }

@ -2,6 +2,75 @@
Утилита `backend/cmd/balanceall` прогоняет **архетипы монстров** из **PostgreSQL** (таблица `enemies`) через то же ядро боя, что онлайн/оффлайн (`game.ResolveCombatToEndWithDuration`), на **референсном герое** (`game.NewReferenceHeroForBalance`). Утилита `backend/cmd/balanceall` прогоняет **архетипы монстров** из **PostgreSQL** (таблица `enemies`) через то же ядро боя, что онлайн/оффлайн (`game.ResolveCombatToEndWithDuration`), на **референсном герое** (`game.NewReferenceHeroForBalance`).
## Режим метрик без подбора врагов
Если нужно **оценивать силу предметов**, не меняя таблицу `enemies`, используйте флаг:
- `-adjust-enemies=false`
В этом режиме утилита **не подбирает** `hp/attack` у монстров и **не печатает** `UPDATE` SQL. Она только прогоняет сетку и печатает метрики (медианы длительности, HP героя, win rate), что удобно для подгонки формулы `ScalePrimary`, `refGearBase` и параметров лута.
Пример:
```bash
cd backend
set DATABASE_URL=postgres://...
go run ./cmd/balanceall -adjust-enemies=false
```
## Проверка баланса шмота (`-gear-check`)
Сравнивает **референсного героя в common** (`ReferenceGearBaseline`: меч + средняя броня) и **в legendary** (`ReferenceGearMax`) на каждом уровне в полосе `min_level..max_level` выбранных шаблонов монстров. Для каждого уровня:
- **Скорость убийства:** медиана длительности победы в лучшем шмоте не должна быть короче медианы в базовом более чем на `-gear-check-max-speedup-pct` процентов (по умолчанию **20%** — то есть `dur_max >= dur_baseline × 0.80`).
- **HP после боя:** медиана доли HP героя после побед в лучшем шмоте не выше `-gear-check-max-hero-hp-pct` (по умолчанию **75%**).
При нарушении печатаются строки `FAIL`, процесс завершаётся с кодом **1**. Подбор врагов и SQL не выполняются; сетка `-gear-variants` в этом режиме не используется.
Дополнительно:
- **`-gear-check-level-min` / `-gear-check-level-max`** — пересечь полосу уровней шаблона с нужным диапазоном (по умолчанию `0` = взять `min_level` / `max_level` шаблона). Удобно для калибровки на «типичном» уровне или mid/late без прогона всей полосы.
- **`-gear-check-strict`** — если у baseline нет медианы длительности победы (нет побед), считать **FAIL** вместо `SKIP`, и пустое пересечение уровней тоже **FAIL**. Нужен, чтобы не получать ложный `PASSED` без реальных сравнений.
Пример — один шаблон, уровни 515, строгий режим:
```bash
go run ./cmd/balanceall -gear-check -enemy-type wolf_l5_5_meadow -gear-check-level-min 5 -gear-check-level-max 15 -gear-check-strict -iterations 200
```
Пример — все строки БД с семейством `wolf` (колонка `enemies.archetype`):
```bash
go run ./cmd/balanceall -gear-check -enemy-archetype wolf -iterations 200
```
Список значений `archetype` из БД: **`go run ./cmd/balanceall -list-archetypes`**.
### Локальный оверлей каталога (`-gear-overlay`)
Каталог предметов сначала загружается из БД (`weapons`, `armor`, `equipment_items`) или из **встроенного** набора (`-gear-base=code`), затем в памяти накладывается JSON с частичными полями `GearFamily` — по ключу **имя предмета** или **`slot:name`** (например `main_hand:Soul Reaver`, `chest:Chainmail`). Поля: `basePrimary`, `speedModifier`, `baseCrit`, `agilityBonus`, `statType`. Так можно крутить баланс **без** записи в БД, по аналогии с `-config` для монстров.
Пример:
```bash
go run ./cmd/balanceall -gear-check -enemy-type wolf_l2_2_forest -gear-check-level-min 2 -gear-check-level-max 2 ^
-gear-overlay ./cmd/balanceall/testdata/gear_overlay_balanced.json -iterations 200
```
### SQL после подбора (`-gear-print-sql`)
Когда значения в оверлее устраивают (`-gear-check` проходит), сгенерируйте `UPDATE` для `gear`, `weapons`, `armor` (и при необходимости `equipment_items` для не-слотов main_hand/chest):
```bash
go run ./cmd/balanceall -gear-overlay ./cmd/balanceall/testdata/gear_overlay_balanced.json -gear-print-sql
```
Команда завершает работу после вывода SQL. Перенесите результат в миграцию (см. пример `backend/migrations/000012_gear_balance_overlay.sql`).
### Калибровка без полного перебора
Выберите типичных монстров (`-enemy-type`, `-gear-check-level-*`) и итерируйте **JSON оверлей**, пока `-gear-check` не проходит; затем зафиксируйте изменения миграцией. Глобальные множители редкости остаются в `runtime_config` / `ScalePrimary`.
## Режим по умолчанию: сетка (`-grid`, по умолчанию `true`) ## Режим по умолчанию: сетка (`-grid`, по умолчанию `true`)
Для каждого архетипа строится сетка сценариев: Для каждого архетипа строится сетка сценариев:
@ -56,11 +125,12 @@ go build -o balanceall ./cmd/balanceall
## Область прогона ## Область прогона
- **Все архетипы из БД** — по умолчанию: строки из `enemies` в порядке `ORDER BY min_level, type` (как `ListEnemyRows`). - **Все шаблоны из БД** — по умолчанию: строки из `enemies` в порядке `ORDER BY min_level, archetype, type` (как `ListEnemyRows`).
- **Один монстр по `enemies.id`** — `-enemy-id <id>` (удобно после `go run ./cmd/balanceall -list-enemies`). - **Один шаблон по `enemies.id`** — `-enemy-id <id>` (удобно после `-list-enemies`).
- **Один архетип по строке `type`** — `-enemy-type <type>` (в таблице `enemies` это строка вроде `wolf`, не catalog id `enemy.wolf_forest`). - **Один шаблон по уникальному slug `type`** — `-enemy-type <type>` (например `wolf_l5_5_meadow`, не короткое имя `wolf`).
- **Все шаблоны с семейством `archetype`** — `-enemy-archetype <name>` (колонка `enemies.archetype`, напр. `wolf` — все волки из БД).
Нельзя одновременно задавать `-enemy-id` и `-enemy-type`. Нельзя одновременно задавать `-enemy-id` и `-enemy-type`. Если заданы **`-enemy-type` и `-enemy-archetype`**, slug должен принадлежать этому семейству (иначе ошибка). С **`-enemy-id`** можно указать **`-enemy-archetype`** для проверки согласованности строки в БД.
## JSON-оверлей (`-config`) ## JSON-оверлей (`-config`)
@ -93,7 +163,9 @@ go build -o balanceall ./cmd/balanceall
|------|----------------|--------| |------|----------------|--------|
| `-dsn` | `""` | Postgres DSN; если пусто — берётся `DATABASE_URL`. | | `-dsn` | `""` | Postgres DSN; если пусто — берётся `DATABASE_URL`. |
| `-enemy-id` | 0 | Только строка с этим `enemies.id`. | | `-enemy-id` | 0 | Только строка с этим `enemies.id`. |
| `-enemy-type` | `""` | Только архетип с этим `type`. | | `-enemy-type` | `""` | Только шаблон с этим `enemies.type` (slug). |
| `-enemy-archetype` | `""` | Все шаблоны с этим `enemies.archetype`, если не заданы `-enemy-id`/`-enemy-type`. |
| `-list-archetypes` | false | Список уникальных `enemies.archetype` и выход. |
| `-config` | `""` | Путь к JSON: частичные шаблоны по ключу `type`, поверх БД. | | `-config` | `""` | Путь к JSON: частичные шаблоны по ключу `type`, поверх БД. |
| `-grid` | `true` | Сетка уровней × шмот; `false` — legacy (один уровень, медианный шмот). | | `-grid` | `true` | Сетка уровней × шмот; `false` — legacy (один уровень, медианный шмот). |
| `-gear-variants` | 4 | Режим сетки: число профилей шмота на уровень (1 median + N1 rolled). | | `-gear-variants` | 4 | Режим сетки: число профилей шмота на уровень (1 median + N1 rolled). |
@ -110,6 +182,16 @@ go build -o balanceall ./cmd/balanceall
| `-list-enemies` | false | Список архетипов из БД (с колонкой `id`) и выход. | | `-list-enemies` | false | Список архетипов из БД (с колонкой `id`) и выход. |
| `-filter` | `""` | Подстрока для `-list-enemies`. | | `-filter` | `""` | Подстрока для `-list-enemies`. |
| `-limit` | 50 | Максимум строк для `-list-enemies` (1500). | | `-limit` | 50 | Максимум строк для `-list-enemies` (1500). |
| `-adjust-enemies` | `true` | Если `false` — только метрики, без подбора и без SQL. |
| `-gear-check` | `false` | Сравнение baseline vs legendary reference gear по уровням; `exit 1` при нарушении порогов. |
| `-gear-check-max-speedup-pct` | `20` | Макс. ускорение убийства с лучшим шмотом относительно baseline (%). |
| `-gear-check-max-hero-hp-pct` | `75` | Верхняя граница медианы HP героя на победах с лучшим шмотом (%). |
| `-gear-check-level-min` | `0` | Нижняя граница уровня для gear-check (`0` = `min_level` шаблона). |
| `-gear-check-level-max` | `0` | Верхняя граница уровня для gear-check (`0` = `max_level` шаблона). |
| `-gear-check-strict` | `false` | Нет побед у baseline / пустой диапазон уровней → `FAIL`. |
| `-gear-base` | `db` | `db` — каталог из БД; `code` — только встроенный каталог до оверлея. |
| `-gear-overlay` | `""` | JSON с патчами полей предметов поверх каталога (только память). |
| `-gear-print-sql` | `false` | Напечатать `UPDATE` для таблиц каталога и выйти (нужен `-gear-overlay`). |
Примеры: Примеры:
@ -134,6 +216,7 @@ go run ./cmd/balanceall -sql=false
- Загрузка из БД: `internal/storage/content_store.go` (`LoadEnemyTemplates`, `ListEnemyRows`). - Загрузка из БД: `internal/storage/content_store.go` (`LoadEnemyTemplates`, `ListEnemyRows`).
- Сетка: `cmd/balanceall/grid.go`. - Сетка: `cmd/balanceall/grid.go`.
- Проверка шмота: `cmd/balanceall/gear_check.go`.
- Одиночная симуляция: `cmd/balancesim` + `DATABASE_URL`. - Одиночная симуляция: `cmd/balancesim` + `DATABASE_URL`.
- Краткий снимок для контента: `docs/monster-catalog-balanced-v1.md`. - Краткий снимок для контента: `docs/monster-catalog-balanced-v1.md`.

@ -457,22 +457,22 @@ AutoHero — это idle/incremental RPG с изометрическим вид
Коэффициент (плавный рост от `ilvl`, без ступеней): Коэффициент (плавный рост от `ilvl`, без ступеней):
- `α = 0.03` - `base = 1.10`
- `L(ilvl) = 1 + α × max(0, ilvl 1)` - `L(ilvl) = base ^ max(0, ilvl 1)`
Примеры: `L(1) = 1.00`; `L(10) = 1.27`; `L(25) = 1.72`; `L(50) = 2.47`. Примеры: `L(1) = 1.00`; `L(10) ≈ 2.36`; `L(25) ≈ 9.85`.
#### 6.4.2 Множитель редкости #### 6.4.2 Множитель редкости
Одинаков для всех типов предметов, для воспроизводимости: Одинаков для всех типов предметов, для воспроизводимости. Якорь: **Legendary = +40% к Common** при том же `ilvl`. Базовые числа семейства в каталоге (`weapons.damage`, `armor.defense`, …) задаются **одинаковыми** для линейки типа; разброс редкости даёт только `M(rarity)` (без двойного масштабирования в БД).
| Редкость | `M(rarity)` | | Редкость | `M(rarity)` |
|----------|----------------| |----------|----------------|
| Common | `1.00` | | Common | `1.00` |
| Uncommon | `1.12` | | Uncommon | `1.0878` |
| Rare | `1.30` | | Rare | `1.1832` |
| Epic | `1.52` | | Epic | `1.2871` |
| Legendary | `1.78` | | Legendary | `1.40` |
#### 6.4.3 Первичные статы предмета (урон / защита от предмета) #### 6.4.3 Первичные статы предмета (урон / защита от предмета)
@ -484,15 +484,14 @@ primaryOut = round( basePrimary × L(ilvl) × M(rarity) )
`basePrimary` — целое из каталога для семейства предмета на «эталоне» `ilvl = 1`, `Common`. `basePrimary` — целое из каталога для семейства предмета на «эталоне» `ilvl = 1`, `Common`.
**Свойство баланса:** при тех же `basePrimary` возможны ситуации, когда **редкий предмет с меньшим `ilvl` даёт больше или столько же**, чем **обычный с большим `ilvl`**, потому что `M(rarity)` растёт быстрее, чем `L(ilvl)` в типичных диапазонах дропа. Примеры (без привязки к слоту, `basePrimary = 10`): **Свойство баланса:** при данной кривой `ilvl` **уровень предмета даёт более сильный вклад**, чем раньше, а редкость заметно усиливает предмет **внутри одного уровня**. Пример (без привязки к слоту, `basePrimary = 10`):
| Конфигурация | Вычисление | `primaryOut` | | Конфигурация | Вычисление | `primaryOut` |
|--------------|------------|----------------| |--------------|------------|----------------|
| Common, ilvl 25 | `round(10 × 1.72 × 1.00)` | `17` | | Common, ilvl 12 | `round(10 × 2.85 × 1.00)` | `29` |
| Rare, ilvl 13 | `round(10 × 1.36 × 1.30)` | `18` | | Rare, ilvl 10 | `round(10 × 2.36 × 1.1832)` | `28` |
| Epic, ilvl 7 | `round(10 × 1.18 × 1.52)` | `18` |
То есть **Rare ниже по ilvl** и **Epic ещё ниже по ilvl** могут превосходить **Common высокого ilvl** — это намеренно. То есть **более высокий `ilvl`** обычно перевешивает **более низкую редкость**, но редкость остаётся значимой при равных уровнях.
#### 6.4.4 Вторичные статы (крит, пробитие, малые штрафы скорости у аммуниции) #### 6.4.4 Вторичные статы (крит, пробитие, малые штрафы скорости у аммуниции)

Loading…
Cancel
Save