From dd21bff29d1adbef3649cbe6eb33ebb62cf3dc7e Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 12:36:03 +0300 Subject: [PATCH] offline movements --- backend/cmd/server/main.go | 27 ++-- backend/internal/game/engine.go | 175 +++++++++++++++++++++++-- backend/internal/game/offline.go | 85 ++++-------- backend/internal/game/offline_test.go | 21 ++- backend/internal/handler/game.go | 16 +++ backend/internal/handler/ws.go | 5 +- backend/internal/storage/hero_store.go | 49 +++++++ 7 files changed, 288 insertions(+), 90 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6eee233..8d3e509 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -119,6 +119,8 @@ func main() { } hub.SendToHero(heroID, "adventure_log_line", line) }) + engine.SetDigestStore(digestStore) + engine.SetHeroSubscriber(hub.IsHeroConnected) // Hub callbacks: on connect, load hero and register movement; on disconnect, persist. hub.OnConnect = func(heroID int64) { @@ -130,11 +132,12 @@ func main() { engine.RegisterHeroMovement(hero) } hub.OnDisconnect = func(heroID int64, remainingSameHero int) { - engine.HeroSocketDetached(heroID, remainingSameHero == 0) + disconnectAt := time.Now() + engine.HeroSocketDetached(heroID, remainingSameHero == 0, disconnectAt) if remainingSameHero == 0 { dctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - if err := heroStore.SetWsDisconnectedAt(dctx, heroID, time.Now()); err != nil { + if err := heroStore.SetWsDisconnectedAt(dctx, heroID, disconnectAt); err != nil { logger.Warn("set ws_disconnected_at", "hero_id", heroID, "error", err) } } @@ -177,24 +180,20 @@ func main() { } }() - // Start game engine. - go func() { - if err := engine.Run(ctx); err != nil && err != context.Canceled { - logger.Error("game engine error", "error", err) - } - }() - // Record server start time for catch-up gap calculation. serverStartedAt := time.Now() - offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool { - return engine.IsTimePaused() - }, engine.HeroHasActiveMovement). + bootstrapSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, nil, nil). WithCombatTickRate(engine.TickRate()). WithRewardStores(gearStore, achievementStore, taskStore). WithDigestStore(digestStore) + bootCtx, bootCancel := context.WithTimeout(ctx, 3*time.Minute) + game.BootstrapResidentHeroes(bootCtx, engine, heroStore, bootstrapSim, 500, logger) + bootCancel() + + // Start game engine (after resident heroes are registered). go func() { - if err := offlineSim.Run(ctx); err != nil && err != context.Canceled { - logger.Error("offline simulator error", "error", err) + if err := engine.Run(ctx); err != nil && err != context.Canceled { + logger.Error("game engine error", "error", err) } }() diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index fdab256..f8bb07c 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -77,18 +77,28 @@ type Engine struct { // npcAlmsHandler runs when the client accepts a wandering merchant offer (WS). npcAlmsHandler func(context.Context, int64) error + + digestStore *storage.OfflineDigestStore + // heroSubscriber reports whether the hero has at least one WebSocket client (optional). + heroSubscriber func(heroID int64) bool + // lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber. + lastDisconnectedFullSave map[int64]time.Time } +// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected. +const offlineDisconnectedFullSaveInterval = 30 * time.Second + // 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, + 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, + lastDisconnectedFullSave: make(map[int64]time.Time), } heap.Init(&e.queue) return e @@ -98,7 +108,24 @@ 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). +// MergeResidentHeroState copies the authoritative in-engine hero into dst after SyncToHero. +// Returns false if the hero is not resident. Used by REST init so the client sees the same state the Engine simulates. +func (e *Engine) MergeResidentHeroState(dst *model.Hero) bool { + if dst == nil { + return false + } + e.mu.RLock() + hm := e.movements[dst.ID] + e.mu.RUnlock() + if hm == nil || hm.Hero == nil { + return false + } + hm.SyncToHero() + *dst = *hm.Hero + return true +} + +// HeroHasActiveMovement is true while the hero has an in-engine movement session (resident world actor). func (e *Engine) HeroHasActiveMovement(heroID int64) bool { e.mu.RLock() defer e.mu.RUnlock() @@ -272,6 +299,28 @@ func (e *Engine) SetAdventureLog(w AdventureLogWriter) { e.adventureLog = w } +// SetDigestStore wires persistent offline digest accumulation (after disconnect grace). +func (e *Engine) SetDigestStore(d *storage.OfflineDigestStore) { + e.mu.Lock() + defer e.mu.Unlock() + e.digestStore = d +} + +// SetHeroSubscriber sets an optional callback: return true if the hero has at least one WebSocket client. +// Used for periodic full saves when the world keeps simulating without a subscriber. +func (e *Engine) SetHeroSubscriber(fn func(heroID int64) bool) { + e.mu.Lock() + defer e.mu.Unlock() + e.heroSubscriber = fn +} + +func (e *Engine) applyOfflineDigest(ctx context.Context, heroID int64, hero *model.Hero, now time.Time, delta storage.OfflineDigestDelta) { + if e.digestStore == nil || hero == nil || !OfflineDigestCollecting(hero.WsDisconnectedAt, now) { + return + } + _ = e.digestStore.ApplyDelta(ctx, heroID, delta) +} + // IncomingCh returns the channel for routing client WS commands into the engine. func (e *Engine) IncomingCh() chan<- IncomingMessage { return e.incomingCh @@ -594,6 +643,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { // 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.WsDisconnectedAt = hero.WsDisconnectedAt existing.Hero.EnsureGearMap() existing.Hero.RefreshDerivedCombatStats(now) e.logger.Info("hero movement reattached (existing session)", @@ -621,6 +671,19 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { hm.MarkTownPausePersisted(hm.townPausePersistSignature()) hm.SyncToHero() + // DB said fighting but engine has no combat (e.g. after restart): attach a new encounter. + if hm.State == model.StateFighting { + if _, exists := e.combats[hero.ID]; !exists { + en := PickEnemyForLevel(hero.Level) + if en.Slug != "" { + e.startCombatLocked(hm.Hero, &en) + } else { + hm.State = model.StateWalking + hm.Hero.State = model.StateWalking + } + } + } + e.logger.Info("hero movement registered", "hero_id", hero.ID, "state", hm.State, @@ -647,15 +710,16 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { } } -// 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) { +// HeroSocketDetached persists hero state on every WS disconnect. Movement and combat stay in the engine +// so the world keeps simulating; disconnectedAt is stored on the in-memory hero for offline digest timing. +func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool, disconnectedAt time.Time) { e.mu.Lock() hm, ok := e.movements[heroID] if ok { hm.SyncToHero() - if lastConnection { - delete(e.movements, heroID) + if lastConnection && !disconnectedAt.IsZero() && hm.Hero != nil { + t := disconnectedAt + hm.Hero.WsDisconnectedAt = &t } } var heroSnap *model.Hero @@ -1225,11 +1289,15 @@ func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) { return cs, ok } -// processCombatTick is the 100ms combat processing tick. +// processCombatTick is the combat processing tick (typically 100ms cadence). func (e *Engine) processCombatTick(now time.Time) { e.mu.Lock() defer e.mu.Unlock() + e.processCombatTickLocked(now) +} +// processCombatTickLocked runs combat logic; caller must hold e.mu. +func (e *Engine) processCombatTickLocked(now time.Time) { // Heroes resting or touring town must not keep fighting in the background. var purgeCombat []int64 for heroID := range e.combats { @@ -1299,6 +1367,9 @@ func (e *Engine) processCombatTick(now time.Time) { if hm, ok := e.movements[heroID]; ok { hm.Die() } + dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + e.applyOfflineDigest(dctx, heroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1}) + cancel() delete(e.combats, heroID) } } @@ -1462,6 +1533,9 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) { if hm, ok := e.movements[cs.HeroID]; ok { hm.Die() } + dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + e.applyOfflineDigest(dctx, cs.HeroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1}) + cancel() delete(e.combats, cs.HeroID) e.logger.Info("hero died", @@ -1515,6 +1589,18 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { victoryDrops = e.onEnemyDeath(hero, enemy, now) } + if hero != nil { + dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{ + MonstersKilled: 1, + XPGained: enemy.XPReward, + GoldGained: model.SumGoldFromLootDrops(victoryDrops), + LevelsGained: hero.Level - oldLevel, + LootAppend: NonGoldLootForDigest(victoryDrops), + }) + cancel() + } + e.emitEvent(model.CombatEvent{ Type: "combat_end", HeroID: cs.HeroID, @@ -1572,6 +1658,50 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { ) } +// processAutoReviveLocked revives dead heroes after AutoReviveAfterMs downtime. Caller holds e.mu. +func (e *Engine) processAutoReviveLocked(now time.Time) { + if e.heroStore == nil { + return + } + gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond + for heroID, hm := range e.movements { + if hm == nil || hm.Hero == nil { + continue + } + h := hm.Hero + if h.State != model.StateDead && h.HP > 0 { + continue + } + if now.Sub(h.UpdatedAt) <= gap { + continue + } + h.HP = int(float64(h.MaxHP) * tuning.Get().ReviveHpPercent) + if h.HP < 1 { + h.HP = 1 + } + h.State = model.StateWalking + h.Debuffs = nil + hm.State = model.StateWalking + hm.SyncToHero() + dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + e.applyOfflineDigest(dctx, heroID, h, now, storage.OfflineDigestDelta{Revives: 1}) + cancel() + if e.adventureLog != nil { + e.adventureLog(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogAutoReviveAfterSec, + Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)}, + }, + }) + } + ctx, cancelSave := context.WithTimeout(context.Background(), 5*time.Second) + if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil { + e.logger.Error("persist hero after auto-revive", "hero_id", heroID, "error", err) + } + cancelSave() + } +} + // processMovementTick advances all walking heroes and checks for encounters. // Runs on the configured movement cadence. func (e *Engine) processMovementTick(now time.Time) { @@ -1582,6 +1712,8 @@ func (e *Engine) processMovementTick(now time.Time) { return } + e.processAutoReviveLocked(now) + startCombat := func(hm *HeroMovement, enemy *model.Enemy, t time.Time) { e.startCombatLocked(hm.Hero, enemy) } @@ -1612,6 +1744,21 @@ func (e *Engine) processMovementTick(now time.Time) { hm.MarkTownPausePersisted(sig) e.syncTownSessionRedis(heroID, hm) } + if e.heroStore != nil && e.heroSubscriber != nil && hm.Hero != nil && !e.heroSubscriber(heroID) { + last := e.lastDisconnectedFullSave[heroID] + if last.IsZero() || now.Sub(last) >= offlineDisconnectedFullSaveInterval { + hm.SyncToHero() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := e.heroStore.Save(ctx, hm.Hero); err != nil { + if e.logger != nil { + e.logger.Error("persist disconnected resident hero", "hero_id", heroID, "error", err) + } + } else { + e.lastDisconnectedFullSave[heroID] = now + } + cancel() + } + } } } diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 6eac82e..8cd51ae 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -13,9 +13,19 @@ import ( "github.com/denisovdennis/autohero/internal/tuning" ) -// OfflineSimulator runs periodic background ticks for heroes that are offline, -// advancing movement the same way as the online engine (without WebSocket payloads) -// and resolving random encounters with SimulateOneFight. +// OfflineDigestGrace is the delay after the last WS disconnect before offline events count toward the digest. +const OfflineDigestGrace = 30 * time.Second + +// OfflineDigestCollecting is true when digest deltas should be applied (disconnect + grace elapsed). +func OfflineDigestCollecting(disconnect *time.Time, now time.Time) bool { + if disconnect == nil { + return false + } + return !now.Before(disconnect.Add(OfflineDigestGrace)) +} + +// OfflineSimulator holds dependencies for one-shot wall-time catch-up (server downtime, cold-start bootstrap). +// Live progression runs in the Engine for all resident heroes. type OfflineSimulator struct { store *storage.HeroStore logStore *storage.LogStore @@ -35,9 +45,8 @@ type OfflineSimulator struct { digestStore *storage.OfflineDigestStore } -// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds. -// isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause). -// skipIfLive may be nil; if it returns true for a hero id, that hero is skipped this tick. +// NewOfflineSimulator builds a catch-up runner used by BootstrapResidentHeroes and REST init gap recovery. +// isPaused and skipIfLive are optional filters for SimulateHeroAt callers; Run() is a no-op. func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, questStore *storage.QuestStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator { return &OfflineSimulator{ store: store, @@ -75,8 +84,8 @@ func (s *OfflineSimulator) WithDigestStore(d *storage.OfflineDigestStore) *Offli return s } -// nonGoldLootForDigest keeps equipment/potion lines only; gold belongs in gold_gained counter. -func nonGoldLootForDigest(drops []model.LootDrop) []model.LootDrop { +// NonGoldLootForDigest keeps equipment/potion lines only; gold belongs in gold_gained counter. +func NonGoldLootForDigest(drops []model.LootDrop) []model.LootDrop { if len(drops) == 0 { return nil } @@ -93,58 +102,18 @@ func nonGoldLootForDigest(drops []model.LootDrop) []model.LootDrop { return out } -// Run starts the offline simulation loop. It blocks until the context is cancelled. +// Run is a no-op waiter: progression runs in the game Engine for all resident heroes. +// Kept so callers can block on the same context lifecycle as before. func (s *OfflineSimulator) Run(ctx context.Context) error { - ticker := time.NewTicker(s.interval) - defer ticker.Stop() - - s.logger.Info("offline simulator started", "interval", s.interval) - - for { - select { - case <-ctx.Done(): - s.logger.Info("offline simulator shutting down") - return ctx.Err() - case <-ticker.C: - s.processTick(ctx) - } - } -} - -// processTick finds all offline heroes and simulates one fight for each. -func (s *OfflineSimulator) processTick(ctx context.Context) { - if s.isPaused != nil && s.isPaused() { - return - } - heroes, err := s.store.ListOfflineHeroes(ctx, s.interval*2, 100) - if err != nil { - s.logger.Error("offline simulator: failed to list offline heroes", "error", err) - return - } - - if len(heroes) == 0 { - return - } - - s.logger.Debug("offline simulator tick", "offline_heroes", len(heroes)) - - for _, hero := range heroes { - if s.skipIfLive != nil && s.skipIfLive(hero.ID) { - continue - } - if err := s.simulateHeroTick(ctx, hero, time.Now(), true); err != nil { - s.logger.Error("offline simulator: hero tick failed", - "hero_id", hero.ID, - "error", err, - ) - // Continue with other heroes — don't crash on one failure. - } + <-ctx.Done() + if s.logger != nil { + s.logger.Info("offline simulator stub shutting down (engine-authoritative world)") } + return ctx.Err() } // simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now, -// then persists. Random encounters use the same rolls as online; combat is resolved -// synchronously via SimulateOneFight (no WebSocket). +// then persists. Encounters resolve combat via SimulateOneFight (batch-only; live play uses Engine combat). func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error { // Auto-revive after configured downtime (autoReviveAfterMs). @@ -162,7 +131,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)}, }, }) - if s.digestStore != nil { + if s.digestStore != nil && OfflineDigestCollecting(hero.WsDisconnectedAt, now) { _ = s.digestStore.ApplyDelta(ctx, hero.ID, storage.OfflineDigestDelta{Revives: 1}) } } @@ -198,7 +167,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her rewardDeps := s.rewardDeps(tickNow) levelBefore := hm.Hero.Level survived, en, xpGained, goldGained, drops := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps) - if s.digestStore != nil { + if s.digestStore != nil && OfflineDigestCollecting(hm.Hero.WsDisconnectedAt, tickNow) { if survived { levelGain := hm.Hero.Level - levelBefore _ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{ @@ -206,7 +175,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her XPGained: xpGained, GoldGained: goldGained, LevelsGained: levelGain, - LootAppend: nonGoldLootForDigest(drops), + LootAppend: NonGoldLootForDigest(drops), }) } else { _ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{Deaths: 1}) diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index 1991328..9a46381 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -7,6 +7,21 @@ import ( "github.com/denisovdennis/autohero/internal/model" ) +func TestOfflineDigestCollecting(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + recent := now.Add(-20 * time.Second) + if OfflineDigestCollecting(&recent, now) { + t.Error("expected false before grace window") + } + old := now.Add(-2 * time.Minute) + if !OfflineDigestCollecting(&old, now) { + t.Error("expected true after grace window") + } + if OfflineDigestCollecting(nil, now) { + t.Error("expected false when disconnect time is nil") + } +} + func TestSimulateOneFight_HeroSurvives(t *testing.T) { hero := &model.Hero{ Level: 1, XP: 0, @@ -122,14 +137,14 @@ func TestNonGoldLootForDigest(t *testing.T) { {ItemType: "potion", Rarity: model.RarityCommon}, {ItemType: "gold", Rarity: model.RarityCommon, GoldAmount: 5}, } - out := nonGoldLootForDigest(drops) + out := NonGoldLootForDigest(drops) if len(out) != 1 || out[0].ItemType != "potion" { t.Fatalf("want single potion line, got %#v", out) } - if nonGoldLootForDigest(nil) != nil { + if NonGoldLootForDigest(nil) != nil { t.Fatal("nil in -> nil out") } - if nonGoldLootForDigest([]model.LootDrop{{ItemType: "gold", GoldAmount: 1}}) != nil { + if NonGoldLootForDigest([]model.LootDrop{{ItemType: "gold", GoldAmount: 1}}) != nil { t.Fatal("gold-only -> nil") } } diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 530c20d..1550d2f 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -193,6 +193,11 @@ func (h *GameHandler) GetHero(w http.ResponseWriter, r *http.Request) { h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err) } } + if h.engine != nil && !h.engine.IsTimePaused() && h.engine.MergeResidentHeroState(hero) { + if err := h.store.Save(r.Context(), hero); err != nil { + h.logger.Warn("failed to persist engine-merged hero on get", "hero_id", hero.ID, "error", err) + } + } hero.RefreshDerivedCombatStats(now) writeHeroJSON(w, http.StatusOK, hero) } @@ -757,6 +762,10 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b if h.engine != nil && h.engine.IsTimePaused() { return false } + // Engine already advanced this hero since process start; do not run batch SimulateOneFight (second combat path). + if h.engine != nil && h.engine.HeroHasActiveMovement(hero.ID) { + return false + } gapDuration := h.serverStartedAt.Sub(hero.UpdatedAt) if gapDuration < 30*time.Second { return false @@ -838,6 +847,13 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { } } + // Resident heroes: single source of truth is the Engine (same ticks as WS observers). + if h.engine != nil && !simFrozen && h.engine.MergeResidentHeroState(hero) { + if err := h.store.Save(r.Context(), hero); err != nil { + h.logger.Warn("failed to persist engine-merged hero on init", "hero_id", hero.ID, "error", err) + } + } + // Catch-up simulation: cover the gap between hero.UpdatedAt and serverStartedAt // (the period when the server was down and the offline simulator wasn't running). offlineDuration := time.Since(hero.UpdatedAt) diff --git a/backend/internal/handler/ws.go b/backend/internal/handler/ws.go index 8ddcbd2..0730856 100644 --- a/backend/internal/handler/ws.go +++ b/backend/internal/handler/ws.go @@ -105,7 +105,7 @@ func (h *Hub) Run() { h.mu.Unlock() h.logger.Info("client disconnected", "hero_id", heroID, "remaining_same_hero", remaining) - // Always persist; engine drops in-memory movement only when remaining == 0. + // Always persist; engine keeps simulating movement/combat without a subscriber. // Synchronous so a reconnect that loads from DB sees the latest save. if existed && h.OnDisconnect != nil { h.OnDisconnect(heroID, remaining) @@ -135,6 +135,9 @@ func (h *Hub) BroadcastEvent(event model.CombatEvent) { // SendToHero sends a typed message to all WebSocket connections for a specific hero. func (h *Hub) SendToHero(heroID int64, msgType string, payload any) { + if !h.IsHeroConnected(heroID) { + return + } if msgType == "hero_state" { if hero, ok := payload.(*model.Hero); ok { model.AttachDebuffCatalogForClient(hero) diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index d4f52a9..efa5b8a 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -713,6 +713,55 @@ func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time return heroes, nil } +// ListHeroesForEngineBootstrap returns heroes that should be loaded into the game engine after a cold start: +// session ended (ws_disconnected_at set) and simulatable world state. Limit caps memory use. +func (s *HeroStore) ListHeroesForEngineBootstrap(ctx context.Context, limit int) ([]*model.Hero, error) { + if limit <= 0 { + limit = 500 + } + if limit > 2000 { + limit = 2000 + } + + query := heroSelectQuery + ` + WHERE h.hp > 0 AND h.ws_disconnected_at IS NOT NULL + AND ( + (h.state = 'walking' + AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting'))) + OR h.state IN ('resting', 'in_town', 'fighting') + ) + ORDER BY h.updated_at ASC + LIMIT $1 + ` + + rows, err := s.pool.Query(ctx, query, limit) + if err != nil { + return nil, fmt.Errorf("list heroes for engine bootstrap: %w", err) + } + defer rows.Close() + + var heroes []*model.Hero + for rows.Next() { + h, err := scanHeroFromRows(rows) + if err != nil { + return nil, fmt.Errorf("list heroes for engine bootstrap scan: %w", err) + } + heroes = append(heroes, h) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list heroes for engine bootstrap rows: %w", err) + } + for _, h := range heroes { + if err := s.loadHeroGear(ctx, h); err != nil { + return nil, fmt.Errorf("list heroes for engine bootstrap load gear: %w", err) + } + if err := s.loadHeroInventory(ctx, h); err != nil { + return nil, fmt.Errorf("list heroes for engine bootstrap load inventory: %w", err) + } + } + return heroes, nil +} + // scanHeroFromRows scans the current row from pgx.Rows into a Hero struct. // Gear is loaded separately via loadHeroGear after scanning. func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {