From ae6fa7bb9cbaf56d746f85024f174c4cf2f628b4 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 02:43:47 +0300 Subject: [PATCH] update gear --- backend/cmd/balanceall/main.go | 254 ++++++++++++-- backend/internal/game/balance_reference.go | 89 +++-- backend/internal/model/gear.go | 79 +++-- backend/internal/model/item_scaling.go | 25 +- backend/internal/tuning/runtime.go | 380 +++++++++++---------- docs/balanceall.md | 93 ++++- docs/specification.md | 25 +- 7 files changed, 644 insertions(+), 301 deletions(-) diff --git a/backend/cmd/balanceall/main.go b/backend/cmd/balanceall/main.go index f09f3a1..0046ebd 100644 --- a/backend/cmd/balanceall/main.go +++ b/backend/cmd/balanceall/main.go @@ -10,6 +10,7 @@ import ( "math/rand" "os" "sort" + "strconv" "strings" "time" @@ -18,18 +19,21 @@ import ( "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)") - listEnemies = flag.Bool("list-enemies", false, "print enemy archetypes from DB and exit") - filter = flag.String("filter", "", "optional substring filter for -list-enemies (id/type/name)") - listLimit = flag.Int("limit", 50, "max rows for -list-enemies") + listEnemies = flag.Bool("list-enemies", false, "print enemy rows from DB and exit") + listArchetypes = flag.Bool("list-archetypes", false, "print distinct enemies.archetype values from DB and exit") + 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") - enemyTypeFlag = flag.String("enemy-type", "", "if set, balance only this archetype (type string); ignored if -enemy-id is set") + enemyIDFlag = flag.Int64("enemy-id", 0, "if set, balance only this enemies.id row") + 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") @@ -51,7 +55,19 @@ func main() { 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)") 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") + + 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() @@ -72,6 +88,9 @@ func main() { log.Fatalf("open db: %v", err) } 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) enemies, err := cs.LoadEnemyTemplates(ctx) if err != nil { @@ -86,6 +105,56 @@ func main() { } 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 *listLimit <= 0 || *listLimit > 500 { log.Fatal("limit must be 1..500") @@ -96,22 +165,23 @@ func main() { log.Fatalf("list enemies: %v", err) } 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 for _, r := range dbRows { idStr := fmt.Sprintf("%d", r.ID) if f != "" { if !strings.Contains(strings.ToLower(idStr), 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 } } if printed >= *listLimit { break } - fmt.Printf("%-8d %-22s %-32s %6d %6d %6d %5v\n", - r.ID, r.Type, trimName32(r.Name), r.MinLevel, r.MaxLevel, r.BaseLevel, r.IsElite) + fmt.Printf("%-8d %-12s %-22s %-32s %6d %6d %6d %5v\n", + r.ID, trimName12(r.Archetype), r.Type, trimName32(r.Name), r.MinLevel, r.MaxLevel, r.BaseLevel, r.IsElite) printed++ } if f != "" { @@ -123,7 +193,7 @@ func main() { if *iterations < 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") } if *gridMode && *tieredTargets && *tierLevelMax <= *tierLevelMin { @@ -147,11 +217,84 @@ func main() { 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 { 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)) tol := *tolerancePct / 100.0 lowTarget := time.Duration(float64(target) * (1.0 - tol)) @@ -159,12 +302,11 @@ func main() { legacyLowSec := *targetSec * (1.0 - tol) legacyHighSec := *targetSec * (1.0 + tol) - overlayNote := "" - if strings.TrimSpace(*configJSON) != "" { - overlayNote = fmt.Sprintf(" | overlay=%q", strings.TrimSpace(*configJSON)) - } if *gridMode { 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 { 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) @@ -176,6 +318,9 @@ func main() { } 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", *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 { @@ -187,6 +332,15 @@ func main() { 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", 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 dotHeavy := enemyTemplateHasPeriodicDoT(tmpl) @@ -276,6 +430,14 @@ func main() { if 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) --- hpScale := findHPScaleForDuration(tmpl, lvl, *iterations, typeSeed, lowTarget, highTarget) @@ -519,6 +681,23 @@ func max(a, b int) int { 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 { const max = 32 s = strings.TrimSpace(s) @@ -545,35 +724,54 @@ func archetypeTierNorm(t model.Enemy, globalMin, globalMax int) float64 { return n } -// archetypeOrder returns which enemy slugs to balance: one row by -enemy-id, one by -enemy-type, or all (DB order). -func archetypeOrder(ctx context.Context, templates map[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType string) ([]string, error) { +// archetypeOrder returns which enemy template slugs (enemies.type) to run: one row by -enemy-id, +// 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 { - cs := storage.NewContentStore(pool) - rows, err := cs.ListEnemyRows(ctx) - if err != nil { - return nil, err - } - for _, r := range rows { + for _, r := range dbRows { if r.ID != enemyID { continue } if _, ok := templates[r.Type]; !ok { 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 nil, fmt.Errorf("no enemy row with id %d", enemyID) } if enemyType != "" { - if _, ok := templates[enemyType]; !ok { + t, ok := templates[enemyType] + if !ok { 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 } - cs := storage.NewContentStore(pool) - dbRows, err := cs.ListEnemyRows(ctx) - if err != nil { - return nil, err + if enemyArchetype != "" { + out := make([]string, 0) + for _, r := range dbRows { + 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)) for _, r := range dbRows { diff --git a/backend/internal/game/balance_reference.go b/backend/internal/game/balance_reference.go index faf4005..4f5304e 100644 --- a/backend/internal/game/balance_reference.go +++ b/backend/internal/game/balance_reference.go @@ -12,10 +12,14 @@ import ( type ReferenceGearProfile int 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 // ReferenceGearRolled uses RollIlvl(level, false) per slot with rng (matches base-monster drop spread). 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 @@ -57,34 +61,63 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra aIlvl = rollIlvlForBalance(level, false, rng) } - // Typical mid-tier drops: uncommon sword + mail (catalog bases; slightly above 10 for elite/DoT balance targets). - const refGearBase = 12 - wPrimary := model.ScalePrimary(refGearBase, wIlvl, model.RarityUncommon) - h.Gear[model.SlotMainHand] = &model.GearItem{ - Slot: model.SlotMainHand, - FormID: "gear.form.main_hand.sword", - Name: "Steel Sword", - Subtype: "sword", - Rarity: model.RarityUncommon, - Ilvl: wIlvl, - BasePrimary: refGearBase, - PrimaryStat: wPrimary, - StatType: "attack", - SpeedModifier: 1.0, - CritChance: 0.05, + var wRarity, aRarity model.Rarity + var refGearBase int + switch profile { + case ReferenceGearBaseline: + wRarity, aRarity = model.RarityCommon, model.RarityCommon + refGearBase = 7 + case ReferenceGearMax: + wRarity, aRarity = model.RarityLegendary, model.RarityLegendary + refGearBase = 7 + default: + // Median, Rolled, unknown: uncommon + mid-tier ref base (legacy balance target). + wRarity, aRarity = model.RarityUncommon, model.RarityUncommon + refGearBase = 12 } - aPrimary := model.ScalePrimary(refGearBase, aIlvl, model.RarityUncommon) - h.Gear[model.SlotChest] = &model.GearItem{ - Slot: model.SlotChest, - FormID: "gear.form.chest.medium", - Name: "Reinforced Mail", - Subtype: "medium", - Rarity: model.RarityUncommon, - Ilvl: aIlvl, - BasePrimary: refGearBase, - PrimaryStat: aPrimary, - StatType: "defense", - SpeedModifier: 1.0, + + if profile == ReferenceGearBaseline || profile == ReferenceGearMax { + wName, aName := "Iron Sword", "Chainmail" + if profile == ReferenceGearMax { + wName, aName = "Soul Reaver", "Crown of Eternity" + } + if wf := model.GearFamilyByName(wName); wf != nil { + h.Gear[model.SlotMainHand] = model.NewGearItem(wf, wIlvl, wRarity) + } + if af := model.GearFamilyByName(aName); af != nil { + h.Gear[model.SlotChest] = model.NewGearItem(af, aIlvl, aRarity) + } + } + 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() diff --git a/backend/internal/model/gear.go b/backend/internal/model/gear.go index 9a2b0e4..b9176c8 100644 --- a/backend/internal/model/gear.go +++ b/backend/internal/model/gear.go @@ -104,6 +104,23 @@ func init() { 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) { if len(families) == 0 { return @@ -191,22 +208,22 @@ type legacyWeaponEntry struct { var legacyWeapons = []legacyWeaponEntry{ // Daggers {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: "Assassin's Blade", Type: "daggers", Rarity: RarityRare, Damage: 8, Speed: 1.35, CritChance: 0.20}, - {Name: "Phantom Edge", Type: "daggers", Rarity: RarityEpic, Damage: 12, Speed: 1.4, CritChance: 0.25}, - {Name: "Fang of the Void", Type: "daggers", Rarity: RarityLegendary, Damage: 18, Speed: 1.5, CritChance: 0.30}, + {Name: "Iron Dagger", Type: "daggers", Rarity: RarityUncommon, Damage: 3, Speed: 1.3, CritChance: 0.08}, + {Name: "Assassin's Blade", Type: "daggers", Rarity: RarityRare, Damage: 3, Speed: 1.35, CritChance: 0.20}, + {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: 3, Speed: 1.5, CritChance: 0.30}, // Swords {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: "Longsword", Type: "sword", Rarity: RarityRare, Damage: 15, Speed: 1.0, CritChance: 0.08}, - {Name: "Excalibur", Type: "sword", Rarity: RarityEpic, Damage: 22, Speed: 1.05, CritChance: 0.10}, - {Name: "Soul Reaver", Type: "sword", Rarity: RarityLegendary, Damage: 30, Speed: 1.1, CritChance: 0.12, SpecialEffect: "lifesteal"}, + {Name: "Steel Sword", Type: "sword", Rarity: RarityUncommon, Damage: 7, Speed: 1.0, CritChance: 0.05}, + {Name: "Longsword", Type: "sword", Rarity: RarityRare, Damage: 7, Speed: 1.0, CritChance: 0.08}, + {Name: "Excalibur", Type: "sword", Rarity: RarityEpic, Damage: 7, Speed: 1.05, CritChance: 0.10}, + {Name: "Soul Reaver", Type: "sword", Rarity: RarityLegendary, Damage: 7, Speed: 1.1, CritChance: 0.12, SpecialEffect: "lifesteal"}, // Axes {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: "War Axe", Type: "axe", Rarity: RarityRare, Damage: 25, Speed: 0.75, CritChance: 0.06}, - {Name: "Infernal Axe", Type: "axe", Rarity: RarityEpic, Damage: 35, 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: "Battle Axe", Type: "axe", Rarity: RarityUncommon, Damage: 12, Speed: 0.7, CritChance: 0.04}, + {Name: "War Axe", Type: "axe", Rarity: RarityRare, Damage: 12, Speed: 0.75, CritChance: 0.06}, + {Name: "Infernal Axe", Type: "axe", Rarity: RarityEpic, Damage: 12, Speed: 0.75, CritChance: 0.08}, + {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. @@ -222,28 +239,28 @@ type legacyArmorEntry struct { } var legacyArmors = []legacyArmorEntry{ - // Light armor - {Name: "Leather Armor", Type: "light", Rarity: RarityCommon, Defense: 3, SpeedModifier: 1.05, AgilityBonus: 3}, - {Name: "Ranger's Vest", Type: "light", Rarity: RarityUncommon, Defense: 5, 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: "Phantom Garb", Type: "light", Rarity: RarityEpic, Defense: 12, 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"}, + // Light armor — same base Defense per class; rarity scaling via M(rarity) in ScalePrimary. + {Name: "Leather Armor", Type: "light", Rarity: RarityCommon, Defense: 2, SpeedModifier: 1.05, AgilityBonus: 3}, + {Name: "Ranger's Vest", Type: "light", Rarity: RarityUncommon, Defense: 2, SpeedModifier: 1.08, AgilityBonus: 5}, + {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: 2, SpeedModifier: 1.12, AgilityBonus: 12, 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 - {Name: "Chainmail", Type: "medium", Rarity: RarityCommon, Defense: 7, SpeedModifier: 1.0}, - {Name: "Reinforced Mail", Type: "medium", Rarity: RarityUncommon, Defense: 10, SpeedModifier: 1.0}, - {Name: "Battle Armor", Type: "medium", Rarity: RarityRare, Defense: 15, 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: "Crown of Eternity", Type: "medium", Rarity: RarityLegendary, Defense: 30, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"}, + {Name: "Chainmail", Type: "medium", Rarity: RarityCommon, Defense: 4, SpeedModifier: 1.0}, + {Name: "Reinforced Mail", Type: "medium", Rarity: RarityUncommon, Defense: 4, SpeedModifier: 1.0}, + {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: 4, 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 - {Name: "Iron Plate", Type: "heavy", Rarity: RarityCommon, Defense: 14, SpeedModifier: 0.7, AgilityBonus: -3}, - {Name: "Steel Plate", Type: "heavy", Rarity: RarityUncommon, Defense: 20, 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: "Dragon Scale", Type: "heavy", Rarity: RarityEpic, Defense: 38, 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: "Iron Plate", Type: "heavy", Rarity: RarityCommon, Defense: 10, 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: 10, 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: 10, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"}, // 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 Bastion", Type: "heavy", Rarity: RarityEpic, Defense: 42, 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: "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: 10, SpeedModifier: 0.7, AgilityBonus: 4, 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. diff --git a/backend/internal/model/item_scaling.go b/backend/internal/model/item_scaling.go index e12d935..1bc720a 100644 --- a/backend/internal/model/item_scaling.go +++ b/backend/internal/model/item_scaling.go @@ -7,33 +7,44 @@ import ( "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 { d := ilvl - 1 if 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. func RarityMultiplier(rarity Rarity) float64 { switch rarity { case RarityCommon: - return tuning.Get().RarityMultiplierCommon + return safeMultiplier(tuning.Get().RarityMultiplierCommon, tuning.DefaultValues().RarityMultiplierCommon) case RarityUncommon: - return tuning.Get().RarityMultiplierUncommon + return safeMultiplier(tuning.Get().RarityMultiplierUncommon, tuning.DefaultValues().RarityMultiplierUncommon) case RarityRare: - return tuning.Get().RarityMultiplierRare + return safeMultiplier(tuning.Get().RarityMultiplierRare, tuning.DefaultValues().RarityMultiplierRare) case RarityEpic: - return tuning.Get().RarityMultiplierEpic + return safeMultiplier(tuning.Get().RarityMultiplierEpic, tuning.DefaultValues().RarityMultiplierEpic) case RarityLegendary: - return tuning.Get().RarityMultiplierLegendary + return safeMultiplier(tuning.Get().RarityMultiplierLegendary, tuning.DefaultValues().RarityMultiplierLegendary) default: 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)). func ScalePrimary(basePrimary int, ilvl int, rarity Rarity) int { return int(math.Round(float64(basePrimary) * IlvlFactor(ilvl) * RarityMultiplier(rarity))) diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 06303c6..13c6188 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -14,9 +14,9 @@ type Values struct { EncounterCooldownBaseMs int64 `json:"encounterCooldownBaseMs"` EncounterActivityBase float64 `json:"encounterActivityBase"` - BaseMoveSpeed float64 `json:"baseMoveSpeed"` - MovementTickRateMs int64 `json:"movementTickRateMs"` - PositionSyncRateMs int64 `json:"positionSyncRateMs"` + BaseMoveSpeed float64 `json:"baseMoveSpeed"` + MovementTickRateMs int64 `json:"movementTickRateMs"` + PositionSyncRateMs int64 `json:"positionSyncRateMs"` TownRestMinMs int64 `json:"townRestMinMs"` TownRestMaxMs int64 `json:"townRestMaxMs"` @@ -29,12 +29,12 @@ type Values struct { // TownNPCInteractChance: offline only — after reaching an NPC, probability of “using” services // (buy potion, full heal, accept a quest) instead of walking past. TownNPCInteractChance float64 `json:"townNpcInteractChance"` - TownNPCRollMinMs int64 `json:"townNpcRollMinMs"` - TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` - TownNPCRetryMs int64 `json:"townNpcRetryMs"` - TownNPCPauseMs int64 `json:"townNpcPauseMs"` - TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` - TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` + TownNPCRollMinMs int64 `json:"townNpcRollMinMs"` + TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` + TownNPCRetryMs int64 `json:"townNpcRetryMs"` + TownNPCPauseMs int64 `json:"townNpcPauseMs"` + TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` + TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` // TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach). TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"` // 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"` // GoldDropChance is P(at least one gold line) per kill before luck; rolled first, then rarity/amount. GoldDropChance float64 `json:"goldDropChance"` - PotionDropChance float64 `json:"potionDropChance"` - EquipmentDropBase float64 `json:"equipmentDropBase"` - - GoldCommonMin int64 `json:"goldCommonMin"` - GoldCommonMax int64 `json:"goldCommonMax"` - GoldUncommonMin int64 `json:"goldUncommonMin"` - GoldUncommonMax int64 `json:"goldUncommonMax"` - GoldRareMin int64 `json:"goldRareMin"` - GoldRareMax int64 `json:"goldRareMax"` - GoldEpicMin int64 `json:"goldEpicMin"` - GoldEpicMax int64 `json:"goldEpicMax"` + PotionDropChance float64 `json:"potionDropChance"` + EquipmentDropBase float64 `json:"equipmentDropBase"` + + GoldCommonMin int64 `json:"goldCommonMin"` + GoldCommonMax int64 `json:"goldCommonMax"` + GoldUncommonMin int64 `json:"goldUncommonMin"` + GoldUncommonMax int64 `json:"goldUncommonMax"` + GoldRareMin int64 `json:"goldRareMin"` + GoldRareMax int64 `json:"goldRareMax"` + GoldEpicMin int64 `json:"goldEpicMin"` + GoldEpicMax int64 `json:"goldEpicMax"` GoldLegendaryMin int64 `json:"goldLegendaryMin"` GoldLegendaryMax int64 `json:"goldLegendaryMax"` @@ -88,25 +88,25 @@ type Values struct { // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). QuestOfferRefreshHours int `json:"questOfferRefreshHours"` - CombatDamageScale float64 `json:"combatDamageScale"` - CombatDamageRollMin float64 `json:"combatDamageRollMin"` - CombatDamageRollMax float64 `json:"combatDamageRollMax"` + CombatDamageScale float64 `json:"combatDamageScale"` + CombatDamageRollMin float64 `json:"combatDamageRollMin"` + CombatDamageRollMax float64 `json:"combatDamageRollMax"` // EnemyCombatDamageScale / Roll* apply only when an enemy hits the hero (not hero→enemy). EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"` EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"` EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"` // EnemyAttackIntervalMultiplier applies only to enemy attack spacing (hero cadence unchanged). Pair with enemy damage scale for similar incoming DPS. EnemyAttackIntervalMultiplier float64 `json:"enemyAttackIntervalMultiplier"` - EnemyDodgeChance float64 `json:"enemyDodgeChance"` - EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` - EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` - HeroCritChanceCap float64 `json:"heroCritChanceCap"` - HeroBlockChancePerDefense float64 `json:"heroBlockChancePerDefense"` - HeroBlockChanceCap float64 `json:"heroBlockChanceCap"` - EnemyBurstEveryN int64 `json:"enemyBurstEveryN"` - EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"` - EnemyChainEveryN int64 `json:"enemyChainEveryN"` - EnemyChainMultiplier float64 `json:"enemyChainMultiplier"` + EnemyDodgeChance float64 `json:"enemyDodgeChance"` + EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` + EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` + HeroCritChanceCap float64 `json:"heroCritChanceCap"` + HeroBlockChancePerDefense float64 `json:"heroBlockChancePerDefense"` + HeroBlockChanceCap float64 `json:"heroBlockChanceCap"` + EnemyBurstEveryN int64 `json:"enemyBurstEveryN"` + EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"` + EnemyChainEveryN int64 `json:"enemyChainEveryN"` + EnemyChainMultiplier float64 `json:"enemyChainMultiplier"` DebuffProcBurn float64 `json:"debuffProcBurn"` DebuffProcPoison float64 `json:"debuffProcPoison"` @@ -115,21 +115,21 @@ type Values struct { DebuffProcFreeze float64 `json:"debuffProcFreeze"` DebuffProcIceSlow float64 `json:"debuffProcIceSlow"` - EnemyRegenDefault float64 `json:"enemyRegenDefault"` - EnemyRegenSkeletonKing float64 `json:"enemyRegenSkeletonKing"` - EnemyRegenForestWarden float64 `json:"enemyRegenForestWarden"` - EnemyRegenBattleLizard float64 `json:"enemyRegenBattleLizard"` - SummonCycleSeconds int64 `json:"summonCycleSeconds"` - SummonDamageDivisor int64 `json:"summonDamageDivisor"` - LuckBuffMultiplier float64 `json:"luckBuffMultiplier"` + EnemyRegenDefault float64 `json:"enemyRegenDefault"` + EnemyRegenSkeletonKing float64 `json:"enemyRegenSkeletonKing"` + EnemyRegenForestWarden float64 `json:"enemyRegenForestWarden"` + EnemyRegenBattleLizard float64 `json:"enemyRegenBattleLizard"` + SummonCycleSeconds int64 `json:"summonCycleSeconds"` + SummonDamageDivisor int64 `json:"summonDamageDivisor"` + LuckBuffMultiplier float64 `json:"luckBuffMultiplier"` - MinAttackIntervalMs int64 `json:"minAttackIntervalMs"` + MinAttackIntervalMs int64 `json:"minAttackIntervalMs"` CombatPaceMultiplier int64 `json:"combatPaceMultiplier"` - PotionHealPercent float64 `json:"potionHealPercent"` - PotionAutoUseThreshold float64 `json:"potionAutoUseThreshold"` - ReviveHpPercent float64 `json:"reviveHpPercent"` - AutoReviveAfterMs int64 `json:"autoReviveAfterMs"` + PotionHealPercent float64 `json:"potionHealPercent"` + PotionAutoUseThreshold float64 `json:"potionAutoUseThreshold"` + ReviveHpPercent float64 `json:"reviveHpPercent"` + AutoReviveAfterMs int64 `json:"autoReviveAfterMs"` XPCurveEarlyBase float64 `json:"xpCurveEarlyBase"` XPCurveEarlyScale float64 `json:"xpCurveEarlyScale"` @@ -140,35 +140,37 @@ type Values struct { LevelUpHPEvery int64 `json:"levelUpHpEvery"` // LevelUpHpBase is added to MaxHP together with Constitution/6 when LevelUpHPEvery fires (spec §3.3 cadence). - LevelUpHpBase int `json:"levelUpHpBase"` - LevelUpATKEvery int64 `json:"levelUpAtkEvery"` - LevelUpDEFEvery int64 `json:"levelUpDefEvery"` - LevelUpSTREvery int64 `json:"levelUpStrEvery"` - LevelUpCONEvery int64 `json:"levelUpConEvery"` - LevelUpAGIEvery int64 `json:"levelUpAgiEvery"` + LevelUpHpBase int `json:"levelUpHpBase"` + LevelUpATKEvery int64 `json:"levelUpAtkEvery"` + LevelUpDEFEvery int64 `json:"levelUpDefEvery"` + LevelUpSTREvery int64 `json:"levelUpStrEvery"` + LevelUpCONEvery int64 `json:"levelUpConEvery"` + LevelUpAGIEvery int64 `json:"levelUpAgiEvery"` LevelUpLUCKEvery int64 `json:"levelUpLuckEvery"` - AgilityCoef float64 `json:"agilityCoef"` + AgilityCoef float64 `json:"agilityCoef"` MaxAttackSpeed float64 `json:"maxAttackSpeed"` MinAttackSpeed float64 `json:"minAttackSpeed"` - IlvlFactorSlope float64 `json:"ilvlFactorSlope"` - RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"` - RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"` - RarityMultiplierRare float64 `json:"rarityMultiplierRare"` - RarityMultiplierEpic float64 `json:"rarityMultiplierEpic"` - RarityMultiplierLegendary float64 `json:"rarityMultiplierLegendary"` - RollIlvlEliteBaseChance float64 `json:"rollIlvlEliteBaseChance"` + // IlvlFactorSlope is deprecated; kept for backward-compatible payloads. + IlvlFactorSlope float64 `json:"ilvlFactorSlope"` + IlvlPerLevelMultiplier float64 `json:"ilvlPerLevelMultiplier"` + RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"` + RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"` + RarityMultiplierRare float64 `json:"rarityMultiplierRare"` + RarityMultiplierEpic float64 `json:"rarityMultiplierEpic"` + RarityMultiplierLegendary float64 `json:"rarityMultiplierLegendary"` + RollIlvlEliteBaseChance float64 `json:"rollIlvlEliteBaseChance"` RollIlvlElitePlusOneChance float64 `json:"rollIlvlElitePlusOneChance"` - BuffChargePeriodMs int64 `json:"buffChargePeriodMs"` + BuffChargePeriodMs int64 `json:"buffChargePeriodMs"` FreeBuffActivationsPerPeriod int64 `json:"freeBuffActivationsPerPeriod"` - SubscriptionDurationMs int64 `json:"subscriptionDurationMs"` - SubscriptionWeeklyPriceRUB int64 `json:"subscriptionWeeklyPriceRub"` - BuffRefillPriceRUB int64 `json:"buffRefillPriceRub"` - ResurrectionRefillPriceRUB int64 `json:"resurrectionRefillPriceRub"` - MaxRevivesFree int64 `json:"maxRevivesFree"` - MaxRevivesSubscriber int64 `json:"maxRevivesSubscriber"` + SubscriptionDurationMs int64 `json:"subscriptionDurationMs"` + SubscriptionWeeklyPriceRUB int64 `json:"subscriptionWeeklyPriceRub"` + BuffRefillPriceRUB int64 `json:"buffRefillPriceRub"` + ResurrectionRefillPriceRUB int64 `json:"resurrectionRefillPriceRub"` + MaxRevivesFree int64 `json:"maxRevivesFree"` + MaxRevivesSubscriber int64 `json:"maxRevivesSubscriber"` EnemyScaleBandHP float64 `json:"enemyScaleBandHp"` EnemyScaleOvercapHP float64 `json:"enemyScaleOvercapHp"` @@ -231,26 +233,26 @@ type Values struct { func DefaultValues() Values { return Values{ - EncounterCooldownBaseMs: 12_000, - EncounterActivityBase: 0.035, - BaseMoveSpeed: 2.0, - MovementTickRateMs: 500, - PositionSyncRateMs: 10_000, - TownRestMinMs: 5 * 60 * 1000, - TownRestMaxMs: 20 * 60 * 1000, - TownRestHPPerS: 0.002, - TownArrivalRadius: 0.5, - TownNPCVisitChance: 0.78, - TownNPCApproachChance: 1.0, - TownNPCInteractChance: 0.65, - TownNPCRollMinMs: 800, - TownNPCRollMaxMs: 2600, - TownNPCRetryMs: 450, - TownNPCPauseMs: 30_000, - TownNPCLogIntervalMs: 5_000, - TownNPCWalkSpeed: 3.0, - TownNPCStandoffWorld: 0.65, - TownAfterNPCRestChance: 0.78, + EncounterCooldownBaseMs: 12_000, + EncounterActivityBase: 0.035, + BaseMoveSpeed: 2.0, + MovementTickRateMs: 500, + PositionSyncRateMs: 10_000, + TownRestMinMs: 5 * 60 * 1000, + TownRestMaxMs: 20 * 60 * 1000, + TownRestHPPerS: 0.002, + TownArrivalRadius: 0.5, + TownNPCVisitChance: 0.78, + TownNPCApproachChance: 1.0, + TownNPCInteractChance: 0.65, + TownNPCRollMinMs: 800, + TownNPCRollMaxMs: 2600, + TownNPCRetryMs: 450, + TownNPCPauseMs: 30_000, + TownNPCLogIntervalMs: 5_000, + TownNPCWalkSpeed: 3.0, + TownNPCStandoffWorld: 0.65, + TownAfterNPCRestChance: 0.78, WanderingMerchantPromptTimeoutMs: 15_000, MerchantCostBase: 20, MerchantCostPerLevel: 5, @@ -291,108 +293,109 @@ func DefaultValues() Values { QuestOffersPerNPC: 2, QuestOfferRefreshHours: 2, // combatDamageScale tracks combatPaceMultiplier: DPS ~ scale/pace, so halving pace halves scale to keep fight length. - CombatDamageScale: 0.216, - CombatDamageRollMin: 0.60, - CombatDamageRollMax: 1.10, - EnemyCombatDamageScale: DefaultEnemyCombatDamageScale, - EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin, - EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax, - EnemyAttackIntervalMultiplier: DefaultEnemyAttackIntervalMultiplier, - EnemyDodgeChance: 0.14, - EnemyCriticalMinChance: 0.10, - EnemyCritChanceCap: 0.20, - HeroCritChanceCap: 0.12, - HeroBlockChancePerDefense: 0.0025, - HeroBlockChanceCap: 0.20, - EnemyBurstEveryN: 3, - EnemyBurstMultiplier: 1.5, - EnemyChainEveryN: 6, - EnemyChainMultiplier: 3.0, - DebuffProcBurn: 0.18, - DebuffProcPoison: 0.10, - DebuffProcSlow: 0.25, - DebuffProcStun: 0.25, - DebuffProcFreeze: 0.20, - DebuffProcIceSlow: 0.20, - EnemyRegenDefault: DefaultEnemyRegenDefault, - EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing, - EnemyRegenForestWarden: DefaultEnemyRegenForestWarden, - EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard, - SummonCycleSeconds: 18, - SummonDamageDivisor: 10, - LuckBuffMultiplier: 1.75, - MinAttackIntervalMs: 250, - CombatPaceMultiplier: 14, - PotionHealPercent: 0.30, - PotionAutoUseThreshold: 0.30, - ReviveHpPercent: 0.50, - AutoReviveAfterMs: int64(time.Hour / time.Millisecond), - XPCurveEarlyBase: 100, - XPCurveEarlyScale: 1.5, - XPCurveMidBase: 2947, - XPCurveMidScale: 1.15, - XPCurveLateBase: 48232, - XPCurveLateScale: 1.10, - LevelUpHPEvery: 4, - LevelUpHpBase: 10, - LevelUpATKEvery: 3, - LevelUpDEFEvery: 3, - LevelUpSTREvery: 2, - LevelUpCONEvery: 2, - LevelUpAGIEvery: 2, - LevelUpLUCKEvery: 5, - AgilityCoef: 0.03, - MaxAttackSpeed: 4.0, - MinAttackSpeed: 0.1, - IlvlFactorSlope: 0.03, - RarityMultiplierCommon: 1.00, - RarityMultiplierUncommon: 1.12, - RarityMultiplierRare: 1.30, - RarityMultiplierEpic: 1.52, - RarityMultiplierLegendary: 1.78, - RollIlvlEliteBaseChance: 0.4, - RollIlvlElitePlusOneChance: 0.4, - BuffChargePeriodMs: 24 * 60 * 60 * 1000, - FreeBuffActivationsPerPeriod: 2, - SubscriptionDurationMs: 7 * 24 * 60 * 60 * 1000, - SubscriptionWeeklyPriceRUB: 299, - BuffRefillPriceRUB: 50, - ResurrectionRefillPriceRUB: 150, - MaxRevivesFree: 1, - MaxRevivesSubscriber: 2, - EnemyScaleBandHP: 0.062, - EnemyScaleOvercapHP: 0.031, - EnemyScaleBandATK: 0.044, - EnemyScaleOvercapATK: 0.024, - EnemyScaleBandDEF: 0.038, - EnemyScaleOvercapDEF: 0.020, - EnemyScaleBandXP: 0.05, - EnemyScaleOvercapXP: 0.03, - EnemyScaleBandGold: 0.05, - EnemyScaleOvercapGold: 0.025, - AutoEquipThreshold: 1.03, - LootHistoryLimit: 50, - - AdventureStartChance: 0.0001, - AdventureCooldownMs: 300_000, - AdventureOutDurationMs: 20_000, - AdventureWildMinMs: 560_000, - AdventureWildMaxMs: 2_960_000, - AdventureReturnDurationMs: 20_000, - AdventureDepthWorldUnits: 40.0, - AdventureEncounterCooldownMs: 6_000, - AdventureReturnEncounterEnabled: true, - AdventureReturnWildnessMin: 0.35, - - LowHpThreshold: 0.25, - RoadsideRestExitHp: 0.70, - AdventureRestTargetHp: 0.70, - RoadsideRestMinMs: 240_000, - RoadsideRestMaxMs: 600_000, - RoadsideRestHpPerS: 0.003, - AdventureRestHpPerS: 0.004, - - RoadsideRestDepthWorldUnits: 12.0, + CombatDamageScale: 0.216, + CombatDamageRollMin: 0.60, + CombatDamageRollMax: 1.10, + EnemyCombatDamageScale: DefaultEnemyCombatDamageScale, + EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin, + EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax, + EnemyAttackIntervalMultiplier: DefaultEnemyAttackIntervalMultiplier, + EnemyDodgeChance: 0.14, + EnemyCriticalMinChance: 0.10, + EnemyCritChanceCap: 0.20, + HeroCritChanceCap: 0.12, + HeroBlockChancePerDefense: 0.0025, + HeroBlockChanceCap: 0.20, + EnemyBurstEveryN: 3, + EnemyBurstMultiplier: 1.5, + EnemyChainEveryN: 6, + EnemyChainMultiplier: 3.0, + DebuffProcBurn: 0.18, + DebuffProcPoison: 0.10, + DebuffProcSlow: 0.25, + DebuffProcStun: 0.25, + DebuffProcFreeze: 0.20, + DebuffProcIceSlow: 0.20, + EnemyRegenDefault: DefaultEnemyRegenDefault, + EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing, + EnemyRegenForestWarden: DefaultEnemyRegenForestWarden, + EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard, + SummonCycleSeconds: 18, + SummonDamageDivisor: 10, + LuckBuffMultiplier: 1.75, + MinAttackIntervalMs: 250, + CombatPaceMultiplier: 14, + PotionHealPercent: 0.30, + PotionAutoUseThreshold: 0.30, + ReviveHpPercent: 0.50, + AutoReviveAfterMs: int64(time.Hour / time.Millisecond), + XPCurveEarlyBase: 100, + XPCurveEarlyScale: 1.5, + XPCurveMidBase: 2947, + XPCurveMidScale: 1.15, + XPCurveLateBase: 48232, + XPCurveLateScale: 1.10, + LevelUpHPEvery: 4, + LevelUpHpBase: 10, + LevelUpATKEvery: 3, + LevelUpDEFEvery: 3, + LevelUpSTREvery: 2, + LevelUpCONEvery: 2, + LevelUpAGIEvery: 2, + LevelUpLUCKEvery: 5, + AgilityCoef: 0.03, + MaxAttackSpeed: 4.0, + MinAttackSpeed: 0.1, + IlvlFactorSlope: 0.03, + IlvlPerLevelMultiplier: 1.10, + RarityMultiplierCommon: 1.00, + RarityMultiplierUncommon: 1.0877573, + RarityMultiplierRare: 1.1832160, + RarityMultiplierEpic: 1.2870518, + RarityMultiplierLegendary: 1.40, + RollIlvlEliteBaseChance: 0.4, + RollIlvlElitePlusOneChance: 0.4, + BuffChargePeriodMs: 24 * 60 * 60 * 1000, + FreeBuffActivationsPerPeriod: 2, + SubscriptionDurationMs: 7 * 24 * 60 * 60 * 1000, + SubscriptionWeeklyPriceRUB: 299, + BuffRefillPriceRUB: 50, + ResurrectionRefillPriceRUB: 150, + MaxRevivesFree: 1, + MaxRevivesSubscriber: 2, + EnemyScaleBandHP: 0.062, + EnemyScaleOvercapHP: 0.031, + EnemyScaleBandATK: 0.044, + EnemyScaleOvercapATK: 0.024, + EnemyScaleBandDEF: 0.038, + EnemyScaleOvercapDEF: 0.020, + EnemyScaleBandXP: 0.05, + EnemyScaleOvercapXP: 0.03, + EnemyScaleBandGold: 0.05, + EnemyScaleOvercapGold: 0.025, + AutoEquipThreshold: 1.03, + LootHistoryLimit: 50, + + AdventureStartChance: 0.0001, + AdventureCooldownMs: 300_000, + AdventureOutDurationMs: 20_000, + AdventureWildMinMs: 560_000, + AdventureWildMaxMs: 2_960_000, + AdventureReturnDurationMs: 20_000, + AdventureDepthWorldUnits: 40.0, + AdventureEncounterCooldownMs: 6_000, + AdventureReturnEncounterEnabled: true, + AdventureReturnWildnessMin: 0.35, + + LowHpThreshold: 0.25, + RoadsideRestExitHp: 0.70, + AdventureRestTargetHp: 0.70, + RoadsideRestMinMs: 240_000, + RoadsideRestMaxMs: 600_000, + RoadsideRestHpPerS: 0.003, + AdventureRestHpPerS: 0.004, + + RoadsideRestDepthWorldUnits: 12.0, } } @@ -505,4 +508,3 @@ func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) e Set(next) return nil } - diff --git a/docs/balanceall.md b/docs/balanceall.md index 7e7f9cc..2ba7433 100644 --- a/docs/balanceall.md +++ b/docs/balanceall.md @@ -2,6 +2,75 @@ Утилита `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` без реальных сравнений. + +Пример — один шаблон, уровни 5–15, строгий режим: + +```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`) Для каждого архетипа строится сетка сценариев: @@ -56,11 +125,12 @@ go build -o balanceall ./cmd/balanceall ## Область прогона -- **Все архетипы из БД** — по умолчанию: строки из `enemies` в порядке `ORDER BY min_level, type` (как `ListEnemyRows`). -- **Один монстр по `enemies.id`** — `-enemy-id ` (удобно после `go run ./cmd/balanceall -list-enemies`). -- **Один архетип по строке `type`** — `-enemy-type ` (в таблице `enemies` это строка вроде `wolf`, не catalog id `enemy.wolf_forest`). +- **Все шаблоны из БД** — по умолчанию: строки из `enemies` в порядке `ORDER BY min_level, archetype, type` (как `ListEnemyRows`). +- **Один шаблон по `enemies.id`** — `-enemy-id ` (удобно после `-list-enemies`). +- **Один шаблон по уникальному slug `type`** — `-enemy-type ` (например `wolf_l5_5_meadow`, не короткое имя `wolf`). +- **Все шаблоны с семейством `archetype`** — `-enemy-archetype ` (колонка `enemies.archetype`, напр. `wolf` — все волки из БД). -Нельзя одновременно задавать `-enemy-id` и `-enemy-type`. +Нельзя одновременно задавать `-enemy-id` и `-enemy-type`. Если заданы **`-enemy-type` и `-enemy-archetype`**, slug должен принадлежать этому семейству (иначе ошибка). С **`-enemy-id`** можно указать **`-enemy-archetype`** для проверки согласованности строки в БД. ## JSON-оверлей (`-config`) @@ -93,7 +163,9 @@ go build -o balanceall ./cmd/balanceall |------|----------------|--------| | `-dsn` | `""` | Postgres DSN; если пусто — берётся `DATABASE_URL`. | | `-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`, поверх БД. | | `-grid` | `true` | Сетка уровней × шмот; `false` — legacy (один уровень, медианный шмот). | | `-gear-variants` | 4 | Режим сетки: число профилей шмота на уровень (1 median + N−1 rolled). | @@ -110,6 +182,16 @@ go build -o balanceall ./cmd/balanceall | `-list-enemies` | false | Список архетипов из БД (с колонкой `id`) и выход. | | `-filter` | `""` | Подстрока для `-list-enemies`. | | `-limit` | 50 | Максимум строк для `-list-enemies` (1–500). | +| `-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`). - Сетка: `cmd/balanceall/grid.go`. +- Проверка шмота: `cmd/balanceall/gear_check.go`. - Одиночная симуляция: `cmd/balancesim` + `DATABASE_URL`. - Краткий снимок для контента: `docs/monster-catalog-balanced-v1.md`. diff --git a/docs/specification.md b/docs/specification.md index 6253d57..763bb3c 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -457,22 +457,22 @@ AutoHero — это idle/incremental RPG с изометрическим вид Коэффициент (плавный рост от `ilvl`, без ступеней): -- `α = 0.03` -- `L(ilvl) = 1 + α × max(0, ilvl − 1)` +- `base = 1.10` +- `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 Множитель редкости -Одинаков для всех типов предметов, для воспроизводимости: +Одинаков для всех типов предметов, для воспроизводимости. Якорь: **Legendary = +40% к Common** при том же `ilvl`. Базовые числа семейства в каталоге (`weapons.damage`, `armor.defense`, …) задаются **одинаковыми** для линейки типа; разброс редкости даёт только `M(rarity)` (без двойного масштабирования в БД). | Редкость | `M(rarity)` | |----------|----------------| | Common | `1.00` | -| Uncommon | `1.12` | -| Rare | `1.30` | -| Epic | `1.52` | -| Legendary | `1.78` | +| Uncommon | `1.0878` | +| Rare | `1.1832` | +| Epic | `1.2871` | +| Legendary | `1.40` | #### 6.4.3 Первичные статы предмета (урон / защита от предмета) @@ -484,15 +484,14 @@ primaryOut = round( basePrimary × L(ilvl) × M(rarity) ) `basePrimary` — целое из каталога для семейства предмета на «эталоне» `ilvl = 1`, `Common`. -**Свойство баланса:** при тех же `basePrimary` возможны ситуации, когда **редкий предмет с меньшим `ilvl` даёт больше или столько же**, чем **обычный с большим `ilvl`**, потому что `M(rarity)` растёт быстрее, чем `L(ilvl)` в типичных диапазонах дропа. Примеры (без привязки к слоту, `basePrimary = 10`): +**Свойство баланса:** при данной кривой `ilvl` **уровень предмета даёт более сильный вклад**, чем раньше, а редкость заметно усиливает предмет **внутри одного уровня**. Пример (без привязки к слоту, `basePrimary = 10`): | Конфигурация | Вычисление | `primaryOut` | |--------------|------------|----------------| -| Common, ilvl 25 | `round(10 × 1.72 × 1.00)` | `17` | -| Rare, ilvl 13 | `round(10 × 1.36 × 1.30)` | `18` | -| Epic, ilvl 7 | `round(10 × 1.18 × 1.52)` | `18` | +| Common, ilvl 12 | `round(10 × 2.85 × 1.00)` | `29` | +| Rare, ilvl 10 | `round(10 × 2.36 × 1.1832)` | `28` | -То есть **Rare ниже по ilvl** и **Epic ещё ниже по ilvl** могут превосходить **Common высокого ilvl** — это намеренно. +То есть **более высокий `ilvl`** обычно перевешивает **более низкую редкость**, но редкость остаётся значимой при равных уровнях. #### 6.4.4 Вторичные статы (крит, пробитие, малые штрафы скорости у аммуниции)