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.

1658 lines
45 KiB
Go

package game
import (
"container/heap"
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
// MessageSender is the interface the engine uses to push WS messages.
// Implemented by handler.Hub (injected to avoid import cycle).
type MessageSender interface {
SendToHero(heroID int64, msgType string, payload any)
BroadcastEvent(event model.CombatEvent)
}
// 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
}
hero.RefreshDerivedCombatStats(now)
hm.refreshSpeed(now)
if cs, ok := e.combats[msg.HeroID]; ok {
cs.Hero = hero
}
if e.heroStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil {
e.logger.Error("failed to save hero after buff", "hero_id", hero.ID, "error", err)
}
}
if e.sender != nil {
e.sender.SendToHero(msg.HeroID, "hero_state", hero)
e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{
BuffType: payload.BuffType,
Duration: ab.Buff.Duration.Seconds(),
Magnitude: ab.Buff.Magnitude,
})
}
}
// handleUsePotion processes the use_potion client command.
func (e *Engine) handleUsePotion(msg IncomingMessage) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[msg.HeroID]
if !ok {
e.sendError(msg.HeroID, "no_hero", "hero not connected")
return
}
hero := hm.Hero
// Validate: hero is in combat, has potions, is alive.
if hm.State != model.StateFighting {
e.sendError(msg.HeroID, "not_fighting", "hero is not in combat")
return
}
if hero.Potions <= 0 {
e.sendError(msg.HeroID, "no_potions", "no potions available")
return
}
if hero.HP <= 0 {
e.sendError(msg.HeroID, "dead", "hero is dead")
return
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
hm.SyncToHero()
// Keep combat state's hero pointer aligned with movement (authoritative live hero).
if cs, ok := e.combats[msg.HeroID]; ok {
cs.Hero = hm.Hero
}
if e.heroStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil {
e.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err)
}
}
if e.adventureLog != nil {
e.adventureLog(msg.HeroID, 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)
e.logCombatAttack(cs, combatEvt)
// Push attack envelope.
if e.sender != nil {
e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{
Source: combatEvt.Source,
Damage: combatEvt.Damage,
IsCrit: combatEvt.IsCrit,
Outcome: combatEvt.Outcome,
HeroHP: combatEvt.HeroHP,
EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied,
})
if combatEvt.DebuffApplied != "" {
if dt, ok := model.ValidDebuffType(combatEvt.DebuffApplied); ok {
if def, ok := model.DebuffDefinition(dt); ok {
e.sender.SendToHero(cs.HeroID, "debuff_applied", model.DebuffAppliedPayload{
DebuffType: string(dt),
DurationMs: def.Duration.Milliseconds(),
Magnitude: def.Magnitude,
ExpiresAt: now.Add(def.Duration),
})
}
}
}
}
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)
e.logCombatAttack(cs, combatEvt)
// Push attack envelope.
if e.sender != nil {
e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{
Source: combatEvt.Source,
Damage: combatEvt.Damage,
IsCrit: combatEvt.IsCrit,
Outcome: combatEvt.Outcome,
HeroHP: combatEvt.HeroHP,
EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied,
})
}
// 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) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
if e.adventureLog == nil || cs == nil {
return
}
enemyName := cs.Enemy.Name
critSuffix := ""
if evt.IsCrit {
critSuffix = " (crit)"
}
var msg string
switch evt.Source {
case "hero":
switch evt.Outcome {
case attackOutcomeStun:
msg = "You are stunned and cannot attack."
case attackOutcomeDodge:
msg = enemyName + " dodged your attack."
default:
msg = "You hit " + enemyName + " for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "."
}
case "enemy":
switch evt.Outcome {
case attackOutcomeBlock:
msg = "You block " + enemyName + "'s attack."
default:
msg = enemyName + " hits you for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "."
}
}
if evt.DebuffApplied != "" {
msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied."
}
if msg != "" {
e.adventureLog(cs.HeroID, msg)
}
}
func debuffDisplayName(debuffType string) string {
dt, ok := model.ValidDebuffType(debuffType)
if !ok {
return debuffType
}
if def, ok := model.DebuffDefinition(dt); ok && def.Name != "" {
return def.Name
}
return debuffType
}
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,
}
}