From 1fdfdbfcdaa409bfbb023cd5f100a755825add1e Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Sun, 29 Mar 2026 19:43:48 +0300 Subject: [PATCH] some fixes for heroes and local testing --- backend/cmd/server/main.go | 4 +- backend/internal/game/engine.go | 50 ++++++++++++++++---- backend/internal/game/movement.go | 77 +++++++++++++++++++++++++------ backend/internal/handler/ws.go | 23 ++++++--- 4 files changed, 123 insertions(+), 31 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3d7ae15..6b11826 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -89,8 +89,8 @@ func main() { } engine.RegisterHeroMovement(hero) } - hub.OnDisconnect = func(heroID int64) { - engine.UnregisterHeroMovement(heroID) + hub.OnDisconnect = func(heroID int64, remainingSameHero int) { + engine.HeroSocketDetached(heroID, remainingSameHero == 0) } // Bridge hub incoming client messages to engine's command channel. diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index e9648fd..bd11d1b 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -329,6 +329,31 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { } 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 @@ -357,24 +382,33 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { } } -// UnregisterHeroMovement removes movement state and persists hero to DB. -// Called when a WS client disconnects. -func (e *Engine) UnregisterHeroMovement(heroID int64) { +// 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() - delete(e.movements, heroID) + if lastConnection { + delete(e.movements, heroID) + } + } + var heroSnap *model.Hero + if ok { + heroSnap = hm.Hero } e.mu.Unlock() - if ok && e.heroStore != nil { + if ok && e.heroStore != nil && heroSnap != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := e.heroStore.Save(ctx, hm.Hero); err != nil { - e.logger.Error("failed to save hero on disconnect", "hero_id", heroID, "error", err) + 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 disconnect", "hero_id", heroID) + e.logger.Info("hero state persisted on ws disconnect", + "hero_id", heroID, + "last_connection", lastConnection, + ) } } } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 44f4c6f..84bc4c1 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -89,15 +89,24 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov dir = -1 } - // Add per-hero position offset so heroes on the same road don't overlap. - // Use hero ID to create a stable lateral offset of ±1.5 tiles. - lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5 + // Persisted (x,y) already include any in-world offset from prior sessions; do not add + // lateral jitter again on reconnect (that doubled the shift every reload). Only spread + // new heroes that still sit at the default origin. + var curX, curY float64 + if hero.PositionX == 0 && hero.PositionY == 0 { + lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5 + curX = lateralOffset * 0.3 + curY = lateralOffset * 0.7 + } else { + curX = hero.PositionX + curY = hero.PositionY + } hm := &HeroMovement{ HeroID: hero.ID, Hero: hero, - CurrentX: hero.PositionX + lateralOffset*0.3, - CurrentY: hero.PositionY + lateralOffset*0.7, + CurrentX: curX, + CurrentY: curY, State: hero.State, LastMoveTick: now, Direction: dir, @@ -278,18 +287,56 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) { } hm.Road = jitteredRoad - hm.WaypointIndex = 0 - hm.WaypointFraction = 0 - - // Position the hero at the start of the road if they're very close to the origin town. - if len(jitteredWaypoints) > 0 { - start := jitteredWaypoints[0] - dist := math.Hypot(hm.CurrentX-start.X, hm.CurrentY-start.Y) - if dist < 5.0 { - hm.CurrentX = start.X - hm.CurrentY = start.Y + // Restore progress along this hero's jittered polyline from saved world position. + // Otherwise WaypointIndex stays 0 and the next AdvanceTick snaps (x,y) to waypoint[0] + // (departure town), which looks like "teleport back to the city" on reload. + hm.snapProgressToNearestPointOnRoad() +} + +// snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y +// to the closest point on the current road polyline to the incoming position. +func (hm *HeroMovement) snapProgressToNearestPointOnRoad() { + if hm.Road == nil || len(hm.Road.Waypoints) < 2 { + hm.WaypointIndex = 0 + hm.WaypointFraction = 0 + return + } + hx, hy := hm.CurrentX, hm.CurrentY + bestIdx := 0 + bestT := 0.0 + bestDistSq := math.MaxFloat64 + bestX, bestY := hx, hy + for i := 0; i < len(hm.Road.Waypoints)-1; i++ { + ax, ay := hm.Road.Waypoints[i].X, hm.Road.Waypoints[i].Y + bx, by := hm.Road.Waypoints[i+1].X, hm.Road.Waypoints[i+1].Y + dx, dy := bx-ax, by-ay + segLenSq := dx*dx + dy*dy + var t float64 + if segLenSq < 1e-12 { + t = 0 + } else { + t = ((hx-ax)*dx + (hy-ay)*dy) / segLenSq + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + } + px := ax + t*dx + py := ay + t*dy + dSq := (hx-px)*(hx-px) + (hy-py)*(hy-py) + if dSq < bestDistSq { + bestDistSq = dSq + bestIdx = i + bestT = t + bestX, bestY = px, py } } + hm.WaypointIndex = bestIdx + hm.WaypointFraction = bestT + hm.CurrentX = bestX + hm.CurrentY = bestY } // refreshSpeed recalculates the effective movement speed using hero buffs/debuffs. diff --git a/backend/internal/handler/ws.go b/backend/internal/handler/ws.go index 6585f5a..b84e782 100644 --- a/backend/internal/handler/ws.go +++ b/backend/internal/handler/ws.go @@ -39,8 +39,9 @@ type Hub struct { OnConnect func(heroID int64) // OnDisconnect is called when a client is unregistered. - // Set by the engine to persist state and remove movement. May be nil. - OnDisconnect func(heroID int64) + // remainingSameHero is how many other WS clients for this hero are still connected. + // Set by the engine to persist state; may be nil. + OnDisconnect func(heroID int64, remainingSameHero int) } // Client represents a single WebSocket connection. @@ -87,17 +88,27 @@ func (h *Hub) Run() { } case client := <-h.unregister: + heroID := client.heroID h.mu.Lock() + existed := false if _, ok := h.clients[client]; ok { delete(h.clients, client) + existed = true close(client.send) } + remaining := 0 + for c := range h.clients { + if c.heroID == heroID { + remaining++ + } + } h.mu.Unlock() - h.logger.Info("client disconnected", "hero_id", client.heroID) + h.logger.Info("client disconnected", "hero_id", heroID, "remaining_same_hero", remaining) - // Notify engine of disconnection. - if h.OnDisconnect != nil { - go h.OnDisconnect(client.heroID) + // Always persist; engine drops in-memory movement only when remaining == 0. + // Synchronous so a reconnect that loads from DB sees the latest save. + if existed && h.OnDisconnect != nil { + h.OnDisconnect(heroID, remaining) } case env := <-h.broadcast: