From 39ed3382fc88a8ddb7228834c52086095fa9ea12 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Tue, 31 Mar 2026 00:54:31 +0300 Subject: [PATCH] fix reset on reconnect in town --- backend/cmd/server/main.go | 1 + backend/internal/game/engine.go | 89 +++++++++++++++++++++++++++++++ backend/internal/game/movement.go | 46 ++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 78d352a..311690a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -104,6 +104,7 @@ func main() { engine.SetSender(hub) // Hub implements game.MessageSender engine.SetRoadGraph(roadGraph) engine.SetHeroStore(heroStore) + engine.SetTownSessionStore(storage.NewTownSessionStore(redisClient)) engine.SetQuestStore(questStore) engine.SetAdventureLog(func(heroID int64, msg string) { logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 5271fd9..f928c5b 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -61,6 +61,7 @@ type Engine struct { roadGraph *RoadGraph sender MessageSender heroStore *storage.HeroStore + townSession *storage.TownSessionStore questStore *storage.QuestStore incomingCh chan IncomingMessage // client commands mu sync.RWMutex @@ -224,6 +225,13 @@ func (e *Engine) SetHeroStore(hs *storage.HeroStore) { 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() @@ -547,6 +555,11 @@ func (e *Engine) sendError(heroID int64, code, message string) { // 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() @@ -584,6 +597,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { hm := NewHeroMovement(hero, e.roadGraph, now) e.movements[hero.ID] = hm + hm.MarkTownPausePersisted(hm.townPausePersistSignature()) hm.SyncToHero() e.logger.Info("hero movement registered", @@ -639,6 +653,7 @@ func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) { "hero_id", heroID, "last_connection", lastConnection, ) + e.syncTownSessionRedisFromHero(heroID, heroSnap) } } } @@ -1374,7 +1389,80 @@ func (e *Engine) processMovementTick(now time.Time) { 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) } } @@ -1389,6 +1477,7 @@ func (e *Engine) persistHeroAfterTownEnter(h *model.Hero) { e.logger.Error("persist hero after town enter", "hero_id", h.ID, "error", err) return } + e.syncTownSessionRedisFromHero(h.ID, h) e.applyVisitTownQuestProgress(h) } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index c1d96b7..1badf9d 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -134,6 +134,34 @@ type townPausePersistSignature struct { ExcursionDepthWorldUnits float64 ExcursionRoadFreezeWaypoint int ExcursionRoadFreezeFraction float64 + + // In-town NPC tour: coarse milestones only (not per-tick x,y during walks). + InTown bool + InTownNextRoll time.Time + InTownLeave time.Time + InTownVisitStarted time.Time + InTownVisitLogs int + InTownNPCWalkTarget int64 + InTownNPCWalkStart time.Time + InTownNPCWalkArrive time.Time + InTownPlazaHeal bool + InTownCenterWalkStart time.Time + InTownCenterWalkArrive time.Time + InTownNPCQueueLen int + InTownNPCQueueFP uint64 + InTownVisitName string + InTownVisitType string +} + +func npcQueueFingerprint(q []int64) uint64 { + const prime64 = 1099511628211 + var h uint64 = 1469598103934665603 + for _, id := range q { + h ^= uint64(id) + h *= prime64 + } + h ^= uint64(len(q)) << 32 + return h } // NewHeroMovement creates a HeroMovement for a hero that just connected. @@ -1075,6 +1103,24 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature { sig.ExcursionRoadFreezeWaypoint = s.RoadFreezeWaypoint sig.ExcursionRoadFreezeFraction = s.RoadFreezeFraction } + + if hm.State == model.StateInTown { + sig.InTown = true + sig.InTownNextRoll = hm.NextTownNPCRollAt + sig.InTownLeave = hm.TownLeaveAt + sig.InTownVisitStarted = hm.TownVisitStartedAt + sig.InTownVisitLogs = hm.TownVisitLogsEmitted + sig.InTownNPCWalkTarget = hm.TownNPCWalkTargetID + sig.InTownNPCWalkStart = hm.TownNPCWalkStart + sig.InTownNPCWalkArrive = hm.TownNPCWalkArrive + sig.InTownPlazaHeal = hm.TownPlazaHealActive + sig.InTownCenterWalkStart = hm.TownCenterWalkStart + sig.InTownCenterWalkArrive = hm.TownCenterWalkArrive + sig.InTownNPCQueueLen = len(hm.TownNPCQueue) + sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue) + sig.InTownVisitName = hm.TownVisitNPCName + sig.InTownVisitType = hm.TownVisitNPCType + } return sig }