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.
869 lines
22 KiB
Go
869 lines
22 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()
|
|
|
|
// 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,
|
|
}
|
|
}
|