You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

985 lines
28 KiB
Go

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
}