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.

2402 lines
74 KiB
Go

package handler
import (
"context"
"encoding/base64"
"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
adminUser string
adminPass string
}
// 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, adminUser, adminPass string) *AdminHandler {
return &AdminHandler{
store: store,
gearStore: gearStore,
questStore: questStore,
engine: engine,
hub: hub,
pool: pool,
logger: logger,
adminUser: adminUser,
adminPass: adminPass,
}
}
// ── 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"`
}
// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct {
Online bool `json:"online"`
MoveState string `json:"moveState,omitempty"`
RestUntil *time.Time `json:"restUntil,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
CurrentTownID int64 `json:"currentTownId,omitempty"`
DestinationTownID int64 `json:"destinationTownId,omitempty"`
WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
ExcursionPhase string `json:"excursionPhase,omitempty"`
ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"`
ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,omitempty"`
}
// adminHeroDetailResponse is the full admin JSON for one hero: base hero + persisted town_pause + live movement snapshot.
type adminHeroDetailResponse struct {
model.Hero
TownPause *model.TownPausePersisted `json:"townPause,omitempty"`
AdminLiveMovement *adminLiveMovementJSON `json:"adminLiveMovement,omitempty"`
HeroMovement *game.HeroMovement `json:"heroMovement,omitempty"`
}
// adminWSSnapshot is the admin live WebSocket payload: hero detail + last hero_move (client WS) sample.
type adminWSSnapshot struct {
Hero adminHeroDetailResponse `json:"hero"`
HeroMove *model.HeroMovePayload `json:"heroMove"`
}
func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
if hm == nil {
return nil
}
s := &adminLiveMovementJSON{
Online: true,
MoveState: string(hm.State),
}
if !hm.RestUntil.IsZero() {
t := hm.RestUntil
s.RestUntil = &t
}
if !hm.TownLeaveAt.IsZero() {
t := hm.TownLeaveAt
s.TownLeaveAt = &t
}
if !hm.NextTownNPCRollAt.IsZero() {
t := hm.NextTownNPCRollAt
s.NextTownNPCRollAt = &t
}
if hm.CurrentTownID != 0 {
s.CurrentTownID = hm.CurrentTownID
}
if hm.DestinationTownID != 0 {
s.DestinationTownID = hm.DestinationTownID
}
if !hm.WanderingMerchantDeadline.IsZero() {
t := hm.WanderingMerchantDeadline
s.WanderingMerchantDeadline = &t
}
if hm.Excursion.Active() {
s.ExcursionPhase = string(hm.Excursion.Phase)
if !hm.Excursion.WildUntil.IsZero() {
t := hm.Excursion.WildUntil
s.ExcursionWildUntil = &t
}
if !hm.Excursion.ReturnUntil.IsZero() {
t := hm.Excursion.ReturnUntil
s.ExcursionReturnUntil = &t
}
}
return s
}
func (h *AdminHandler) writeAdminHeroDetail(w http.ResponseWriter, hero *model.Hero) {
out, err := h.buildAdminHeroDetail(hero)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, out)
}
func (h *AdminHandler) buildAdminHeroDetail(hero *model.Hero) (adminHeroDetailResponse, error) {
if hero == nil {
return adminHeroDetailResponse{}, fmt.Errorf("nil hero")
}
now := time.Now()
hero.RefreshDerivedCombatStats(now)
out := adminHeroDetailResponse{Hero: *hero, TownPause: hero.TownPause}
if hm := h.engine.GetMovements(hero.ID); hm != nil && hm.Hero != nil {
out.Hero = *hm.Hero
out.Hero.RefreshDerivedCombatStats(now)
out.TownPause = hm.Hero.TownPause
out.AdminLiveMovement = buildAdminLiveMovementSnap(hm)
out.HeroMovement = hm
} else if rg := h.engine.RoadGraph(); rg != nil {
out.HeroMovement = game.NewHeroMovement(hero, rg, now)
}
return out, nil
}
func (h *AdminHandler) buildAdminWSSnapshot(ctx context.Context, heroID int64) (adminWSSnapshot, error) {
hero, err := h.store.GetByID(ctx, heroID)
if err != nil {
return adminWSSnapshot{}, err
}
if hero == nil {
return adminWSSnapshot{}, fmt.Errorf("hero not found")
}
detail, err := h.buildAdminHeroDetail(hero)
if err != nil {
return adminWSSnapshot{}, err
}
now := time.Now()
var move *model.HeroMovePayload
if hm := h.engine.GetMovements(heroID); hm != nil {
p := hm.MovePayload(now)
move = &p
}
return adminWSSnapshot{Hero: detail, HeroMove: move}, nil
}
// 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} — questId is hero_quests.id (log row).
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
}
h.writeAdminHeroDetail(w, 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)
}
type applyBuffAdminRequest struct {
BuffType string `json:"buffType"`
}
// ApplyHeroBuff applies a buff from the catalog without consuming free-charge quota (admin/testing).
// POST /admin/heroes/{heroId}/apply-buff
func (h *AdminHandler) ApplyHeroBuff(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 applyBuffAdminRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body: " + err.Error(),
})
return
}
bt, ok := model.ValidBuffType(req.BuffType)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid buffType: " + req.BuffType,
})
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for apply-buff", "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()
if game.ApplyBuff(hero, bt, now) == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "buff could not be applied (unknown catalog entry)",
})
return
}
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("admin: save hero after apply-buff", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
h.logger.Info("admin: buff applied", "hero_id", heroID, "buff_type", bt)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now)
h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, hero)
}
type applyDebuffAdminRequest struct {
DebuffType string `json:"debuffType"`
}
// ApplyHeroDebuff applies a debuff from the catalog (admin/testing).
// POST /admin/heroes/{heroId}/apply-debuff
func (h *AdminHandler) ApplyHeroDebuff(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 applyDebuffAdminRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body: " + err.Error(),
})
return
}
dt, ok := model.ValidDebuffType(req.DebuffType)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid debuffType: " + req.DebuffType,
})
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for apply-debuff", "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()
if _, defOk := model.DebuffDefinition(dt); !defOk {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "debuff not in catalog: " + req.DebuffType,
})
return
}
game.ApplyDebuff(hero, dt, now)
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("admin: save hero after apply-debuff", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
h.logger.Info("admin: debuff applied", "hero_id", heroID, "debuff_type", dt)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now)
h.engine.ApplyAdminHeroSnapshot(hero)
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})
}
// 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
}
h.logger.Info("admin: start rest", "hero_id", heroID)
h.writeAdminHeroDetail(w, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
if !hm.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)
h.writeAdminHeroDetail(w, 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)
}
// StartHeroRoadsideRest forces a hero into roadside rest at the current road position.
// POST /admin/heroes/{heroId}/start-roadside-rest
func (h *AdminHandler) StartHeroRoadsideRest(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid heroId: " + err.Error(),
})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for start-roadside-rest", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be alive and not in combat"})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStartRoadsideRest(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start roadside rest"})
return
}
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after start-roadside-rest", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.logger.Info("admin: start roadside rest", "hero_id", heroID)
h.writeAdminHeroDetail(w, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
if !hm.AdminStartRoadsideRest(now) {
return fmt.Errorf("cannot start roadside rest")
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.logger.Info("admin: start roadside rest (offline)", "hero_id", heroID)
h.writeAdminHeroDetail(w, hero2)
}
// StopHeroRest exits a hero from non-town rest (roadside or adventure-inline) back to walking.
// POST /admin/heroes/{heroId}/stop-rest
func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid heroId: " + err.Error(),
})
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for stop-rest", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStopRest(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in roadside/adventure rest"})
return
}
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after stop-rest", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.logger.Info("admin: stop rest", "hero_id", heroID)
h.writeAdminHeroDetail(w, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
if !hm.AdminStopRest(now) {
return fmt.Errorf("hero is not in roadside/adventure rest")
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.logger.Info("admin: stop rest (offline)", "hero_id", heroID)
h.writeAdminHeroDetail(w, hero2)
}
// StartHeroExcursion forces a walking hero on a road into a mini-adventure (excursion) session.
// POST /admin/heroes/{heroId}/start-adventure
func (h *AdminHandler) StartHeroExcursion(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid heroId: " + err.Error(),
})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for start-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be alive and not in combat"})
return
}
if hero.State != model.StateWalking {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be walking on the road to start an excursion"})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStartExcursion(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start excursion (need active road segment, or excursion already active)"})
return
}
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after start-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.logger.Info("admin: start excursion", "hero_id", heroID)
h.writeAdminHeroDetail(w, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
if !hm.AdminStartExcursion(now) {
return fmt.Errorf("cannot start excursion (need active road segment, or excursion already active)")
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.logger.Info("admin: start excursion (offline)", "hero_id", heroID)
h.writeAdminHeroDetail(w, hero2)
}
// StopHeroExcursion ends the hero's mini-adventure session immediately.
// POST /admin/heroes/{heroId}/stop-adventure
func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid heroId: " + err.Error(),
})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for stop-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStopExcursion(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero has no active excursion"})
return
}
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after stop-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.logger.Info("admin: stop excursion", "hero_id", heroID)
h.writeAdminHeroDetail(w, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
if !hm.AdminStopExcursion(now) {
return fmt.Errorf("hero has no active excursion")
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.logger.Info("admin: stop excursion (offline)", "hero_id", heroID)
h.writeAdminHeroDetail(w, hero2)
}
// PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls.
// POST /admin/time/pause
func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) {
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(),
})
}
// AdminHeroSnapshotWS streams hero detail + movement snapshot for admin UI.
// GET /admin-ws/hero/{heroId}?auth=BASE64(user:pass)
func (h *AdminHandler) AdminHeroSnapshotWS(w http.ResponseWriter, r *http.Request) {
if !h.adminWSAuthorized(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="Admin", charset="UTF-8"`)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId"})
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
h.logger.Error("admin ws upgrade failed", "error", err)
return
}
defer conn.Close()
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
sendSnapshot := func() error {
snap, err := h.buildAdminWSSnapshot(r.Context(), heroID)
if err != nil {
return err
}
conn.SetWriteDeadline(time.Now().Add(writeWait))
return conn.WriteJSON(snap)
}
if err := sendSnapshot(); err != nil {
_ = conn.WriteJSON(map[string]string{"error": err.Error()})
return
}
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-r.Context().Done():
return
case <-ticker.C:
// Align admin live stream with engine: no periodic snapshots while global time is paused.
if h.engine != nil && h.engine.IsTimePaused() {
continue
}
if err := sendSnapshot(); err != nil {
_ = conn.WriteJSON(map[string]string{"error": err.Error()})
return
}
}
}
}
// ── 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 (h *AdminHandler) adminWSAuthorized(r *http.Request) bool {
if user, pass, ok := r.BasicAuth(); ok {
if basicAuthCredentialsMatch(user, pass, h.adminUser, h.adminPass) {
return true
}
}
q := r.URL.Query()
if raw := strings.TrimSpace(q.Get("auth")); raw != "" {
if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil {
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) == 2 && basicAuthCredentialsMatch(parts[0], parts[1], h.adminUser, h.adminPass) {
return true
}
}
}
user := q.Get("user")
pass := q.Get("pass")
return basicAuthCredentialsMatch(user, pass, h.adminUser, h.adminPass)
}
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
now := time.Now()
hero.ResetBuffCharges(nil, now)
}