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" ) // 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"` } // 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 incomingCh chan IncomingMessage // client commands mu sync.RWMutex eventCh chan model.CombatEvent logger *slog.Logger onEnemyDeath EnemyDeathCallback startedAt time.Time running bool } const minAttackInterval = 250 * time.Millisecond // combatPaceMultiplier stretches time between swings (MVP: longer fights). const combatPaceMultiplier = 5 // 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] } // 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 } // 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 } // 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: e.processCombatTick(now) case now := <-moveTicker.C: e.processMovementTick(now) case now := <-syncTicker.C: e.processPositionSync(now) case msg := <-e.incomingCh: e.handleClientMessage(msg) } } } // handleClientMessage routes a single inbound client command. func (e *Engine) handleClientMessage(msg IncomingMessage) { switch msg.Type { case "activate_buff": e.handleActivateBuff(msg) case "use_potion": e.handleUsePotion(msg) case "revive": e.handleRevive(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 } buffType := model.BuffType(payload.BuffType) now := time.Now() ab := ApplyBuff(hm.Hero, buffType, now) if ab == nil { e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType)) return } 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 := hero.MaxHP * 30 / 100 hero.HP += healAmount if hero.HP > hero.MaxHP { hero.HP = hero.MaxHP } // 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, }) } } // handleRevive processes the revive client command. 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 = hero.MaxHP / 2 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 { e.sender.SendToHero(msg.HeroID, "hero_revived", model.HeroRevivedPayload{HP: hero.HP}) e.sender.SendToHero(msg.HeroID, "hero_state", 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) { 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.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 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 { hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hero.ID, "hero_state", 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, ) } } } // 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, } } // 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() 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() } 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() 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) routeAssigned = true } if e.sender == nil { return } hm.Hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hero.ID, "hero_revived", model.HeroRevivedPayload{HP: hm.Hero.HP}) 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) } } } // 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() // 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 // 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.RefreshDerivedCombatStats(now) e.sender.SendToHero(cs.HeroID, "hero_state", hero) } } delete(e.combats, cs.HeroID) // Resume walking. if hm, ok := e.movements[cs.HeroID]; ok { hm.ResumeWalking(now) } e.logger.Info("enemy defeated", "hero_id", cs.HeroID, "enemy", enemy.Name, ) } // processMovementTick advances all walking heroes and checks for encounters. // Called at 2 Hz (500ms). 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) } } // processPositionSync sends drift-correction position_sync messages. // Called at 0.1 Hz (every 10s). func (e *Engine) processPositionSync(now time.Time) { e.mu.RLock() defer e.mu.RUnlock() if e.sender == nil { return } for heroID, hm := range e.movements { if hm.State == model.StateWalking { e.sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now)) } } } 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 { if speed <= 0 { return time.Second * combatPaceMultiplier // fallback: 1 attack per second, scaled } interval := time.Duration(float64(time.Second)/speed) * combatPaceMultiplier if interval < minAttackInterval*combatPaceMultiplier { return minAttackInterval * combatPaceMultiplier } 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, } }