rebalance

master
Denis Ranneft 1 month ago
parent 380fcd41ae
commit 2f00103b90

@ -1,5 +0,0 @@
{
"fire_demon": {
"specialAbilities": []
}
}

@ -12,6 +12,18 @@ import (
"github.com/denisovdennis/autohero/internal/model" "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). // gridScenario: fair fight heroLv == enemyLv at each tier, × gear variants (median + rolled).
type gridScenario struct { type gridScenario struct {
heroLv int heroLv int
@ -242,22 +254,34 @@ func findHPScaleForAggDurationGrid(tmpl model.Enemy, et model.EnemyType, scenari
return -1 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) a := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minMedWin)
if a >= 0 { if a >= 0 {
return a return a
} }
hpLo2 := hpLow - 0.10 extra := 0.10
if hpLo2 < 0.15 { if dotHeavy {
hpLo2 = 0.15 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 { 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) 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. // 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 ~1015% median cell win rate through to binary search
}
if r0.medWinRate < minMedWin*earlyFrac {
return -1 return -1
} }
if r0.medOfMedHp >= hpLow && r0.medOfMedHp <= hpHigh { 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++ { for iter := 0; iter < 56; iter++ {
mid := (lo + hi) / 2 mid := (lo + hi) / 2
r := aggregateGrid(tmpl, et, scenarios, hpScale, mid, n, seedBase) 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 hi = mid
best = mid best = mid
continue 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. // 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( func balanceArchetypeGrid(
tmpl model.Enemy, tmpl model.Enemy,
et model.EnemyType, et model.EnemyType,
@ -338,6 +367,7 @@ func balanceArchetypeGrid(
hpLow, hpHigh float64, hpLow, hpHigh float64,
minWinRate float64, minWinRate float64,
refinePasses int, refinePasses int,
dotHeavy bool,
) (hpScale, atkScale float64, final gridAggResult, ok bool) { ) (hpScale, atkScale float64, final gridAggResult, ok bool) {
if len(scenarios) == 0 { if len(scenarios) == 0 {
return 0, 0, gridAggResult{}, false return 0, 0, gridAggResult{}, false
@ -353,17 +383,27 @@ func balanceArchetypeGrid(
maxRounds = 10 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 ~1220%.
minWinGate = minWinRate * 0.52
}
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinGate)
if hpScale < 0 { 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 { 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 { if hpScale < 0 {
return 0, 0, gridAggResult{}, false 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 { if atkScale < 0 {
return 0, 0, gridAggResult{}, false return 0, 0, gridAggResult{}, false
} }
@ -372,7 +412,7 @@ func balanceArchetypeGrid(
a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase) a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase)
durOk := a.medOfMedDur > 0 && a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec durOk := a.medOfMedDur > 0 && a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec
hpOk := a.medOfMedHp >= hpLow && a.medOfMedHp <= hpHigh hpOk := a.medOfMedHp >= hpLow && a.medOfMedHp <= hpHigh
if durOk && hpOk && a.medWinRate >= minWinRate { if durOk && hpOk && a.medWinRate >= minWinGate {
break break
} }
if a.medOfMedDur > 0 && !durOk { if a.medOfMedDur > 0 && !durOk {
@ -382,7 +422,7 @@ func balanceArchetypeGrid(
break 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 atkScale = atk2
} else { } else {
break break
@ -391,7 +431,7 @@ func balanceArchetypeGrid(
} }
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) 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 atkScale = atk2
} }
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
@ -408,20 +448,29 @@ func balanceArchetypeGrid(
if hpScale < 0.02 || hpScale > 28 { if hpScale < 0.02 || hpScale > 28 {
break 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 atkScale = atk2
} }
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase) ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
} }
final = aggregateGrid(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 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 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 0, 0, gridAggResult{}, false
} }
return hpScale, atkScale, final, true return hpScale, atkScale, final, true

@ -188,6 +188,7 @@ func main() {
et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate) et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64 var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64
dotHeavy := enemyTemplateHasPeriodicDoT(tmpl)
if *tieredTargets { if *tieredTargets {
norm := archetypeTierNorm(tmpl, *tierLevelMin, *tierLevelMax) norm := archetypeTierNorm(tmpl, *tierLevelMin, *tierLevelMax)
tPow := math.Pow(norm, *tierGamma) tPow := math.Pow(norm, *tierGamma)
@ -225,6 +226,20 @@ func main() {
if hpHighGrid > 1 { if hpHighGrid > 1 {
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", 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) norm, tPow, targetSecEff, heroHpMidEff, lowSec, highSec, 100*hpLowGrid, 100*hpHighGrid, ppPts)
} else { } else {
@ -238,6 +253,7 @@ func main() {
hpScale, atkScale, gfinal, ok := balanceArchetypeGrid( hpScale, atkScale, gfinal, ok := balanceArchetypeGrid(
tmpl, et, scenarios, *iterations, typeSeed, tmpl, et, scenarios, *iterations, typeSeed,
lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses, lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses,
dotHeavy,
) )
if !ok { if !ok {
fmt.Printf("## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n", et) fmt.Printf("## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n", et)

@ -113,27 +113,28 @@ func CloneHeroForCombatSim(h *model.Hero) *model.Hero {
} }
// PrepareHeroForAdminCombatSim returns a clone of h for the admin combat simulator: gear copied, // 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. // 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 { func PrepareHeroForAdminCombatSim(h *model.Hero, combatTimelineStart time.Time) *model.Hero {
hero := CloneHeroForCombatSim(h) hero := CloneHeroForCombatSim(h)
if hero == nil { if hero == nil {
return nil return nil
} }
hero.Buffs = nil
hero.Debuffs = nil hero.Debuffs = nil
hero.DebuffCatalog = nil hero.DebuffCatalog = nil
now := combatTimelineStart now := combatTimelineStart
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
hero.State = model.StateWalking
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
if hero.MaxHP <= 0 { if hero.MaxHP <= 0 {
hero.MaxHP = 1 hero.MaxHP = 1
} }
hero.HP = hero.MaxHP hero.HP = hero.MaxHP
if hero.State == model.StateDead {
hero.State = model.StateWalking
}
return hero return hero
} }

@ -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"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return 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] tmpl, ok := model.EnemyTemplates[enemyType]
if !ok { if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"})

@ -1,6 +1,5 @@
-- Enemy combat stats from balanceall grid (see balanceall-out.txt and fire_demon run). -- Enemy combat stats from balanceall grid (tiered targets, DoT-aware path for burn/poison).
-- fire_demon: grid balance with in-memory overlay only (specialAbilities cleared for sim); -- fire_demon: tuned with burn enabled (grid dot branch in cmd/balanceall).
-- column special_abilities in DB is unchanged — burn remains unless changed elsewhere.
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 = 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'; 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 = 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 = 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 = 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 = 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 = 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'; UPDATE enemies SET hp = 349, max_hp = 349, hp_per_level = 8.0285, attack = 45, attack_per_level = 3.1288 WHERE type = 'water_element';

Loading…
Cancel
Save