|
|
|
|
@ -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
|
|
|
|
|
|