From 2f00103b9026cc736bed330e8140fe6a3f360d7e Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Tue, 31 Mar 2026 22:47:16 +0300 Subject: [PATCH] rebalance --- .../fire_demon_balance_overlay.json | 5 -- backend/cmd/balanceall/grid.go | 85 +++++++++++++++---- backend/cmd/balanceall/main.go | 16 ++++ backend/internal/game/balance_reference.go | 9 +- backend/internal/handler/admin.go | 6 ++ .../migrations/000003_balanceall_enemies.sql | 7 +- 6 files changed, 97 insertions(+), 31 deletions(-) delete mode 100644 backend/cmd/balanceall/fire_demon_balance_overlay.json diff --git a/backend/cmd/balanceall/fire_demon_balance_overlay.json b/backend/cmd/balanceall/fire_demon_balance_overlay.json deleted file mode 100644 index d26d00e..0000000 --- a/backend/cmd/balanceall/fire_demon_balance_overlay.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "fire_demon": { - "specialAbilities": [] - } -} diff --git a/backend/cmd/balanceall/grid.go b/backend/cmd/balanceall/grid.go index aeee5b7..8a3be1c 100644 --- a/backend/cmd/balanceall/grid.go +++ b/backend/cmd/balanceall/grid.go @@ -12,6 +12,18 @@ import ( "github.com/denisovdennis/autohero/internal/model" ) +// enemyTemplateHasPeriodicDoT is true if the template applies poison/burn DoT in combat +// (longer fights stack more periodic damage — conflicts with naive hp×atk grid balance). +func enemyTemplateHasPeriodicDoT(t model.Enemy) bool { + for _, a := range t.SpecialAbilities { + switch a { + case model.AbilityBurn, model.AbilityPoison: + return true + } + } + return false +} + // gridScenario: fair fight heroLv == enemyLv at each tier, × gear variants (median + rolled). type gridScenario struct { heroLv int @@ -242,22 +254,34 @@ func findHPScaleForAggDurationGrid(tmpl model.Enemy, et model.EnemyType, scenari return -1 } -func findAtkScaleForAggHeroHpGridRelaxed(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 { +func findAtkScaleForAggHeroHpGridRelaxed(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64, dotHeavy bool) float64 { a := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minMedWin) if a >= 0 { return a } - hpLo2 := hpLow - 0.10 - if hpLo2 < 0.15 { - hpLo2 = 0.15 + extra := 0.10 + if dotHeavy { + extra = 0.16 } - return findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLo2, hpHigh+0.02, minMedWin) + hpLo2 := hpLow - extra + if hpLo2 < 0.12 { + hpLo2 = 0.12 + } + hpHi2 := hpHigh + 0.02 + if dotHeavy { + hpHi2 = hpHigh + 0.06 + } + return findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLo2, hpHi2, minMedWin) } func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 { r0 := aggregateGrid(tmpl, et, scenarios, hpScale, 1.0, n, seedBase) // Median win rate across cells can dip when HP is scaled for long fights; do not abort too early. - if r0.medWinRate < minMedWin*0.55 { + earlyFrac := 0.55 + if minMedWin < 0.24 { + earlyFrac = 0.35 // DoT / relaxed gate: allow ~10–15% median cell win rate through to binary search + } + if r0.medWinRate < minMedWin*earlyFrac { return -1 } if r0.medOfMedHp >= hpLow && r0.medOfMedHp <= hpHigh { @@ -273,7 +297,11 @@ func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenario for iter := 0; iter < 56; iter++ { mid := (lo + hi) / 2 r := aggregateGrid(tmpl, et, scenarios, hpScale, mid, n, seedBase) - if r.medWinRate < minMedWin*0.5 { + binReject := minMedWin * 0.5 + if minMedWin < 0.24 { + binReject = minMedWin * 0.38 + } + if r.medWinRate < binReject { hi = mid best = mid continue @@ -327,6 +355,7 @@ func ensurePositiveMinWinGrid(tmpl model.Enemy, et model.EnemyType, scenarios [] } // balanceArchetypeGrid runs grid balance for one enemy type; returns ok=false if skipped/failed. +// dotHeavy: burn/poison — shorter target duration band (set in caller) and looser final duration/atk checks. func balanceArchetypeGrid( tmpl model.Enemy, et model.EnemyType, @@ -338,6 +367,7 @@ func balanceArchetypeGrid( hpLow, hpHigh float64, minWinRate float64, refinePasses int, + dotHeavy bool, ) (hpScale, atkScale float64, final gridAggResult, ok bool) { if len(scenarios) == 0 { return 0, 0, gridAggResult{}, false @@ -353,17 +383,27 @@ func balanceArchetypeGrid( maxRounds = 10 } - hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinRate) + minWinGate := minWinRate + if dotHeavy { + // Grid cells with burn vary wildly; median-of-medians win rate can sit ~12–20%. + minWinGate = minWinRate * 0.52 + } + + hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinGate) if hpScale < 0 { - hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-18, highSec+18, minWinRate*0.80) + hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-18, highSec+18, minWinGate*0.80) } if hpScale < 0 { - hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-40, highSec+40, minWinRate*0.72) + pad := 40.0 + if dotHeavy { + pad = 55 + } + hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-pad, highSec+pad, minWinGate*0.72) } if hpScale < 0 { return 0, 0, gridAggResult{}, false } - atkScale = findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate) + atkScale = findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy) if atkScale < 0 { return 0, 0, gridAggResult{}, false } @@ -372,7 +412,7 @@ func balanceArchetypeGrid( a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase) durOk := a.medOfMedDur > 0 && a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec hpOk := a.medOfMedHp >= hpLow && a.medOfMedHp <= hpHigh - if durOk && hpOk && a.medWinRate >= minWinRate { + if durOk && hpOk && a.medWinRate >= minWinGate { break } if a.medOfMedDur > 0 && !durOk { @@ -382,7 +422,7 @@ func balanceArchetypeGrid( break } } - if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 { + if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 { atkScale = atk2 } else { break @@ -391,7 +431,7 @@ func balanceArchetypeGrid( } ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) - if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 { + if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 { atkScale = atk2 } ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) @@ -408,20 +448,29 @@ func balanceArchetypeGrid( if hpScale < 0.02 || hpScale > 28 { break } - if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 { + if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 { atkScale = atk2 } ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) } final = aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase) - if final.medWinRate < minWinRate { + if final.medWinRate < minWinGate { return 0, 0, gridAggResult{}, false } - if atkScale < 0.22 { + minAtk := 0.22 + if dotHeavy { + minAtk = 0.18 + } + if atkScale < minAtk { return 0, 0, gridAggResult{}, false } - if final.medOfMedDur > 0 && (final.medOfMedDur < lowSec || final.medOfMedDur > highSec) { + durLowChk, durHighChk := lowSec, highSec + if dotHeavy { + durLowChk -= 25 + durHighChk += 35 + } + if final.medOfMedDur > 0 && (final.medOfMedDur < durLowChk || final.medOfMedDur > durHighChk) { return 0, 0, gridAggResult{}, false } return hpScale, atkScale, final, true diff --git a/backend/cmd/balanceall/main.go b/backend/cmd/balanceall/main.go index 5a8673b..362944b 100644 --- a/backend/cmd/balanceall/main.go +++ b/backend/cmd/balanceall/main.go @@ -188,6 +188,7 @@ func main() { et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate) var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64 + dotHeavy := enemyTemplateHasPeriodicDoT(tmpl) if *tieredTargets { norm := archetypeTierNorm(tmpl, *tierLevelMin, *tierLevelMax) tPow := math.Pow(norm, *tierGamma) @@ -225,6 +226,20 @@ func main() { if hpHighGrid > 1 { hpHighGrid = 1 } + if dotHeavy { + // Slightly shorter center time (less DoT stacks) but keep band wide enough vs baseline noise. + targetSecEff *= 0.93 + lowSec = targetSecEff * (1.0 - tol) + highSec = targetSecEff * (1.0 + tol) + // Extra duration slack: DoT makes med-of-med duration jumpy across cells. + halfW := (highSec - lowSec) * 0.5 * 1.12 + mid := (lowSec + highSec) * 0.5 + lowSec = mid - halfW + highSec = mid + halfW + hpLowGrid = math.Max(0.10, hpLowGrid-0.14) + hpHighGrid = math.Min(1.0, hpHighGrid+0.06) + fmt.Printf("# dot: burn/poison — targetSec×0.93, dur band +12%%, HP band widened\n") + } fmt.Printf("# tier: norm=%.3f curve=%.3f | targetSec=%.1fs heroHpMid=%.1f%% | bands dur [%.1f,%.1f] hp [%.1f%%,%.1f%%] (±%.1f pp)\n", norm, tPow, targetSecEff, heroHpMidEff, lowSec, highSec, 100*hpLowGrid, 100*hpHighGrid, ppPts) } else { @@ -238,6 +253,7 @@ func main() { hpScale, atkScale, gfinal, ok := balanceArchetypeGrid( tmpl, et, scenarios, *iterations, typeSeed, lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses, + dotHeavy, ) if !ok { fmt.Printf("## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n", et) diff --git a/backend/internal/game/balance_reference.go b/backend/internal/game/balance_reference.go index 3ec1fb0..faf4005 100644 --- a/backend/internal/game/balance_reference.go +++ b/backend/internal/game/balance_reference.go @@ -113,27 +113,28 @@ func CloneHeroForCombatSim(h *model.Hero) *model.Hero { } // PrepareHeroForAdminCombatSim returns a clone of h for the admin combat simulator: gear copied, -// all debuffs cleared, derived stats refreshed, HP set to max. Does not persist; does not mutate h. +// all buffs and debuffs cleared, derived stats refreshed from base+gear only, HP set to max, +// state normalized to walking (fresh duel — ignores in-combat / resting flags on the source snapshot). +// Does not persist; does not mutate h. // combatTimelineStart should match the start time passed to ResolveCombatToEndWithDuration (e.g. CombatSimDeterministicStart); if zero, time.Now() is used. func PrepareHeroForAdminCombatSim(h *model.Hero, combatTimelineStart time.Time) *model.Hero { hero := CloneHeroForCombatSim(h) if hero == nil { return nil } + hero.Buffs = nil hero.Debuffs = nil hero.DebuffCatalog = nil now := combatTimelineStart if now.IsZero() { now = time.Now() } + hero.State = model.StateWalking hero.RefreshDerivedCombatStats(now) if hero.MaxHP <= 0 { hero.MaxHP = 1 } hero.HP = hero.MaxHP - if hero.State == model.StateDead { - hero.State = model.StateWalking - } return hero } diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 78ee53d..3793937 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -2447,6 +2447,12 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) return } + // Live session (engine) is authoritative for gear/stats while online; DB can lag during combat. + if h.engine != nil { + if hm := h.engine.GetMovements(req.HeroID); hm != nil && hm.Hero != nil { + baseHero = game.CloneHeroForCombatSim(hm.Hero) + } + } tmpl, ok := model.EnemyTemplates[enemyType] if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"}) diff --git a/backend/migrations/000003_balanceall_enemies.sql b/backend/migrations/000003_balanceall_enemies.sql index 4b3f140..1f2964a 100644 --- a/backend/migrations/000003_balanceall_enemies.sql +++ b/backend/migrations/000003_balanceall_enemies.sql @@ -1,6 +1,5 @@ --- Enemy combat stats from balanceall grid (see balanceall-out.txt and fire_demon run). --- fire_demon: grid balance with in-memory overlay only (specialAbilities cleared for sim); --- column special_abilities in DB is unchanged — burn remains unless changed elsewhere. +-- Enemy combat stats from balanceall grid (tiered targets, DoT-aware path for burn/poison). +-- fire_demon: tuned with burn enabled (grid dot branch in cmd/balanceall). UPDATE enemies SET hp = 94, max_hp = 94, hp_per_level = 7.8681, attack = 20, attack_per_level = 2.7054 WHERE type = 'wolf'; UPDATE enemies SET hp = 102, max_hp = 102, hp_per_level = 8.2826, attack = 25, attack_per_level = 2.3190 WHERE type = 'boar'; @@ -9,7 +8,7 @@ UPDATE enemies SET hp = 118, max_hp = 118, hp_per_level = 12.0614, attack = 24, UPDATE enemies SET hp = 113, max_hp = 113, hp_per_level = 7.1338, attack = 27, attack_per_level = 2.6581 WHERE type = 'orc'; UPDATE enemies SET hp = 132, max_hp = 132, hp_per_level = 8.5586, attack = 28, attack_per_level = 2.2939 WHERE type = 'skeleton_archer'; UPDATE enemies SET hp = 105, max_hp = 105, hp_per_level = 5.7476, attack = 32, attack_per_level = 2.4140 WHERE type = 'battle_lizard'; -UPDATE enemies SET hp = 193, max_hp = 193, hp_per_level = 12.7956, attack = 28, attack_per_level = 2.9413 WHERE type = 'fire_demon'; +UPDATE enemies SET hp = 177, max_hp = 177, hp_per_level = 11.7200, attack = 25, attack_per_level = 2.6587 WHERE type = 'fire_demon'; UPDATE enemies SET hp = 208, max_hp = 208, hp_per_level = 7.5649, attack = 37, attack_per_level = 3.0394 WHERE type = 'ice_guardian'; UPDATE enemies SET hp = 149, max_hp = 149, hp_per_level = 4.1663, attack = 24, attack_per_level = 1.8339 WHERE type = 'skeleton_king'; UPDATE enemies SET hp = 349, max_hp = 349, hp_per_level = 8.0285, attack = 45, attack_per_level = 3.1288 WHERE type = 'water_element';