package handler import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "runtime" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" ) var serverStartedAt = time.Now() // AdminHandler provides administrative endpoints for hero management, // engine inspection, and server diagnostics. type AdminHandler struct { store *storage.HeroStore engine *game.Engine hub *Hub pool *pgxpool.Pool logger *slog.Logger } // NewAdminHandler creates a new AdminHandler with all required dependencies. func NewAdminHandler(store *storage.HeroStore, engine *game.Engine, hub *Hub, pool *pgxpool.Pool, logger *slog.Logger) *AdminHandler { return &AdminHandler{ store: store, engine: engine, hub: hub, pool: pool, logger: logger, } } // ── Hero Management ───────────────────────────────────────────────── type heroSummary struct { ID int64 `json:"id"` TelegramID int64 `json:"telegramId"` Name string `json:"name"` Level int `json:"level"` Gold int64 `json:"gold"` HP int `json:"hp"` MaxHP int `json:"maxHp"` State model.GameState `json:"state"` UpdatedAt time.Time `json:"updatedAt"` } // ListHeroes returns a paginated list of all heroes. // GET /admin/heroes?limit=20&offset=0 func (h *AdminHandler) ListHeroes(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) if limit <= 0 { limit = 20 } heroes, err := h.store.ListHeroes(r.Context(), limit, offset) if err != nil { h.logger.Error("admin: list heroes failed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to list heroes", }) return } summaries := make([]heroSummary, len(heroes)) for i, hero := range heroes { summaries[i] = heroSummary{ ID: hero.ID, TelegramID: hero.TelegramID, Name: hero.Name, Level: hero.Level, Gold: hero.Gold, HP: hero.HP, MaxHP: hero.MaxHP, State: hero.State, UpdatedAt: hero.UpdatedAt, } } writeJSON(w, http.StatusOK, map[string]any{ "heroes": summaries, "limit": limit, "offset": offset, }) } // GetHero returns full hero detail by database ID. // GET /admin/heroes/{heroId} func (h *AdminHandler) GetHero(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 failed", "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 } hero.RefreshDerivedCombatStats(time.Now()) writeJSON(w, http.StatusOK, h.engine.GetMovements(heroID).Hero) } type setLevelRequest struct { Level int `json:"level"` } // SetHeroLevel sets the hero to a specific level, recalculating stats. // POST /admin/heroes/{heroId}/set-level func (h *AdminHandler) SetHeroLevel(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 } var req setLevelRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body: " + err.Error(), }) return } if req.Level < 1 { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "level must be >= 1", }) return } const maxAdminLevel = 200 if req.Level > maxAdminLevel { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "level must be <= 200", }) return } hero, err := h.store.GetByID(r.Context(), heroID) if err != nil { h.logger.Error("admin: get hero for set-level", "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 h.isHeroInCombat(w, heroID) { return } savedGold := hero.Gold resetHeroToLevel1(hero) hero.Gold = savedGold for hero.Level < req.Level { hero.XP = model.XPToNextLevel(hero.Level) if !hero.LevelUp() { break } } if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Error("admin: save hero after set-level", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.logger.Info("admin: hero level set", "hero_id", heroID, "level", hero.Level) hero.RefreshDerivedCombatStats(time.Now()) writeJSON(w, http.StatusOK, hero) } type setGoldRequest struct { Gold int64 `json:"gold"` } // SetHeroGold sets the hero's gold to an exact value. // POST /admin/heroes/{heroId}/set-gold func (h *AdminHandler) SetHeroGold(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 } var req setGoldRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body: " + err.Error(), }) return } if req.Gold < 0 { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "gold must be >= 0", }) return } hero, err := h.store.GetByID(r.Context(), heroID) if err != nil { h.logger.Error("admin: get hero for set-gold", "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 h.isHeroInCombat(w, heroID) { return } hero.Gold = req.Gold if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Error("admin: save hero after set-gold", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.logger.Info("admin: hero gold set", "hero_id", heroID, "gold", hero.Gold) hero.RefreshDerivedCombatStats(time.Now()) writeJSON(w, http.StatusOK, hero) } type addPotionsRequest struct { Potions int `json:"potions"` } // SetHeroGold sets the hero's gold to an exact value. // POST /admin/heroes/{heroId}/add-potions func (h *AdminHandler) AddPotions(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 } var req addPotionsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body: " + err.Error(), }) return } if req.Potions < 0 { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "potions must be >= 1", }) return } var hero = h.engine.GetMovements(heroID).Hero if hero == nil { writeJSON(w, http.StatusNotFound, map[string]string{ "error": "hero not found", }) return } hero.Potions += req.Potions if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Error("admin: save hero after set-gold", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.logger.Info("admin: hero added potions", "hero_id", heroID, "potions", hero.Potions) hero.RefreshDerivedCombatStats(time.Now()) h.engine.SyncHeroState(hero) writeJSON(w, http.StatusOK, hero) } type setHPRequest struct { HP int `json:"hp"` } // SetHeroHP sets the hero's current HP, clamped to [1, maxHp]. // POST /admin/heroes/{heroId}/set-hp func (h *AdminHandler) SetHeroHP(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 } var req setHPRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body: " + err.Error(), }) return } hero, err := h.store.GetByID(r.Context(), heroID) if err != nil { h.logger.Error("admin: get hero for set-hp", "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 h.isHeroInCombat(w, heroID) { return } hp := req.HP if hp < 1 { hp = 1 } if hp > hero.MaxHP { hp = hero.MaxHP } hero.HP = hp if hero.State == model.StateDead && hero.HP > 0 { hero.State = model.StateWalking } if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Error("admin: save hero after set-hp", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.logger.Info("admin: hero HP set", "hero_id", heroID, "hp", hero.HP) hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) h.engine.ApplyAdminHeroSnapshot(hero) writeJSON(w, http.StatusOK, hero) } // ReviveHero force-revives a hero to full HP regardless of current state. // POST /admin/heroes/{heroId}/revive func (h *AdminHandler) ReviveHero(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 revive", "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 } hero.HP = hero.MaxHP hero.State = model.StateWalking hero.Buffs = nil hero.Debuffs = nil if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Error("admin: save hero after revive", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.engine.ApplyAdminHeroRevive(hero) h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP) hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) writeJSON(w, http.StatusOK, hero) } // ResetHero resets a hero to fresh level 1 defaults. // POST /admin/heroes/{heroId}/reset func (h *AdminHandler) ResetHero(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 reset", "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 h.isHeroInCombat(w, heroID) { return } resetHeroToLevel1(hero) if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Error("admin: save hero after reset", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.logger.Info("admin: hero reset", "hero_id", heroID) hero.RefreshDerivedCombatStats(time.Now()) writeJSON(w, http.StatusOK, hero) } type resetBuffChargesRequest struct { BuffType string `json:"buffType"` // optional — if empty, reset ALL } // ResetBuffCharges resets per-buff free charges to their maximums. // If buffType is provided, only that buff is reset; otherwise all are reset. // POST /admin/heroes/{heroId}/reset-buff-charges func (h *AdminHandler) ResetBuffCharges(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 } var req resetBuffChargesRequest if r.Body != nil && r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body: " + err.Error(), }) return } } hero, err := h.store.GetByID(r.Context(), heroID) if err != nil { h.logger.Error("admin: get hero for reset-buff-charges", "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 } now := time.Now() hero.EnsureBuffChargesPopulated(now) if req.BuffType != "" { bt, ok := model.ValidBuffType(req.BuffType) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid buffType: " + req.BuffType, }) return } hero.ResetBuffCharges(&bt, now) h.logger.Info("admin: buff charges reset (single)", "hero_id", heroID, "buff_type", bt) } else { hero.ResetBuffCharges(nil, now) h.logger.Info("admin: buff charges reset (all)", "hero_id", heroID) } if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Error("admin: save hero after reset-buff-charges", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } hero.RefreshDerivedCombatStats(now) writeJSON(w, http.StatusOK, hero) } // DeleteHero permanently removes a hero from the database. // DELETE /admin/heroes/{heroId} func (h *AdminHandler) DeleteHero(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 delete", "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 } h.engine.StopCombat(heroID) if err := h.store.DeleteByID(r.Context(), heroID); err != nil { h.logger.Error("admin: delete hero failed", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to delete hero", }) return } h.logger.Info("admin: hero deleted", "hero_id", heroID) writeJSON(w, http.StatusOK, map[string]string{ "status": "deleted", }) } // ── Game Engine ───────────────────────────────────────────────────── // EngineStatus returns operational status of the game engine. // GET /admin/engine/status func (h *AdminHandler) EngineStatus(w http.ResponseWriter, r *http.Request) { status := h.engine.Status() writeJSON(w, http.StatusOK, map[string]any{ "running": status.Running, "tickRateMs": status.TickRate.Milliseconds(), "activeCombats": status.ActiveCombats, "activeMovements": status.ActiveMovements, "timePaused": status.TimePaused, "uptimeMs": status.UptimeMs, }) } type teleportTownRequest struct { TownID int64 `json:"townId"` } // ListTowns returns town ids from the loaded road graph (for admin teleport). // GET /admin/towns func (h *AdminHandler) ListTowns(w http.ResponseWriter, r *http.Request) { rg := h.engine.RoadGraph() if rg == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "error": "road graph not loaded", }) return } type row struct { ID int64 `json:"id"` Name string `json:"name"` WorldX float64 `json:"worldX"` WorldY float64 `json:"worldY"` } out := make([]row, 0, len(rg.TownOrder)) for _, id := range rg.TownOrder { if t := rg.Towns[id]; t != nil { out = append(out, row{ID: t.ID, Name: t.Name, WorldX: t.WorldX, WorldY: t.WorldY}) } } writeJSON(w, http.StatusOK, map[string]any{"towns": out}) } // StartHeroAdventure forces off-road adventure for a hero (online or offline). // POST /admin/heroes/{heroId}/start-adventure func (h *AdminHandler) StartHeroAdventure(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 } var hm = h.engine.GetMovements(heroID); hero = hm.Hero; if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "hero must be alive and not in combat", "hero": hero, }) return } if hm := h.engine.GetMovements(heroID); hm != nil { out, ok := h.engine.ApplyAdminStartAdventure(heroID) if !ok || out == nil { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "cannot start adventure (hero must be walking on a road)", }) 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 } out.RefreshDerivedCombatStats(time.Now()) h.logger.Info("admin: start adventure", "hero_id", heroID) writeJSON(w, http.StatusOK, out) return } hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { if !hm.StartAdventureForced(now) { return fmt.Errorf("cannot start adventure (hero must be walking on a road)") } return nil }) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } h.logger.Info("admin: start adventure (offline)", "hero_id", heroID) writeJSON(w, http.StatusOK, hero2) } // TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest). // POST /admin/heroes/{heroId}/teleport-town func (h *AdminHandler) TeleportHeroTown(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 } var req teleportTownRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body: " + err.Error(), }) return } if req.TownID == 0 { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "townId is required", }) return } hero, err := h.store.GetByID(r.Context(), heroID) if err != nil { h.logger.Error("admin: get hero for teleport", "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 } townID := req.TownID if hm := h.engine.GetMovements(heroID); hm != nil { out, ok := h.engine.ApplyAdminTeleportTown(heroID, townID) if !ok || out == nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "teleport failed (unknown town or hero not online with movement)", }) return } if err := h.store.Save(r.Context(), out); err != nil { h.logger.Error("admin: save after teleport", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } out.RefreshDerivedCombatStats(time.Now()) h.logger.Info("admin: teleport town", "hero_id", heroID, "town_id", townID) writeJSON(w, http.StatusOK, out) return } hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { return hm.AdminPlaceInTown(rg, townID, now) }) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } h.logger.Info("admin: teleport town (offline)", "hero_id", heroID, "town_id", townID) writeJSON(w, http.StatusOK, hero2) } // StartHeroRest forces resting state (duration same as town rest). // POST /admin/heroes/{heroId}/start-rest func (h *AdminHandler) StartHeroRest(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-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.ApplyAdminStartRest(heroID) if !ok || out == nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "cannot start rest", }) return } if err := h.store.Save(r.Context(), out); err != nil { h.logger.Error("admin: save after start-rest", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } out.RefreshDerivedCombatStats(time.Now()) h.logger.Info("admin: start rest", "hero_id", heroID) writeJSON(w, http.StatusOK, out) return } hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { if !hm.AdminStartRest(now, rg) { return fmt.Errorf("cannot start rest") } return nil }) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } h.logger.Info("admin: start rest (offline)", "hero_id", heroID) writeJSON(w, http.StatusOK, 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) { h.engine.SetTimePaused(true) h.logger.Info("admin: global time paused") writeJSON(w, http.StatusOK, map[string]any{"timePaused": true}) } // ResumeTime resumes engine and offline simulation. // POST /admin/time/resume func (h *AdminHandler) ResumeTime(w http.ResponseWriter, r *http.Request) { h.engine.SetTimePaused(false) h.logger.Info("admin: global time resumed") writeJSON(w, http.StatusOK, map[string]any{"timePaused": false}) } // adminMovementOffline rebuilds movement from DB, applies fn, persists. func (h *AdminHandler) adminMovementOffline(ctx context.Context, hero *model.Hero, fn func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error) (*model.Hero, error) { rg := h.engine.RoadGraph() if rg == nil { return nil, fmt.Errorf("road graph not loaded") } now := time.Now() hm := game.NewHeroMovement(hero, rg, now) if err := fn(hm, rg, now); err != nil { return nil, err } hm.SyncToHero() if err := h.store.Save(ctx, hero); err != nil { return nil, fmt.Errorf("failed to save hero: %w", err) } hero.RefreshDerivedCombatStats(now) return hero, nil } // ActiveCombats returns all active combat sessions. // GET /admin/engine/combats func (h *AdminHandler) ActiveCombats(w http.ResponseWriter, r *http.Request) { combats := h.engine.ListActiveCombats() writeJSON(w, http.StatusOK, map[string]any{ "combats": combats, "count": len(combats), }) } // ── WebSocket Hub ─────────────────────────────────────────────────── // WSConnections returns active WebSocket connection info. // GET /admin/ws/connections func (h *AdminHandler) WSConnections(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "connectionCount": h.hub.ConnectionCount(), "heroIds": h.hub.ConnectedHeroIDs(), }) } // ── Server Info ───────────────────────────────────────────────────── // ServerInfo returns general server diagnostics. // GET /admin/info func (h *AdminHandler) ServerInfo(w http.ResponseWriter, r *http.Request) { poolStat := h.pool.Stat() writeJSON(w, http.StatusOK, map[string]any{ "version": "0.1.0-dev", "goVersion": runtime.Version(), "uptimeMs": time.Since(serverStartedAt).Milliseconds(), "dbPool": map[string]any{ "totalConns": poolStat.TotalConns(), "acquiredConns": poolStat.AcquiredConns(), "idleConns": poolStat.IdleConns(), "maxConns": poolStat.MaxConns(), }, }) } // ── Helpers ───────────────────────────────────────────────────────── func parseHeroID(r *http.Request) (int64, error) { return strconv.ParseInt(chi.URLParam(r, "heroId"), 10, 64) } // isHeroInCombat checks if the hero is in active engine combat and writes // a 409 Conflict response if so. Returns true when the caller should abort. func (h *AdminHandler) isHeroInCombat(w http.ResponseWriter, heroID int64) bool { if _, active := h.engine.GetCombat(heroID); active { writeJSON(w, http.StatusConflict, map[string]string{ "error": "hero is in active combat — stop combat first", }) return true } return false } // resetHeroToLevel1 restores a hero to fresh level 1 defaults, // preserving identity fields (ID, TelegramID, Name, CreatedAt). func resetHeroToLevel1(hero *model.Hero) { hero.Level = 1 hero.XP = 0 hero.Gold = 0 hero.HP = 100 hero.MaxHP = 100 hero.Attack = 10 hero.Defense = 5 hero.Speed = 1.0 hero.Strength = 1 hero.Constitution = 1 hero.Agility = 1 hero.Luck = 1 hero.State = model.StateWalking hero.Buffs = nil hero.Debuffs = nil hero.BuffCharges = nil hero.BuffFreeChargesRemaining = model.FreeBuffActivationsPerPeriod hero.BuffQuotaPeriodEnd = nil }