diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 311690a..ed292c6 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -65,6 +65,9 @@ func main() { heroStore := storage.NewHeroStore(pgPool, logger) logStore := storage.NewLogStore(pgPool) questStore := storage.NewQuestStore(pgPool) + gearStore := storage.NewGearStore(pgPool) + achievementStore := storage.NewAchievementStore(pgPool) + taskStore := storage.NewDailyTaskStore(pgPool) runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool) if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil { logger.Error("failed to load runtime config", "error", err) @@ -177,7 +180,9 @@ func main() { serverStartedAt := time.Now() offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool { return engine.IsTimePaused() - }, engine.HeroHasActiveMovement) + }, engine.HeroHasActiveMovement). + WithCombatTickRate(engine.TickRate()). + WithRewardStores(gearStore, achievementStore, taskStore) go func() { if err := offlineSim.Run(ctx); err != nil && err != context.Canceled { logger.Error("offline simulator error", "error", err) diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 37bbd5f..bcf4ccc 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -680,6 +680,13 @@ func (e *Engine) Status() EngineStatus { } } +// TickRate returns the combat tick rate configured for the engine. +func (e *Engine) TickRate() time.Duration { + e.mu.RLock() + defer e.mu.RUnlock() + return e.tickRate +} + // ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival). func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) { e.mu.Lock() diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 3e0de3a..8348d5b 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -20,9 +20,13 @@ 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) @@ -41,11 +45,28 @@ func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, q 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 +} + // 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) @@ -85,7 +106,7 @@ func (s *OfflineSimulator) processTick(ctx context.Context) { if s.skipIfLive != nil && s.skipIfLive(hero.ID) { continue } - if err := s.simulateHeroTick(ctx, hero); err != nil { + if err := s.simulateHeroTick(ctx, hero, time.Now(), true); err != nil { s.logger.Error("offline simulator: hero tick failed", "hero_id", hero.ID, "error", err, @@ -98,12 +119,11 @@ func (s *OfflineSimulator) processTick(ctx context.Context) { // 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() +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) && time.Since(hero.UpdatedAt) > gap { + 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 @@ -136,9 +156,8 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her 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) - }) + rewardDeps := s.rewardDeps(tickNow) + survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps) 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) @@ -180,19 +199,49 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her hm.SyncToHero() hero.RefreshDerivedCombatStats(now) - if err := s.store.Save(ctx, hero); err != nil { - return fmt.Errorf("save hero after offline tick: %w", err) + 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, msg string) { + logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.logStore.Add(logCtx, heroID, msg); 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). func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { _ = graph @@ -296,78 +345,26 @@ func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message str } } -// 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). +// 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, onInventoryDiscard func(string)) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) { +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) { if encounterEnemy != nil { enemy = *encounterEnemy } else { enemy = PickEnemyForLevel(hero.Level) } - - allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY) - - combatStart := now - lastTick := now - var regenRemainder float64 - 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, ®enRemainder) - 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)) + if rewardDeps.InTown == nil && g != nil { + rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool { + return g.HeroInTownAt(posX, posY) } } - // 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-- - } + survived = ResolveCombatToEnd(hero, &enemy, now, CombatSimOptions{ + TickRate: tickRate, + AutoUsePotion: OfflineAutoPotionHook, + }) - if hero.HP <= 0 { + if !survived || hero.HP <= 0 { hero.HP = 0 hero.State = model.StateDead hero.TotalDeaths++ @@ -375,56 +372,21 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene 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 + drops := ApplyVictoryRewards(hero, &enemy, now, rewardDeps) + goldGained = sumGoldFromDrops(drops) + hero.RefreshDerivedCombatStats(now) + return true, enemy, xpGained, goldGained +} - // Loot generation. - luckMult := LuckMultiplier(hero, now) - drops := model.GenerateLoot(enemy.Type, luckMult) +func sumGoldFromDrops(drops []model.LootDrop) int64 { + var total int64 for _, drop := range drops { - // Track legendary equipment drops for achievements. - if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" { - hero.LegendaryDrops++ + if drop.ItemType == "gold" || drop.GoldAmount > 0 { + total += drop.GoldAmount } - 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 + return total } // PickEnemyForLevel selects a random enemy appropriate for the hero's level diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index c387936..beddd90 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -17,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) { } now := time.Now() - survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, nil, nil) + survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) if !survived { t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name) @@ -42,7 +42,7 @@ func TestSimulateOneFight_HeroDies(t *testing.T) { } now := time.Now() - survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, nil) + survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) if survived { t.Fatal("1 HP hero should die to any enemy") @@ -66,7 +66,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) { } now := time.Now() - survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, nil) + survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) if !survived { t.Fatal("overpowered hero should survive") @@ -79,30 +79,17 @@ func TestSimulateOneFight_LevelUp(t *testing.T) { } } -func TestSimulateOneFight_PotionUsage(t *testing.T) { - // Create a hero that will take significant damage but survive. +func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) { hero := &model.Hero{ - Level: 1, XP: 0, - MaxHP: 100, HP: 100, - Attack: 50, Defense: 3, Speed: 1.0, - Potions: 5, - State: model.StateWalking, + MaxHP: 100, + HP: 100, + Potions: 3, } - - now := time.Now() - startPotions := hero.Potions - - // Run multiple fights — at least one should use a potion. - for i := 0; i < 20; i++ { - if hero.HP <= 0 { - break - } - hero.HP = 25 // force low HP to trigger potion usage - SimulateOneFight(hero, now, nil, nil, nil) + if used := OfflineAutoPotionHook(hero, time.Now()); used { + t.Fatal("expected no potion usage when hero is above threshold") } - - if hero.Potions >= startPotions { - t.Log("no potions used after 20 fights with low HP — may be probabilistic, not a hard failure") + if hero.Potions != 3 { + t.Fatalf("expected potions unchanged, got %d", hero.Potions) } } diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 2b04e67..4b8ea64 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -101,161 +101,30 @@ func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now tim // sets hero state to walking, and records loot history. // Returns the drops for API response building. func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop { - oldLevel := hero.Level - hero.XP += enemy.XPReward - levelsGained := 0 - for hero.LevelUp() { - levelsGained++ - } - hero.State = model.StateWalking - - luckMult := game.LuckMultiplier(hero, now) - drops := model.GenerateLoot(enemy.Type, luckMult) - - ctxTown, cancelTown := context.WithTimeout(context.Background(), 2*time.Second) - inTown := h.isHeroInTown(ctxTown, hero.PositionX, hero.PositionY) - cancelTown() - h.lootMu.Lock() defer h.lootMu.Unlock() - for i := range drops { - drop := &drops[i] - - switch drop.ItemType { - case "gold": - hero.Gold += 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) - - // Persist the gear item to DB. - if h.gearStore != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - if err := h.gearStore.CreateItem(ctx, item); err != nil { - h.logger.Warn("failed to create gear item", "slot", slot, "error", err) - cancel() - if inTown { - sellPrice := model.AutoSellPrice(drop.Rarity) - hero.Gold += sellPrice - drop.GoldAmount = sellPrice - } else { - drop.GoldAmount = 0 - } - goto recordLoot - } - cancel() - } - - drop.ItemID = item.ID - drop.ItemName = item.Name - - equipped := h.tryAutoEquipGear(hero, item, now) - if equipped { - h.addLog(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name)) - } else { - hero.EnsureInventorySlice() - if len(hero.Inventory) >= model.MaxInventorySlots { - ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second) - if h.gearStore != nil && item.ID != 0 { - if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil { - h.logger.Warn("failed to delete gear (inventory full)", "gear_id", item.ID, "error", err) - } - } - cancelDel() - drop.ItemID = 0 - drop.ItemName = "" - drop.GoldAmount = 0 - h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity)) - } else { - ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second) - var err error - if h.gearStore != nil { - err = h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID) - } - cancelInv() - if err != nil { - h.logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err) - ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second) - if h.gearStore != nil && item.ID != 0 { - _ = h.gearStore.DeleteGearItem(ctxDel, item.ID) - } - cancelDel() - drop.ItemID = 0 - drop.ItemName = "" - drop.GoldAmount = 0 - } else { - hero.Inventory = append(hero.Inventory, item) - drop.GoldAmount = 0 - } - } - } - } else if inTown { - sellPrice := model.AutoSellPrice(drop.Rarity) - hero.Gold += sellPrice - drop.GoldAmount = sellPrice + return game.ApplyVictoryRewards(hero, enemy, now, game.VictoryRewardDeps{ + GearStore: h.gearStore, + QuestProgressor: h.questStore, + AchievementCheck: h.achievementStore, + TaskProgressor: h.taskStore, + LogWriter: h.addLog, + InTown: func(ctx context.Context, posX, posY float64) bool { + return h.isHeroInTown(ctx, posX, posY) + }, + LootRecorder: func(entry model.LootHistory) { + h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry) + lootHistoryLimit := int(tuning.Get().LootHistoryLimit) + if lootHistoryLimit < 1 { + lootHistoryLimit = int(tuning.DefaultValues().LootHistoryLimit) } - } - - recordLoot: - entry := model.LootHistory{ - HeroID: hero.ID, - EnemyType: string(enemy.Type), - ItemType: drop.ItemType, - ItemID: drop.ItemID, - Rarity: drop.Rarity, - GoldAmount: drop.GoldAmount, - CreatedAt: now, - } - h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry) - lootHistoryLimit := int(tuning.Get().LootHistoryLimit) - if lootHistoryLimit < 1 { - lootHistoryLimit = int(tuning.DefaultValues().LootHistoryLimit) - } - if len(h.lootCache[hero.ID]) > lootHistoryLimit { - h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:] - } - } - - // Log the victory. - h.addLog(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, enemy.GoldReward)) - - // Log level-ups. - for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ { - h.addLog(hero.ID, fmt.Sprintf("Leveled up to %d!", l)) - } - - // Stat tracking for achievements. - hero.TotalKills++ - hero.KillsSinceDeath++ - if enemy.IsElite { - hero.EliteKills++ - } - // Track legendary drops for achievement conditions. - for _, drop := range drops { - if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" { - hero.LegendaryDrops++ - } - } - - // Quest progress hooks (fire-and-forget, errors logged but not fatal). - h.progressQuestsAfterKill(hero.ID, enemy) - - // Achievement check (fire-and-forget). - h.checkAchievementsAfterKill(hero) - - // Daily/weekly task progress (fire-and-forget). - h.progressTasksAfterKill(hero.ID, enemy, drops) - - return drops + if len(h.lootCache[hero.ID]) > lootHistoryLimit { + h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:] + } + }, + Logger: h.logger, + }) } // resolveTelegramID extracts the Telegram user ID from auth context, @@ -894,48 +763,21 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b gapDuration = 8 * time.Hour } - // Auto-revive if hero has been dead for more than 1 hour (spec section 3.3). - if (hero.State == model.StateDead || hero.HP <= 0) && gapDuration > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond { - hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent) - if hero.HP < 1 { - hero.HP = 1 - } - hero.State = model.StateWalking - hero.Debuffs = nil - h.addLog(hero.ID, "Auto-revived after 1 hour") + var rg *game.RoadGraph + if h.engine != nil { + rg = h.engine.RoadGraph() } - - totalFights := int(gapDuration.Seconds()) / 10 - if totalFights <= 0 { - return false + sim := game.NewOfflineSimulator(h.store, h.logStore, h.questStore, rg, h.logger, nil, nil). + WithRewardStores(h.gearStore, h.achievementStore, h.taskStore) + if h.engine != nil { + sim.WithCombatTickRate(h.engine.TickRate()) } - - now := time.Now() - performed := false - - for i := 0; i < totalFights; i++ { - if hero.HP <= 0 || hero.State == model.StateDead { - break - } - - var rg *game.RoadGraph - if h.engine != nil { - rg = h.engine.RoadGraph() - } - survived, enemy, xpGained, goldGained := game.SimulateOneFight(hero, now, nil, rg, func(msg string) { - h.addLog(hero.ID, msg) - }) - performed = true - - h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name)) - if survived { - h.addLog(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, xpGained, goldGained)) - } else { - h.addLog(hero.ID, fmt.Sprintf("Died fighting %s", enemy.Name)) - } + before := hero.UpdatedAt + if err := sim.SimulateHeroAt(ctx, hero, h.serverStartedAt, false); err != nil { + h.logger.Error("catch-up sim failed", "hero_id", hero.ID, "error", err) + return false } - - return performed + return hero.UpdatedAt.After(before) } // parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern.