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

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