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, ®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, 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 }