You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

464 lines
14 KiB
Go

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"
)
// 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
questStore *storage.QuestStore
graph *RoadGraph
interval time.Duration
logger *slog.Logger
// 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
}
// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
// isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause).
// skipIfLive may be nil; if it returns true for a hero id, that hero is skipped this tick.
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,
isPaused: isPaused,
skipIfLive: skipIfLive,
}
}
// 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) {
if s.isPaused != nil && s.isPaused() {
return
}
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 s.skipIfLive != nil && s.skipIfLive(hero.ID) {
continue
}
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 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()
// 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 {
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, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second)))
}
// 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, 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)
})
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
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, "Encountered a Wandering Merchant on the road")
}
adventureLog := func(heroID int64, msg string) {
s.addLog(ctx, heroID, msg)
}
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 err := s.store.Save(ctx, hero); err != nil {
return fmt.Errorf("save hero after offline tick: %w", err)
}
return nil
}
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)
}
}
// 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
_ = 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
}
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, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
}
potionCost, _ := tuning.EffectiveNPCShopCosts()
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 {
h.Gold -= potionCost
h.Potions++
if al != nil {
al(heroID, fmt.Sprintf("Purchased a Healing Potion from %s.", npc.Name))
}
}
case "healer":
_, healCost := tuning.EffectiveNPCShopCosts()
if h.HP < h.MaxHP && healCost > 0 && h.Gold >= healCost {
h.Gold -= healCost
h.HP = h.MaxHP
if al != nil {
al(heroID, fmt.Sprintf("Paid %s to restore full health.", npc.Name))
}
}
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, h.Level)
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, fmt.Sprintf("Checked in with %s — nothing new.", npc.Name))
}
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 {
al(heroID, fmt.Sprintf("Accepted quest: %s", pick.Title))
}
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, 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.
// 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.
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) {
if encounterEnemy != nil {
enemy = *encounterEnemy
} else {
enemy = PickEnemyForLevel(hero.Level)
}
allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY)
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 < 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--
}
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)
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
}
// 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
}
cfg := tuning.Get()
hpMul := 1.0 + bandDelta*cfg.EnemyScaleBandHP + overcapDelta*cfg.EnemyScaleOvercapHP
atkMul := 1.0 + bandDelta*cfg.EnemyScaleBandATK + overcapDelta*cfg.EnemyScaleOvercapATK
defMul := 1.0 + bandDelta*cfg.EnemyScaleBandDEF + overcapDelta*cfg.EnemyScaleOvercapDEF
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*cfg.EnemyScaleBandXP + overcapDelta*cfg.EnemyScaleOvercapXP
goldMul := 1.0 + bandDelta*cfg.EnemyScaleBandGold + overcapDelta*cfg.EnemyScaleOvercapGold
picked.XPReward = int64(math.Round(float64(picked.XPReward) * xpMul))
picked.GoldReward = int64(math.Round(float64(picked.GoldReward) * goldMul))
return picked
}