You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
112 lines
3.1 KiB
Go
112 lines
3.1 KiB
Go
package game
|
|
|
|
import (
|
|
"math/rand"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/denisovdennis/autohero/internal/constants"
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
)
|
|
|
|
// BalanceEnemyMode selects how the opponent is chosen for balance Monte Carlo.
|
|
type BalanceEnemyMode int
|
|
|
|
const (
|
|
// BalanceEnemyWolfOnly uses a single scaled Forest Wolf (canonical curve check).
|
|
BalanceEnemyWolfOnly BalanceEnemyMode = iota
|
|
// BalanceEnemyMixedSpawn matches PickEnemyForLevelWithRNG (weighted random template in band).
|
|
BalanceEnemyMixedSpawn
|
|
)
|
|
|
|
// BalanceMonteCarloResult aggregates outcomes from RunBalanceMonteCarlo.
|
|
type BalanceMonteCarloResult struct {
|
|
Iterations int
|
|
Wins int
|
|
WinRate float64
|
|
MedianDur time.Duration
|
|
P90Dur time.Duration
|
|
MeanDur time.Duration
|
|
}
|
|
|
|
// RunBalanceMonteCarlo runs N independent fights at hero level against scaled enemies.
|
|
// Per-iteration RNG is derived from seed so results are reproducible.
|
|
// Global math/rand is re-seeded per fight for damage/crit/dodge rolls (same as legacy combat).
|
|
func RunBalanceMonteCarlo(level int, iterations int, seed int64, gearProfile ReferenceGearProfile, enemyMode BalanceEnemyMode) BalanceMonteCarloResult {
|
|
if iterations <= 0 {
|
|
return BalanceMonteCarloResult{}
|
|
}
|
|
var wins int
|
|
durations := make([]time.Duration, 0, iterations)
|
|
var sumDur time.Duration
|
|
|
|
for i := 0; i < iterations; i++ {
|
|
var gearRng *rand.Rand
|
|
if gearProfile == ReferenceGearRolled {
|
|
gearRng = rand.New(rand.NewSource(seed + int64(i)*1_000_003))
|
|
}
|
|
baseHero := NewReferenceHeroForBalance(level, gearProfile, gearRng)
|
|
hero := CloneHeroForCombatSim(baseHero)
|
|
|
|
// Combat RNG (damage rolls, dodge, crit, debuff procs).
|
|
rand.Seed(seed + int64(i)*9_999_983)
|
|
|
|
var enemy model.Enemy
|
|
switch enemyMode {
|
|
case BalanceEnemyWolfOnly:
|
|
enemy = firstEnemyForBalance(level)
|
|
case BalanceEnemyMixedSpawn:
|
|
pickRNG := rand.New(rand.NewSource(seed + int64(i)*2_000_001))
|
|
enemy = PickEnemyForLevelWithRNG(level, pickRNG, hero)
|
|
default:
|
|
enemy = firstEnemyForBalance(level)
|
|
}
|
|
|
|
survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{
|
|
TickRate: 100 * time.Millisecond,
|
|
MaxSteps: constants.CombatSimMaxStepsLong,
|
|
})
|
|
if survived {
|
|
wins++
|
|
}
|
|
durations = append(durations, elapsed)
|
|
sumDur += elapsed
|
|
}
|
|
|
|
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
|
|
median := durations[len(durations)/2]
|
|
p90idx := int(0.9 * float64(len(durations)-1))
|
|
if p90idx < 0 {
|
|
p90idx = 0
|
|
}
|
|
p90 := durations[p90idx]
|
|
|
|
return BalanceMonteCarloResult{
|
|
Iterations: iterations,
|
|
Wins: wins,
|
|
WinRate: float64(wins) / float64(iterations),
|
|
MedianDur: median,
|
|
P90Dur: p90,
|
|
MeanDur: time.Duration(int64(sumDur) / int64(iterations)),
|
|
}
|
|
}
|
|
|
|
func firstEnemyForBalance(level int) model.Enemy {
|
|
var best model.Enemy
|
|
bestSet := false
|
|
for _, t := range model.EnemyTemplates {
|
|
if !bestSet {
|
|
best = t
|
|
bestSet = true
|
|
continue
|
|
}
|
|
if t.BaseLevel > 0 && (best.BaseLevel == 0 || t.BaseLevel < best.BaseLevel) {
|
|
best = t
|
|
}
|
|
}
|
|
if !bestSet {
|
|
return model.Enemy{}
|
|
}
|
|
return ScaleEnemyTemplate(best, level)
|
|
}
|