package game import ( "container/heap" "context" "encoding/json" "fmt" "log/slog" "sync" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" ) // MessageSender is the interface the engine uses to push WS messages. // Implemented by handler.Hub (injected to avoid import cycle). type MessageSender interface { SendToHero(heroID int64, msgType string, payload any) BroadcastEvent(event model.CombatEvent) } // EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS. type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop type merchantOfferSession struct { NPCID int64 TownID int64 Items []*model.GearItem Costs []int64 // parallel to Items — rolled when stock opens Created time.Time } // EngineStatus contains a snapshot of the engine's operational state. type EngineStatus struct { Running bool `json:"running"` TickRate time.Duration `json:"tickRate"` ActiveCombats int `json:"activeCombats"` ActiveMovements int `json:"activeMovements"` UptimeMs int64 `json:"uptimeMs"` TimePaused bool `json:"timePaused"` } // CombatInfo is a read-only snapshot of a single active combat. type CombatInfo struct { HeroID int64 `json:"heroId"` EnemyName string `json:"enemyName"` EnemyType string `json:"enemyType"` HeroHP int `json:"heroHp"` EnemyHP int `json:"enemyHp"` StartedAt time.Time `json:"startedAt"` } // IncomingMessage is a client command received from the WS layer. type IncomingMessage struct { HeroID int64 Type string Payload json.RawMessage } // Engine is the tick-based game loop that drives combat simulation and hero movement. type Engine struct { tickRate time.Duration combats map[int64]*model.CombatState // keyed by hero ID queue model.AttackQueue movements map[int64]*HeroMovement // keyed by hero ID roadGraph *RoadGraph sender MessageSender heroStore *storage.HeroStore townSession *storage.TownSessionStore questStore *storage.QuestStore incomingCh chan IncomingMessage // client commands mu sync.RWMutex eventCh chan model.CombatEvent logger *slog.Logger onEnemyDeath EnemyDeathCallback adventureLog AdventureLogWriter startedAt time.Time running bool // timePaused: when true, combat/movement/sync ticks and WS game commands are no-ops. timePaused bool // pauseStartedAt is wall clock when global pause began (zero when running). pauseStartedAt time.Time // npcAlmsHandler runs when the client accepts a wandering merchant offer (WS). npcAlmsHandler func(context.Context, int64) error digestStore *storage.OfflineDigestStore // heroSubscriber reports whether the hero has at least one WebSocket client (optional). heroSubscriber func(heroID int64) bool // lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber. lastDisconnectedFullSave map[int64]time.Time // merchantStock: ephemeral town merchant rows (heroID) until purchase or dialog close. merchantStock map[int64]*merchantOfferSession heroMeetLastRoll map[int64]time.Time heroMeetLastMsg map[int64]time.Time } // offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected. const offlineDisconnectedFullSaveInterval = 30 * time.Second // restHealPersistInterval is how often we persist the full hero row while resting with active HP regen. const restHealPersistInterval = 5 * time.Second // NewEngine creates a new game engine with the given tick rate. func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *slog.Logger) *Engine { e := &Engine{ tickRate: tickRate, combats: make(map[int64]*model.CombatState), queue: make(model.AttackQueue, 0), movements: make(map[int64]*HeroMovement), incomingCh: make(chan IncomingMessage, 256), eventCh: eventCh, logger: logger, lastDisconnectedFullSave: make(map[int64]time.Time), merchantStock: make(map[int64]*merchantOfferSession), heroMeetLastRoll: make(map[int64]time.Time), heroMeetLastMsg: make(map[int64]time.Time), } heap.Init(&e.queue) return e } func (e *Engine) GetMovements(heroId int64) *HeroMovement { return e.movements[heroId] } // LiveHeroByTelegramID returns the resident in-memory hero for an active movement session, or nil. func (e *Engine) LiveHeroByTelegramID(telegramID int64) *model.Hero { if e == nil || telegramID == 0 { return nil } e.mu.RLock() defer e.mu.RUnlock() for _, hm := range e.movements { if hm != nil && hm.Hero != nil && hm.Hero.TelegramID == telegramID { return hm.Hero } } return nil } // MergeResidentHeroState copies the authoritative in-engine hero into dst after SyncToHero. // Returns false if the hero is not resident. Used by REST init so the client sees the same state the Engine simulates. func (e *Engine) MergeResidentHeroState(dst *model.Hero) bool { if dst == nil { return false } e.mu.RLock() hm := e.movements[dst.ID] e.mu.RUnlock() if hm == nil || hm.Hero == nil { return false } hm.SyncToHero() *dst = *hm.Hero return true } // HeroHasActiveMovement is true while the hero has an in-engine movement session (resident world actor). func (e *Engine) HeroHasActiveMovement(heroID int64) bool { e.mu.RLock() defer e.mu.RUnlock() _, ok := e.movements[heroID] return ok } // HeroWorldPositionForCombat returns world X,Y for town/combat checks (includes movement display offset). func (e *Engine) HeroWorldPositionForCombat(heroID int64) (x, y float64, ok bool) { e.mu.RLock() defer e.mu.RUnlock() hm, found := e.movements[heroID] if !found || hm == nil || hm.Hero == nil { return 0, 0, false } now := time.Now() ox, oy := hm.displayOffset(now) return hm.CurrentX + ox, hm.CurrentY + oy, true } // OverlayResidentWorldPositionsOnNearby overwrites each summary's PositionX/Y when that hero // has an active movement session (authoritative in-engine coords). Used by GET /hero/nearby so // clients see meet-stand / frozen positions instead of stale DB rows. func (e *Engine) OverlayResidentWorldPositionsOnNearby(heroes []storage.HeroSummary) { if len(heroes) == 0 { return } e.mu.RLock() defer e.mu.RUnlock() now := time.Now() for i := range heroes { hm := e.movements[heroes[i].ID] if hm == nil || hm.Hero == nil { continue } ox, oy := hm.displayOffset(now) heroes[i].PositionX = hm.CurrentX + ox heroes[i].PositionY = hm.CurrentY + oy } } // RoadGraph returns the loaded world graph (for admin tools), or nil. func (e *Engine) RoadGraph() *RoadGraph { e.mu.RLock() defer e.mu.RUnlock() return e.roadGraph } // SetTimePaused freezes all engine simulation ticks and client command handling. // On resume, movement/combat timers are shifted so wall time spent paused does not advance the sim // (no invisible travel or burst combat). func (e *Engine) SetTimePaused(paused bool) { e.mu.Lock() defer e.mu.Unlock() if paused { if !e.timePaused { e.pauseStartedAt = time.Now() e.timePaused = true if e.logger != nil { e.logger.Info("game time paused") } } return } if !e.timePaused { return } now := time.Now() var pauseDur time.Duration if !e.pauseStartedAt.IsZero() { pauseDur = now.Sub(e.pauseStartedAt) } e.timePaused = false e.pauseStartedAt = time.Time{} if pauseDur > 0 { for _, hm := range e.movements { hm.ShiftGameDeadlines(pauseDur, now) if hm.Hero != nil { model.ShiftHeroEffectDeadlines(hm.Hero, pauseDur) } } e.resyncCombatAfterPauseLocked(now, pauseDur) if e.logger != nil { e.logger.Info("game time resumed", "paused_wall_ms", pauseDur.Milliseconds()) } } } // resyncCombatAfterPauseLocked shifts combat scheduling by pauseDur and rebuilds the attack heap. // Caller must hold e.mu (write lock). func (e *Engine) resyncCombatAfterPauseLocked(now time.Time, pauseDur time.Duration) { if len(e.combats) == 0 { return } for _, cs := range e.combats { cs.LastTickAt = now cs.StartedAt = cs.StartedAt.Add(pauseDur) hna := cs.HeroNextAttack.Add(pauseDur) ena := cs.EnemyNextAttack.Add(pauseDur) if cs.Hero != nil { if hna.Before(now) { hna = now.Add(attackInterval(cs.Hero.EffectiveSpeed())) } } else if hna.Before(now) { cfg := tuning.Get() minAttack := time.Duration(cfg.MinAttackIntervalMs) * time.Millisecond if cfg.CombatPaceMultiplier < 1 { cfg.CombatPaceMultiplier = 1 } hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier)) } if ena.Before(now) { ena = now.Add(attackIntervalEnemy(cs.Enemy.Speed)) } cs.HeroNextAttack = hna cs.EnemyNextAttack = ena } e.queue = make(model.AttackQueue, 0) for heroID, cs := range e.combats { heap.Push(&e.queue, &model.AttackEvent{NextAttackAt: cs.HeroNextAttack, IsHero: true, CombatID: heroID}) heap.Push(&e.queue, &model.AttackEvent{NextAttackAt: cs.EnemyNextAttack, IsHero: false, CombatID: heroID}) } heap.Init(&e.queue) } // IsTimePaused reports whether global simulation time is frozen. func (e *Engine) IsTimePaused() bool { e.mu.RLock() defer e.mu.RUnlock() return e.timePaused } // SetSender sets the WS message sender (typically handler.Hub). func (e *Engine) SetSender(s MessageSender) { e.mu.Lock() defer e.mu.Unlock() e.sender = s } // SetRoadGraph sets the road graph used for hero movement. func (e *Engine) SetRoadGraph(rg *RoadGraph) { e.mu.Lock() defer e.mu.Unlock() e.roadGraph = rg } // SetHeroStore sets the hero store used for persisting hero state on disconnect. func (e *Engine) SetHeroStore(hs *storage.HeroStore) { e.mu.Lock() defer e.mu.Unlock() e.heroStore = hs } // SetTownSessionStore sets the Redis-backed mirror for in-town NPC tour state (reconnect recovery). func (e *Engine) SetTownSessionStore(ts *storage.TownSessionStore) { e.mu.Lock() defer e.mu.Unlock() e.townSession = ts } // SetQuestStore sets the quest store used for visit_town progress on town arrival. func (e *Engine) SetQuestStore(qs *storage.QuestStore) { e.mu.Lock() defer e.mu.Unlock() e.questStore = qs } // SetOnEnemyDeath registers a callback for enemy death events (e.g. loot generation). func (e *Engine) SetOnEnemyDeath(cb EnemyDeathCallback) { e.mu.Lock() defer e.mu.Unlock() e.onEnemyDeath = cb } // SetNPCAlmsHandler registers the handler for npc_alms_accept WebSocket commands. func (e *Engine) SetNPCAlmsHandler(h func(context.Context, int64) error) { e.mu.Lock() defer e.mu.Unlock() e.npcAlmsHandler = h } // SetAdventureLog registers a writer for town NPC visit lines (optional). func (e *Engine) SetAdventureLog(w AdventureLogWriter) { e.mu.Lock() defer e.mu.Unlock() e.adventureLog = w } // SetDigestStore wires persistent offline digest accumulation (after disconnect grace). func (e *Engine) SetDigestStore(d *storage.OfflineDigestStore) { e.mu.Lock() defer e.mu.Unlock() e.digestStore = d } // SetHeroSubscriber sets an optional callback: return true if the hero has at least one WebSocket client. // Used for periodic full saves when the world keeps simulating without a subscriber. func (e *Engine) SetHeroSubscriber(fn func(heroID int64) bool) { e.mu.Lock() defer e.mu.Unlock() e.heroSubscriber = fn } func (e *Engine) applyOfflineDigest(ctx context.Context, heroID int64, hero *model.Hero, now time.Time, delta storage.OfflineDigestDelta) { if e.digestStore == nil || hero == nil || !OfflineDigestCollecting(hero.WsDisconnectedAt, now) { return } _ = e.digestStore.ApplyDelta(ctx, heroID, delta) } // IncomingCh returns the channel for routing client WS commands into the engine. func (e *Engine) IncomingCh() chan<- IncomingMessage { return e.incomingCh } // Run starts the game loop. It blocks until the context is cancelled. func (e *Engine) Run(ctx context.Context) error { combatTicker := time.NewTicker(e.tickRate) moveTicker := time.NewTicker(movementTickRate()) syncTicker := time.NewTicker(positionSyncRate()) defer combatTicker.Stop() defer moveTicker.Stop() defer syncTicker.Stop() e.mu.Lock() e.startedAt = time.Now() e.running = true e.mu.Unlock() e.logger.Info("game engine started", "tick_rate", e.tickRate) defer func() { e.mu.Lock() e.running = false e.mu.Unlock() }() for { select { case <-ctx.Done(): e.logger.Info("game engine shutting down") return ctx.Err() case now := <-combatTicker.C: if !e.IsTimePaused() { e.processCombatTick(now) } case now := <-moveTicker.C: if !e.IsTimePaused() { e.processMovementTick(now) } case now := <-syncTicker.C: if !e.IsTimePaused() { e.processPositionSync(now) } case msg := <-e.incomingCh: e.handleClientMessage(msg) } } } // handleClientMessage routes a single inbound client command. func (e *Engine) handleClientMessage(msg IncomingMessage) { if e.IsTimePaused() { e.sendError(msg.HeroID, "time_paused", "server time is paused") return } switch msg.Type { case "activate_buff": e.handleActivateBuff(msg) case "use_potion": e.handleUsePotion(msg) case "revive": e.handleRevive(msg) case "npc_alms_accept": e.handleNPCAlmsAccept(msg) case "npc_alms_decline": e.handleNPCAlmsDecline(msg) case "town_tour_npc_dialog_closed": e.handleTownTourNPCDialogClosed(msg) case "town_tour_npc_interaction_opened": e.handleTownTourNPCInteractionOpened(msg) case "town_tour_npc_interaction_closed": e.handleTownTourNPCInteractionClosed(msg) case "hero_meet_send_message": e.handleHeroMeetSendMessage(msg) case "hero_meet_end_conversation": e.handleHeroMeetEndConversation(msg) default: // Commands like accept_quest, claim_quest, npc_interact etc. // are handled by their respective REST handlers for now. e.logger.Debug("unhandled client ws message", "type", msg.Type, "hero_id", msg.HeroID) } } // handleActivateBuff processes the activate_buff client command. func (e *Engine) handleActivateBuff(msg IncomingMessage) { var payload model.ActivateBuffPayload if err := json.Unmarshal(msg.Payload, &payload); err != nil { e.sendError(msg.HeroID, "invalid_payload", "invalid activate_buff payload") return } e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[msg.HeroID] if !ok { e.sendError(msg.HeroID, "no_hero", "hero not connected") return } bt, ok := model.ValidBuffType(payload.BuffType) if !ok { e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType)) return } hero := hm.Hero now := time.Now() hero.RefreshSubscription(now) hero.EnsureBuffChargesPopulated(now) if err := hero.ConsumeBuffCharge(bt, now); err != nil { e.sendError(msg.HeroID, "buff_quota_exhausted", err.Error()) return } ab := ApplyBuff(hero, bt, now) if ab == nil { hero.RefundBuffCharge(bt) e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType)) return } hero.RefreshDerivedCombatStats(now) hm.refreshSpeed(now) if cs, ok := e.combats[msg.HeroID]; ok { cs.Hero = hero } if e.heroStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil { e.logger.Error("failed to save hero after buff", "hero_id", hero.ID, "error", err) } } if e.sender != nil { e.sender.SendToHero(msg.HeroID, "hero_state", hero) e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{ BuffType: payload.BuffType, Duration: ab.Buff.Duration.Seconds(), Magnitude: ab.Buff.Magnitude, }) } } // handleUsePotion processes the use_potion client command. func (e *Engine) handleUsePotion(msg IncomingMessage) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[msg.HeroID] if !ok { e.sendError(msg.HeroID, "no_hero", "hero not connected") return } hero := hm.Hero // Validate: hero is in combat, has potions, is alive. if hm.State != model.StateFighting { e.sendError(msg.HeroID, "not_fighting", "hero is not in combat") return } if hero.Potions <= 0 { e.sendError(msg.HeroID, "no_potions", "no potions available") return } if hero.HP <= 0 { e.sendError(msg.HeroID, "dead", "hero is dead") return } hero.Potions-- 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 } hm.SyncToHero() // Keep combat state's hero pointer aligned with movement (authoritative live hero). if cs, ok := e.combats[msg.HeroID]; ok { cs.Hero = hm.Hero } if e.heroStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil { e.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err) } } if e.adventureLog != nil { e.adventureLog(msg.HeroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseUsedHealingPotion, Args: map[string]any{"amount": healAmount}, }, }) } // Emit as an attack-like event so the client shows it. cs, hasCombat := e.combats[msg.HeroID] enemyHP := 0 if hasCombat { enemyHP = cs.Enemy.HP } if e.sender != nil { e.sender.SendToHero(msg.HeroID, "attack", model.AttackPayload{ Source: "potion", Damage: -healAmount, // negative = heal HeroHP: hero.HP, EnemyHP: enemyHP, }) hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) e.sender.SendToHero(msg.HeroID, "hero_state", hero) } } func (e *Engine) handleNPCAlmsAccept(msg IncomingMessage) { e.mu.RLock() h := e.npcAlmsHandler e.mu.RUnlock() if h == nil { e.sendError(msg.HeroID, "not_supported", "wandering merchant is not available") return } if err := h(context.Background(), msg.HeroID); err != nil { e.sendError(msg.HeroID, "alms_failed", err.Error()) } } func (e *Engine) handleNPCAlmsDecline(msg IncomingMessage) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[msg.HeroID] if !ok { return } if hm.WanderingMerchantDeadline.IsZero() { return } hm.WanderingMerchantDeadline = time.Time{} if e.sender != nil { e.sender.SendToHero(msg.HeroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "declined"}) } } // handleRevive processes the revive client command (same rules as POST /api/v1/hero/revive; // both paths use the resident in-memory hero only). func (e *Engine) handleRevive(msg IncomingMessage) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[msg.HeroID] if !ok { e.sendError(msg.HeroID, "no_hero", "hero not connected") return } hero := hm.Hero if !IsEffectivelyDead(hero) { e.sendError(msg.HeroID, "not_dead", "hero is not dead") return } if err := CheckPlayerReviveQuota(hero); err != nil { e.sendError(msg.HeroID, "revive_quota", "free revive limit reached (subscribe for unlimited revives)") return } ApplyHeroReviveMechanical(hero) ApplyPlayerReviveProgressCounters(hero) // Persist revive to DB immediately so disconnect doesn't revert it. if e.heroStore != nil { if err := e.heroStore.Save(context.Background(), hero); err != nil { e.logger.Error("failed to save hero after revive", "hero_id", hero.ID, "error", err) } } if e.adventureLog != nil { e.adventureLog(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}}) } e.applyResidentReviveSyncLocked(hero) } // sendError sends an error envelope to a hero. func (e *Engine) sendError(heroID int64, code, message string) { if e.sender != nil { e.sender.SendToHero(heroID, "error", model.ErrorPayload{Code: code, Message: message}) } } // RegisterHeroMovement creates a HeroMovement for an online hero and sends initial state. // Called when a WS client connects. func (e *Engine) RegisterHeroMovement(hero *model.Hero) { if hero == nil { return } e.mergeTownSessionFromRedis(hero) e.mu.Lock() defer e.mu.Unlock() if e.roadGraph == nil { e.logger.Warn("cannot register movement: road graph not loaded", "hero_id", hero.ID) return } now := time.Now() // Reconnect while the previous socket is still tearing down: keep live movement so we // do not replace (x,y) and route with a stale DB snapshot. if existing, ok := e.movements[hero.ID]; ok { existing.Hero.WsDisconnectedAt = hero.WsDisconnectedAt existing.Hero.EnsureGearMap() existing.Hero.RefreshDerivedCombatStats(now) e.logger.Info("hero movement reattached (existing session)", "hero_id", hero.ID, "state", existing.State, "pos_x", existing.CurrentX, "pos_y", existing.CurrentY, ) if e.sender != nil { e.sender.SendToHero(hero.ID, "hero_state", existing.Hero) if route := existing.RoutePayload(); route != nil { e.sender.SendToHero(hero.ID, "route_assigned", route) } if cs, ok := e.combats[hero.ID]; ok { e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{ Enemy: enemyToInfo(&cs.Enemy), }) } e.pushHeroMeetIfActiveLocked(hero.ID) } return } hm := NewHeroMovement(hero, e.roadGraph, now) e.movements[hero.ID] = hm hm.MarkTownPausePersisted(hm.townPausePersistSignature()) hm.SyncToHero() // DB said fighting but engine has no combat (e.g. after restart): attach a new encounter. if hm.State == model.StateFighting { if _, exists := e.combats[hero.ID]; !exists { en := PickEnemyForHero(hero) if en.Slug != "" { e.startCombatLocked(hm.Hero, &en) } else { hm.State = model.StateWalking hm.Hero.State = model.StateWalking } } } e.logger.Info("hero movement registered", "hero_id", hero.ID, "state", hm.State, "pos_x", hm.CurrentX, "pos_y", hm.CurrentY, ) // Send initial state via WS. if e.sender != nil { hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(hero.ID, "route_assigned", route) } // If mid-combat, send combat_start so client can resume UI. if cs, ok := e.combats[hero.ID]; ok { e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{ Enemy: enemyToInfo(&cs.Enemy), }) } e.pushHeroMeetIfActiveLocked(hero.ID) } } // HeroSocketDetached persists hero state on every WS disconnect. Movement and combat stay in the engine // so the world keeps simulating; disconnectedAt is stored on the in-memory hero for offline digest timing. func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool, disconnectedAt time.Time) { e.mu.Lock() hm, ok := e.movements[heroID] if ok { hm.SyncToHero() if lastConnection && !disconnectedAt.IsZero() && hm.Hero != nil { t := disconnectedAt hm.Hero.WsDisconnectedAt = &t } } var heroSnap *model.Hero if ok { heroSnap = hm.Hero } e.mu.Unlock() if ok && e.heroStore != nil && heroSnap != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.heroStore.Save(ctx, heroSnap); err != nil { e.logger.Error("failed to save hero on ws disconnect", "hero_id", heroID, "error", err) } else { e.logger.Info("hero state persisted on ws disconnect", "hero_id", heroID, "last_connection", lastConnection, ) e.syncTownSessionRedisFromHero(heroID, heroSnap) } } } // Status returns a snapshot of the engine's current operational state. func (e *Engine) Status() EngineStatus { e.mu.RLock() defer e.mu.RUnlock() var uptimeMs int64 if e.running { uptimeMs = time.Since(e.startedAt).Milliseconds() } return EngineStatus{ Running: e.running, TickRate: e.tickRate, ActiveCombats: len(e.combats), ActiveMovements: len(e.movements), UptimeMs: uptimeMs, TimePaused: e.timePaused, } } // 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). func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok || e.roadGraph == nil { return nil, false } now := time.Now() if err := hm.AdminPlaceInTown(e.roadGraph, townID, now); err != nil { return nil, false } delete(e.combats, heroID) hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "hero_state", h) town := e.roadGraph.Towns[hm.CurrentTownID] if town != nil { npcInfos := e.roadGraph.TownNPCInfos(hm.CurrentTownID) buildingInfos := e.roadGraph.TownBuildingInfos(hm.CurrentTownID) var restMs int64 if hm.State == model.StateResting { restMs = hm.RestUntil.Sub(now).Milliseconds() } e.sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{ TownID: town.ID, TownName: town.Name, Biome: town.Biome, NPCs: npcInfos, Buildings: buildingInfos, RestDurationMs: restMs, }) } } e.applyVisitTownQuestProgress(h) return h, true } // ApplyAdminForceLeaveTown ends resting or in-town pause, assigns a new road, and notifies the client. func (e *Engine) ApplyAdminForceLeaveTown(heroID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok || e.roadGraph == nil { return nil, false } if hm.State != model.StateResting && hm.State != model.StateInTown { return nil, false } now := time.Now() hm.LeaveTown(e.roadGraph, now) hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "town_exit", model.TownExitPayload{}) if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(heroID, "route_assigned", route) } } if e.heroStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil { e.logger.Error("persist hero after force leave town", "hero_id", h.ID, "error", err) } } return h, true } // ApplyAdminTownTourApproachNPC forces npc_approach toward a specific NPC during ExcursionKindTown (hero must be online). func (e *Engine) ApplyAdminTownTourApproachNPC(heroID, npcID int64) (*model.Hero, error) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok || e.roadGraph == nil { return nil, fmt.Errorf("hero not online or graph missing") } now := time.Now() if err := hm.AdminTownTourApproachNPC(e.roadGraph, npcID, now); err != nil { return nil, err } hm.SyncToHero() h := hm.Hero if e.sender != nil { NotifyTownTourClients(e.sender, heroID, hm, e.roadGraph, now) } return h, nil } // ApplyAdminStartRest puts an online hero into town-style rest at the current location. func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok || e.roadGraph == nil { return nil, false } now := time.Now() if !hm.AdminStartRest(now, e.roadGraph) { return nil, false } hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "hero_state", h) } return h, true } // ApplyAdminStartRoadsideRest puts an online hero into roadside rest at the current road position. func (e *Engine) ApplyAdminStartRoadsideRest(heroID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok { return nil, false } now := time.Now() if !hm.AdminStartRoadsideRest(now) { return nil, false } hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } return h, true } // ApplyAdminStopRest exits a hero from non-town rest (roadside / adventure-inline) back to walking. func (e *Engine) ApplyAdminStopRest(heroID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok { return nil, false } now := time.Now() if !hm.AdminStopRest(now) { return nil, false } hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(heroID, "route_assigned", route) } } return h, true } // ApplyAdminStopAnyRest ends whichever rest or town pause applies: first roadside/adventure-inline // (must not use LeaveTown — that would corrupt excursion state), otherwise town rest or in-town flow. func (e *Engine) ApplyAdminStopAnyRest(heroID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok || e.roadGraph == nil { return nil, false } now := time.Now() if hm.AdminStopRest(now) { hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(heroID, "route_assigned", route) } } return h, true } if hm.State != model.StateResting && hm.State != model.StateInTown { return nil, false } hm.LeaveTown(e.roadGraph, now) hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "town_exit", model.TownExitPayload{}) if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(heroID, "route_assigned", route) } } if e.heroStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil { e.logger.Error("persist hero after admin stop any rest (leave town)", "hero_id", heroID, "error", err) } } return h, true } // ApplyAdminLethalEnemyKill applies a killing blow from the hero and runs the normal victory path (rewards, WS, persist). func (e *Engine) ApplyAdminLethalEnemyKill(heroID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() cs, ok := e.combats[heroID] if !ok || cs == nil || cs.Hero == nil || !cs.Enemy.IsAlive() { return nil, false } now := time.Now() dmg := cs.Enemy.HP cs.Enemy.HP = 0 combatEvt := model.CombatEvent{ Type: "attack", HeroID: heroID, Damage: dmg, Source: "hero", Outcome: attackOutcomeHit, HeroHP: cs.Hero.HP, EnemyHP: 0, Timestamp: now, } e.emitEvent(combatEvt) e.logCombatAttack(cs, combatEvt) if e.sender != nil { e.sender.SendToHero(heroID, "attack", model.AttackPayload{ Source: combatEvt.Source, Damage: combatEvt.Damage, IsCrit: false, Outcome: combatEvt.Outcome, HeroHP: combatEvt.HeroHP, EnemyHP: 0, }) } e.handleEnemyDeath(cs, now) if hm, ok := e.movements[heroID]; ok && hm.Hero != nil { return hm.Hero, true } if cs.Hero != nil { return cs.Hero, true } return nil, false } // ApplyAdminStartExcursion forces an online hero into a mini-adventure session on the current road. func (e *Engine) ApplyAdminStartExcursion(heroID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok { return nil, false } now := time.Now() if !hm.AdminStartExcursion(now) { return nil, false } hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "excursion_start", model.ExcursionStartPayload{ DepthWorldUnits: hm.Excursion.DepthWorldUnits, }) e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } return h, true } // ApplyAdminStopExcursion forces the return leg of an active excursion (admin "stop adventure"). func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[heroID] if !ok { return nil, false } now := time.Now() if !hm.AdminStopExcursion(now) { return nil, false } hm.SyncToHero() h := hm.Hero if e.sender != nil { h.EnsureGearMap() h.RefreshDerivedCombatStats(now) e.sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)}) e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(heroID, "route_assigned", route) } } return h, true } // ListActiveCombats returns a snapshot of all active combat sessions. func (e *Engine) ListActiveCombats() []CombatInfo { e.mu.RLock() defer e.mu.RUnlock() out := make([]CombatInfo, 0, len(e.combats)) for _, cs := range e.combats { heroHP := 0 if cs.Hero != nil { heroHP = cs.Hero.HP } out = append(out, CombatInfo{ HeroID: cs.HeroID, EnemyName: cs.Enemy.Name, EnemyType: cs.Enemy.Slug, HeroHP: heroHP, EnemyHP: cs.Enemy.HP, StartedAt: cs.StartedAt, }) } return out } // StartCombat registers a new combat encounter between a hero and an enemy. func (e *Engine) StartCombat(hero *model.Hero, enemy *model.Enemy) { e.mu.Lock() defer e.mu.Unlock() e.startCombatLocked(hero, enemy) } // startCombatLocked is the internal version that assumes the lock is already held. func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) { now := time.Now() if _, exists := e.combats[hero.ID]; exists { e.logger.Debug("skip combat start: already in combat", "hero_id", hero.ID) return } if hm, ok := e.movements[hero.ID]; ok { if hm.State == model.StateResting || hm.State == model.StateInTown { e.logger.Debug("skip combat start: hero in town", "hero_id", hero.ID) return } } if e.roadGraph != nil { var wx, wy float64 if hm, ok := e.movements[hero.ID]; ok && hm.Hero != nil { ox, oy := hm.displayOffset(now) wx, wy = hm.CurrentX+ox, hm.CurrentY+oy } if e.roadGraph.HeroInTownAt(wx, wy) { e.logger.Debug("skip combat start: hero inside town radius", "hero_id", hero.ID) return } } cs := &model.CombatState{ HeroID: hero.ID, Hero: hero, Enemy: *enemy, HeroNextAttack: now.Add(attackInterval(hero.EffectiveSpeed())), EnemyNextAttack: now.Add(attackIntervalEnemy(enemy.Speed)), StartedAt: now, LastTickAt: now, } if tmpl, ok := model.EnemyBySlug(enemy.Slug); ok { baseScaled, afterGlobal := EnemyEncounterStatStages(tmpl, enemy.Level) cs.EnemyStatsBasePreEncounterMult = &model.EncounterCombatStatsSnapshot{ MaxHP: baseScaled.MaxHP, Attack: baseScaled.Attack, Defense: baseScaled.Defense, } cs.EnemyStatsAfterGlobalEncounterMult = &model.EncounterCombatStatsSnapshot{ MaxHP: afterGlobal.MaxHP, Attack: afterGlobal.Attack, Defense: afterGlobal.Defense, } } e.combats[hero.ID] = cs hero.State = model.StateFighting // Update movement state. if hm, ok := e.movements[hero.ID]; ok { hm.StartFighting() hm.SyncToHero() } heap.Push(&e.queue, &model.AttackEvent{ NextAttackAt: cs.HeroNextAttack, IsHero: true, CombatID: hero.ID, }) heap.Push(&e.queue, &model.AttackEvent{ NextAttackAt: cs.EnemyNextAttack, IsHero: false, CombatID: hero.ID, }) // New: send typed combat_start envelope. if e.sender != nil { e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{ Enemy: enemyToInfo(enemy), }) } if e.adventureLog != nil { e.adventureLog(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseEncounteredEnemy, Args: map[string]any{"enemyType": enemy.Slug}, }, }) } if e.logger != nil { mult := EnemyEncounterMultiplierBreakdownForHero(hero) e.logger.Info("combat started", "hero_id", hero.ID, "hero_level", hero.Level, "enemy_slug", enemy.Slug, "enemy_name", enemy.Name, "enemy_level", enemy.Level, "enemy_hp", enemy.HP, "enemy_max_hp", enemy.MaxHP, "enemy_attack", enemy.Attack, "enemy_defense", enemy.Defense, "enemy_speed", enemy.Speed, "enemy_crit_chance", enemy.CritChance, "enemy_is_elite", enemy.IsElite, "enemy_xp_reward", enemy.XPReward, "enemy_gold_reward", enemy.GoldReward, "mult_global_encounter", mult.GlobalEncounterStatMultiplier, "mult_unequipped_config", mult.UnequippedHeroStatMultiplier, "mult_unequipped_applied", mult.UnequippedScalingApplied, ) } } // StopCombat removes a combat session. func (e *Engine) StopCombat(heroID int64) { e.mu.Lock() defer e.mu.Unlock() delete(e.combats, heroID) } func (e *Engine) SyncHeroState(hero *model.Hero) { if hero == nil { return } e.mu.Lock() defer e.mu.Unlock() if e.sender == nil { return } hero.EnsureGearMap() e.sender.SendToHero(hero.ID, "hero_state", hero) } // ApplyAdminHeroSnapshot merges a persisted hero (e.g. after admin set-hp) into the live // movement session and pushes hero_state (+ route_assigned when a new road was bound). func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) { if hero == nil { return } e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[hero.ID] if !ok { if e.sender != nil { now := time.Now() hero.EnsureGearMap() hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hero.ID, "hero_state", hero) } return } now := time.Now() *hm.Hero = *hero hm.State = hero.State hm.CurrentX = hero.PositionX hm.CurrentY = hero.PositionY hm.LastMoveTick = now hm.refreshSpeed(now) routeAssigned := false if e.roadGraph != nil && hm.State == model.StateWalking && hm.Road == nil { hm.pickDestination(e.roadGraph) hm.assignRoad(e.roadGraph, false) routeAssigned = true } if e.sender == nil { return } hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) if routeAssigned { if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(hero.ID, "route_assigned", route) } } } // ApplyPersistedHeroSnapshot copies a DB-persisted hero onto the live movement session and pushes hero_state. func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) { if hero == nil { return } e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[hero.ID] if ok { now := time.Now() *hm.Hero = *hero hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) } if e.sender == nil { return } if ok { e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) } else { hero.EnsureGearMap() e.sender.SendToHero(hero.ID, "hero_state", hero) } } // ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into // the live movement session and pushes hero_state when a sender is configured. func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) { if hero == nil { return } e.mu.Lock() if hm, ok := e.movements[hero.ID]; ok { hm.WanderingMerchantDeadline = time.Time{} } e.mu.Unlock() e.ApplyPersistedHeroSnapshot(hero) } // applyResidentReviveSyncLocked clears combat, merges a persisted hero into the live session, // and pushes hero_state + hero_revived. Caller must hold e.mu. func (e *Engine) applyResidentReviveSyncLocked(hero *model.Hero) { if hero == nil { return } delete(e.combats, hero.ID) hm, ok := e.movements[hero.ID] if !ok { return } now := time.Now() *hm.Hero = *hero hm.CurrentX = hero.PositionX hm.CurrentY = hero.PositionY hm.State = hero.State hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownLastNPCLingerUntil = time.Time{} hm.LastMoveTick = now hm.refreshSpeed(now) routeAssigned := false if hm.State == model.StateWalking && hm.Road == nil && e.roadGraph != nil { hm.pickDestination(e.roadGraph) hm.assignRoad(e.roadGraph, false) routeAssigned = true } if e.sender == nil { return } hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) // Full snapshot first so clients never briefly drop gear after hero_revived. e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) e.sender.SendToHero(hero.ID, "hero_revived", model.HeroRevivedPayload{HP: hm.Hero.HP}) if routeAssigned { if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(hero.ID, "route_assigned", route) } } } // ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted // the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online), // restores movement/route when needed, and pushes WS events so the client matches the DB. func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) { if hero == nil { return } e.mu.Lock() defer e.mu.Unlock() e.applyResidentReviveSyncLocked(hero) } // ApplyAdminHeroDeath merges a persisted dead hero after POST /admin/.../force-death, clears combat, // updates live movement (if any), and pushes hero_state; optionally hero_died for clients. func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) { if hero == nil { return } e.mu.Lock() defer e.mu.Unlock() delete(e.combats, hero.ID) hm, ok := e.movements[hero.ID] if !ok { if e.sender != nil { now := time.Now() hero.EnsureGearMap() hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hero.ID, "hero_state", hero) if sendDiedEvent { e.sender.SendToHero(hero.ID, "hero_died", model.HeroDiedPayload{ KilledBy: "admin", }) } } return } now := time.Now() hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownLastNPCLingerUntil = time.Time{} *hm.Hero = *hero hm.State = model.StateDead hm.Hero.State = model.StateDead hm.Hero.HP = 0 hm.Die() if e.sender == nil { return } hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) if sendDiedEvent { e.sender.SendToHero(hero.ID, "hero_died", model.HeroDiedPayload{ KilledBy: "admin", }) } } // GetCombat returns the current combat state for a hero, if any. func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) { e.mu.RLock() defer e.mu.RUnlock() cs, ok := e.combats[heroID] return cs, ok } // processCombatTick is the combat processing tick (typically 100ms cadence). func (e *Engine) processCombatTick(now time.Time) { e.mu.Lock() defer e.mu.Unlock() e.processCombatTickLocked(now) } // processCombatTickLocked runs combat logic; caller must hold e.mu. func (e *Engine) processCombatTickLocked(now time.Time) { // Heroes resting or touring town must not keep fighting in the background. var purgeCombat []int64 for heroID := range e.combats { if hm, ok := e.movements[heroID]; ok { if hm.State == model.StateResting || hm.State == model.StateInTown { purgeCombat = append(purgeCombat, heroID) } } } for _, heroID := range purgeCombat { delete(e.combats, heroID) if hm, ok := e.movements[heroID]; ok { hm.Hero.State = hm.State } } // Apply periodic effects (debuff DoT, enemy regen, summon damage) for all active combats. for heroID, cs := range e.combats { if cs.Hero == nil { continue } tickDur := now.Sub(cs.LastTickAt) if tickDur <= 0 { continue } dotDmg := ProcessDebuffDamage(cs.Hero, tickDur, now) regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder) summonDmg := ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now) cs.LastTickAt = now if e.sender != nil { if dotDmg > 0 { e.sender.SendToHero(heroID, "attack", model.AttackPayload{ Source: "dot", Damage: dotDmg, HeroHP: cs.Hero.HP, EnemyHP: cs.Enemy.HP, }) } if regenHealed > 0 { e.sender.SendToHero(heroID, "enemy_regen", model.EnemyRegenPayload{ Amount: regenHealed, EnemyHP: cs.Enemy.HP, }) } if summonDmg > 0 { e.sender.SendToHero(heroID, "attack", model.AttackPayload{ Source: "summon", Damage: summonDmg, HeroHP: cs.Hero.HP, EnemyHP: cs.Enemy.HP, }) } } if CheckDeath(cs.Hero, now) { e.emitEvent(model.CombatEvent{ Type: "death", HeroID: heroID, Source: "hero", HeroHP: 0, EnemyHP: cs.Enemy.HP, Timestamp: now, }) if e.sender != nil { e.sender.SendToHero(heroID, "hero_died", model.HeroDiedPayload{ KilledBy: cs.Enemy.Name, }) } // Update movement state to dead. if hm, ok := e.movements[heroID]; ok { hm.Die() } e.persistHeroDeathLocked(heroID, cs.Hero) dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) e.applyOfflineDigest(dctx, heroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1}) cancel() delete(e.combats, heroID) } } // Process all attacks that are due. for e.queue.Len() > 0 { next := e.queue[0] if next.NextAttackAt.After(now) { break } evt := heap.Pop(&e.queue).(*model.AttackEvent) cs, ok := e.combats[evt.CombatID] if !ok { continue // combat ended } e.processAttackEvent(evt, cs, now) } } func (e *Engine) processAttackEvent(evt *model.AttackEvent, cs *model.CombatState, now time.Time) { if evt.IsHero { e.processHeroAttack(cs, now) } else { e.processEnemyAttack(cs, now) } } // persistHeroDeathLocked writes the dead hero snapshot immediately so DB state // never lags behind the live in-memory death state. // Caller must hold e.mu. func (e *Engine) persistHeroDeathLocked(heroID int64, hero *model.Hero) { if e.heroStore == nil || hero == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := e.heroStore.Save(ctx, hero) cancel() if err != nil && e.logger != nil { e.logger.Error("persist hero after death", "hero_id", heroID, "error", err) } } // sendDebuffAppliedForString pushes debuff_applied when a debuff proc string is non-empty. func (e *Engine) sendDebuffAppliedForString(heroID int64, debuffTypeStr string, now time.Time) { if e.sender == nil || debuffTypeStr == "" { return } dt, ok := model.ValidDebuffType(debuffTypeStr) if !ok { return } def, ok := model.DebuffDefinition(dt) if !ok { return } e.sender.SendToHero(heroID, "debuff_applied", model.DebuffAppliedPayload{ DebuffType: string(dt), DurationMs: def.Duration.Milliseconds(), Magnitude: def.Magnitude, ExpiresAt: now.Add(def.Duration), }) } // rescheduleHeroAttackAfterSlowDebuff stretches the hero's pending swing when attack speed drops (freeze, ice_slow). func (e *Engine) rescheduleHeroAttackAfterSlowDebuff(cs *model.CombatState, speedBefore float64, now time.Time) { if cs.Hero == nil { return } speedAfter := cs.Hero.EffectiveSpeedAt(now) if speedAfter >= speedBefore || speedBefore <= 0 { return } oldInt := attackInterval(speedBefore) newInt := attackInterval(speedAfter) if oldInt <= 0 || newInt <= 0 { return } ratio := float64(newInt) / float64(oldInt) if cs.HeroNextAttack.After(now) { remaining := cs.HeroNextAttack.Sub(now) scaled := time.Duration(float64(remaining) * ratio) cs.HeroNextAttack = now.Add(scaled) } else { cs.HeroNextAttack = now.Add(newInt) } for i := range e.queue { if e.queue[i].CombatID == cs.HeroID && e.queue[i].IsHero { e.queue[i].NextAttackAt = cs.HeroNextAttack heap.Fix(&e.queue, i) return } } } func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) { if cs.Hero == nil { e.logger.Error("processHeroAttack: nil hero reference", "hero_id", cs.HeroID) return } combatEvt := ProcessAttack(cs.Hero, &cs.Enemy, now) e.emitEvent(combatEvt) e.logCombatAttack(cs, combatEvt) // Push attack envelope. if e.sender != nil { e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{ Source: combatEvt.Source, Damage: combatEvt.Damage, IsCrit: combatEvt.IsCrit, Outcome: combatEvt.Outcome, HeroHP: combatEvt.HeroHP, EnemyHP: combatEvt.EnemyHP, DebuffApplied: combatEvt.DebuffApplied, }) e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now) } if !cs.Enemy.IsAlive() { e.handleEnemyDeath(cs, now) return } // Reschedule hero's next attack using actual effective speed. cs.HeroNextAttack = now.Add(attackInterval(cs.Hero.EffectiveSpeed())) heap.Push(&e.queue, &model.AttackEvent{ NextAttackAt: cs.HeroNextAttack, IsHero: true, CombatID: cs.HeroID, }) } func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) { if cs.Hero == nil { e.logger.Error("processEnemyAttack: nil hero reference", "hero_id", cs.HeroID) return } speedBefore := cs.Hero.EffectiveSpeedAt(now) combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now) e.emitEvent(combatEvt) e.logCombatAttack(cs, combatEvt) // Push attack envelope. if e.sender != nil { e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{ Source: combatEvt.Source, Damage: combatEvt.Damage, IsCrit: combatEvt.IsCrit, Outcome: combatEvt.Outcome, HeroHP: combatEvt.HeroHP, EnemyHP: combatEvt.EnemyHP, DebuffApplied: combatEvt.DebuffApplied, }) e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now) } e.rescheduleHeroAttackAfterSlowDebuff(cs, speedBefore, now) // Check if the hero died from this attack. if CheckDeath(cs.Hero, now) { e.emitEvent(model.CombatEvent{ Type: "death", HeroID: cs.HeroID, Source: "hero", HeroHP: 0, EnemyHP: cs.Enemy.HP, Timestamp: now, }) if e.sender != nil { e.sender.SendToHero(cs.HeroID, "hero_died", model.HeroDiedPayload{ KilledBy: cs.Enemy.Name, }) } if hm, ok := e.movements[cs.HeroID]; ok { hm.Die() } e.persistHeroDeathLocked(cs.HeroID, cs.Hero) dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) e.applyOfflineDigest(dctx, cs.HeroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1}) cancel() delete(e.combats, cs.HeroID) e.logger.Info("hero died", "hero_id", cs.HeroID, "enemy", cs.Enemy.Name, ) return } // Reschedule enemy's next attack. cs.EnemyNextAttack = now.Add(attackIntervalEnemy(cs.Enemy.Speed)) heap.Push(&e.queue, &model.AttackEvent{ NextAttackAt: cs.EnemyNextAttack, IsHero: false, CombatID: cs.HeroID, }) } func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) { if e.adventureLog == nil || cs == nil { return } args := map[string]any{ "damage": evt.Damage, "isCrit": evt.IsCrit, "enemyType": cs.Enemy.Slug, } if evt.DebuffApplied != "" { args["debuffType"] = evt.DebuffApplied } e.adventureLog(cs.HeroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: combatLogPhraseKey(evt.Source, evt.Outcome), Args: args, }, }) } func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { hero := cs.Hero enemy := &cs.Enemy oldLevel := hero.Level // Rewards (XP, gold, loot, level-ups) are handled by the onEnemyDeath callback // via processVictoryRewards -- the single source of truth. var victoryDrops []model.LootDrop if e.onEnemyDeath != nil { victoryDrops = e.onEnemyDeath(hero, enemy, now) } dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{ MonstersKilled: 1, XPGained: enemy.XPReward, GoldGained: model.SumGoldFromLootDrops(victoryDrops), LevelsGained: hero.Level - oldLevel, LootAppend: NonGoldLootForDigest(victoryDrops), }) cancel() e.emitEvent(model.CombatEvent{ Type: "combat_end", HeroID: cs.HeroID, Source: "system", EnemyHP: 0, Timestamp: now, }) leveledUp := hero.Level > oldLevel delete(e.combats, cs.HeroID) // Resume walking before hero_state so positions match hero_move. if hm, ok := e.movements[cs.HeroID]; ok { hm.ResumeWalking(now) prevExcPhase := hm.Excursion.Phase hm.TryAdventureReturnAfterCombat(now) if e.sender != nil && hm.Excursion.Phase != prevExcPhase && hm.Excursion.Phase == model.ExcursionReturn { e.sender.SendToHero(cs.HeroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)}) e.sender.SendToHero(cs.HeroID, "hero_move", hm.MovePayload(now)) } hm.SyncToHero() } // Persist progression (XP, gold, level/stats after level-up, inventory, world state) // so a disconnect or crash does not roll back combat rewards. if e.heroStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := e.heroStore.Save(ctx, hero) cancel() if err != nil && e.logger != nil { e.logger.Error("persist hero after combat victory", "hero_id", hero.ID, "error", err) } } // Push typed combat_end envelope (gold from loot rolls, not enemy template column). if e.sender != nil { goldFromLoot := model.SumGoldFromLootDrops(victoryDrops) e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{ XPGained: enemy.XPReward, GoldGained: goldFromLoot, Loot: model.LootDropsToLootItems(victoryDrops), LeveledUp: leveledUp, NewLevel: hero.Level, }) if leveledUp { e.sender.SendToHero(cs.HeroID, "level_up", model.LevelUpPayload{ NewLevel: hero.Level, }) } hero.EnsureGearMap() hero.EnsureInventorySlice() hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(cs.HeroID, "hero_state", hero) } e.logger.Info("enemy defeated", "hero_id", cs.HeroID, "enemy", enemy.Name, ) } // processAutoReviveLocked revives dead heroes after AutoReviveAfterMs downtime. Caller holds e.mu. func (e *Engine) processAutoReviveLocked(now time.Time) { if e.heroStore == nil { return } gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond for heroID, hm := range e.movements { if hm == nil || hm.Hero == nil { continue } h := hm.Hero if h.State != model.StateDead && h.HP > 0 { continue } if now.Sub(h.UpdatedAt) <= gap { continue } ApplyHeroReviveMechanical(h) hm.State = model.StateWalking hm.SyncToHero() dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) e.applyOfflineDigest(dctx, heroID, h, now, storage.OfflineDigestDelta{Revives: 1}) cancel() if e.adventureLog != nil { e.adventureLog(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseAutoReviveAfterSec, Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)}, }, }) } ctx, cancelSave := context.WithTimeout(context.Background(), 5*time.Second) if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil { e.logger.Error("persist hero after auto-revive", "hero_id", heroID, "error", err) } cancelSave() e.applyResidentReviveSyncLocked(h) } } // processMovementTick advances all walking heroes and checks for encounters. // Runs on the configured movement cadence. func (e *Engine) processMovementTick(now time.Time) { e.mu.Lock() defer e.mu.Unlock() if e.roadGraph == nil { return } e.processAutoReviveLocked(now) startCombat := func(hm *HeroMovement, enemy *model.Enemy, t time.Time) { e.startCombatLocked(hm.Hero, enemy) } for heroID, hm := range e.movements { if hm == nil { continue } // Do not run movement FSM, AdvanceTick, or encounters for dead heroes. if hm.skipMovementSimulation() { continue } ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil, e.logger) if hm.State != model.StateResting { hm.lastRestHealPersistAt = time.Time{} } if e.heroStore == nil || hm.Hero == nil { continue } if sig, ok := hm.TownPausePersistDue(); ok { hm.SyncToHero() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := e.heroStore.Save(ctx, hm.Hero) cancel() if err != nil { if e.logger != nil { e.logger.Error("persist hero excursion/rest failed", "hero_id", heroID, "error", err) } continue } hm.MarkTownPausePersisted(sig) if hm.State == model.StateResting { hm.lastRestHealPersistAt = now } e.syncTownSessionRedis(heroID, hm) } if hm.State == model.StateResting && hm.restHPRegenActive() { if hm.lastRestHealPersistAt.IsZero() || now.Sub(hm.lastRestHealPersistAt) >= restHealPersistInterval { hm.SyncToHero() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := e.heroStore.Save(ctx, hm.Hero) cancel() if err != nil { if e.logger != nil { e.logger.Error("persist hero during rest heal", "hero_id", heroID, "error", err) } } else { hm.lastRestHealPersistAt = now e.syncTownSessionRedis(heroID, hm) } } } if e.heroStore != nil && e.heroSubscriber != nil && hm.Hero != nil && !e.heroSubscriber(heroID) { last := e.lastDisconnectedFullSave[heroID] if last.IsZero() || now.Sub(last) >= offlineDisconnectedFullSaveInterval { hm.SyncToHero() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if err := e.heroStore.Save(ctx, hm.Hero); err != nil { if e.logger != nil { e.logger.Error("persist disconnected resident hero", "hero_id", heroID, "error", err) } } else { e.lastDisconnectedFullSave[heroID] = now } cancel() } } } e.checkHeroMeetApproachArrivalLocked(now) e.checkHeroMeetReturnArrivalLocked(now) e.tryRandomHeroMeetProximityLocked(now) e.processHeroMeetTickLocked(now) for heroID, hm := range e.movements { if hm == nil || e.heroStore == nil || hm.Hero == nil { continue } if sig, ok := hm.TownPausePersistDue(); ok { hm.SyncToHero() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := e.heroStore.Save(ctx, hm.Hero) cancel() if err != nil { if e.logger != nil { e.logger.Error("persist hero after hero meet tick", "hero_id", heroID, "error", err) } continue } hm.MarkTownPausePersisted(sig) e.syncTownSessionRedis(heroID, hm) } } } // mergeTownSessionFromRedis overlays a fresher in-town snapshot when Postgres row is stale (e.g. missed town_pause save). func (e *Engine) mergeTownSessionFromRedis(hero *model.Hero) { if e.townSession == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() snap, err := e.townSession.Load(ctx, hero.ID) if err != nil { if e.logger != nil { e.logger.Warn("town session redis load failed", "hero_id", hero.ID, "error", err) } return } if snap == nil || snap.State != model.StateInTown || snap.TownPause == nil { return } if snap.CurrentTownID > 0 && hero.CurrentTownID != nil && *hero.CurrentTownID != snap.CurrentTownID { return } if snap.SavedAtUnixNano <= hero.UpdatedAt.UnixNano() { return } hero.State = model.StateInTown hero.TownPause = snap.TownPause hero.PositionX = snap.PositionX hero.PositionY = snap.PositionY if snap.CurrentTownID > 0 { tid := snap.CurrentTownID if hero.CurrentTownID == nil { hero.CurrentTownID = new(int64) } *hero.CurrentTownID = tid } } func (e *Engine) syncTownSessionRedis(heroID int64, hm *HeroMovement) { if e.townSession == nil || hm == nil || hm.Hero == nil { return } hm.SyncToHero() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if hm.State == model.StateInTown { if err := e.townSession.Save(ctx, heroID, hm.Hero); err != nil && e.logger != nil { e.logger.Warn("town session redis save failed", "hero_id", heroID, "error", err) } return } if err := e.townSession.Delete(ctx, heroID); err != nil && e.logger != nil { e.logger.Warn("town session redis delete failed", "hero_id", heroID, "error", err) } } func (e *Engine) syncTownSessionRedisFromHero(heroID int64, h *model.Hero) { if e.townSession == nil || h == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if h.State == model.StateInTown { if err := e.townSession.Save(ctx, heroID, h); err != nil && e.logger != nil { e.logger.Warn("town session redis save failed", "hero_id", heroID, "error", err) } return } if err := e.townSession.Delete(ctx, heroID); err != nil && e.logger != nil { e.logger.Warn("town session redis delete failed", "hero_id", heroID, "error", err) } } // persistHeroAfterTownEnter writes the hero row after a walk-in town arrival (town_pause + state). func (e *Engine) persistHeroAfterTownEnter(h *model.Hero) { if e.heroStore == nil || h == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil { e.logger.Error("persist hero after town enter", "hero_id", h.ID, "error", err) return } e.syncTownSessionRedisFromHero(h.ID, h) e.applyVisitTownQuestProgress(h) } // applyVisitTownQuestProgress advances visit_town quests when the hero is in a town (matches quests.target_town_id). func (e *Engine) applyVisitTownQuestProgress(h *model.Hero) { if e.questStore == nil || h == nil || h.CurrentTownID == nil || *h.CurrentTownID <= 0 { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.questStore.IncrementVisitTownProgress(ctx, h.ID, *h.CurrentTownID); err != nil && e.logger != nil { e.logger.Warn("visit town quest progress failed", "hero_id", h.ID, "town_id", *h.CurrentTownID, "error", err) } } // processPositionSync sends drift-correction position_sync messages and persists world (x,y). // Called at low cadence (see tuning positionSyncRateMs). func (e *Engine) processPositionSync(now time.Time) { type posSnap struct { id int64 x float64 y float64 } var snaps []posSnap e.mu.RLock() sender := e.sender for heroID, hm := range e.movements { if hm == nil { continue } if hm.skipMovementSimulation() { continue } if sender != nil && hm.State == model.StateWalking { sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now)) } shouldPersistPos := hm.State == model.StateWalking || hm.State == model.StateResting || hm.Excursion.Active() if shouldPersistPos && hm.Hero != nil { hm.SyncToHero() snaps = append(snaps, posSnap{id: heroID, x: hm.Hero.PositionX, y: hm.Hero.PositionY}) } } heroStore := e.heroStore e.mu.RUnlock() if heroStore == nil || len(snaps) == 0 { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() for _, p := range snaps { if err := heroStore.SavePosition(ctx, p.id, p.x, p.y); err != nil && e.logger != nil { e.logger.Error("position sync persist failed", "hero_id", p.id, "error", err) } } } func (e *Engine) emitEvent(evt model.CombatEvent) { select { case e.eventCh <- evt: default: e.logger.Warn("combat event channel full, dropping event", "type", evt.Type, "hero_id", evt.HeroID, ) } } // attackInterval converts an attacks-per-second speed to a duration between attacks. func attackInterval(speed float64) time.Duration { cfg := tuning.Get() minAttack := time.Duration(cfg.MinAttackIntervalMs) * time.Millisecond if cfg.CombatPaceMultiplier < 1 { cfg.CombatPaceMultiplier = 1 } pace := time.Duration(cfg.CombatPaceMultiplier) if speed <= 0 { return time.Second * pace // fallback: 1 attack per second, scaled } interval := time.Duration(float64(time.Second)/speed) * pace if interval < minAttack*pace { return minAttack * pace } return interval } // attackIntervalEnemy applies EnemyAttackIntervalMultiplier only to monsters (slower, heavier swings vs hero cadence). func attackIntervalEnemy(speed float64) time.Duration { base := attackInterval(speed) m := tuning.EffectiveEnemyAttackIntervalMultiplier() return time.Duration(float64(base) * m) } // enemyToInfo converts a model.Enemy to the WS payload info struct. func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo { return model.CombatEnemyInfo{ Name: e.Name, Type: e.Slug, Archetype: e.Archetype, Biome: e.Biome, Level: e.Level, HP: e.HP, MaxHP: e.MaxHP, Attack: e.Attack, Defense: e.Defense, Speed: e.Speed, IsElite: e.IsElite, } } // SetTownNPCUILock freezes town tour welcome/service timers while the client shows NPCDialog. func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) { if e == nil { return } e.mu.Lock() defer e.mu.Unlock() hm := e.movements[heroID] if hm == nil { return } if hm.Excursion.Kind == model.ExcursionKindTown { hm.townTourSetDialogOpen(locked) return } hm.TownNPCUILock = locked } // SkipTownNPCNarrationAfterDialog applies town tour dialog-closed semantics (legacy name for REST). func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) { if e == nil { return } e.mu.Lock() defer e.mu.Unlock() hm := e.movements[heroID] if hm == nil || e.roadGraph == nil { return } hm.townTourNPCDialogClosed(time.Now(), e.roadGraph) } func (e *Engine) handleTownTourNPCDialogClosed(msg IncomingMessage) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[msg.HeroID] if !ok || e.roadGraph == nil { return } hm.townTourNPCDialogClosed(time.Now(), e.roadGraph) if e.sender != nil && hm.Hero != nil { e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero) } } func (e *Engine) handleTownTourNPCInteractionOpened(msg IncomingMessage) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[msg.HeroID] if !ok || e.roadGraph == nil { return } hm.townTourNPCInteractionOpened(time.Now(), e.roadGraph) if e.sender != nil && hm.Hero != nil { e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero) } } func (e *Engine) handleTownTourNPCInteractionClosed(msg IncomingMessage) { e.mu.Lock() defer e.mu.Unlock() hm, ok := e.movements[msg.HeroID] if !ok || e.roadGraph == nil { return } hm.townTourNPCInteractionClosed(time.Now(), e.roadGraph) if e.sender != nil && hm.Hero != nil { e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero) } } // SetMerchantStock replaces ephemeral merchant offers for a hero (copies items, ids cleared). // costs must have the same length as items (gold price locked at roll time). func (e *Engine) SetMerchantStock(heroID, npcID, townID int64, items []*model.GearItem, costs []int64) { if e == nil { return } if len(costs) != len(items) { return } e.mu.Lock() defer e.mu.Unlock() if e.merchantStock == nil { e.merchantStock = make(map[int64]*merchantOfferSession) } copies := make([]*model.GearItem, len(items)) prices := make([]int64, len(costs)) for i, it := range items { copies[i] = model.CloneGearItem(it) prices[i] = costs[i] } e.merchantStock[heroID] = &merchantOfferSession{ NPCID: npcID, TownID: townID, Items: copies, Costs: prices, Created: time.Now(), } } // ClearMerchantStock drops cached merchant rows (e.g. dialog closed). func (e *Engine) ClearMerchantStock(heroID int64) { if e == nil { return } e.mu.Lock() defer e.mu.Unlock() delete(e.merchantStock, heroID) } // TakeMerchantOffer validates npc and index, removes the row, returns a template for DB insert (id 0) and locked price. func (e *Engine) TakeMerchantOffer(heroID, npcID int64, index int) (*model.GearItem, int64, bool) { if e == nil { return nil, 0, false } e.mu.Lock() defer e.mu.Unlock() s, ok := e.merchantStock[heroID] if !ok || s == nil || s.NPCID != npcID || index < 0 || index >= len(s.Items) { return nil, 0, false } if len(s.Costs) != len(s.Items) || index >= len(s.Costs) { return nil, 0, false } item := model.CloneGearItem(s.Items[index]) price := s.Costs[index] s.Items = append(s.Items[:index], s.Items[index+1:]...) s.Costs = append(s.Costs[:index], s.Costs[index+1:]...) if len(s.Items) == 0 { delete(e.merchantStock, heroID) } return item, price, true } // UnshiftMerchantOffer puts an offer row back (e.g. failed persist after TakeMerchantOffer). func (e *Engine) UnshiftMerchantOffer(heroID, npcID, townID int64, item *model.GearItem, cost int64) { if e == nil || item == nil { return } e.mu.Lock() defer e.mu.Unlock() if e.merchantStock == nil { e.merchantStock = make(map[int64]*merchantOfferSession) } s := e.merchantStock[heroID] clone := model.CloneGearItem(item) if s == nil || s.NPCID != npcID { e.merchantStock[heroID] = &merchantOfferSession{ NPCID: npcID, TownID: townID, Items: []*model.GearItem{clone}, Costs: []int64{cost}, Created: time.Now(), } return } s.Items = append([]*model.GearItem{clone}, s.Items...) s.Costs = append([]int64{cost}, s.Costs...) }