@ -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 ~10– 15% 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 ~12– 20%.
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 , minWin G ate* 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 , minWin Rate )
atkScale = findAtkScaleForAggHeroHpGridRelaxed ( tmpl , et , scenarios , hpScale , n , seedBase , hpLow , hpHigh , minWin Gate, 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 >= minWin R ate {
if durOk && hpOk && a . medWinRate >= minWin G ate {
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 , minWin Rate ) ; atk2 > 0 {
if atk2 := findAtkScaleForAggHeroHpGridRelaxed ( tmpl , et , scenarios , hpScale , n , seedBase , hpLow , hpHigh , minWin Gate, 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 , minWin Rate ) ; atk2 > 0 {
if atk2 := findAtkScaleForAggHeroHpGridRelaxed ( tmpl , et , scenarios , hpScale , n , seedBase , hpLow , hpHigh , minWin Gate, 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 , minWin Rate ) ; atk2 > 0 {
if atk2 := findAtkScaleForAggHeroHpGridRelaxed ( tmpl , et , scenarios , hpScale , n , seedBase , hpLow , hpHigh , minWin Gate, 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 < minWin R ate {
if final . medWinRate < minWin G ate {
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