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.
160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
package game
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
"github.com/denisovdennis/autohero/internal/constants"
|
|
)
|
|
|
|
|
|
// 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, force bool) int
|
|
// 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 = constants.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, ®enRemainder)
|
|
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, false)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
|