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 is invoked when an enemy dies, passing the hero and enemy type. // Used to wire loot generation without coupling the engine to the handler layer. type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now 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 } // 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, } heap.Init(&e.queue) return e } func (e *Engine) GetMovements(heroId int64) *HeroMovement { return e.movements[heroId] } // HeroHasActiveMovement is true while the hero has an in-engine movement session (typically WebSocket-connected). func (e *Engine) HeroHasActiveMovement(heroID int64) bool { e.mu.RLock() defer e.mu.RUnlock() _, ok := e.movements[heroID] return ok } // 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(attackInterval(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 } // 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) 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 } 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, "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, fmt.Sprintf("Used healing potion, restored %d HP", 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) } } // handleRevive processes the revive client command. 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"}) } } 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 hero.HP > 0 && hm.State != model.StateDead { e.sendError(msg.HeroID, "not_dead", "hero is not dead") return } hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent) if hero.HP < 1 { hero.HP = 1 } hero.State = model.StateWalking hero.Debuffs = nil hero.ReviveCount++ hm.State = model.StateWalking hm.LastMoveTick = time.Now() hm.refreshSpeed(time.Now()) // Remove any active combat. delete(e.combats, msg.HeroID) // 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.sender != nil { hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) e.sender.SendToHero(msg.HeroID, "hero_state", hero) e.sender.SendToHero(msg.HeroID, "hero_revived", model.HeroRevivedPayload{HP: hero.HP}) } } // 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.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), }) } } return } hm := NewHeroMovement(hero, e.roadGraph, now) e.movements[hero.ID] = hm hm.MarkTownPausePersisted(hm.townPausePersistSignature()) hm.SyncToHero() 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), }) } } } // HeroSocketDetached persists hero state on every WS disconnect and removes in-memory // movement only when lastConnection is true (no other tabs/sockets for this hero). func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) { e.mu.Lock() hm, ok := e.movements[heroID] if ok { hm.SyncToHero() if lastConnection { delete(e.movements, heroID) } } 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, } } // 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 } // 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 } // 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 ends an online hero's excursion immediately. 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_end", model.ExcursionEndPayload{}) 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: string(cs.Enemy.Type), 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 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 } else if hero != nil { wx, wy = hero.PositionX, hero.PositionY } 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(attackInterval(enemy.Speed)), StartedAt: now, LastTickAt: now, } 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, }) // Legacy event channel (for backward compat bridge). e.emitEvent(model.CombatEvent{ Type: "combat_start", HeroID: hero.ID, Source: "system", HeroHP: hero.HP, EnemyHP: enemy.HP, Timestamp: now, }) // New: send typed combat_start envelope. if e.sender != nil { e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{ Enemy: enemyToInfo(enemy), }) } e.logger.Info("combat started", "hero_id", hero.ID, "enemy", enemy.Name, ) } // 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) } } } // 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() defer e.mu.Unlock() hm, ok := e.movements[hero.ID] if ok { now := time.Now() hm.WanderingMerchantDeadline = time.Time{} *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) } } // 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() 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.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) } } } // 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 100ms combat processing tick. func (e *Engine) processCombatTick(now time.Time) { e.mu.Lock() defer e.mu.Unlock() // 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 } ProcessDebuffDamage(cs.Hero, tickDur, now) ProcessEnemyRegen(&cs.Enemy, tickDur) ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now) cs.LastTickAt = now 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() } 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) } } 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) // Push attack envelope. if e.sender != nil { e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{ Source: combatEvt.Source, Damage: combatEvt.Damage, IsCrit: combatEvt.IsCrit, HeroHP: combatEvt.HeroHP, EnemyHP: combatEvt.EnemyHP, DebuffApplied: combatEvt.DebuffApplied, }) } 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 } combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now) e.emitEvent(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, HeroHP: combatEvt.HeroHP, EnemyHP: combatEvt.EnemyHP, DebuffApplied: combatEvt.DebuffApplied, }) } // 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() } 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(attackInterval(cs.Enemy.Speed)) heap.Push(&e.queue, &model.AttackEvent{ NextAttackAt: cs.EnemyNextAttack, IsHero: false, CombatID: cs.HeroID, }) } 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. if e.onEnemyDeath != nil && hero != nil { e.onEnemyDeath(hero, enemy, now) } 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 (road + forest offset). if hm, ok := e.movements[cs.HeroID]; ok { hm.ResumeWalking(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 && hero != 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. if e.sender != nil { e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{ XPGained: enemy.XPReward, GoldGained: enemy.GoldReward, 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, ) } // 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 } startCombat := func(hm *HeroMovement, enemy *model.Enemy, t time.Time) { e.startCombatLocked(hm.Hero, enemy) } for heroID, hm := range e.movements { ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil) if e.heroStore == nil || hm == 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) 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.MoveState = string(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 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 } // 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: string(e.Type), HP: e.HP, MaxHP: e.MaxHP, Attack: e.Attack, Defense: e.Defense, Speed: e.Speed, IsElite: e.IsElite, } }