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.
765 lines
24 KiB
Go
765 lines
24 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 IsEffectivelyDead(hero) && now.Sub(hero.UpdatedAt) > gap {
|
|
ApplyHeroReviveMechanical(hero)
|
|
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)
|
|
hm.TryAdventureReturnAfterCombat(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
|
|
offlineTownTour := s.offlineTownTourAtNPC(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, offlineTownTour, nil)
|
|
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) offlineTownTourAtNPC(ctx context.Context) TownTourOfflineAtNPC {
|
|
return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
|
|
s.applyOfflineTownTourNPCVisit(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,
|
|
}
|
|
}
|
|
|
|
// applyOfflineTownTourNPCVisit resolves one town-tour NPC stop without UI: quest → merchant upgrade → healer heal → potion → fallbacks.
|
|
func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
|
|
h := hm.Hero
|
|
if h == nil {
|
|
return
|
|
}
|
|
var town *model.Town
|
|
if graph != nil {
|
|
town = graph.Towns[hm.CurrentTownID]
|
|
}
|
|
townLv := TownEffectiveLevel(town)
|
|
cfg := tuning.Get()
|
|
|
|
tryQuest := func() bool {
|
|
if npc.Type != "quest_giver" || s.questStore == nil {
|
|
return false
|
|
}
|
|
hqs, err := s.questStore.ListHeroQuests(ctx, heroID)
|
|
if err != nil {
|
|
s.logger.Warn("offline town tour: list hero quests", "error", err)
|
|
return false
|
|
}
|
|
taken := make(map[int64]struct{}, len(hqs))
|
|
for _, hq := range hqs {
|
|
taken[hq.QuestID] = struct{}{}
|
|
}
|
|
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv)
|
|
if err != nil {
|
|
s.logger.Warn("offline town tour: list quests by npc", "error", err)
|
|
return false
|
|
}
|
|
for _, q := range offered {
|
|
if _, ok := taken[q.ID]; ok {
|
|
continue
|
|
}
|
|
ok, err := s.questStore.TryAcceptQuest(ctx, heroID, q.ID)
|
|
if err != nil {
|
|
s.logger.Warn("offline town tour: try accept quest", "error", err)
|
|
return false
|
|
}
|
|
if ok && al != nil {
|
|
qk := q.QuestKey
|
|
if qk == "" {
|
|
qk = fmt.Sprintf("quest.%d", q.ID)
|
|
}
|
|
al(heroID, model.AdventureLogLine{
|
|
Event: &model.AdventureLogEvent{
|
|
Code: model.LogPhraseQuestAccepted,
|
|
Args: map[string]any{"questKey": qk},
|
|
},
|
|
})
|
|
}
|
|
return ok
|
|
}
|
|
return false
|
|
}
|
|
|
|
if tryQuest() {
|
|
return
|
|
}
|
|
|
|
if npc.Type == "merchant" {
|
|
gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
|
|
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost {
|
|
items := RollTownMerchantStockItems(townLv, 1)
|
|
if len(items) > 0 && TownMerchantRollIsUpgrade(h, items[0], now) {
|
|
h.Gold -= gearCost
|
|
drop, err := ApplyPreparedTownMerchantPurchase(ctx, s.gearStore, h, items[0], now)
|
|
if err != nil {
|
|
h.Gold += gearCost
|
|
s.logger.Warn("offline town tour merchant gear", "hero_id", heroID, "error", err)
|
|
} else if al != nil && drop != nil {
|
|
townKey := ""
|
|
if town != nil {
|
|
townKey = town.NameKey
|
|
}
|
|
al(heroID, model.AdventureLogLine{
|
|
Event: &model.AdventureLogEvent{
|
|
Code: model.LogPhraseBoughtGearTownMerchant,
|
|
Args: map[string]any{
|
|
"npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
|
|
"rarity": string(drop.Rarity), "itemId": drop.ItemID,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
_, healCost := tuning.EffectiveNPCShopCosts()
|
|
potionCost, _ := tuning.EffectiveNPCShopCosts()
|
|
if npc.Type == "healer" && h.MaxHP > 0 {
|
|
hpFrac := float64(h.HP) / float64(h.MaxHP)
|
|
if hpFrac < 0.5 && healCost > 0 && h.Gold >= healCost && h.HP < h.MaxHP {
|
|
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},
|
|
},
|
|
})
|
|
}
|
|
return
|
|
}
|
|
if potionCost > 0 && h.Gold >= potionCost {
|
|
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},
|
|
},
|
|
})
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if npc.Type == "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},
|
|
},
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if npc.Type == "quest_giver" && al != nil {
|
|
al(heroID, model.AdventureLogLine{
|
|
Event: &model.AdventureLogEvent{
|
|
Code: model.LogPhraseQuestGiverChecked,
|
|
Args: map[string]any{"npcKey": npc.NameKey},
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 = PickEnemyForHero(hero)
|
|
}
|
|
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.
|
|
// hero is nil: no unequipped-hero weakening is applied (still uses global encounter stat multiplier).
|
|
func PickEnemyForLevel(level int) model.Enemy {
|
|
return pickEnemyForHeroLevel(nil, level, nil)
|
|
}
|
|
|
|
// PickEnemyForHero is like PickEnemyForLevel but applies unequipped-hero monster scaling when hero has no gear.
|
|
func PickEnemyForHero(hero *model.Hero) model.Enemy {
|
|
if hero == nil {
|
|
return model.Enemy{}
|
|
}
|
|
return pickEnemyForHeroLevel(hero, hero.Level, nil)
|
|
}
|
|
|
|
// PickEnemyForLevelWithRNG is like PickEnemyForLevel but uses rng for template selection (deterministic sims).
|
|
// Pass hero when simulating a specific hero so unequipped scaling matches live encounters (may be nil).
|
|
func PickEnemyForLevelWithRNG(level int, rng *rand.Rand, hero *model.Hero) model.Enemy {
|
|
return pickEnemyForHeroLevel(hero, level, rng)
|
|
}
|
|
|
|
func pickEnemyForHeroLevel(hero *model.Hero, level int, rng *rand.Rand) model.Enemy {
|
|
candidates := enemyCandidatesForHeroLevel(level)
|
|
if len(candidates) == 0 {
|
|
return model.Enemy{}
|
|
}
|
|
var picked model.Enemy
|
|
if rng != nil {
|
|
picked = candidates[rng.Intn(len(candidates))]
|
|
} else {
|
|
picked = candidates[rand.Intn(len(candidates))]
|
|
}
|
|
e := buildEnemyInstance(picked, hero, rng)
|
|
|
|
return e
|
|
}
|
|
|
|
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, hero *model.Hero, rng *rand.Rand,) model.Enemy {
|
|
picked := tmpl
|
|
instanceLevel := enemyInstanceLevel(picked.BaseLevel, hero.Level, picked.LevelVariance, picked.MaxHeroLevelDiff, rng)
|
|
return BuildEnemyInstanceForLevel(picked, instanceLevel, hero)
|
|
}
|
|
|
|
// 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, hero *model.Hero, rng *rand.Rand) model.Enemy {
|
|
return buildEnemyInstance(tmpl, hero, 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, nil)
|
|
}
|
|
|
|
// BuildEnemyInstanceForLevelScaledOnly returns the runtime enemy after level-based progression only.
|
|
// It does not apply EnemyEncounterStatMultiplier or unequipped-hero scaling.
|
|
func BuildEnemyInstanceForLevelScaledOnly(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
|
|
}
|
|
|
|
// EnemyEncounterStatStages returns level-scaled combat stats, then the same after the global encounter multiplier only.
|
|
func EnemyEncounterStatStages(tmpl model.Enemy, level int) (baseScaled model.Enemy, afterGlobal model.Enemy) {
|
|
baseScaled = BuildEnemyInstanceForLevelScaledOnly(tmpl, level)
|
|
afterGlobal = baseScaled
|
|
cfg := tuning.Get()
|
|
gMult := cfg.EnemyEncounterStatMultiplier
|
|
if gMult <= 0 {
|
|
gMult = tuning.DefaultValues().EnemyEncounterStatMultiplier
|
|
}
|
|
if gMult > 0 && gMult != 1 {
|
|
applyEnemyEncounterCombatMult(&afterGlobal, gMult)
|
|
}
|
|
return baseScaled, afterGlobal
|
|
}
|
|
|
|
// BuildEnemyInstanceForLevel creates a deterministic enemy instance at an explicit level.
|
|
func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int, hero *model.Hero) model.Enemy {
|
|
picked := BuildEnemyInstanceForLevelScaledOnly(tmpl, level)
|
|
cfg := tuning.Get()
|
|
|
|
var m float64
|
|
if hero != nil && !HeroHasEquippedGearForCombat(hero) {
|
|
m = cfg.EnemyStatMultiplierVsUnequippedHero
|
|
} else {
|
|
m = cfg.EnemyEncounterStatMultiplier
|
|
}
|
|
|
|
applyEnemyEncounterCombatMult(&picked, m)
|
|
|
|
return picked
|
|
}
|
|
|
|
// HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear.
|
|
func HeroHasEquippedGear(h *model.Hero) bool {
|
|
if h == nil {
|
|
return false
|
|
}
|
|
h.EnsureGearMap()
|
|
for _, it := range h.Gear {
|
|
if it != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HeroHasEquippedGearForCombat is true if the hero has any equipped item (weapon/armor/etc.).
|
|
func HeroHasEquippedGearForCombat(h *model.Hero) bool {
|
|
h.EnsureGearMap()
|
|
c := 0
|
|
for _, it := range h.Gear {
|
|
if it != nil {
|
|
c++
|
|
}
|
|
}
|
|
return c > 0
|
|
}
|
|
|
|
func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) {
|
|
if e == nil || mult <= 0 || mult == 1 {
|
|
return
|
|
}
|
|
e.MaxHP = max(1, int(math.Round(float64(e.MaxHP)*mult)))
|
|
e.HP = e.MaxHP
|
|
e.Attack = max(1, int(math.Round(float64(e.Attack)*mult)))
|
|
e.Defense = max(0, int(math.Round(float64(e.Defense)*mult)))
|
|
}
|
|
|
|
// EnemyEncounterMultiplierBreakdown documents tuning multipliers used when building encounter enemies
|
|
// (global encounter strength vs hero; extra scaling when the hero has almost no gear).
|
|
type EnemyEncounterMultiplierBreakdown struct {
|
|
GlobalEncounterStatMultiplier float64 `json:"globalEncounterStatMultiplier"`
|
|
UnequippedHeroStatMultiplier float64 `json:"unequippedHeroStatMultiplier"`
|
|
UnequippedScalingApplied bool `json:"unequippedScalingApplied"`
|
|
}
|
|
|
|
// EnemyEncounterMultiplierBreakdownForHero returns active tuning values and whether unequipped-hero scaling would apply.
|
|
func EnemyEncounterMultiplierBreakdownForHero(hero *model.Hero) EnemyEncounterMultiplierBreakdown {
|
|
cfg := tuning.Get()
|
|
g := cfg.EnemyEncounterStatMultiplier
|
|
if g <= 0 {
|
|
g = tuning.DefaultValues().EnemyEncounterStatMultiplier
|
|
}
|
|
m := cfg.EnemyStatMultiplierVsUnequippedHero
|
|
if m <= 0 {
|
|
m = tuning.DefaultValues().EnemyStatMultiplierVsUnequippedHero
|
|
}
|
|
applied := hero != nil && !HeroHasEquippedGearForCombat(hero) && m > 0 && m != 1 && m <= 10
|
|
return EnemyEncounterMultiplierBreakdown{
|
|
GlobalEncounterStatMultiplier: g,
|
|
UnequippedHeroStatMultiplier: m,
|
|
UnequippedScalingApplied: applied,
|
|
}
|
|
}
|
|
|
|
// ApplyEnemyEncounterHeroScaling applies a multiplier to enemy combat stats when the hero has no equipped gear.
|
|
func ApplyEnemyEncounterHeroScaling(hero *model.Hero, enemy *model.Enemy) {
|
|
if hero == nil || enemy == nil || HeroHasEquippedGearForCombat(hero) {
|
|
return
|
|
}
|
|
cfg := tuning.Get()
|
|
m := cfg.EnemyStatMultiplierVsUnequippedHero
|
|
if m <= 0 {
|
|
m = tuning.DefaultValues().EnemyStatMultiplierVsUnequippedHero
|
|
}
|
|
if m <= 0 || m > 10 || m == 1 {
|
|
return
|
|
}
|
|
applyEnemyEncounterCombatMult(enemy, m)
|
|
}
|
|
|
|
func absInt(v int) int {
|
|
if v < 0 {
|
|
return -v
|
|
}
|
|
return v
|
|
}
|