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.
2111 lines
65 KiB
Go
2111 lines
65 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"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"
|
|
"github.com/denisovdennis/autohero/internal/tuning"
|
|
)
|
|
|
|
var serverStartedAt = time.Now()
|
|
|
|
// AdminHandler provides administrative endpoints for hero management,
|
|
// engine inspection, and server diagnostics.
|
|
type AdminHandler struct {
|
|
store *storage.HeroStore
|
|
gearStore *storage.GearStore
|
|
questStore *storage.QuestStore
|
|
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, gearStore *storage.GearStore, questStore *storage.QuestStore, engine *game.Engine, hub *Hub, pool *pgxpool.Pool, logger *slog.Logger) *AdminHandler {
|
|
return &AdminHandler{
|
|
store: store,
|
|
gearStore: gearStore,
|
|
questStore: questStore,
|
|
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"))
|
|
query := r.URL.Query().Get("query")
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
heroes, err := h.store.ListHeroesFiltered(r.Context(), limit, offset, query)
|
|
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,
|
|
"query": query,
|
|
})
|
|
}
|
|
|
|
// ListPayments returns payments with optional hero filter.
|
|
// GET /admin/payments?heroId=1&limit=50&offset=0
|
|
func (h *AdminHandler) ListPayments(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
heroID, _ := strconv.ParseInt(r.URL.Query().Get("heroId"), 10, 64)
|
|
|
|
payments, err := h.store.ListPayments(r.Context(), heroID, limit, offset)
|
|
if err != nil {
|
|
h.logger.Error("admin: list payments failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list payments"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"payments": payments,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"heroId": heroID,
|
|
})
|
|
}
|
|
|
|
// GetPayment returns a single payment by ID.
|
|
// GET /admin/payments/{paymentId}
|
|
func (h *AdminHandler) GetPayment(w http.ResponseWriter, r *http.Request) {
|
|
paymentID, err := parsePaymentID(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid paymentId: " + err.Error()})
|
|
return
|
|
}
|
|
p, err := h.store.GetPaymentByID(r.Context(), paymentID)
|
|
if err != nil {
|
|
h.logger.Error("admin: get payment failed", "payment_id", paymentID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load payment"})
|
|
return
|
|
}
|
|
if p == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "payment not found"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
// GetHeroGear returns hero equipped and inventory items.
|
|
// GET /admin/heroes/{heroId}/gear
|
|
func (h *AdminHandler) GetHeroGear(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
|
|
}
|
|
equipped, err := h.gearStore.GetHeroGear(r.Context(), heroID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load equipped gear"})
|
|
return
|
|
}
|
|
inventory, err := h.gearStore.GetHeroInventory(r.Context(), heroID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load inventory"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"equipped": equipped,
|
|
"inventory": inventory,
|
|
})
|
|
}
|
|
|
|
type grantGearRequest struct {
|
|
Slot string `json:"slot"`
|
|
FormID string `json:"formId"`
|
|
Rarity string `json:"rarity"`
|
|
Ilvl int `json:"ilvl"`
|
|
SourceGearID int64 `json:"sourceGearId"` // optional: clone existing `gear` row into a new instance for the hero
|
|
}
|
|
|
|
// GrantHeroGear creates a gear item and adds it to hero inventory.
|
|
// POST /admin/heroes/{heroId}/gear/grant
|
|
func (h *AdminHandler) GrantHeroGear(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 grantGearRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if req.SourceGearID > 0 {
|
|
src, err := h.gearStore.GetItem(r.Context(), req.SourceGearID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load source gear"})
|
|
return
|
|
}
|
|
if src == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "source gear not found"})
|
|
return
|
|
}
|
|
clone := *src
|
|
clone.ID = 0
|
|
if err := h.gearStore.CreateItem(r.Context(), &clone); err != nil {
|
|
h.logger.Error("admin: grant gear clone", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gear copy"})
|
|
return
|
|
}
|
|
if err := h.gearStore.AddToInventory(r.Context(), heroID, clone.ID); err != nil {
|
|
_ = h.gearStore.DeleteGearItem(r.Context(), clone.ID)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "granted, but failed to sync hero snapshot"})
|
|
return
|
|
}
|
|
h.GetHeroGear(w, r)
|
|
return
|
|
}
|
|
slot, err := parseEquipmentSlot(req.Slot)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
rarity, err := parseRarity(req.Rarity)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if req.Ilvl <= 0 {
|
|
req.Ilvl = 1
|
|
}
|
|
if req.Ilvl > 200 {
|
|
req.Ilvl = 200
|
|
}
|
|
|
|
var family *model.GearFamily
|
|
for i := range model.GearCatalog {
|
|
gf := model.GearCatalog[i]
|
|
if gf.Slot == slot && (req.FormID == "" || gf.FormID == req.FormID) {
|
|
family = &gf
|
|
break
|
|
}
|
|
}
|
|
if family == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "gear family not found for slot/formId"})
|
|
return
|
|
}
|
|
|
|
item := model.NewGearItem(family, req.Ilvl, rarity)
|
|
if err := h.gearStore.CreateItem(r.Context(), item); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gear item"})
|
|
return
|
|
}
|
|
if err := h.gearStore.AddToInventory(r.Context(), heroID, item.ID); err != nil {
|
|
_ = h.gearStore.DeleteGearItem(r.Context(), item.ID)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "granted, but failed to sync hero snapshot"})
|
|
return
|
|
}
|
|
h.GetHeroGear(w, r)
|
|
}
|
|
|
|
type equipGearRequest struct {
|
|
ItemID int64 `json:"itemId"`
|
|
}
|
|
|
|
// EquipHeroGear equips an inventory item by ID.
|
|
// POST /admin/heroes/{heroId}/gear/equip
|
|
func (h *AdminHandler) EquipHeroGear(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 equipGearRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ItemID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
item, err := h.gearStore.GetItem(r.Context(), req.ItemID)
|
|
if err != nil || item == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "gear item not found"})
|
|
return
|
|
}
|
|
if err := h.gearStore.EquipItem(r.Context(), heroID, item.Slot, item.ID); err != nil {
|
|
if errors.Is(err, storage.ErrInventoryFull) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "inventory full — free a backpack slot to swap this piece",
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to equip item"})
|
|
return
|
|
}
|
|
if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "equipped, but failed to sync hero snapshot"})
|
|
return
|
|
}
|
|
h.GetHeroGear(w, r)
|
|
}
|
|
|
|
type unequipGearRequest struct {
|
|
Slot string `json:"slot"`
|
|
}
|
|
|
|
// UnequipHeroGear unequips a slot.
|
|
// POST /admin/heroes/{heroId}/gear/unequip
|
|
func (h *AdminHandler) UnequipHeroGear(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 unequipGearRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
slot, err := parseEquipmentSlot(req.Slot)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.gearStore.UnequipSlot(r.Context(), heroID, slot); err != nil {
|
|
if errors.Is(err, storage.ErrInventoryFull) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "inventory full — free a backpack slot before unequipping",
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to unequip slot"})
|
|
return
|
|
}
|
|
if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unequipped, but failed to sync hero snapshot"})
|
|
return
|
|
}
|
|
h.GetHeroGear(w, r)
|
|
}
|
|
|
|
// DeleteHeroGear deletes an item row.
|
|
// DELETE /admin/heroes/{heroId}/gear/{itemId}
|
|
func (h *AdminHandler) DeleteHeroGear(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
|
|
}
|
|
itemID, err := parseItemID(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid itemId: " + err.Error()})
|
|
return
|
|
}
|
|
if err := h.gearStore.DeleteGearItem(r.Context(), itemID); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "deleted, but failed to sync hero snapshot"})
|
|
return
|
|
}
|
|
h.GetHeroGear(w, r)
|
|
}
|
|
|
|
// GearCatalog returns available gear families.
|
|
// GET /admin/gear/catalog
|
|
func (h *AdminHandler) GearCatalog(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"catalog": model.GearCatalog})
|
|
}
|
|
|
|
// GetHeroQuests returns all quests assigned to a hero.
|
|
// GET /admin/heroes/{heroId}/quests
|
|
func (h *AdminHandler) GetHeroQuests(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
|
|
}
|
|
quests, err := h.questStore.ListHeroQuests(r.Context(), heroID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero quests"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"quests": quests})
|
|
}
|
|
|
|
// AcceptHeroQuest accepts quest for a hero.
|
|
// POST /admin/heroes/{heroId}/quests/{questId}/accept
|
|
func (h *AdminHandler) AcceptHeroQuest(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
|
|
}
|
|
questID, err := parseQuestID(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()})
|
|
return
|
|
}
|
|
if err := h.questStore.AcceptQuest(r.Context(), heroID, questID); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"})
|
|
}
|
|
|
|
// ClaimHeroQuest claims a completed quest reward for a hero.
|
|
// POST /admin/heroes/{heroId}/quests/{questId}/claim
|
|
func (h *AdminHandler) ClaimHeroQuest(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
|
|
}
|
|
questID, err := parseQuestID(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()})
|
|
return
|
|
}
|
|
reward, err := h.questStore.ClaimQuestReward(r.Context(), heroID, questID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
hero, err := h.store.GetByID(r.Context(), heroID)
|
|
if err != nil || hero == nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
|
|
return
|
|
}
|
|
hero.XP += reward.XP
|
|
hero.Gold += reward.Gold
|
|
hero.Potions += reward.Potions
|
|
for hero.LevelUp() {
|
|
}
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero rewards"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, hero)
|
|
}
|
|
|
|
// AbandonHeroQuest removes quest from hero log.
|
|
// DELETE /admin/heroes/{heroId}/quests/{questId}
|
|
func (h *AdminHandler) AbandonHeroQuest(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
|
|
}
|
|
questID, err := parseQuestID(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()})
|
|
return
|
|
}
|
|
if err := h.questStore.AbandonQuest(r.Context(), heroID, questID); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "abandoned"})
|
|
}
|
|
|
|
// ListTownsForQuests returns all towns for quest management.
|
|
// GET /admin/quests/towns
|
|
func (h *AdminHandler) ListTownsForQuests(w http.ResponseWriter, r *http.Request) {
|
|
towns, err := h.questStore.ListTowns(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list towns"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"towns": towns})
|
|
}
|
|
|
|
// ListTownNPCsForQuests returns NPCs in town.
|
|
// GET /admin/quests/towns/{townId}/npcs
|
|
func (h *AdminHandler) ListTownNPCsForQuests(w http.ResponseWriter, r *http.Request) {
|
|
townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64)
|
|
if err != nil || townID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"})
|
|
return
|
|
}
|
|
npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"npcs": npcs})
|
|
}
|
|
|
|
// ContentAllQuests returns all quest template rows (global content).
|
|
// GET /admin/content/quests
|
|
func (h *AdminHandler) ContentAllQuests(w http.ResponseWriter, r *http.Request) {
|
|
quests, err := h.questStore.ListAllQuestTemplates(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("admin: list all quest templates failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list quests"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"quests": quests})
|
|
}
|
|
|
|
// ContentGearBase returns template rows from the unified `gear` table (global item definitions).
|
|
// GET /admin/content/gear-base
|
|
// Optional query params:
|
|
//
|
|
// query — ILIKE name/form_id/slot/subtype/rarity + exact id match;
|
|
// slot, rarity, subtype — exact filters (AND with query when combined);
|
|
// limit — cap rows (default 200 if only query; 500 if any filter but no limit; omit for full scan when no filters).
|
|
func (h *AdminHandler) ContentGearBase(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
q := strings.TrimSpace(r.URL.Query().Get("query"))
|
|
slotF := strings.TrimSpace(r.URL.Query().Get("slot"))
|
|
rarityF := strings.TrimSpace(r.URL.Query().Get("rarity"))
|
|
subtypeF := strings.TrimSpace(r.URL.Query().Get("subtype"))
|
|
limit := 0
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
var clauses []string
|
|
var args []any
|
|
n := 1
|
|
if slotF != "" {
|
|
clauses = append(clauses, fmt.Sprintf("slot = $%d", n))
|
|
args = append(args, slotF)
|
|
n++
|
|
}
|
|
if rarityF != "" {
|
|
clauses = append(clauses, fmt.Sprintf("LOWER(rarity) = LOWER($%d)", n))
|
|
args = append(args, rarityF)
|
|
n++
|
|
}
|
|
if subtypeF != "" {
|
|
clauses = append(clauses, fmt.Sprintf("subtype = $%d", n))
|
|
args = append(args, subtypeF)
|
|
n++
|
|
}
|
|
if q != "" {
|
|
pat := "%" + q + "%"
|
|
clauses = append(clauses, fmt.Sprintf("(name ILIKE $%d OR form_id ILIKE $%d OR slot ILIKE $%d OR subtype ILIKE $%d OR rarity ILIKE $%d OR CAST(id AS TEXT) = $%d)",
|
|
n, n, n, n, n, n+1))
|
|
args = append(args, pat, q)
|
|
n += 2
|
|
}
|
|
|
|
sqlText := `
|
|
SELECT id, slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
|
|
speed_modifier, crit_chance, agility_bonus, set_name, special_effect
|
|
FROM gear`
|
|
if len(clauses) > 0 {
|
|
sqlText += " WHERE " + strings.Join(clauses, " AND ")
|
|
}
|
|
sqlText += " ORDER BY id ASC"
|
|
|
|
hasWhere := len(clauses) > 0
|
|
if hasWhere && limit == 0 {
|
|
limit = 500
|
|
}
|
|
if q != "" && limit == 0 {
|
|
limit = 200
|
|
}
|
|
if limit > 0 {
|
|
sqlText += ` LIMIT ` + strconv.Itoa(limit)
|
|
}
|
|
|
|
rows, err := h.pool.Query(ctx, sqlText, args...)
|
|
if err != nil {
|
|
h.logger.Error("admin: load gear templates failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load gear templates"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var list []map[string]any
|
|
for rows.Next() {
|
|
var id int64
|
|
var slot, formID, name, subtype, rarity, statType, setName, special string
|
|
var ilvl, basePri, pri, agi int
|
|
var speedMod, crit float64
|
|
if err := rows.Scan(
|
|
&id, &slot, &formID, &name, &subtype, &rarity, &ilvl, &basePri, &pri, &statType,
|
|
&speedMod, &crit, &agi, &setName, &special,
|
|
); err != nil {
|
|
h.logger.Error("admin: scan gear row", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "scan gear"})
|
|
return
|
|
}
|
|
list = append(list, map[string]any{
|
|
"id": id, "slot": slot, "formId": formID, "name": name, "subtype": subtype,
|
|
"rarity": rarity, "ilvl": ilvl, "basePrimary": basePri, "primaryStat": pri, "statType": statType,
|
|
"speedModifier": speedMod, "critChance": crit, "agilityBonus": agi,
|
|
"setName": setName, "specialEffect": special,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gear rows"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"gear": list})
|
|
}
|
|
|
|
// ContentCreateGear inserts a new row into `gear`.
|
|
// POST /admin/content/gear
|
|
func (h *AdminHandler) ContentCreateGear(w http.ResponseWriter, r *http.Request) {
|
|
var item model.GearItem
|
|
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
item.ID = 0
|
|
if err := h.gearStore.CreateItem(r.Context(), &item); err != nil {
|
|
h.logger.Error("admin: create gear", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"id": item.ID, "gear": item})
|
|
}
|
|
|
|
// ContentUpdateGear updates a `gear` row by id.
|
|
// PUT /admin/content/gear/{gearId}
|
|
func (h *AdminHandler) ContentUpdateGear(w http.ResponseWriter, r *http.Request) {
|
|
gearID, err := parseContentGearID(r)
|
|
if err != nil || gearID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid gearId"})
|
|
return
|
|
}
|
|
var item model.GearItem
|
|
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
item.ID = gearID
|
|
if err := h.gearStore.UpdateItem(r.Context(), &item); err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
h.logger.Error("admin: update gear", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"gear": item})
|
|
}
|
|
|
|
func normalizeAdminQuest(q *model.Quest) {
|
|
if q.MinLevel <= 0 {
|
|
q.MinLevel = 1
|
|
}
|
|
if q.MaxLevel <= 0 {
|
|
q.MaxLevel = 100
|
|
}
|
|
if q.TargetCount <= 0 {
|
|
q.TargetCount = 1
|
|
}
|
|
}
|
|
|
|
// ContentCreateQuest inserts a quest template row.
|
|
// POST /admin/content/quests
|
|
func (h *AdminHandler) ContentCreateQuest(w http.ResponseWriter, r *http.Request) {
|
|
var q model.Quest
|
|
if err := json.NewDecoder(r.Body).Decode(&q); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
q.ID = 0
|
|
normalizeAdminQuest(&q)
|
|
if err := h.questStore.CreateQuestTemplate(r.Context(), &q); err != nil {
|
|
h.logger.Error("admin: create quest", "error", err)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"id": q.ID, "quest": q})
|
|
}
|
|
|
|
// ContentUpdateQuest updates a quest template by id.
|
|
// PUT /admin/content/quests/{questId}
|
|
func (h *AdminHandler) ContentUpdateQuest(w http.ResponseWriter, r *http.Request) {
|
|
qid, err := parseQuestID(r)
|
|
if err != nil || qid <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId"})
|
|
return
|
|
}
|
|
var q model.Quest
|
|
if err := json.NewDecoder(r.Body).Decode(&q); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
q.ID = qid
|
|
normalizeAdminQuest(&q)
|
|
if err := h.questStore.UpdateQuestTemplate(r.Context(), &q); err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
h.logger.Error("admin: update quest", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"quest": q})
|
|
}
|
|
|
|
// ListNPCQuestsForAdmin returns quest templates for NPC.
|
|
// GET /admin/quests/npcs/{npcId}
|
|
func (h *AdminHandler) ListNPCQuestsForAdmin(w http.ResponseWriter, r *http.Request) {
|
|
npcID, err := strconv.ParseInt(chi.URLParam(r, "npcId"), 10, 64)
|
|
if err != nil || npcID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npcId"})
|
|
return
|
|
}
|
|
quests, err := h.questStore.ListQuestsByNPC(r.Context(), npcID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npc quests"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"quests": quests})
|
|
}
|
|
|
|
// 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())
|
|
// Prefer live movement hero when online; otherwise return DB hero (GetMovements is nil offline).
|
|
if hm := h.engine.GetMovements(heroID); hm != nil && hm.Hero != nil {
|
|
writeJSON(w, http.StatusOK, hm.Hero)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, 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.EnsureGearMap()
|
|
hero.RefreshDerivedCombatStats(time.Now())
|
|
h.engine.ApplyAdminHeroSnapshot(hero)
|
|
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.EnsureGearMap()
|
|
hero.RefreshDerivedCombatStats(time.Now())
|
|
h.engine.ApplyAdminHeroSnapshot(hero)
|
|
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)
|
|
}
|
|
|
|
// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online.
|
|
// POST /admin/heroes/{heroId}/leave-town
|
|
func (h *AdminHandler) ForceLeaveTown(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 leave-town", "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.StateResting && hero.State != model.StateInTown {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "hero is not resting or in town",
|
|
})
|
|
return
|
|
}
|
|
|
|
if hm := h.engine.GetMovements(heroID); hm != nil {
|
|
out, ok := h.engine.ApplyAdminForceLeaveTown(heroID)
|
|
if !ok || out == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "cannot leave town (movement state changed?)",
|
|
})
|
|
return
|
|
}
|
|
out.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: force leave town", "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.State != model.StateResting && hm.State != model.StateInTown {
|
|
return fmt.Errorf("hero is not resting or in town")
|
|
}
|
|
hm.LeaveTown(rg, now)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
h.logger.Info("admin: force leave town (offline)", "hero_id", heroID)
|
|
writeJSON(w, http.StatusOK, hero2)
|
|
}
|
|
|
|
// StartRoadsideRest forces roadside rest (must be walking on a road). POST /admin/heroes/{heroId}/start-roadside-rest
|
|
func (h *AdminHandler) StartRoadsideRest(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 (hero must be walking with an assigned road)",
|
|
})
|
|
return
|
|
}
|
|
out.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: start roadside 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.AdminStartRoadsideRest(now) {
|
|
return fmt.Errorf("cannot start roadside rest (hero must be walking with an assigned road)")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
hero2.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: start roadside rest (offline)", "hero_id", heroID)
|
|
writeJSON(w, http.StatusOK, hero2)
|
|
}
|
|
|
|
// StopRoadsideRest ends roadside rest if active; if already not resting, returns current hero (200). POST /admin/heroes/{heroId}/stop-rest
|
|
func (h *AdminHandler) StopRoadsideRest(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, heroErr := h.store.GetByID(r.Context(), heroID)
|
|
if heroErr != nil {
|
|
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
|
|
}
|
|
isRoadsideRest := hero.State == model.StateResting &&
|
|
hero.TownPause != nil &&
|
|
(hero.TownPause.RestKind == "roadside" || hero.TownPause.RoadsideRestActive)
|
|
if (hero.State == model.StateResting && !isRoadsideRest) || hero.State == model.StateInTown {
|
|
if hm := h.engine.GetMovements(heroID); hm != nil {
|
|
out, ok := h.engine.ApplyAdminForceLeaveTown(heroID)
|
|
if !ok || out == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "cannot stop town rest (movement state changed?)",
|
|
})
|
|
return
|
|
}
|
|
out.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: stop rest (town, online)", "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.State != model.StateResting && hm.State != model.StateInTown {
|
|
return fmt.Errorf("hero is not resting or in town")
|
|
}
|
|
hm.LeaveTown(rg, now)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
hero2.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: stop rest (town, offline)", "hero_id", heroID)
|
|
writeJSON(w, http.StatusOK, hero2)
|
|
return
|
|
}
|
|
|
|
if hm := h.engine.GetMovements(heroID); hm != nil {
|
|
out, _ := h.engine.ApplyAdminStopRoadsideRest(heroID)
|
|
if out == nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "movement session unavailable",
|
|
})
|
|
return
|
|
}
|
|
out.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: stop roadside 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 {
|
|
_ = rg
|
|
_ = now
|
|
hm.EndRoadsideRest()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
hero2.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: stop roadside rest (offline)", "hero_id", heroID)
|
|
writeJSON(w, http.StatusOK, hero2)
|
|
}
|
|
|
|
// StopHeroRoadsideRest ends only roadside pull-over rest (live movement session).
|
|
// Does not end town / inn rest (use stop-rest or leave-town). POST /admin/heroes/{heroId}/stop-roadside-rest
|
|
func (h *AdminHandler) StopHeroRoadsideRest(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, heroErr := h.store.GetByID(r.Context(), heroID)
|
|
if heroErr != nil {
|
|
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, _ := h.engine.ApplyAdminStopRoadsideRest(heroID)
|
|
if out == nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "movement session unavailable",
|
|
})
|
|
return
|
|
}
|
|
out.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: stop roadside rest only", "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 {
|
|
_ = rg
|
|
_ = now
|
|
hm.EndRoadsideRest()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
hero2.RefreshDerivedCombatStats(time.Now())
|
|
h.logger.Info("admin: stop roadside rest only (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(),
|
|
},
|
|
// In-memory merged runtime tuning (defaults + last reload from DB payload).
|
|
"effective": tuning.Get(),
|
|
})
|
|
}
|
|
|
|
// GetRuntimeConfig returns current DB payload and effective merged config.
|
|
// GET /admin/runtime-config
|
|
func (h *AdminHandler) GetRuntimeConfig(w http.ResponseWriter, r *http.Request) {
|
|
var payload []byte
|
|
if err := h.pool.QueryRow(r.Context(), `SELECT payload FROM runtime_config WHERE id = TRUE`).Scan(&payload); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to load runtime config",
|
|
})
|
|
return
|
|
}
|
|
var raw map[string]any
|
|
if len(payload) > 0 {
|
|
_ = json.Unmarshal(payload, &raw)
|
|
}
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"payload": raw,
|
|
"effective": tuning.Get(),
|
|
})
|
|
}
|
|
|
|
// UpdateRuntimeConfig overwrites runtime_config.payload JSONB.
|
|
// POST /admin/runtime-config
|
|
func (h *AdminHandler) UpdateRuntimeConfig(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "invalid json body",
|
|
})
|
|
return
|
|
}
|
|
if body == nil {
|
|
body = map[string]any{}
|
|
}
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "failed to serialize payload",
|
|
})
|
|
return
|
|
}
|
|
if _, err := h.pool.Exec(r.Context(), `
|
|
UPDATE runtime_config
|
|
SET payload = $1::jsonb, updated_at = now()
|
|
WHERE id = TRUE
|
|
`, payload); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to save runtime config",
|
|
})
|
|
return
|
|
}
|
|
loader := storage.NewRuntimeConfigStore(h.pool)
|
|
if err := tuning.ReloadNow(r.Context(), h.logger, loader); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "config saved but reload failed",
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
})
|
|
}
|
|
|
|
// ReloadRuntimeConfig applies DB runtime_config payload to in-memory config immediately.
|
|
// POST /admin/runtime-config/reload
|
|
func (h *AdminHandler) ReloadRuntimeConfig(w http.ResponseWriter, r *http.Request) {
|
|
loader := storage.NewRuntimeConfigStore(h.pool)
|
|
if err := tuning.ReloadNow(r.Context(), h.logger, loader); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to reload runtime config",
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "reloaded",
|
|
})
|
|
}
|
|
|
|
// GetBuffDebuffConfig returns DB payload and effective buff/debuff catalog.
|
|
// GET /admin/buff-debuff-config
|
|
func (h *AdminHandler) GetBuffDebuffConfig(w http.ResponseWriter, r *http.Request) {
|
|
var payload []byte
|
|
if err := h.pool.QueryRow(r.Context(), `SELECT payload FROM buff_debuff_config WHERE id = TRUE`).Scan(&payload); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to load buff/debuff config",
|
|
})
|
|
return
|
|
}
|
|
var raw map[string]any
|
|
if len(payload) > 0 {
|
|
_ = json.Unmarshal(payload, &raw)
|
|
}
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
effB, effD := model.BuffCatalogEffectiveJSON()
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"payload": raw,
|
|
"effectiveBuffs": effB,
|
|
"effectiveDebuffs": effD,
|
|
})
|
|
}
|
|
|
|
// UpdateBuffDebuffConfig overwrites buff_debuff_config.payload JSONB.
|
|
// POST /admin/buff-debuff-config
|
|
func (h *AdminHandler) UpdateBuffDebuffConfig(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "invalid json body",
|
|
})
|
|
return
|
|
}
|
|
if body == nil {
|
|
body = map[string]any{}
|
|
}
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "failed to serialize payload",
|
|
})
|
|
return
|
|
}
|
|
if _, err := h.pool.Exec(r.Context(), `
|
|
UPDATE buff_debuff_config
|
|
SET payload = $1::jsonb, updated_at = now()
|
|
WHERE id = TRUE
|
|
`, payload); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to save buff/debuff config",
|
|
})
|
|
return
|
|
}
|
|
loader := storage.NewBuffDebuffConfigStore(h.pool)
|
|
if err := model.ReloadBuffDebuffCatalog(r.Context(), h.logger, loader); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "config saved but reload failed",
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
})
|
|
}
|
|
|
|
// ReloadBuffDebuffConfig reloads catalog from DB without writing.
|
|
// POST /admin/buff-debuff-config/reload
|
|
func (h *AdminHandler) ReloadBuffDebuffConfig(w http.ResponseWriter, r *http.Request) {
|
|
loader := storage.NewBuffDebuffConfigStore(h.pool)
|
|
if err := model.ReloadBuffDebuffCatalog(r.Context(), h.logger, loader); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to reload buff/debuff config",
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "reloaded",
|
|
})
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
func parseHeroID(r *http.Request) (int64, error) {
|
|
return strconv.ParseInt(chi.URLParam(r, "heroId"), 10, 64)
|
|
}
|
|
|
|
func parseQuestID(r *http.Request) (int64, error) {
|
|
return strconv.ParseInt(chi.URLParam(r, "questId"), 10, 64)
|
|
}
|
|
|
|
func parsePaymentID(r *http.Request) (int64, error) {
|
|
return strconv.ParseInt(chi.URLParam(r, "paymentId"), 10, 64)
|
|
}
|
|
|
|
func parseItemID(r *http.Request) (int64, error) {
|
|
return strconv.ParseInt(chi.URLParam(r, "itemId"), 10, 64)
|
|
}
|
|
|
|
func parseContentGearID(r *http.Request) (int64, error) {
|
|
return strconv.ParseInt(chi.URLParam(r, "gearId"), 10, 64)
|
|
}
|
|
|
|
func parseEquipmentSlot(raw string) (model.EquipmentSlot, error) {
|
|
val := model.EquipmentSlot(strings.TrimSpace(raw))
|
|
for _, slot := range model.AllEquipmentSlots {
|
|
if val == slot {
|
|
return val, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("invalid slot: %s", raw)
|
|
}
|
|
|
|
func parseRarity(raw string) (model.Rarity, error) {
|
|
v := model.Rarity(strings.TrimSpace(strings.ToLower(raw)))
|
|
switch v {
|
|
case model.RarityCommon, model.RarityUncommon, model.RarityRare, model.RarityEpic, model.RarityLegendary:
|
|
return v, nil
|
|
default:
|
|
return "", fmt.Errorf("invalid rarity: %s", raw)
|
|
}
|
|
}
|
|
|
|
func (h *AdminHandler) syncHeroSnapshot(ctx context.Context, heroID int64) error {
|
|
hero, err := h.store.GetByID(ctx, heroID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hero == nil {
|
|
return fmt.Errorf("hero not found")
|
|
}
|
|
hero.EnsureGearMap()
|
|
hero.RefreshDerivedCombatStats(time.Now())
|
|
h.engine.ApplyAdminHeroSnapshot(hero)
|
|
return nil
|
|
}
|
|
|
|
// 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.FreeBuffActivationsPerPeriodRuntime()
|
|
hero.BuffQuotaPeriodEnd = nil
|
|
}
|