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.
353 lines
10 KiB
Go
353 lines
10 KiB
Go
package game
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"math/rand"
|
|
"time"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
"github.com/denisovdennis/autohero/internal/storage"
|
|
)
|
|
|
|
// OfflineSimulator runs periodic background ticks for heroes that are offline,
|
|
// advancing movement the same way as the online engine (without WebSocket payloads)
|
|
// and resolving random encounters with SimulateOneFight.
|
|
type OfflineSimulator struct {
|
|
store *storage.HeroStore
|
|
logStore *storage.LogStore
|
|
graph *RoadGraph
|
|
interval time.Duration
|
|
logger *slog.Logger
|
|
// isPaused, when set, skips simulation ticks while global server time is frozen.
|
|
isPaused func() bool
|
|
}
|
|
|
|
// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
|
|
// isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause).
|
|
func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool) *OfflineSimulator {
|
|
return &OfflineSimulator{
|
|
store: store,
|
|
logStore: logStore,
|
|
graph: graph,
|
|
interval: 30 * time.Second,
|
|
logger: logger,
|
|
isPaused: isPaused,
|
|
}
|
|
}
|
|
|
|
// Run starts the offline simulation loop. It blocks until the context is cancelled.
|
|
func (s *OfflineSimulator) Run(ctx context.Context) error {
|
|
ticker := time.NewTicker(s.interval)
|
|
defer ticker.Stop()
|
|
|
|
s.logger.Info("offline simulator started", "interval", s.interval)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
s.logger.Info("offline simulator shutting down")
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
s.processTick(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processTick finds all offline heroes and simulates one fight for each.
|
|
func (s *OfflineSimulator) processTick(ctx context.Context) {
|
|
if s.isPaused != nil && s.isPaused() {
|
|
return
|
|
}
|
|
heroes, err := s.store.ListOfflineHeroes(ctx, s.interval*2, 100)
|
|
if err != nil {
|
|
s.logger.Error("offline simulator: failed to list offline heroes", "error", err)
|
|
return
|
|
}
|
|
|
|
if len(heroes) == 0 {
|
|
return
|
|
}
|
|
|
|
s.logger.Debug("offline simulator tick", "offline_heroes", len(heroes))
|
|
|
|
for _, hero := range heroes {
|
|
if err := s.simulateHeroTick(ctx, hero); err != nil {
|
|
s.logger.Error("offline simulator: hero tick failed",
|
|
"hero_id", hero.ID,
|
|
"error", err,
|
|
)
|
|
// Continue with other heroes — don't crash on one failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
// simulateHeroTick catches up movement (500ms steps) from hero.UpdatedAt to now,
|
|
// then persists. Random encounters use the same rolls as online; combat is resolved
|
|
// synchronously via SimulateOneFight (no WebSocket).
|
|
func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero) error {
|
|
now := time.Now()
|
|
|
|
// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3).
|
|
if (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > 1*time.Hour {
|
|
hero.HP = hero.MaxHP / 2
|
|
if hero.HP < 1 {
|
|
hero.HP = 1
|
|
}
|
|
hero.State = model.StateWalking
|
|
hero.Debuffs = nil
|
|
s.addLog(ctx, hero.ID, "Auto-revived after 1 hour")
|
|
}
|
|
|
|
// Dead heroes cannot move or fight.
|
|
if hero.State == model.StateDead || hero.HP <= 0 {
|
|
return nil
|
|
}
|
|
|
|
// Do not simulate roads/encounters while the hero is in town (rest or NPC tour).
|
|
if hero.State == model.StateInTown || hero.State == model.StateResting {
|
|
return nil
|
|
}
|
|
if hero.MoveState == string(model.StateInTown) || hero.MoveState == string(model.StateResting) {
|
|
return nil
|
|
}
|
|
|
|
if s.graph == nil {
|
|
s.logger.Warn("offline simulator: road graph nil, skipping movement tick", "hero_id", hero.ID)
|
|
return nil
|
|
}
|
|
|
|
hm := NewHeroMovement(hero, s.graph, now)
|
|
if hero.UpdatedAt.IsZero() {
|
|
hm.LastMoveTick = now.Add(-MovementTickRate)
|
|
} else {
|
|
hm.LastMoveTick = hero.UpdatedAt
|
|
}
|
|
|
|
encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
|
|
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Encountered %s", enemy.Name))
|
|
survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, func(msg string) {
|
|
s.addLog(ctx, hm.Hero.ID, msg)
|
|
})
|
|
if survived {
|
|
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained))
|
|
hm.ResumeWalking(tickNow)
|
|
} else {
|
|
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Died fighting %s", en.Name))
|
|
hm.Die()
|
|
}
|
|
}
|
|
|
|
const maxOfflineMovementSteps = 200000
|
|
step := 0
|
|
for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps {
|
|
step++
|
|
next := hm.LastMoveTick.Add(MovementTickRate)
|
|
if next.After(now) {
|
|
next = now
|
|
}
|
|
if !next.After(hm.LastMoveTick) {
|
|
break
|
|
}
|
|
onMerchant := func(hm *HeroMovement, tickNow time.Time, cost int64) {
|
|
_ = tickNow
|
|
_ = cost
|
|
s.addLog(ctx, hm.Hero.ID, "Encountered a Wandering Merchant on the road")
|
|
}
|
|
adventureLog := func(heroID int64, msg string) {
|
|
s.addLog(ctx, heroID, msg)
|
|
}
|
|
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog)
|
|
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
|
|
break
|
|
}
|
|
}
|
|
if step >= maxOfflineMovementSteps && hm.LastMoveTick.Before(now) {
|
|
s.logger.Warn("offline movement step cap reached", "hero_id", hero.ID)
|
|
}
|
|
|
|
hm.SyncToHero()
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
|
if err := s.store.Save(ctx, hero); err != nil {
|
|
return fmt.Errorf("save hero after offline tick: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// addLog is a fire-and-forget helper that writes an adventure log entry.
|
|
func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message string) {
|
|
logCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
defer cancel()
|
|
if err := s.logStore.Add(logCtx, heroID, message); err != nil {
|
|
s.logger.Warn("offline simulator: failed to write adventure log",
|
|
"hero_id", heroID,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// SimulateOneFight runs one combat encounter for an offline hero.
|
|
// It mutates the hero (HP, XP, gold, potions, level, equipment, state).
|
|
// If encounterEnemy is non-nil, that enemy is used (same as movement encounter roll);
|
|
// otherwise a new enemy is picked for the hero's level.
|
|
// onInventoryDiscard is called when a gear drop cannot be equipped and the backpack is full (may be nil).
|
|
// Returns whether the hero survived, the enemy fought, XP gained, and gold gained.
|
|
func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy, g *RoadGraph, onInventoryDiscard func(string)) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) {
|
|
if encounterEnemy != nil {
|
|
enemy = *encounterEnemy
|
|
} else {
|
|
enemy = PickEnemyForLevel(hero.Level)
|
|
}
|
|
|
|
allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY)
|
|
|
|
heroDmgPerHit := hero.EffectiveAttackAt(now) - enemy.Defense
|
|
if heroDmgPerHit < 1 {
|
|
heroDmgPerHit = 1
|
|
}
|
|
enemyDmgPerHit := enemy.Attack - hero.EffectiveDefenseAt(now)
|
|
if enemyDmgPerHit < 1 {
|
|
enemyDmgPerHit = 1
|
|
}
|
|
|
|
hitsToKill := (enemy.MaxHP + heroDmgPerHit - 1) / heroDmgPerHit
|
|
dmgTaken := enemyDmgPerHit * hitsToKill
|
|
|
|
hero.HP -= dmgTaken
|
|
|
|
// Use potion if HP drops below 30% and hero has potions.
|
|
if hero.HP > 0 && hero.HP < hero.MaxHP*30/100 && hero.Potions > 0 {
|
|
healAmount := hero.MaxHP * 30 / 100
|
|
hero.HP += healAmount
|
|
if hero.HP > hero.MaxHP {
|
|
hero.HP = hero.MaxHP
|
|
}
|
|
hero.Potions--
|
|
}
|
|
|
|
if hero.HP <= 0 {
|
|
hero.HP = 0
|
|
hero.State = model.StateDead
|
|
hero.TotalDeaths++
|
|
hero.KillsSinceDeath = 0
|
|
return false, enemy, 0, 0
|
|
}
|
|
|
|
// Hero survived — apply rewards and stat tracking.
|
|
hero.TotalKills++
|
|
hero.KillsSinceDeath++
|
|
if enemy.IsElite {
|
|
hero.EliteKills++
|
|
}
|
|
|
|
xpGained = enemy.XPReward
|
|
hero.XP += xpGained
|
|
|
|
// Loot generation.
|
|
luckMult := LuckMultiplier(hero, now)
|
|
drops := model.GenerateLoot(enemy.Type, luckMult)
|
|
for _, drop := range drops {
|
|
// Track legendary equipment drops for achievements.
|
|
if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" {
|
|
hero.LegendaryDrops++
|
|
}
|
|
switch drop.ItemType {
|
|
case "gold":
|
|
hero.Gold += drop.GoldAmount
|
|
goldGained += drop.GoldAmount
|
|
case "potion":
|
|
hero.Potions++
|
|
default:
|
|
// All equipment drops go through the unified gear system.
|
|
slot := model.EquipmentSlot(drop.ItemType)
|
|
family := model.PickGearFamily(slot)
|
|
if family != nil {
|
|
ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
|
|
item := model.NewGearItem(family, ilvl, drop.Rarity)
|
|
TryEquipOrStashOffline(hero, item, now, onInventoryDiscard)
|
|
} else if allowSell {
|
|
hero.Gold += model.AutoSellPrices[drop.Rarity]
|
|
goldGained += model.AutoSellPrices[drop.Rarity]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also add the base gold reward from the enemy.
|
|
hero.Gold += enemy.GoldReward
|
|
goldGained += enemy.GoldReward
|
|
|
|
// Level-up loop.
|
|
for hero.LevelUp() {
|
|
}
|
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
return true, enemy, xpGained, goldGained
|
|
}
|
|
|
|
// PickEnemyForLevel selects a random enemy appropriate for the hero's level
|
|
// and scales its stats. Exported for use by both the offline simulator and handler.
|
|
func PickEnemyForLevel(level int) model.Enemy {
|
|
candidates := make([]model.Enemy, 0, len(model.EnemyTemplates))
|
|
for _, t := range model.EnemyTemplates {
|
|
if level >= t.MinLevel && level <= t.MaxLevel {
|
|
candidates = append(candidates, t)
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
// Hero exceeds all level bands — pick enemies from the highest band.
|
|
highestMin := 0
|
|
for _, t := range model.EnemyTemplates {
|
|
if t.MinLevel > highestMin {
|
|
highestMin = t.MinLevel
|
|
}
|
|
}
|
|
for _, t := range model.EnemyTemplates {
|
|
if t.MinLevel >= highestMin {
|
|
candidates = append(candidates, t)
|
|
}
|
|
}
|
|
}
|
|
picked := candidates[rand.Intn(len(candidates))]
|
|
return ScaleEnemyTemplate(picked, level)
|
|
}
|
|
|
|
// ScaleEnemyTemplate applies band-based level scaling to stats and rewards.
|
|
// Exported for reuse across handler and offline simulation.
|
|
func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {
|
|
picked := tmpl
|
|
|
|
bandLevel := heroLevel
|
|
if bandLevel < tmpl.MinLevel {
|
|
bandLevel = tmpl.MinLevel
|
|
}
|
|
if bandLevel > tmpl.MaxLevel {
|
|
bandLevel = tmpl.MaxLevel
|
|
}
|
|
bandDelta := float64(bandLevel - tmpl.MinLevel)
|
|
overcapDelta := float64(heroLevel - tmpl.MaxLevel)
|
|
if overcapDelta < 0 {
|
|
overcapDelta = 0
|
|
}
|
|
|
|
hpMul := 1.0 + bandDelta*0.05 + overcapDelta*0.025
|
|
atkMul := 1.0 + bandDelta*0.035 + overcapDelta*0.018
|
|
defMul := 1.0 + bandDelta*0.035 + overcapDelta*0.018
|
|
|
|
picked.MaxHP = max(1, int(float64(picked.MaxHP)*hpMul))
|
|
picked.HP = picked.MaxHP
|
|
picked.Attack = max(1, int(float64(picked.Attack)*atkMul))
|
|
picked.Defense = max(0, int(float64(picked.Defense)*defMul))
|
|
|
|
xpMul := 1.0 + bandDelta*0.05 + overcapDelta*0.03
|
|
goldMul := 1.0 + bandDelta*0.05 + overcapDelta*0.025
|
|
picked.XPReward = int64(math.Round(float64(picked.XPReward) * xpMul))
|
|
picked.GoldReward = int64(math.Round(float64(picked.GoldReward) * goldMul))
|
|
|
|
return picked
|
|
}
|
|
|