From 3c9c811201d098d085a2a9ccda0976d6430faed5 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Thu, 2 Apr 2026 21:41:07 +0300 Subject: [PATCH] some fixes --- backend/internal/game/engine.go | 17 +++++++++++++++++ backend/internal/handler/admin.go | 10 ++++++++++ backend/internal/storage/hero_store.go | 5 +++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 3f388a6..320db88 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -1582,6 +1582,7 @@ func (e *Engine) processCombatTickLocked(now time.Time) { if hm, ok := e.movements[heroID]; ok { hm.Die() } + e.persistHeroDeathLocked(heroID, cs.Hero) dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) e.applyOfflineDigest(dctx, heroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1}) cancel() @@ -1614,6 +1615,21 @@ func (e *Engine) processAttackEvent(evt *model.AttackEvent, cs *model.CombatStat } } +// persistHeroDeathLocked writes the dead hero snapshot immediately so DB state +// never lags behind the live in-memory death state. +// Caller must hold e.mu. +func (e *Engine) persistHeroDeathLocked(heroID int64, hero *model.Hero) { + if e.heroStore == nil || hero == nil { + return + } + 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 death", "hero_id", heroID, "error", err) + } +} + // sendDebuffAppliedForString pushes debuff_applied when a debuff proc string is non-empty. func (e *Engine) sendDebuffAppliedForString(heroID int64, debuffTypeStr string, now time.Time) { if e.sender == nil || debuffTypeStr == "" { @@ -1748,6 +1764,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) { if hm, ok := e.movements[cs.HeroID]; ok { hm.Die() } + e.persistHeroDeathLocked(cs.HeroID, cs.Hero) dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) e.applyOfflineDigest(dctx, cs.HeroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1}) cancel() diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 5977f97..99c2838 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -1289,6 +1289,16 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) { return } + // Admin UI displays live in-engine state when the hero is online. + // Use that same authoritative snapshot for revive validation to avoid + // false "hero is not dead" when DB lagged behind live movement/combat. + if h.engine != nil { + if hm := h.engine.GetMovements(heroID); hm != nil && hm.Hero != nil { + live := *hm.Hero + hero = &live + } + } + if !game.IsEffectivelyDead(hero) { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "hero is not dead", diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index 9012b71..a5cf458 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -659,8 +659,9 @@ func (s *HeroStore) ClearWsDisconnectedAt(ctx context.Context, heroID int64) err func (s *HeroStore) ListHeroesForEngineBootstrap(ctx context.Context) ([]*model.Hero, error) { query := heroSelectQuery + ` - WHERE h.hp > 0 AND h.ws_disconnected_at IS NOT NULL - ORDER BY h.updated_at ASC + WHERE h.ws_disconnected_at IS NOT NULL + ORDER BY id ASC + LIMIT 100 ` rows, err := s.pool.Query(ctx, query)