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.

622 lines
19 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"
)
// 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)
} 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).
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, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
}
potionCost, _ := tuning.EffectiveNPCShopCosts()
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 {
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 "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, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhrasePaidHealerFull,
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, 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, 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 = PickEnemyForLevel(hero.Level)
}
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.
func PickEnemyForLevel(level int) model.Enemy {
candidates := enemyCandidatesForHeroLevel(level)
if len(candidates) == 0 {
return model.Enemy{}
}
picked := candidates[rand.Intn(len(candidates))]
return buildEnemyInstance(picked, level, nil)
}
// PickEnemyForLevelWithRNG is like PickEnemyForLevel but uses rng for template selection (deterministic sims).
func PickEnemyForLevelWithRNG(level int, rng *rand.Rand) model.Enemy {
if rng == nil {
return PickEnemyForLevel(level)
}
candidates := enemyCandidatesForHeroLevel(level)
if len(candidates) == 0 {
return model.Enemy{}
}
picked := candidates[rng.Intn(len(candidates))]
return buildEnemyInstance(picked, level, rng)
}
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)))
return picked
}
func absInt(v int) int {
if v < 0 {
return -v
}
return v
}