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.

192 lines
5.5 KiB
Go

package game
import (
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
const (
offlineAutoPotionChance = 0.02
offlineAutoPotionHPThresh = 0.40
// CombatSimMaxStepsDefault is the iteration cap when CombatSimOptions.MaxSteps <= 0 (offline, tests).
CombatSimMaxStepsDefault = 200_000
// CombatSimMaxStepsLong is used by balance CLIs and admin combat sim so long fights (DoT/regen) are not cut off early.
CombatSimMaxStepsLong = 3_000_000
)
// CombatSimDeterministicStart is the fixed combat timeline origin for balance tools and admin sim parity (avoids wall-clock drift in tests).
var CombatSimDeterministicStart = time.Unix(1_700_000_000, 0)
// CombatSimOptions configures the shared combat resolution loop.
type CombatSimOptions struct {
// TickRate matches the engine combat tick cadence (used for periodic effects).
TickRate time.Duration
// AutoUsePotion decides whether to consume a potion after damage ticks/attacks.
// It should return true when a potion was used.
AutoUsePotion func(hero *model.Hero, now time.Time) bool
// WallClockDelay adds optional real-time delay between simulation steps.
// 0 means instant simulation (default).
WallClockDelay time.Duration
// OnEvent receives attack/tick/death events emitted by the simulator.
OnEvent func(evt model.CombatEvent)
// MaxSteps caps the simulation loop (default CombatSimMaxStepsDefault). Use CombatSimMaxStepsLong for balance/admin parity on long fights.
MaxSteps int
}
// ResolveCombatToEnd runs a combat loop using the same mechanics as the online engine.
// It mutates hero and enemy until one side dies, returning whether the hero survived.
func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) bool {
survived, _ := resolveCombatToEnd(hero, enemy, start, opts)
return survived
}
// ResolveCombatToEndWithDuration is like ResolveCombatToEnd but also returns simulated combat
// elapsed time (last event time minus start), using the same timeline as the online engine.
func ResolveCombatToEndWithDuration(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) (survived bool, elapsed time.Duration) {
return resolveCombatToEnd(hero, enemy, start, opts)
}
func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) (survived bool, elapsed time.Duration) {
if hero == nil || enemy == nil {
return false, 0
}
tickRate := opts.TickRate
if tickRate <= 0 {
tickRate = 100 * time.Millisecond
}
now := start
heroNext := now.Add(attackInterval(hero.EffectiveSpeed()))
enemyNext := now.Add(attackIntervalEnemy(enemy.Speed))
nextTick := now.Add(tickRate)
lastTickAt := now
var regenRemainder float64
step := 0
maxSteps := opts.MaxSteps
if maxSteps <= 0 {
maxSteps = CombatSimMaxStepsDefault
}
for step < maxSteps {
step++
next := heroNext
if enemyNext.Before(next) {
next = enemyNext
}
if nextTick.Before(next) {
next = nextTick
}
now = next
if now.Equal(nextTick) {
tickDur := now.Sub(lastTickAt)
if tickDur > 0 {
ProcessDebuffDamage(hero, tickDur, now)
ProcessEnemyRegen(enemy, tickDur, &regenRemainder)
ProcessSummonDamage(hero, enemy, start, lastTickAt, now)
lastTickAt = now
if CheckDeath(hero, now) {
hero.HP = 0
emitSimEvent(opts, model.CombatEvent{
Type: "death",
Source: "enemy",
HeroID: hero.ID,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
})
return false, now.Sub(start)
}
}
emitSimEvent(opts, model.CombatEvent{
Type: "tick",
Source: "system",
HeroID: hero.ID,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
})
simStepDelay(opts)
nextTick = nextTick.Add(tickRate)
continue
}
if !heroNext.After(enemyNext) && now.Equal(heroNext) {
evt := ProcessAttack(hero, enemy, now)
emitSimEvent(opts, evt)
simStepDelay(opts)
if !enemy.IsAlive() {
return true, now.Sub(start)
}
heroNext = now.Add(attackInterval(hero.EffectiveSpeed()))
continue
}
if now.Equal(enemyNext) {
evt := ProcessEnemyAttack(hero, enemy, now)
emitSimEvent(opts, evt)
simStepDelay(opts)
if CheckDeath(hero, now) {
hero.HP = 0
emitSimEvent(opts, model.CombatEvent{
Type: "death",
Source: "enemy",
HeroID: hero.ID,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
})
return false, now.Sub(start)
}
if opts.AutoUsePotion != nil {
_ = opts.AutoUsePotion(hero, now)
}
enemyNext = now.Add(attackIntervalEnemy(enemy.Speed))
}
}
win := hero.HP > 0 && enemy.IsAlive() == false
return win, now.Sub(start)
}
func emitSimEvent(opts CombatSimOptions, evt model.CombatEvent) {
if opts.OnEvent != nil {
opts.OnEvent(evt)
}
}
func simStepDelay(opts CombatSimOptions) {
if opts.WallClockDelay > 0 {
time.Sleep(opts.WallClockDelay)
}
}
// OfflineAutoPotionHook is a low-probability offline-only potion usage policy.
func OfflineAutoPotionHook(hero *model.Hero, now time.Time) bool {
if hero == nil || hero.Potions <= 0 || hero.HP <= 0 {
return false
}
hpThresh := int(float64(hero.MaxHP) * offlineAutoPotionHPThresh)
if hero.HP >= hpThresh {
return false
}
if rand.Float64() >= offlineAutoPotionChance {
return false
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
return true
}