update gear

master
Denis Ranneft 1 month ago
parent 485254d6cd
commit ae6fa7bb9c

@ -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)")
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")
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) {
if enemyID > 0 {
// 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)
rows, err := cs.ListEnemyRows(ctx)
dbRows, err := cs.ListEnemyRows(ctx)
if err != nil {
return nil, err
}
for _, r := range rows {
if enemyID > 0 {
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 {

@ -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,15 +61,41 @@ 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)
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
}
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: model.RarityUncommon,
Rarity: wRarity,
Ilvl: wIlvl,
BasePrimary: refGearBase,
PrimaryStat: wPrimary,
@ -73,19 +103,22 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
SpeedModifier: 1.0,
CritChance: 0.05,
}
aPrimary := model.ScalePrimary(refGearBase, aIlvl, model.RarityUncommon)
}
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: model.RarityUncommon,
Rarity: aRarity,
Ilvl: aIlvl,
BasePrimary: refGearBase,
PrimaryStat: aPrimary,
StatType: "defense",
SpeedModifier: 1.0,
}
}
now := time.Now()
h.RefreshDerivedCombatStats(now)

@ -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.

@ -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)))

@ -152,7 +152,9 @@ type Values struct {
MaxAttackSpeed float64 `json:"maxAttackSpeed"`
MinAttackSpeed float64 `json:"minAttackSpeed"`
// 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"`
@ -345,11 +347,12 @@ func DefaultValues() Values {
MaxAttackSpeed: 4.0,
MinAttackSpeed: 0.1,
IlvlFactorSlope: 0.03,
IlvlPerLevelMultiplier: 1.10,
RarityMultiplierCommon: 1.00,
RarityMultiplierUncommon: 1.12,
RarityMultiplierRare: 1.30,
RarityMultiplierEpic: 1.52,
RarityMultiplierLegendary: 1.78,
RarityMultiplierUncommon: 1.0877573,
RarityMultiplierRare: 1.1832160,
RarityMultiplierEpic: 1.2870518,
RarityMultiplierLegendary: 1.40,
RollIlvlEliteBaseChance: 0.4,
RollIlvlElitePlusOneChance: 0.4,
BuffChargePeriodMs: 24 * 60 * 60 * 1000,
@ -505,4 +508,3 @@ func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) e
Set(next)
return nil
}

@ -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` без реальных сравнений.
Пример — один шаблон, уровни 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`)
Для каждого архетипа строится сетка сценариев:
@ -56,11 +125,12 @@ go build -o balanceall ./cmd/balanceall
## Область прогона
- **Все архетипы из БД** — по умолчанию: строки из `enemies` в порядке `ORDER BY min_level, type` (как `ListEnemyRows`).
- **Один монстр по `enemies.id`** — `-enemy-id <id>` (удобно после `go run ./cmd/balanceall -list-enemies`).
- **Один архетип по строке `type`** — `-enemy-type <type>` (в таблице `enemies` это строка вроде `wolf`, не catalog id `enemy.wolf_forest`).
- **Все шаблоны из БД** — по умолчанию: строки из `enemies` в порядке `ORDER BY min_level, archetype, type` (как `ListEnemyRows`).
- **Один шаблон по `enemies.id`** — `-enemy-id <id>` (удобно после `-list-enemies`).
- **Один шаблон по уникальному 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`)
@ -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 + N1 rolled). |
@ -110,6 +182,16 @@ go build -o balanceall ./cmd/balanceall
| `-list-enemies` | false | Список архетипов из БД (с колонкой `id`) и выход. |
| `-filter` | `""` | Подстрока для `-list-enemies`. |
| `-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`).
- Сетка: `cmd/balanceall/grid.go`.
- Проверка шмота: `cmd/balanceall/gear_check.go`.
- Одиночная симуляция: `cmd/balancesim` + `DATABASE_URL`.
- Краткий снимок для контента: `docs/monster-catalog-balanced-v1.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 Вторичные статы (крит, пробитие, малые штрафы скорости у аммуниции)

Loading…
Cancel
Save