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.

2462 lines
70 KiB
Go

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)
}
// NearbySubscriptionManager can attach nearby-hero movement subscriptions for a viewer.
type NearbySubscriptionManager interface {
SetNearbySubscriptions(viewerID int64, targetIDs []int64)
}
// 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 "request_nearby_heroes":
e.handleNearbyHeroesRequest(msg)
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)
}
}
func (e *Engine) handleNearbyHeroesRequest(msg IncomingMessage) {
if e.heroStore == nil || e.sender == nil {
return
}
var req model.NearbyHeroesRequestPayload
if len(msg.Payload) > 0 {
if err := json.Unmarshal(msg.Payload, &req); err != nil {
e.sendError(msg.HeroID, "invalid_payload", "invalid request_nearby_heroes payload")
return
}
}
radius := 50.0
if req.Radius > 0 {
radius = req.Radius
}
if radius > 100 {
radius = 100
}
limit := 5
if req.Limit > 0 {
limit = req.Limit
}
if limit > 5 {
limit = 5
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
hero, err := e.heroStore.GetByID(ctx, msg.HeroID)
if err != nil || hero == nil {
e.sendError(msg.HeroID, "no_hero", "hero not found")
return
}
posX, posY := hero.PositionX, hero.PositionY
if wx, wy, ok := e.HeroWorldPositionForCombat(hero.ID); ok {
posX, posY = wx, wy
}
nearby, err := e.heroStore.GetNearbyHeroes(ctx, hero.ID, posX, posY, radius, limit)
if err != nil {
e.logger.Error("failed to load nearby heroes", "hero_id", hero.ID, "error", err)
e.sendError(msg.HeroID, "nearby_load_failed", "failed to load nearby heroes")
return
}
e.OverlayResidentWorldPositionsOnNearby(nearby)
summaries := make([]model.NearbyHeroSummary, 0, len(nearby))
ids := make([]int64, 0, len(nearby))
for _, h := range nearby {
summaries = append(summaries, model.NearbyHeroSummary{
ID: h.ID,
Name: h.Name,
Level: h.Level,
ModelVariant: h.ModelVariant,
PositionX: h.PositionX,
PositionY: h.PositionY,
})
ids = append(ids, h.ID)
}
e.sender.SendToHero(msg.HeroID, "nearby_heroes", model.NearbyHeroesPayload{
Heroes: summaries,
})
if sub, ok := e.sender.(NearbySubscriptionManager); ok {
sub.SetNearbySubscriptions(msg.HeroID, ids)
}
}
// 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...)
}