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 } // NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds. func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger) *OfflineSimulator { return &OfflineSimulator{ store: store, logStore: logStore, graph: graph, interval: 30 * time.Second, logger: logger, } } // 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) { 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 } 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) 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 } ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter) 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. // Returns whether the hero survived, the enemy fought, XP gained, and gold gained. func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) { if encounterEnemy != nil { enemy = *encounterEnemy } else { enemy = PickEnemyForLevel(hero.Level) } 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) AutoEquipGear(hero, item, now) } else { 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 } const autoEquipThreshold = 1.03 // 3% improvement required // AutoEquipGear equips the gear item if the slot is empty or the new item // improves combat rating by >= 3%; otherwise auto-sells it. func AutoEquipGear(hero *model.Hero, item *model.GearItem, now time.Time) { if hero.Gear == nil { hero.Gear = make(map[model.EquipmentSlot]*model.GearItem) } current := hero.Gear[item.Slot] if current == nil { hero.Gear[item.Slot] = item return } oldRating := hero.CombatRatingAt(now) hero.Gear[item.Slot] = item if hero.CombatRatingAt(now) >= oldRating*autoEquipThreshold { return } // Revert: new item is not an upgrade. hero.Gear[item.Slot] = current hero.Gold += model.AutoSellPrices[item.Rarity] }