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" ) // OfflineDigestGrace is the delay after the last WS disconnect before offline events count toward the digest. const OfflineDigestGrace = 30 * time.Second // OfflineDigestCollecting is true when digest deltas should be applied (disconnect + grace elapsed). func OfflineDigestCollecting(disconnect *time.Time, now time.Time) bool { if disconnect == nil { return false } return !now.Before(disconnect.Add(OfflineDigestGrace)) } // OfflineSimulator holds dependencies for one-shot wall-time catch-up (server downtime, cold-start bootstrap). // Live progression runs in the Engine for all resident heroes. type OfflineSimulator struct { store *storage.HeroStore logStore *storage.LogStore questStore *storage.QuestStore gearStore *storage.GearStore taskStore *storage.DailyTaskStore achStore *storage.AchievementStore graph *RoadGraph interval time.Duration logger *slog.Logger combatTickRate time.Duration // 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 digestStore *storage.OfflineDigestStore } // NewOfflineSimulator builds a catch-up runner used by BootstrapResidentHeroes and REST init gap recovery. // isPaused and skipIfLive are optional filters for SimulateHeroAt callers; Run() is a no-op. 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, combatTickRate: 100 * time.Millisecond, isPaused: isPaused, skipIfLive: skipIfLive, } } // WithCombatTickRate overrides the combat tick rate used in offline simulations. func (s *OfflineSimulator) WithCombatTickRate(tick time.Duration) *OfflineSimulator { if tick > 0 { s.combatTickRate = tick } return s } // WithRewardStores wires optional stores for offline reward hooks. func (s *OfflineSimulator) WithRewardStores(gear *storage.GearStore, achievements *storage.AchievementStore, tasks *storage.DailyTaskStore) *OfflineSimulator { s.gearStore = gear s.achStore = achievements s.taskStore = tasks return s } // WithDigestStore wires persistent offline digest while the hero is processed by OfflineSimulator // (no live WS session for that hero). Counters and loot are cleared when the client loads hero/init. func (s *OfflineSimulator) WithDigestStore(d *storage.OfflineDigestStore) *OfflineSimulator { s.digestStore = d return s } // NonGoldLootForDigest keeps equipment/potion lines only; gold belongs in gold_gained counter. func NonGoldLootForDigest(drops []model.LootDrop) []model.LootDrop { if len(drops) == 0 { return nil } out := make([]model.LootDrop, 0, len(drops)) for _, d := range drops { if d.ItemType == "gold" { continue } out = append(out, d) } if len(out) == 0 { return nil } return out } // Run is a no-op waiter: progression runs in the game Engine for all resident heroes. // Kept so callers can block on the same context lifecycle as before. func (s *OfflineSimulator) Run(ctx context.Context) error { <-ctx.Done() if s.logger != nil { s.logger.Info("offline simulator stub shutting down (engine-authoritative world)") } return ctx.Err() } // simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now, // then persists. Encounters resolve combat via SimulateOneFight (batch-only; live play uses Engine combat). func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error { // Auto-revive after configured downtime (autoReviveAfterMs). gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond if (hero.State == model.StateDead || hero.HP <= 0) && now.Sub(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, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseAutoReviveAfterSec, Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)}, }, }) if s.digestStore != nil && OfflineDigestCollecting(hero.WsDisconnectedAt, now) { _ = s.digestStore.ApplyDelta(ctx, hero.ID, storage.OfflineDigestDelta{Revives: 1}) } } // 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, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseEncounteredEnemy, Args: map[string]any{"enemyType": enemy.Slug}, }, }) rewardDeps := s.rewardDeps(tickNow) levelBefore := hm.Hero.Level survived, en, xpGained, goldGained, drops := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps) if s.digestStore != nil && OfflineDigestCollecting(hm.Hero.WsDisconnectedAt, tickNow) { if survived { levelGain := hm.Hero.Level - levelBefore _ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{ MonstersKilled: 1, XPGained: xpGained, GoldGained: goldGained, LevelsGained: levelGain, LootAppend: NonGoldLootForDigest(drops), }) } else { _ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{Deaths: 1}) } } if survived { s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseDefeatedEnemy, Args: map[string]any{ "enemyType": en.Slug, "xp": xpGained, "gold": goldGained, }, }, }) hm.ResumeWalking(tickNow) hm.TryAdventureReturnAfterCombat(tickNow) } else { s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseDiedFighting, Args: map[string]any{"enemyType": en.Slug}, }, }) 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, model.AdventureLogLine{ Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant}, }) } adventureLog := func(heroID int64, line model.AdventureLogLine) { s.addLog(ctx, heroID, line) } 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 persist && s.store != nil { if err := s.store.Save(ctx, hero); err != nil { return fmt.Errorf("save hero after offline tick: %w", err) } } return nil } // SimulateHeroAt runs a single offline catch-up tick up to the given time. func (s *OfflineSimulator) SimulateHeroAt(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error { return s.simulateHeroTick(ctx, hero, now, persist) } 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) } } func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps { return VictoryRewardDeps{ GearStore: s.gearStore, QuestProgressor: s.questStore, AchievementCheck: s.achStore, TaskProgressor: s.taskStore, LogWriter: func(heroID int64, line model.AdventureLogLine) { logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := s.logStore.Add(logCtx, heroID, line); err != nil && s.logger != nil { s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err) } }, InTown: func(ctx context.Context, posX, posY float64) bool { if s.graph == nil { return false } return s.graph.HeroInTownAt(posX, posY) }, Logger: s.logger, } } // applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI). // With no live WebSocket, service use (gear, potion, heal, quest accept) each fires independently with probability 0.2 when affordable. func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { _ = 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 } var town *model.Town if graph != nil { town = graph.Towns[hm.CurrentTownID] } townLv := TownEffectiveLevel(town) const offlineServiceChance = 0.2 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, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseSoldItemsMerchant, Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold}, }, }) } gearCost := tuning.EffectiveTownMerchantGearCost(townLv) if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance { h.Gold -= gearCost drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now) if err != nil { h.Gold += gearCost s.logger.Warn("offline town merchant gear", "hero_id", heroID, "error", err) } else if al != nil && drop != nil { townKey := "" if town != nil { townKey = town.NameKey } al(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseBoughtGearTownMerchant, Args: map[string]any{ "npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType, "rarity": string(drop.Rarity), "itemId": drop.ItemID, }, }, }) } } case "healer": _, healCost := tuning.EffectiveNPCShopCosts() potionCost, _ := tuning.EffectiveNPCShopCosts() if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance { h.Gold -= healCost h.HP = h.MaxHP if al != nil { al(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhrasePaidHealerFull, Args: map[string]any{"npcKey": npc.NameKey}, }, }) } } if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < offlineServiceChance { h.Gold -= potionCost h.Potions++ if al != nil { al(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhrasePurchasedPotionFromNPC, Args: map[string]any{"npcKey": npc.NameKey}, }, }) } } 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, townLv) 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, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseQuestGiverChecked, Args: map[string]any{"npcKey": npc.NameKey}, }, }) } return true } if rand.Float64() >= offlineServiceChance { if al != nil { al(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseQuestGiverChecked, Args: map[string]any{"npcKey": npc.NameKey}, }, }) } 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 { qk := pick.QuestKey if qk == "" { qk = fmt.Sprintf("quest.%d", pick.ID) } al(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseQuestAccepted, Args: map[string]any{"questKey": qk}, }, }) } 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, line model.AdventureLogLine) { logCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() if err := s.logStore.Add(logCtx, heroID, line); err != nil { s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err, ) } } // SimulateOneFight runs one combat encounter using the shared combat loop and reward logic. // 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, tickRate time.Duration, rewardDeps VictoryRewardDeps) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64, drops []model.LootDrop) { if encounterEnemy != nil { enemy = *encounterEnemy } else { enemy = PickEnemyForHero(hero) } if rewardDeps.InTown == nil && g != nil { rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool { return g.HeroInTownAt(posX, posY) } } survived = ResolveCombatToEnd(hero, &enemy, now, CombatSimOptions{ TickRate: tickRate, AutoUsePotion: OfflineAutoPotionHook, }) if !survived || hero.HP <= 0 { hero.HP = 0 hero.State = model.StateDead hero.TotalDeaths++ hero.KillsSinceDeath = 0 return false, enemy, 0, 0, nil } xpGained = enemy.XPReward drops = ApplyVictoryRewards(hero, &enemy, now, rewardDeps) goldGained = sumGoldFromDrops(drops) hero.RefreshDerivedCombatStats(now) return true, enemy, xpGained, goldGained, drops } func sumGoldFromDrops(drops []model.LootDrop) int64 { var total int64 for _, drop := range drops { if drop.ItemType == "gold" || drop.GoldAmount > 0 { total += drop.GoldAmount } } return total } // PickEnemyForLevel selects a random DB-loaded archetype and builds a runtime instance. // hero is nil: no unequipped-hero weakening is applied (still uses global encounter stat multiplier). func PickEnemyForLevel(level int) model.Enemy { return pickEnemyForHeroLevel(nil, level, nil) } // PickEnemyForHero is like PickEnemyForLevel but applies unequipped-hero monster scaling when hero has no gear. func PickEnemyForHero(hero *model.Hero) model.Enemy { if hero == nil { return model.Enemy{} } return pickEnemyForHeroLevel(hero, hero.Level, nil) } // PickEnemyForLevelWithRNG is like PickEnemyForLevel but uses rng for template selection (deterministic sims). // Pass hero when simulating a specific hero so unequipped scaling matches live encounters (may be nil). func PickEnemyForLevelWithRNG(level int, rng *rand.Rand, hero *model.Hero) model.Enemy { return pickEnemyForHeroLevel(hero, level, rng) } func pickEnemyForHeroLevel(hero *model.Hero, level int, rng *rand.Rand) model.Enemy { candidates := enemyCandidatesForHeroLevel(level) if len(candidates) == 0 { return model.Enemy{} } var picked model.Enemy if rng != nil { picked = candidates[rng.Intn(len(candidates))] } else { picked = candidates[rand.Intn(len(candidates))] } e := buildEnemyInstance(picked, level, rng) ApplyEnemyEncounterHeroScaling(hero, &e) return e } func enemyCandidatesForHeroLevel(level int) []model.Enemy { candidates := make([]model.Enemy, 0, len(model.EnemyTemplates)) for _, t := range model.EnemyTemplates { if t.MinLevel > 0 && t.MaxLevel >= t.MinLevel { if level >= t.MinLevel && level <= t.MaxLevel { candidates = append(candidates, t) } continue } base := t.BaseLevel if base <= 0 { base = 1 } if absInt(level-base) <= max(1, t.MaxHeroLevelDiff) { candidates = append(candidates, t) } } if len(candidates) > 0 { return candidates } nearestDelta := math.MaxInt for _, t := range model.EnemyTemplates { base := t.BaseLevel if base <= 0 { base = max(1, t.MinLevel) } d := absInt(level - base) if d < nearestDelta { nearestDelta = d candidates = candidates[:0] candidates = append(candidates, t) } else if d == nearestDelta { candidates = append(candidates, t) } } return candidates } func enemyInstanceLevel(baseLevel, heroLevel int, variance float64, maxHeroDiff int, rng *rand.Rand) int { if baseLevel <= 0 { baseLevel = 1 } if variance <= 0 { variance = 0.30 } if variance > 0.95 { variance = 0.95 } if maxHeroDiff <= 0 { maxHeroDiff = 5 } minL := int(math.Floor(float64(baseLevel) * (1 - variance))) maxL := int(math.Ceil(float64(baseLevel) * (1 + variance))) if minL < 1 { minL = 1 } if heroLevel > 0 { minL = max(minL, heroLevel-maxHeroDiff) maxL = min(maxL, heroLevel+maxHeroDiff) } if maxL < minL { fallback := baseLevel if heroLevel > 0 { fallback = min(max(fallback, heroLevel-maxHeroDiff), heroLevel+maxHeroDiff) } if fallback < 1 { fallback = 1 } return fallback } if rng != nil { return minL + rng.Intn(maxL-minL+1) } return minL + rand.Intn(maxL-minL+1) } func buildEnemyInstance(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy { picked := tmpl baseLevel := picked.BaseLevel if baseLevel <= 0 { if picked.MinLevel > 0 { baseLevel = picked.MinLevel } else { baseLevel = 1 } } instanceLevel := enemyInstanceLevel(baseLevel, heroLevel, picked.LevelVariance, picked.MaxHeroLevelDiff, rng) return BuildEnemyInstanceForLevel(picked, instanceLevel) } // BuildEnemyInstanceForEncounter builds a runtime enemy like world encounters: rolls instance level // using the template base level, LevelVariance, and MaxHeroLevelDiff vs heroLevel (see enemyInstanceLevel). // Pass rng for deterministic runs; nil uses the global math/rand source. func BuildEnemyInstanceForEncounter(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy { return buildEnemyInstance(tmpl, heroLevel, rng) } // ScaleEnemyTemplate is kept for backward compatibility with existing call sites. // It now builds an instance using DB-driven per-archetype progression. func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy { return BuildEnemyInstanceForLevel(tmpl, heroLevel) } // BuildEnemyInstanceForLevel creates a deterministic enemy instance at an explicit level. func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int) model.Enemy { picked := tmpl baseLevel := picked.BaseLevel if baseLevel <= 0 { if picked.MinLevel > 0 { baseLevel = picked.MinLevel } else { baseLevel = 1 } } if level <= 0 { level = baseLevel } levelDelta := float64(level - baseLevel) picked.Level = level picked.MaxHP = max(1, int(math.Round(float64(picked.MaxHP)+levelDelta*picked.HPPerLevel))) picked.HP = picked.MaxHP picked.Attack = max(1, int(math.Round(float64(picked.Attack)+levelDelta*picked.AttackPerLevel))) picked.Defense = max(0, int(math.Round(float64(picked.Defense)+levelDelta*picked.DefensePerLevel))) xpPerLevel := picked.XPPerLevel // Keep early-game kill cadence predictable (~1 XP from template base for normal mobs); // xp_per_level ramps from instance level 10+ (and always applies to elites). if level < 10 && !picked.IsElite { xpPerLevel = 0 } picked.XPReward = max(1, int64(math.Round(float64(picked.XPReward)+levelDelta*xpPerLevel))) picked.GoldReward = max(0, int64(math.Round(float64(picked.GoldReward)+levelDelta*picked.GoldPerLevel))) cfg := tuning.Get() gMult := cfg.EnemyEncounterStatMultiplier if gMult <= 0 { gMult = tuning.DefaultValues().EnemyEncounterStatMultiplier } if gMult > 0 && gMult != 1 { applyEnemyEncounterCombatMult(&picked, gMult) } return picked } // HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear. func HeroHasEquippedGear(h *model.Hero) bool { if h == nil { return false } h.EnsureGearMap() for _, it := range h.Gear { if it != nil { return true } } return false } // HeroHasEquippedGearForCombat is true if the hero has any equipped item (weapon/armor/etc.). func HeroHasEquippedGearForCombat(h *model.Hero) bool { return HeroHasEquippedGear(h) } func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) { if e == nil || mult <= 0 || mult == 1 { return } e.MaxHP = max(1, int(math.Round(float64(e.MaxHP)*mult))) e.HP = e.MaxHP e.Attack = max(1, int(math.Round(float64(e.Attack)*mult))) e.Defense = max(0, int(math.Round(float64(e.Defense)*mult))) } // ApplyEnemyEncounterHeroScaling applies a multiplier to enemy combat stats when the hero has no equipped gear. func ApplyEnemyEncounterHeroScaling(hero *model.Hero, enemy *model.Enemy) { if hero == nil || enemy == nil || HeroHasEquippedGearForCombat(hero) { return } cfg := tuning.Get() m := cfg.EnemyStatMultiplierVsUnequippedHero if m <= 0 { m = tuning.DefaultValues().EnemyStatMultiplierVsUnequippedHero } if m <= 0 || m > 10 || m == 1 { return } applyEnemyEncounterCombatMult(enemy, m) } func absInt(v int) int { if v < 0 { return -v } return v }