update offline mode

master
Denis Ranneft 1 month ago
parent 220418c4c6
commit 6b8a8d57b2

@ -65,6 +65,9 @@ func main() {
heroStore := storage.NewHeroStore(pgPool, logger) heroStore := storage.NewHeroStore(pgPool, logger)
logStore := storage.NewLogStore(pgPool) logStore := storage.NewLogStore(pgPool)
questStore := storage.NewQuestStore(pgPool) questStore := storage.NewQuestStore(pgPool)
gearStore := storage.NewGearStore(pgPool)
achievementStore := storage.NewAchievementStore(pgPool)
taskStore := storage.NewDailyTaskStore(pgPool)
runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool) runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool)
if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil { if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil {
logger.Error("failed to load runtime config", "error", err) logger.Error("failed to load runtime config", "error", err)
@ -177,7 +180,9 @@ func main() {
serverStartedAt := time.Now() serverStartedAt := time.Now()
offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool { offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool {
return engine.IsTimePaused() return engine.IsTimePaused()
}, engine.HeroHasActiveMovement) }, engine.HeroHasActiveMovement).
WithCombatTickRate(engine.TickRate()).
WithRewardStores(gearStore, achievementStore, taskStore)
go func() { go func() {
if err := offlineSim.Run(ctx); err != nil && err != context.Canceled { if err := offlineSim.Run(ctx); err != nil && err != context.Canceled {
logger.Error("offline simulator error", "error", err) logger.Error("offline simulator error", "error", err)

@ -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). // 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) { func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) {
e.mu.Lock() e.mu.Lock()

@ -20,9 +20,13 @@ type OfflineSimulator struct {
store *storage.HeroStore store *storage.HeroStore
logStore *storage.LogStore logStore *storage.LogStore
questStore *storage.QuestStore questStore *storage.QuestStore
gearStore *storage.GearStore
taskStore *storage.DailyTaskStore
achStore *storage.AchievementStore
graph *RoadGraph graph *RoadGraph
interval time.Duration interval time.Duration
logger *slog.Logger logger *slog.Logger
combatTickRate time.Duration
// isPaused, when set, skips simulation ticks while global server time is frozen. // isPaused, when set, skips simulation ticks while global server time is frozen.
isPaused func() bool isPaused func() bool
// skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session) // 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, graph: graph,
interval: 30 * time.Second, interval: 30 * time.Second,
logger: logger, logger: logger,
combatTickRate: 100 * time.Millisecond,
isPaused: isPaused, isPaused: isPaused,
skipIfLive: skipIfLive, 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. // Run starts the offline simulation loop. It blocks until the context is cancelled.
func (s *OfflineSimulator) Run(ctx context.Context) error { func (s *OfflineSimulator) Run(ctx context.Context) error {
ticker := time.NewTicker(s.interval) ticker := time.NewTicker(s.interval)
@ -85,7 +106,7 @@ func (s *OfflineSimulator) processTick(ctx context.Context) {
if s.skipIfLive != nil && s.skipIfLive(hero.ID) { if s.skipIfLive != nil && s.skipIfLive(hero.ID) {
continue 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", s.logger.Error("offline simulator: hero tick failed",
"hero_id", hero.ID, "hero_id", hero.ID,
"error", err, "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, // 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 // then persists. Random encounters use the same rolls as online; combat is resolved
// synchronously via SimulateOneFight (no WebSocket). // synchronously via SimulateOneFight (no WebSocket).
func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero) error { func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error {
now := time.Now()
// Auto-revive after configured downtime (autoReviveAfterMs). // Auto-revive after configured downtime (autoReviveAfterMs).
gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond 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) hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
if hero.HP < 1 { if hero.HP < 1 {
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) { encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Encountered %s", enemy.Name)) 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) { rewardDeps := s.rewardDeps(tickNow)
s.addLog(ctx, hm.Hero.ID, msg) survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
})
if survived { if survived {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained)) s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained))
hm.ResumeWalking(tickNow) hm.ResumeWalking(tickNow)
@ -180,19 +199,49 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
hm.SyncToHero() hm.SyncToHero()
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
if persist && s.store != nil {
if err := s.store.Save(ctx, hero); err != nil { if err := s.store.Save(ctx, hero); err != nil {
return fmt.Errorf("save hero after offline tick: %w", err) return fmt.Errorf("save hero after offline tick: %w", err)
} }
}
return nil 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 { 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 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) 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). // 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 { func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
_ = graph _ = 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. // SimulateOneFight runs one combat encounter using the shared combat loop and reward logic.
// 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. // 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 { if encounterEnemy != nil {
enemy = *encounterEnemy enemy = *encounterEnemy
} else { } else {
enemy = PickEnemyForLevel(hero.Level) enemy = PickEnemyForLevel(hero.Level)
} }
if rewardDeps.InTown == nil && g != nil {
allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY) rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool {
return g.HeroInTownAt(posX, posY)
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, &regenRemainder)
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. survived = ResolveCombatToEnd(hero, &enemy, now, CombatSimOptions{
if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 { TickRate: tickRate,
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent) AutoUsePotion: OfflineAutoPotionHook,
if healAmount < 1 { })
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
hero.Potions--
}
if hero.HP <= 0 { if !survived || hero.HP <= 0 {
hero.HP = 0 hero.HP = 0
hero.State = model.StateDead hero.State = model.StateDead
hero.TotalDeaths++ hero.TotalDeaths++
@ -375,56 +372,21 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
return false, enemy, 0, 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 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. func sumGoldFromDrops(drops []model.LootDrop) int64 {
luckMult := LuckMultiplier(hero, now) var total int64
drops := model.GenerateLoot(enemy.Type, luckMult)
for _, drop := range drops { for _, drop := range drops {
// Track legendary equipment drops for achievements. if drop.ItemType == "gold" || drop.GoldAmount > 0 {
if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" { total += drop.GoldAmount
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
}
} }
} }
return total
// 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 // PickEnemyForLevel selects a random enemy appropriate for the hero's level

@ -17,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) {
} }
now := time.Now() 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 { if !survived {
t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name) t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name)
@ -42,7 +42,7 @@ func TestSimulateOneFight_HeroDies(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, nil) survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if survived { if survived {
t.Fatal("1 HP hero should die to any enemy") t.Fatal("1 HP hero should die to any enemy")
@ -66,7 +66,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, nil) survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if !survived { if !survived {
t.Fatal("overpowered hero should survive") t.Fatal("overpowered hero should survive")
@ -79,30 +79,17 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
} }
} }
func TestSimulateOneFight_PotionUsage(t *testing.T) { func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) {
// Create a hero that will take significant damage but survive.
hero := &model.Hero{ hero := &model.Hero{
Level: 1, XP: 0, MaxHP: 100,
MaxHP: 100, HP: 100, HP: 100,
Attack: 50, Defense: 3, Speed: 1.0, Potions: 3,
Potions: 5,
State: model.StateWalking,
} }
if used := OfflineAutoPotionHook(hero, time.Now()); used {
now := time.Now() t.Fatal("expected no potion usage when hero is above threshold")
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 hero.Potions != 3 {
if hero.Potions >= startPotions { t.Fatalf("expected potions unchanged, got %d", hero.Potions)
t.Log("no potions used after 20 fights with low HP — may be probabilistic, not a hard failure")
} }
} }

@ -101,120 +101,19 @@ func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now tim
// sets hero state to walking, and records loot history. // sets hero state to walking, and records loot history.
// Returns the drops for API response building. // Returns the drops for API response building.
func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop { 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() h.lootMu.Lock()
defer h.lootMu.Unlock() defer h.lootMu.Unlock()
for i := range drops { return game.ApplyVictoryRewards(hero, enemy, now, game.VictoryRewardDeps{
drop := &drops[i] GearStore: h.gearStore,
QuestProgressor: h.questStore,
switch drop.ItemType { AchievementCheck: h.achievementStore,
case "gold": TaskProgressor: h.taskStore,
hero.Gold += drop.GoldAmount LogWriter: h.addLog,
InTown: func(ctx context.Context, posX, posY float64) bool {
case "potion": return h.isHeroInTown(ctx, posX, posY)
hero.Potions++ },
LootRecorder: func(entry model.LootHistory) {
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
}
}
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) h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry)
lootHistoryLimit := int(tuning.Get().LootHistoryLimit) lootHistoryLimit := int(tuning.Get().LootHistoryLimit)
if lootHistoryLimit < 1 { if lootHistoryLimit < 1 {
@ -223,39 +122,9 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy
if len(h.lootCache[hero.ID]) > lootHistoryLimit { if len(h.lootCache[hero.ID]) > lootHistoryLimit {
h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:] h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:]
} }
} },
Logger: h.logger,
// 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
} }
// resolveTelegramID extracts the Telegram user ID from auth context, // 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 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")
}
totalFights := int(gapDuration.Seconds()) / 10
if totalFights <= 0 {
return false
}
now := time.Now()
performed := false
for i := 0; i < totalFights; i++ {
if hero.HP <= 0 || hero.State == model.StateDead {
break
}
var rg *game.RoadGraph var rg *game.RoadGraph
if h.engine != nil { if h.engine != nil {
rg = h.engine.RoadGraph() rg = h.engine.RoadGraph()
} }
survived, enemy, xpGained, goldGained := game.SimulateOneFight(hero, now, nil, rg, func(msg string) { sim := game.NewOfflineSimulator(h.store, h.logStore, h.questStore, rg, h.logger, nil, nil).
h.addLog(hero.ID, msg) WithRewardStores(h.gearStore, h.achievementStore, h.taskStore)
}) if h.engine != nil {
performed = true sim.WithCombatTickRate(h.engine.TickRate())
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 hero.UpdatedAt.After(before)
return performed
} }
// parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern. // parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern.

Loading…
Cancel
Save