diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 350e192..d22d187 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -798,6 +798,59 @@ func (e *Engine) ApplyAdminStopRest(heroID int64) (*model.Hero, bool) { return h, true } +// ApplyAdminStartExcursion forces an online hero into a mini-adventure session on the current road. +func (e *Engine) ApplyAdminStartExcursion(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.AdminStartExcursion(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, "excursion_start", model.ExcursionStartPayload{ + DepthWorldUnits: hm.Excursion.DepthWorldUnits, + }) + e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) + } + return h, true +} + +// ApplyAdminStopExcursion ends an online hero's excursion immediately. +func (e *Engine) ApplyAdminStopExcursion(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.AdminStopExcursion(now) { + return nil, false + } + hm.SyncToHero() + h := hm.Hero + if e.sender != nil { + h.EnsureGearMap() + h.RefreshDerivedCombatStats(now) + e.sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{}) + 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 d7513cb..e437162 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -613,6 +613,50 @@ func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool { return true } +// AdminStartExcursion forces a mini-adventure (excursion) session while the hero is walking on a road. +// Cooldown and random roll are bypassed; the hero must not already be in an excursion. +func (hm *HeroMovement) AdminStartExcursion(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 + } + if hm.State != model.StateWalking { + return false + } + if hm.Excursion.Active() { + return false + } + if hm.Road == nil || len(hm.Road.Waypoints) < 2 { + return false + } + hm.WanderingMerchantDeadline = time.Time{} + hm.beginExcursion(now) + return true +} + +// AdminStopExcursion ends an active excursion immediately (hero back on the road spine). +// Works during walking phases or adventure-inline rest; rejects combat. +func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool { + if !hm.Excursion.Active() { + return false + } + if hm.State == model.StateFighting { + return false + } + if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindAdventureInline { + hm.ActiveRestKind = model.RestKindNone + hm.RestUntil = time.Time{} + hm.RestHealRemainder = 0 + hm.State = model.StateWalking + hm.Hero.State = model.StateWalking + } + hm.endExcursion(now) + hm.refreshSpeed(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 { diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index b10244b..686c33f 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -72,6 +72,9 @@ type adminLiveMovementJSON struct { CurrentTownID int64 `json:"currentTownId,omitempty"` DestinationTownID int64 `json:"destinationTownId,omitempty"` WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"` + ExcursionPhase string `json:"excursionPhase,omitempty"` + ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"` + ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,omitempty"` } // adminHeroDetailResponse is the full admin JSON for one hero: base hero + persisted town_pause + live movement snapshot. @@ -111,6 +114,17 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLive t := hm.WanderingMerchantDeadline s.WanderingMerchantDeadline = &t } + if hm.Excursion.Active() { + s.ExcursionPhase = string(hm.Excursion.Phase) + if !hm.Excursion.WildUntil.IsZero() { + t := hm.Excursion.WildUntil + s.ExcursionWildUntil = &t + } + if !hm.Excursion.ReturnUntil.IsZero() { + t := hm.Excursion.ReturnUntil + s.ExcursionReturnUntil = &t + } + } return s } @@ -1667,6 +1681,124 @@ func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) { h.writeAdminHeroDetail(w, hero2) } +// StartHeroExcursion forces a walking hero on a road into a mini-adventure (excursion) session. +// POST /admin/heroes/{heroId}/start-adventure +func (h *AdminHandler) StartHeroExcursion(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-adventure", "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 hero.State != model.StateWalking { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be walking on the road to start an excursion"}) + return + } + + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminStartExcursion(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start excursion (need active road segment, or excursion already active)"}) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after start-adventure", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"}) + return + } + h.logger.Info("admin: start excursion", "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.AdminStartExcursion(now) { + return fmt.Errorf("cannot start excursion (need active road segment, or excursion already active)") + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: start excursion (offline)", "hero_id", heroID) + h.writeAdminHeroDetail(w, hero2) +} + +// StopHeroExcursion ends the hero's mini-adventure session immediately. +// POST /admin/heroes/{heroId}/stop-adventure +func (h *AdminHandler) StopHeroExcursion(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 stop-adventure", "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.ApplyAdminStopExcursion(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero has no active excursion"}) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after stop-adventure", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"}) + return + } + h.logger.Info("admin: stop excursion", "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.AdminStopExcursion(now) { + return fmt.Errorf("hero has no active excursion") + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: stop excursion (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 0137e46..268b2ec 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -87,6 +87,8 @@ func New(deps Deps) *chi.Mux { 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}/start-adventure", adminH.StartHeroExcursion) + r.Post("/heroes/{heroId}/stop-adventure", adminH.StopHeroExcursion) r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest) r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown) r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear) diff --git a/scripts/admin-tool.ps1 b/scripts/admin-tool.ps1 index 05084f2..61995be 100644 --- a/scripts/admin-tool.ps1 +++ b/scripts/admin-tool.ps1 @@ -21,6 +21,7 @@ param( "start-rest", "leave-town", "start-roadside-rest", + "stop-adventure", "stop-rest", "time-pause", "time-resume" @@ -172,6 +173,10 @@ switch ($Command) { Require-Value -Name "HeroId" -Value $HeroId $result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/start-roadside-rest" -Body @{} } + "stop-adventure" { + Require-Value -Name "HeroId" -Value $HeroId + $result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/stop-adventure" -Body @{} + } "stop-rest" { Require-Value -Name "HeroId" -Value $HeroId $result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/stop-rest" -Body @{}