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.

835 lines
21 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"
)
// 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()
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),
})
}
}
}
// UnregisterHeroMovement removes movement state and persists hero to DB.
// Called when a WS client disconnects.
func (e *Engine) UnregisterHeroMovement(heroID int64) {
e.mu.Lock()
hm, ok := e.movements[heroID]
if ok {
hm.SyncToHero()
delete(e.movements, heroID)
}
e.mu.Unlock()
if ok && e.heroStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.heroStore.Save(ctx, hm.Hero); err != nil {
e.logger.Error("failed to save hero on disconnect", "hero_id", heroID, "error", err)
} else {
e.logger.Info("hero state persisted on disconnect", "hero_id", heroID)
}
}
}
// 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)
}
}
// 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())
}
}
}
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,
}
}