package game import ( "context" "fmt" "log/slog" "math" "math/rand" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" ) // 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 questStore *storage.QuestStore graph *RoadGraph interval time.Duration logger *slog.Logger // isPaused, when set, skips simulation ticks while global server time is frozen. isPaused func() bool // skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session) // so the same hero is not simulated twice. skipIfLive func(heroID int64) 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). // skipIfLive may be nil; if it returns true for a hero id, that hero is skipped this tick. func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, questStore *storage.QuestStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator { return &OfflineSimulator{ store: store, logStore: logStore, questStore: questStore, graph: graph, interval: 30 * time.Second, logger: logger, isPaused: isPaused, skipIfLive: skipIfLive, } } // 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 s.skipIfLive != nil && s.skipIfLive(hero.ID) { continue } 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 in configured movement-tick 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 after configured downtime (autoReviveAfterMs). gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond if (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > gap { hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent) if hero.HP < 1 { hero.HP = 1 } hero.State = model.StateWalking hero.Debuffs = nil s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second))) } // Dead heroes cannot move or fight. if hero.State == model.StateDead || hero.HP <= 0 { 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 hm.State == model.StateFighting { return nil } 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 offlineNPC := s.offlineTownNPCInteractHook(ctx) 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, nil, offlineNPC) 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 } func (s *OfflineSimulator) offlineTownNPCInteractHook(ctx context.Context) TownNPCOfflineInteractHook { return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { return s.applyOfflineTownNPCVisit(ctx, heroID, hm, graph, npc, now, al) } } // applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI). func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { _ = graph _ = now cfg := tuning.Get() inter := cfg.TownNPCInteractChance if inter <= 0 { inter = tuning.DefaultValues().TownNPCInteractChance } if inter > 1 { inter = 1 } if rand.Float64() >= inter { return false } h := hm.Hero if h == nil { return false } switch npc.Type { case "merchant": share := cfg.MerchantTownAutoSellShare if share <= 0 || share > 1 { share = tuning.DefaultValues().MerchantTownAutoSellShare } soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil) if soldItems > 0 && al != nil { al(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold)) } potionCost, _ := tuning.EffectiveNPCShopCosts() if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 { h.Gold -= potionCost h.Potions++ if al != nil { al(heroID, fmt.Sprintf("Purchased a Healing Potion from %s.", npc.Name)) } } case "healer": _, healCost := tuning.EffectiveNPCShopCosts() if h.HP < h.MaxHP && healCost > 0 && h.Gold >= healCost { h.Gold -= healCost h.HP = h.MaxHP if al != nil { al(heroID, fmt.Sprintf("Paid %s to restore full health.", npc.Name)) } } case "quest_giver": if s.questStore == nil { return true } hqs, err := s.questStore.ListHeroQuests(ctx, heroID) if err != nil { s.logger.Warn("offline town npc: list hero quests", "error", err) return true } taken := make(map[int64]struct{}, len(hqs)) for _, hq := range hqs { taken[hq.QuestID] = struct{}{} } offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, h.Level) if err != nil { s.logger.Warn("offline town npc: list quests by npc", "error", err) return true } var candidates []model.Quest for _, q := range offered { if _, ok := taken[q.ID]; !ok { candidates = append(candidates, q) } } if len(candidates) == 0 { if al != nil { al(heroID, fmt.Sprintf("Checked in with %s — nothing new.", npc.Name)) } return true } pick := candidates[rand.Intn(len(candidates))] ok, err := s.questStore.TryAcceptQuest(ctx, heroID, pick.ID) if err != nil { s.logger.Warn("offline town npc: try accept quest", "error", err) return true } if ok && al != nil { al(heroID, fmt.Sprintf("Accepted quest: %s", pick.Title)) } default: // Other NPC types: treat as a social stop only. } return true } // 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) combatStart := now lastTick := now heroNext := now.Add(attackInterval(hero.EffectiveSpeedAt(now))) enemyNext := now.Add(attackInterval(enemy.Speed)) const maxCombatSteps = 100000 for step := 0; step < maxCombatSteps && hero.IsAlive() && enemy.IsAlive(); step++ { nextTime := heroNext isHero := true if enemyNext.Before(heroNext) { nextTime = enemyNext isHero = false } if !nextTime.After(lastTick) { nextTime = lastTick.Add(time.Millisecond) } tickDur := nextTime.Sub(lastTick) if tickDur > 0 { ProcessDebuffDamage(hero, tickDur, nextTime) ProcessEnemyRegen(&enemy, tickDur) ProcessSummonDamage(hero, &enemy, combatStart, lastTick, nextTime) } lastTick = nextTime if CheckDeath(hero, nextTime) { break } if isHero { ProcessAttack(hero, &enemy, nextTime) if !enemy.IsAlive() { break } heroNext = nextTime.Add(attackInterval(hero.EffectiveSpeedAt(nextTime))) } else { ProcessEnemyAttack(hero, &enemy, nextTime) if CheckDeath(hero, nextTime) { break } enemyNext = nextTime.Add(attackInterval(enemy.Speed)) } } // Use potion if HP drops below 30% and hero has potions. if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 { 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 } 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 { price := model.AutoSellPrice(drop.Rarity) hero.Gold += price goldGained += price } } } // 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 } cfg := tuning.Get() hpMul := 1.0 + bandDelta*cfg.EnemyScaleBandHP + overcapDelta*cfg.EnemyScaleOvercapHP atkMul := 1.0 + bandDelta*cfg.EnemyScaleBandATK + overcapDelta*cfg.EnemyScaleOvercapATK defMul := 1.0 + bandDelta*cfg.EnemyScaleBandDEF + overcapDelta*cfg.EnemyScaleOvercapDEF 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*cfg.EnemyScaleBandXP + overcapDelta*cfg.EnemyScaleOvercapXP goldMul := 1.0 + bandDelta*cfg.EnemyScaleBandGold + overcapDelta*cfg.EnemyScaleOvercapGold picked.XPReward = int64(math.Round(float64(picked.XPReward) * xpMul)) picked.GoldReward = int64(math.Round(float64(picked.GoldReward) * goldMul)) return picked }