From ca9aff89f3474fec8e78bcf9f888ed77535eae5d Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 30 Mar 2026 21:18:02 +0300 Subject: [PATCH] roadside rest --- backend/internal/game/engine.go | 49 +++++++++++++ backend/internal/game/movement.go | 70 ++++++++++++++++--- backend/internal/handler/admin.go | 111 ++++++++++++++++++++++++++++++ backend/internal/router/router.go | 2 + 4 files changed, 224 insertions(+), 8 deletions(-) diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 7104cfe..350e192 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -749,6 +749,55 @@ func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) { 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 +} + // ListActiveCombats returns a snapshot of all active combat sessions. func (e *Engine) ListActiveCombats() []CombatInfo { e.mu.RLock() diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index c721a50..d7513cb 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -153,6 +153,23 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov if hero.State == model.StateResting || hero.State == model.StateInTown { hm.State = hero.State hm.applyTownPauseFromHero(hero, now) + + // For roadside / adventure-inline rest the hero needs a road for display offset calculations. + if hm.ActiveRestKind == model.RestKindRoadside || hm.ActiveRestKind == model.RestKindAdventureInline { + if hm.DestinationTownID == 0 { + hm.pickDestination(graph) + } + hm.assignRoad(graph) + if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 { + hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint + hm.WaypointFraction = hm.Excursion.RoadFreezeFraction + from := hm.Road.Waypoints[hm.WaypointIndex] + to := hm.Road.Waypoints[hm.WaypointIndex+1] + hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction + hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction + } + } + return hm } @@ -583,6 +600,39 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim return nil } +// AdminStartRoadsideRest forces the hero into roadside rest on the current road segment. +func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool { + if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { + return false + } + if hm.State == model.StateFighting { + return false + } + hm.WanderingMerchantDeadline = time.Time{} + hm.beginRoadsideRest(now) + return true +} + +// AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking. +func (hm *HeroMovement) AdminStopRest(now time.Time) bool { + if hm.State != model.StateResting { + return false + } + if hm.ActiveRestKind != model.RestKindRoadside && hm.ActiveRestKind != model.RestKindAdventureInline { + return false + } + if hm.ActiveRestKind == model.RestKindAdventureInline && hm.Excursion.Active() { + hm.endExcursion(now) + } + hm.ActiveRestKind = model.RestKindNone + hm.RestUntil = time.Time{} + hm.RestHealRemainder = 0 + hm.State = model.StateWalking + hm.Hero.State = model.StateWalking + hm.refreshSpeed(now) + return true +} + // AdminStartRest forces a resting period (same duration model as town rest). func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { @@ -846,20 +896,22 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { switch hm.State { case model.StateResting: - if hm.RestUntil.IsZero() { - break - } - t := hm.RestUntil rk := model.RestKindTown if hm.ActiveRestKind != model.RestKindNone { rk = hm.ActiveRestKind } + if rk == model.RestKindTown && hm.RestUntil.IsZero() { + break + } p = &model.TownPausePersisted{ - RestUntil: &t, RestKind: rk, TownRestHealRemainder: hm.TownRestHealRemainder, RestHealRemainder: hm.RestHealRemainder, } + if !hm.RestUntil.IsZero() { + t := hm.RestUntil + p.RestUntil = &t + } case model.StateInTown: p = &model.TownPausePersisted{ TownVisitNPCName: hm.TownVisitNPCName, @@ -923,13 +975,15 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) blob := hero.TownPause switch hero.State { case model.StateResting: - if blob != nil && blob.RestUntil != nil && !blob.RestUntil.IsZero() { - hm.RestUntil = *blob.RestUntil + if blob != nil && (blob.RestKind != model.RestKindNone || (blob.RestUntil != nil && !blob.RestUntil.IsZero())) { + if blob.RestUntil != nil && !blob.RestUntil.IsZero() { + hm.RestUntil = *blob.RestUntil + } hm.ActiveRestKind = blob.RestKind hm.TownRestHealRemainder = blob.TownRestHealRemainder hm.RestHealRemainder = blob.RestHealRemainder } else { - // Legacy row without town_pause: treat rest as already elapsed so offline/ reconnect unblocks. + // Legacy row without town_pause: treat rest as already elapsed so offline/reconnect unblocks. hm.RestUntil = now.Add(-time.Millisecond) } case model.StateInTown: diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index c7cd5a2..b10244b 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -1556,6 +1556,117 @@ func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, hero2) } +// StartHeroRoadsideRest forces a hero into roadside rest at the current road position. +// POST /admin/heroes/{heroId}/start-roadside-rest +func (h *AdminHandler) StartHeroRoadsideRest(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for start-roadside-rest", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) + return + } + if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be alive and not in combat"}) + return + } + + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminStartRoadsideRest(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start roadside rest"}) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after start-roadside-rest", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"}) + return + } + h.logger.Info("admin: start roadside rest", "hero_id", heroID) + h.writeAdminHeroDetail(w, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + if !hm.AdminStartRoadsideRest(now) { + return fmt.Errorf("cannot start roadside rest") + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: start roadside rest (offline)", "hero_id", heroID) + h.writeAdminHeroDetail(w, hero2) +} + +// StopHeroRest exits a hero from non-town rest (roadside or adventure-inline) back to walking. +// POST /admin/heroes/{heroId}/stop-rest +func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for stop-rest", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) + return + } + + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminStopRest(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in roadside/adventure rest"}) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after stop-rest", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"}) + return + } + h.logger.Info("admin: stop rest", "hero_id", heroID) + h.writeAdminHeroDetail(w, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + if !hm.AdminStopRest(now) { + return fmt.Errorf("hero is not in roadside/adventure rest") + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: stop rest (offline)", "hero_id", heroID) + h.writeAdminHeroDetail(w, hero2) +} + // PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls. // POST /admin/time/pause func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 668364d..0137e46 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -86,6 +86,8 @@ func New(deps Deps) *chi.Mux { r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges) r.Post("/heroes/{heroId}/teleport-town", adminH.TeleportHeroTown) r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest) + r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartHeroRoadsideRest) + r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest) r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown) r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear) r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)