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.
3502 lines
111 KiB
Go
3502 lines
111 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
"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"
|
|
"github.com/denisovdennis/autohero/internal/version"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
|
|
// adminTownTourLiveJSON is a snapshot of ExcursionKindTown for the admin UI.
|
|
type adminTownTourLiveJSON struct {
|
|
Phase string `json:"phase,omitempty"`
|
|
NpcID int64 `json:"npcId,omitempty"`
|
|
TownTourEndsAt *time.Time `json:"townTourEndsAt,omitempty"`
|
|
WelcomeUntil *time.Time `json:"townWelcomeUntil,omitempty"`
|
|
ServiceUntil *time.Time `json:"townServiceUntil,omitempty"`
|
|
RestUntil *time.Time `json:"townRestUntil,omitempty"`
|
|
WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
|
|
ExitPending bool `json:"townExitPending,omitempty"`
|
|
DialogOpen bool `json:"townTourDialogOpen,omitempty"`
|
|
InteractionOpen bool `json:"townTourInteractionOpen,omitempty"`
|
|
StandX float64 `json:"townTourStandX,omitempty"`
|
|
StandY float64 `json:"townTourStandY,omitempty"`
|
|
}
|
|
|
|
// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
|
|
type adminLiveMovementJSON struct {
|
|
Online bool `json:"online"`
|
|
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"`
|
|
ExcursionKind string `json:"excursionKind,omitempty"`
|
|
ExcursionPhase string `json:"excursionPhase,omitempty"`
|
|
ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"`
|
|
ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,omitempty"`
|
|
TownTour *adminTownTourLiveJSON `json:"townTour,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"`
|
|
}
|
|
|
|
// adminCombatLiveJSON is the active engine combat session for admin live WS (enemy is full runtime instance + tuning breakdown).
|
|
type adminCombatLiveJSON struct {
|
|
Enemy model.Enemy `json:"enemy"`
|
|
// EnemyStatsBasePreEncounterMult: level-scaled MaxHP/Attack/Defense before encounter multipliers.
|
|
EnemyStatsBasePreEncounterMult *model.EncounterCombatStatsSnapshot `json:"enemyStatsBasePreEncounterMult,omitempty"`
|
|
// EnemyStatsAfterGlobalEncounterMult: same after global encounter mult only (before unequipped scaling).
|
|
EnemyStatsAfterGlobalEncounterMult *model.EncounterCombatStatsSnapshot `json:"enemyStatsAfterGlobalEncounterMult,omitempty"`
|
|
Multipliers game.EnemyEncounterMultiplierBreakdown `json:"multipliers"`
|
|
HeroNextAttack time.Time `json:"heroNextAttack"`
|
|
EnemyNextAttack time.Time `json:"enemyNextAttack"`
|
|
StartedAt time.Time `json:"startedAt"`
|
|
}
|
|
|
|
// 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"`
|
|
Combat *adminCombatLiveJSON `json:"combat,omitempty"`
|
|
}
|
|
|
|
type simulateCombatRequest struct {
|
|
HeroID int64 `json:"heroId"`
|
|
EnemyType string `json:"enemyType"`
|
|
EnemyLevel int `json:"enemyLevel,omitempty"`
|
|
TickRateMs int64 `json:"tickRateMs,omitempty"`
|
|
WallClockDelayMs int64 `json:"wallClockDelayMs,omitempty"`
|
|
MaxEvents int `json:"maxEvents,omitempty"`
|
|
}
|
|
|
|
type simulateCombatResponse struct {
|
|
HeroID int64 `json:"heroId"`
|
|
HeroName string `json:"heroName"`
|
|
EnemyType string `json:"enemyType"`
|
|
EnemyName string `json:"enemyName"`
|
|
EnemyLevel int `json:"enemyLevel"`
|
|
Survived bool `json:"survived"`
|
|
ElapsedMs int64 `json:"elapsedMs"`
|
|
InitialHeroHp int `json:"initialHeroHp"`
|
|
InitialHeroMaxHp int `json:"initialHeroMaxHp"`
|
|
InitialEnemyHp int `json:"initialEnemyHp"`
|
|
InitialEnemyMaxHp int `json:"initialEnemyMaxHp"`
|
|
FinalHeroHP int `json:"finalHeroHp"`
|
|
FinalEnemyHP int `json:"finalEnemyHp"`
|
|
WallClockDelayMs int64 `json:"wallClockDelayMs"`
|
|
TickRateMs int64 `json:"tickRateMs"`
|
|
Events []model.CombatEvent `json:"events"`
|
|
}
|
|
|
|
func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
|
|
if hm == nil {
|
|
return nil
|
|
}
|
|
s := &adminLiveMovementJSON{
|
|
Online: true,
|
|
}
|
|
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.ExcursionKind = string(hm.Excursion.Kind)
|
|
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
|
|
}
|
|
}
|
|
if hm.Excursion.Kind == model.ExcursionKindTown {
|
|
ex := hm.Excursion
|
|
tt := &adminTownTourLiveJSON{
|
|
Phase: ex.TownTourPhase,
|
|
NpcID: ex.TownTourNpcID,
|
|
ExitPending: ex.TownExitPending,
|
|
DialogOpen: ex.TownTourDialogOpen,
|
|
InteractionOpen: ex.TownTourInteractionOpen,
|
|
StandX: ex.TownTourStandX,
|
|
StandY: ex.TownTourStandY,
|
|
}
|
|
if !ex.TownTourEndsAt.IsZero() {
|
|
t := ex.TownTourEndsAt
|
|
tt.TownTourEndsAt = &t
|
|
}
|
|
if !ex.TownWelcomeUntil.IsZero() {
|
|
t := ex.TownWelcomeUntil
|
|
tt.WelcomeUntil = &t
|
|
}
|
|
if !ex.TownServiceUntil.IsZero() {
|
|
t := ex.TownServiceUntil
|
|
tt.ServiceUntil = &t
|
|
}
|
|
if !ex.TownRestUntil.IsZero() {
|
|
t := ex.TownRestUntil
|
|
tt.RestUntil = &t
|
|
}
|
|
if !ex.WanderNextAt.IsZero() {
|
|
t := ex.WanderNextAt
|
|
tt.WanderNextAt = &t
|
|
}
|
|
s.TownTour = tt
|
|
}
|
|
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
|
|
}
|
|
var combat *adminCombatLiveJSON
|
|
if h.engine != nil {
|
|
if cs, ok := h.engine.GetCombat(heroID); ok {
|
|
multHero := cs.Hero
|
|
if multHero == nil {
|
|
multHero = &detail.Hero
|
|
}
|
|
combat = &adminCombatLiveJSON{
|
|
Enemy: cs.Enemy,
|
|
EnemyStatsBasePreEncounterMult: cs.EnemyStatsBasePreEncounterMult,
|
|
EnemyStatsAfterGlobalEncounterMult: cs.EnemyStatsAfterGlobalEncounterMult,
|
|
Multipliers: game.EnemyEncounterMultiplierBreakdownForHero(multHero),
|
|
HeroNextAttack: cs.HeroNextAttack,
|
|
EnemyNextAttack: cs.EnemyNextAttack,
|
|
StartedAt: cs.StartedAt,
|
|
}
|
|
}
|
|
}
|
|
return adminWSSnapshot{Hero: detail, HeroMove: move, Combat: combat}, 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
|
|
}
|
|
writeHeroJSON(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})
|
|
}
|
|
|
|
type adminTownLayoutResponse struct {
|
|
Town *model.Town `json:"town"`
|
|
NPCs []model.NPC `json:"npcs"`
|
|
Buildings []model.TownBuilding `json:"buildings"`
|
|
Objects []model.TownObject `json:"objects"`
|
|
}
|
|
|
|
type adminTownLayoutNPCUpdate struct {
|
|
ID *int64 `json:"id,omitempty"`
|
|
Name string `json:"name"`
|
|
NameKey string `json:"nameKey"`
|
|
Type string `json:"type"`
|
|
OffsetX float64 `json:"offsetX"`
|
|
OffsetY float64 `json:"offsetY"`
|
|
BuildingID *int64 `json:"buildingId,omitempty"`
|
|
}
|
|
|
|
type adminTownLayoutBuildingUpdate struct {
|
|
ID *int64 `json:"id,omitempty"`
|
|
BuildingType string `json:"buildingType"`
|
|
OffsetX float64 `json:"offsetX"`
|
|
OffsetY float64 `json:"offsetY"`
|
|
Facing string `json:"facing"`
|
|
FootprintW float64 `json:"footprintW"`
|
|
FootprintH float64 `json:"footprintH"`
|
|
}
|
|
|
|
type adminTownLayoutObjectUpdate struct {
|
|
ID *int64 `json:"id,omitempty"`
|
|
ObjectType string `json:"objectType"`
|
|
Variant int `json:"variant"`
|
|
OffsetX float64 `json:"offsetX"`
|
|
OffsetY float64 `json:"offsetY"`
|
|
}
|
|
|
|
type adminTownLayoutRequest struct {
|
|
NPCs []adminTownLayoutNPCUpdate `json:"npcs"`
|
|
Buildings []adminTownLayoutBuildingUpdate `json:"buildings"`
|
|
Objects []adminTownLayoutObjectUpdate `json:"objects"`
|
|
DeleteNPCIDs []int64 `json:"deleteNpcIds"`
|
|
DeleteBuildingIDs []int64 `json:"deleteBuildingIds"`
|
|
DeleteObjectIDs []int64 `json:"deleteObjectIds"`
|
|
}
|
|
|
|
var adminTownNPCTypes = map[string]struct{}{
|
|
"quest_giver": {},
|
|
"merchant": {},
|
|
"armorer": {},
|
|
"weapon": {},
|
|
"jeweler": {},
|
|
"bounty_hunter": {},
|
|
"elder": {},
|
|
"healer": {},
|
|
}
|
|
|
|
var adminTownBuildingTypes = map[string]struct{}{
|
|
"house.quest_giver": {},
|
|
"house.merchant": {},
|
|
"house.armorer": {},
|
|
"house.weapon_smith": {},
|
|
"house.jeweler": {},
|
|
"house.bounty_hunter": {},
|
|
"house.elder": {},
|
|
"house.healer": {},
|
|
"decoration.well": {},
|
|
"decoration.stall": {},
|
|
"decoration.signpost": {},
|
|
}
|
|
|
|
var adminTownObjectTypes = map[string]struct{}{
|
|
"tree": {},
|
|
"rock": {},
|
|
"cart": {},
|
|
"barrel": {},
|
|
"bush": {},
|
|
"mushroom": {},
|
|
"leaves": {},
|
|
"stump": {},
|
|
"bones": {},
|
|
"ruin": {},
|
|
}
|
|
|
|
var adminTownBuildingFacings = map[string]struct{}{
|
|
"north": {},
|
|
"south": {},
|
|
"east": {},
|
|
"west": {},
|
|
}
|
|
|
|
func (h *AdminHandler) GetTownLayout(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
|
|
}
|
|
|
|
town, err := h.questStore.GetTown(r.Context(), townID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
|
|
return
|
|
}
|
|
if town == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
|
|
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
|
|
}
|
|
|
|
buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list buildings"})
|
|
return
|
|
}
|
|
objects, err := h.questStore.ListTownObjectsByTown(r.Context(), townID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list objects"})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, adminTownLayoutResponse{
|
|
Town: town,
|
|
NPCs: npcs,
|
|
Buildings: buildings,
|
|
Objects: objects,
|
|
})
|
|
}
|
|
|
|
func (h *AdminHandler) UpdateTownLayout(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
|
|
}
|
|
|
|
town, err := h.questStore.GetTown(r.Context(), townID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
|
|
return
|
|
}
|
|
if town == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
|
|
return
|
|
}
|
|
|
|
var req adminTownLayoutRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
|
|
return
|
|
}
|
|
|
|
npcUpdates := make([]storage.TownLayoutNPCUpdate, 0, len(req.NPCs))
|
|
for _, n := range req.NPCs {
|
|
if n.ID != nil && *n.ID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc id"})
|
|
return
|
|
}
|
|
if _, ok := adminTownNPCTypes[n.Type]; !ok {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc type"})
|
|
return
|
|
}
|
|
if math.IsNaN(n.OffsetX) || math.IsNaN(n.OffsetY) || math.IsInf(n.OffsetX, 0) || math.IsInf(n.OffsetY, 0) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc offsets"})
|
|
return
|
|
}
|
|
if n.ID == nil && strings.TrimSpace(n.Name) == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "npc name is required"})
|
|
return
|
|
}
|
|
npcUpdates = append(npcUpdates, storage.TownLayoutNPCUpdate{
|
|
ID: n.ID,
|
|
Name: n.Name,
|
|
NameKey: n.NameKey,
|
|
Type: n.Type,
|
|
OffsetX: n.OffsetX,
|
|
OffsetY: n.OffsetY,
|
|
BuildingID: n.BuildingID,
|
|
})
|
|
}
|
|
|
|
buildingUpdates := make([]storage.TownLayoutBuildingUpsert, 0, len(req.Buildings))
|
|
for _, b := range req.Buildings {
|
|
if b.ID != nil && *b.ID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building id"})
|
|
return
|
|
}
|
|
if _, ok := adminTownBuildingTypes[b.BuildingType]; !ok {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid buildingType"})
|
|
return
|
|
}
|
|
if _, ok := adminTownBuildingFacings[b.Facing]; !ok {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building facing"})
|
|
return
|
|
}
|
|
if math.IsNaN(b.OffsetX) || math.IsNaN(b.OffsetY) || math.IsInf(b.OffsetX, 0) || math.IsInf(b.OffsetY, 0) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building offsets"})
|
|
return
|
|
}
|
|
if b.FootprintW <= 0 || b.FootprintH <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building footprint"})
|
|
return
|
|
}
|
|
buildingUpdates = append(buildingUpdates, storage.TownLayoutBuildingUpsert{
|
|
ID: b.ID,
|
|
BuildingType: b.BuildingType,
|
|
OffsetX: b.OffsetX,
|
|
OffsetY: b.OffsetY,
|
|
Facing: b.Facing,
|
|
FootprintW: b.FootprintW,
|
|
FootprintH: b.FootprintH,
|
|
})
|
|
}
|
|
|
|
objectUpdates := make([]storage.TownLayoutObjectUpsert, 0, len(req.Objects))
|
|
for _, o := range req.Objects {
|
|
if o.ID != nil && *o.ID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object id"})
|
|
return
|
|
}
|
|
if _, ok := adminTownObjectTypes[o.ObjectType]; !ok {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object type"})
|
|
return
|
|
}
|
|
if o.Variant < 0 || o.Variant > 1 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object variant"})
|
|
return
|
|
}
|
|
if math.IsNaN(o.OffsetX) || math.IsNaN(o.OffsetY) || math.IsInf(o.OffsetX, 0) || math.IsInf(o.OffsetY, 0) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object offsets"})
|
|
return
|
|
}
|
|
objectUpdates = append(objectUpdates, storage.TownLayoutObjectUpsert{
|
|
ID: o.ID,
|
|
ObjectType: o.ObjectType,
|
|
Variant: o.Variant,
|
|
OffsetX: o.OffsetX,
|
|
OffsetY: o.OffsetY,
|
|
})
|
|
}
|
|
|
|
if err := h.questStore.UpdateTownLayout(
|
|
r.Context(),
|
|
townID,
|
|
npcUpdates,
|
|
buildingUpdates,
|
|
objectUpdates,
|
|
req.DeleteNPCIDs,
|
|
req.DeleteBuildingIDs,
|
|
req.DeleteObjectIDs,
|
|
); err != nil {
|
|
if errors.Is(err, storage.ErrTownLayoutMissing) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "npc, building, or object not found in town"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update town layout"})
|
|
return
|
|
}
|
|
|
|
rg, err := game.LoadRoadGraph(r.Context(), h.pool)
|
|
if err != nil {
|
|
h.logger.Error("admin: reload road graph after town layout update", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to reload road graph"})
|
|
return
|
|
}
|
|
h.engine.SetRoadGraph(rg)
|
|
|
|
npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
|
|
return
|
|
}
|
|
buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list buildings"})
|
|
return
|
|
}
|
|
objects, err := h.questStore.ListTownObjectsByTown(r.Context(), townID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list objects"})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, adminTownLayoutResponse{
|
|
Town: town,
|
|
NPCs: npcs,
|
|
Buildings: buildings,
|
|
Objects: objects,
|
|
})
|
|
}
|
|
|
|
// 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 target level by resetting to level 1 (base stats, buffs cleared)
|
|
// and applying LevelUp() in a loop with XP filled to the threshold each step, matching normal
|
|
// progression (gold is preserved).
|
|
// 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)
|
|
writeHeroJSON(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)
|
|
writeHeroJSON(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)
|
|
writeHeroJSON(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)
|
|
writeHeroJSON(w, http.StatusOK, hero)
|
|
}
|
|
|
|
// ReviveHero applies the same revive rules as the in-game revive button (partial HP, quota counters).
|
|
// 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
|
|
}
|
|
|
|
// Admin UI displays live in-engine state when the hero is online.
|
|
// Use that same authoritative snapshot for revive validation to avoid
|
|
// false "hero is not dead" when DB lagged behind live movement/combat.
|
|
if h.engine != nil {
|
|
if hm := h.engine.GetMovements(heroID); hm != nil && hm.Hero != nil {
|
|
live := *hm.Hero
|
|
hero = &live
|
|
}
|
|
}
|
|
|
|
if !game.IsEffectivelyDead(hero) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "hero is not dead",
|
|
})
|
|
return
|
|
}
|
|
|
|
game.ApplyHeroReviveMechanical(hero)
|
|
game.ApplyPlayerReviveProgressCounters(hero)
|
|
|
|
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())
|
|
writeHeroJSON(w, http.StatusOK, hero)
|
|
}
|
|
|
|
type grantSubscriptionRequest struct {
|
|
// Periods is how many subscription durations to add (stacking extends from current expiry). Default 1.
|
|
Periods int `json:"periods"`
|
|
}
|
|
|
|
// GrantHeroSubscription activates or extends subscription like a purchase, without charging RUB (admin grant).
|
|
// POST /admin/heroes/{heroId}/grant-subscription
|
|
func (h *AdminHandler) GrantHeroSubscription(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 grantSubscriptionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "invalid request body: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
periods := req.Periods
|
|
if periods < 1 {
|
|
periods = 1
|
|
}
|
|
if periods > 52 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "periods must be between 1 and 52",
|
|
})
|
|
return
|
|
}
|
|
|
|
hero, err := h.store.GetByID(r.Context(), heroID)
|
|
if err != nil {
|
|
h.logger.Error("admin: get hero for grant-subscription", "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()
|
|
for i := 0; i < periods; i++ {
|
|
hero.ActivateSubscription(now)
|
|
}
|
|
|
|
hero.EnsureBuffChargesPopulated(now)
|
|
for bt := range model.BuffFreeChargesPerType {
|
|
state := hero.GetBuffCharges(bt, now)
|
|
subMax := hero.MaxBuffCharges(bt)
|
|
if state.Remaining < subMax {
|
|
state.Remaining = subMax
|
|
hero.BuffCharges[string(bt)] = state
|
|
}
|
|
}
|
|
|
|
payment := &model.Payment{
|
|
HeroID: hero.ID,
|
|
Type: model.PaymentType("subscription_admin"),
|
|
AmountRUB: 0,
|
|
Status: model.PaymentCompleted,
|
|
CreatedAt: now,
|
|
CompletedAt: &now,
|
|
}
|
|
if err := h.store.CreatePayment(r.Context(), payment); err != nil {
|
|
h.logger.Error("admin: grant-subscription payment row", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to record payment",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
h.logger.Error("admin: save hero after grant-subscription", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to save hero",
|
|
})
|
|
return
|
|
}
|
|
|
|
h.logger.Info("admin: subscription granted",
|
|
"hero_id", heroID,
|
|
"periods", periods,
|
|
"expires_at", hero.SubscriptionExpiresAt,
|
|
)
|
|
hero.EnsureGearMap()
|
|
hero.RefreshDerivedCombatStats(now)
|
|
h.engine.ApplyAdminHeroSnapshot(hero)
|
|
writeHeroJSON(w, http.StatusOK, hero)
|
|
}
|
|
|
|
// RevokeHeroSubscription removes subscription immediately (admin); clamps buff charges and revives to free tier.
|
|
// POST /admin/heroes/{heroId}/revoke-subscription
|
|
func (h *AdminHandler) RevokeHeroSubscription(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 revoke-subscription", "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.RevokeSubscription(now)
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
h.logger.Error("admin: save hero after revoke-subscription", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to save hero",
|
|
})
|
|
return
|
|
}
|
|
|
|
h.logger.Info("admin: subscription revoked", "hero_id", heroID)
|
|
hero.EnsureGearMap()
|
|
hero.RefreshDerivedCombatStats(now)
|
|
h.engine.ApplyAdminHeroSnapshot(hero)
|
|
writeHeroJSON(w, http.StatusOK, hero)
|
|
}
|
|
|
|
// ForceHeroDeath sets the hero to dead (HP 0, state dead), ends active combat, clears buffs/debuffs,
|
|
// and increments death stats when transitioning from alive.
|
|
// POST /admin/heroes/{heroId}/force-death
|
|
func (h *AdminHandler) ForceHeroDeath(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
|
|
}
|
|
|
|
h.engine.StopCombat(heroID)
|
|
|
|
hero, err := h.store.GetByID(r.Context(), heroID)
|
|
if err != nil {
|
|
h.logger.Error("admin: get hero for force-death", "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
|
|
}
|
|
|
|
wasAlive := hero.State != model.StateDead && hero.HP > 0
|
|
hero.HP = 0
|
|
hero.State = model.StateDead
|
|
hero.Buffs = nil
|
|
hero.Debuffs = nil
|
|
if wasAlive {
|
|
hero.TotalDeaths++
|
|
hero.KillsSinceDeath = 0
|
|
}
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
h.logger.Error("admin: save hero after force-death", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to save hero",
|
|
})
|
|
return
|
|
}
|
|
|
|
h.engine.ApplyAdminHeroDeath(hero, wasAlive)
|
|
|
|
h.logger.Info("admin: hero force-death", "hero_id", heroID, "was_alive", wasAlive)
|
|
hero.EnsureGearMap()
|
|
hero.RefreshDerivedCombatStats(time.Now())
|
|
writeHeroJSON(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())
|
|
writeHeroJSON(w, http.StatusOK, hero)
|
|
}
|
|
|
|
// FullResetHero clears all gear and quests, equips the same random starter set as CreateHeroWithSpawn,
|
|
// and resets stats/progression to a newly created hero (100 gold, level 1, random town spawn).
|
|
// POST /admin/heroes/{heroId}/full-reset
|
|
func (h *AdminHandler) FullResetHero(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
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
if err := h.gearStore.WipeAllGearForHero(ctx, heroID); err != nil {
|
|
h.logger.Error("admin: full-reset wipe gear", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to clear gear",
|
|
})
|
|
return
|
|
}
|
|
if err := h.store.ApplyRandomStarterGear(ctx, heroID); err != nil {
|
|
h.logger.Error("admin: full-reset starter gear", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to create starter gear",
|
|
})
|
|
return
|
|
}
|
|
if err := h.questStore.DeleteAllHeroQuests(ctx, heroID); err != nil {
|
|
h.logger.Error("admin: full-reset quests", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to clear quests",
|
|
})
|
|
return
|
|
}
|
|
|
|
hero, err := h.store.GetByID(ctx, heroID)
|
|
if err != nil {
|
|
h.logger.Error("admin: full-reset reload hero", "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
|
|
}
|
|
|
|
applyNewPlayerHeroDefaults(hero)
|
|
if err := h.store.ApplyRandomSpawn(ctx, hero); err != nil {
|
|
h.logger.Error("admin: full-reset spawn", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to assign spawn",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := h.store.Save(ctx, hero); err != nil {
|
|
h.logger.Error("admin: save hero after full-reset", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to save hero",
|
|
})
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
h.logger.Info("admin: hero full reset", "hero_id", heroID)
|
|
hero.EnsureGearMap()
|
|
hero.RefreshDerivedCombatStats(now)
|
|
h.engine.ApplyAdminHeroSnapshot(hero)
|
|
writeHeroJSON(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)
|
|
writeHeroJSON(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)
|
|
writeHeroJSON(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)
|
|
writeHeroJSON(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)
|
|
}
|
|
|
|
// TownTourApproachNPC forces npc_approach toward a specific NPC during ExcursionKindTown (online heroes only).
|
|
// POST /admin/heroes/{heroId}/town-tour-approach-npc body: {"npcId":123}
|
|
func (h *AdminHandler) TownTourApproachNPC(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 struct {
|
|
NpcID int64 `json:"npcId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NpcID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: need {\"npcId\": positive number}"})
|
|
return
|
|
}
|
|
if h.engine.GetMovements(heroID) == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be online (active WS movement session)"})
|
|
return
|
|
}
|
|
out, err := h.engine.ApplyAdminTownTourApproachNPC(heroID, req.NpcID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
out.RefreshDerivedCombatStats(time.Now())
|
|
if err := h.store.Save(r.Context(), out); err != nil {
|
|
h.logger.Error("admin: save after town-tour-approach-npc", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
|
|
return
|
|
}
|
|
h.logger.Info("admin: town tour approach npc", "hero_id", heroID, "npc_id", req.NpcID)
|
|
heroAfter, err := h.store.GetByID(r.Context(), heroID)
|
|
if err != nil || heroAfter == nil {
|
|
h.writeAdminHeroDetail(w, out)
|
|
return
|
|
}
|
|
h.writeAdminHeroDetail(w, heroAfter)
|
|
}
|
|
|
|
// StartHeroMeet forces a paired hero meet: primary stays anchored, other teleports beside them.
|
|
// POST /admin/heroes/{heroId}/start-hero-meet body: {"otherHeroId":123}
|
|
func (h *AdminHandler) StartHeroMeet(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 struct {
|
|
OtherHeroID int64 `json:"otherHeroId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.OtherHeroID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: need {\"otherHeroId\": positive number}"})
|
|
return
|
|
}
|
|
if h.engine.GetMovements(heroID) == nil || h.engine.GetMovements(req.OtherHeroID) == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "both heroes must be online (engine movement session)"})
|
|
return
|
|
}
|
|
out, ok, meetReason := h.engine.ApplyAdminStartHeroMeet(heroID, req.OtherHeroID)
|
|
if !ok || out == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "cannot start hero meet",
|
|
"detail": meetReason,
|
|
})
|
|
return
|
|
}
|
|
out.RefreshDerivedCombatStats(time.Now())
|
|
if err := h.store.Save(r.Context(), out); err != nil {
|
|
h.logger.Error("admin: save after start-hero-meet", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
|
|
return
|
|
}
|
|
if other, err := h.store.GetByID(r.Context(), req.OtherHeroID); err == nil && other != nil {
|
|
_ = h.store.Save(r.Context(), other)
|
|
}
|
|
h.logger.Info("admin: start hero meet", "hero_id", heroID, "other_hero_id", req.OtherHeroID)
|
|
heroAfter, err := h.store.GetByID(r.Context(), heroID)
|
|
if err != nil || heroAfter == nil {
|
|
h.writeAdminHeroDetail(w, out)
|
|
return
|
|
}
|
|
h.writeAdminHeroDetail(w, heroAfter)
|
|
}
|
|
|
|
// ForceLeaveTown is an alias for the unified stop-rest flow (see StopHeroRest).
|
|
// POST /admin/heroes/{heroId}/leave-town
|
|
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {
|
|
h.stopHeroRestOrLeaveTown(w, r, "leave-town")
|
|
}
|
|
|
|
// 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 ends any rest or in-town pause the engine recognizes (roadside, adventure-inline, town rest, town tour).
|
|
// POST /admin/heroes/{heroId}/stop-rest
|
|
func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
|
|
h.stopHeroRestOrLeaveTown(w, r, "stop-rest")
|
|
}
|
|
|
|
// stopHeroRestOrLeaveTown implements unified “stop resting / leave town” for admin (one semantic; two routes for compatibility).
|
|
func (h *AdminHandler) stopHeroRestOrLeaveTown(w http.ResponseWriter, r *http.Request, logLabel string) {
|
|
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 "+logLabel, "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.engine != nil {
|
|
if hm := h.engine.GetMovements(heroID); hm != nil {
|
|
out, ok := h.engine.ApplyAdminStopAnyRest(heroID)
|
|
if !ok || out == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "hero is not in a rest or town state that can be stopped",
|
|
})
|
|
return
|
|
}
|
|
out.RefreshDerivedCombatStats(time.Now())
|
|
if err := h.store.Save(r.Context(), out); err != nil {
|
|
h.logger.Error("admin: save after "+logLabel, "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
|
|
return
|
|
}
|
|
h.logger.Info("admin: "+logLabel+" (online)", "hero_id", heroID)
|
|
if logLabel == "leave-town" {
|
|
writeJSON(w, http.StatusOK, out)
|
|
return
|
|
}
|
|
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 nil
|
|
}
|
|
if hm.State == model.StateResting || hm.State == model.StateInTown {
|
|
hm.LeaveTown(rg, now)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("hero is not in a rest or town state that can be stopped")
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
h.logger.Info("admin: "+logLabel+" (offline)", "hero_id", heroID)
|
|
if logLabel == "leave-town" {
|
|
writeJSON(w, http.StatusOK, hero2)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
// TriggerRandomEncounter starts server combat with a random enemy for the hero's level (same pool as road encounters).
|
|
// Requires an active engine movement session (hero connected via WebSocket). POST /admin/heroes/{heroId}/trigger-random-encounter
|
|
func (h *AdminHandler) TriggerRandomEncounter(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.engine == nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "engine not available"})
|
|
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 random encounter", "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.StateDead || hero.HP <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is dead"})
|
|
return
|
|
}
|
|
|
|
h.engine.ApplyAdminHeroSnapshot(hero)
|
|
|
|
hm := h.engine.GetMovements(heroID)
|
|
if hm == nil || hm.Hero == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "hero has no active engine session — connect the game client (WebSocket) so movement is registered",
|
|
})
|
|
return
|
|
}
|
|
if hm.State == model.StateResting || hm.State == model.StateInTown {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start combat while resting or in town"})
|
|
return
|
|
}
|
|
wx, wy, okPos := h.engine.HeroWorldPositionForCombat(heroID)
|
|
if !okPos {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero has no active engine session — connect the game client (WebSocket) so movement is registered"})
|
|
return
|
|
}
|
|
if rg := h.engine.RoadGraph(); rg != nil && rg.HeroInTownAt(wx, wy) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start combat inside a town radius"})
|
|
return
|
|
}
|
|
|
|
enemy := game.PickEnemyForHero(hm.Hero)
|
|
if enemy.Slug == "" || enemy.MaxHP <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no enemy template available for this hero level"})
|
|
return
|
|
}
|
|
|
|
h.engine.StartCombat(hm.Hero, &enemy)
|
|
|
|
if err := h.store.Save(r.Context(), hm.Hero); err != nil {
|
|
h.logger.Error("admin: save hero after random encounter", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
|
|
return
|
|
}
|
|
|
|
h.logger.Info("admin: random encounter started", "hero_id", heroID, "enemy", enemy.Name, "enemy_level", enemy.Level)
|
|
h.writeAdminHeroDetail(w, hm.Hero)
|
|
}
|
|
|
|
// KillCurrentEnemy applies a lethal hero hit and completes combat like a normal victory (rewards, combat_end, persist).
|
|
// Requires active engine combat (same as random encounter). POST /admin/heroes/{heroId}/kill-current-enemy
|
|
func (h *AdminHandler) KillCurrentEnemy(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.engine == nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "engine not available"})
|
|
return
|
|
}
|
|
if _, active := h.engine.GetCombat(heroID); !active {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in combat"})
|
|
return
|
|
}
|
|
if h.engine.GetMovements(heroID) == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "hero has no active engine session — connect the game client (WebSocket)",
|
|
})
|
|
return
|
|
}
|
|
out, ok := h.engine.ApplyAdminLethalEnemyKill(heroID)
|
|
if !ok || out == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot kill current enemy"})
|
|
return
|
|
}
|
|
if err := h.store.Save(r.Context(), out); err != nil {
|
|
h.logger.Error("admin: save after kill-current-enemy", "hero_id", heroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
|
|
return
|
|
}
|
|
h.logger.Info("admin: kill current enemy", "hero_id", heroID)
|
|
h.writeAdminHeroDetail(w, out)
|
|
}
|
|
|
|
// StopHeroExcursion forces the excursion into the return leg (walk back to road / start point).
|
|
// 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),
|
|
})
|
|
}
|
|
|
|
// SimulateCombat runs a combat simulation for an existing hero and a selected monster archetype.
|
|
// POST /admin/engine/simulate-combat
|
|
func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
|
|
var req simulateCombatRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
|
|
return
|
|
}
|
|
if req.HeroID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "heroId is required"})
|
|
return
|
|
}
|
|
slug := strings.TrimSpace(req.EnemyType)
|
|
if slug == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enemyType is required"})
|
|
return
|
|
}
|
|
|
|
baseHero, err := h.store.GetByID(r.Context(), req.HeroID)
|
|
if err != nil {
|
|
h.logger.Error("admin simulate combat: load hero", "hero_id", req.HeroID, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
|
|
return
|
|
}
|
|
if baseHero == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
|
|
return
|
|
}
|
|
// Live session (engine) is authoritative for gear/stats while online; DB can lag during combat.
|
|
if h.engine != nil {
|
|
if hm := h.engine.GetMovements(req.HeroID); hm != nil && hm.Hero != nil {
|
|
baseHero = game.CloneHeroForCombatSim(hm.Hero)
|
|
}
|
|
}
|
|
tmpl, ok := model.EnemyBySlug(slug)
|
|
if !ok {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"})
|
|
return
|
|
}
|
|
|
|
var enemy model.Enemy
|
|
if req.EnemyLevel > 0 {
|
|
enemy = game.BuildEnemyInstanceForLevel(tmpl, req.EnemyLevel, nil)
|
|
} else {
|
|
// Same level roll as live encounters (variance + hero band), not "enemy level = hero level".
|
|
enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero, nil)
|
|
}
|
|
game.ApplyEnemyEncounterHeroScaling(baseHero, &enemy)
|
|
combatStart := game.CombatSimDeterministicStart
|
|
hero := game.PrepareHeroForAdminCombatSim(baseHero, combatStart)
|
|
|
|
initialHeroHp := hero.HP
|
|
initialHeroMaxHp := hero.MaxHP
|
|
initialEnemyHp := enemy.HP
|
|
initialEnemyMaxHp := enemy.MaxHP
|
|
enemyName := tmpl.Name
|
|
|
|
tickRate := time.Duration(req.TickRateMs) * time.Millisecond
|
|
if tickRate <= 0 {
|
|
tickRate = 100 * time.Millisecond
|
|
}
|
|
wallClockDelay := time.Duration(req.WallClockDelayMs) * time.Millisecond
|
|
if wallClockDelay < 0 {
|
|
wallClockDelay = 0
|
|
}
|
|
maxEvents := req.MaxEvents
|
|
if maxEvents <= 0 || maxEvents > 5000 {
|
|
maxEvents = 1200
|
|
}
|
|
events := make([]model.CombatEvent, 0, min(maxEvents, 256))
|
|
opts := game.CombatSimOptions{
|
|
TickRate: tickRate,
|
|
WallClockDelay: wallClockDelay,
|
|
MaxSteps: game.CombatSimMaxStepsLong,
|
|
OnEvent: func(evt model.CombatEvent) {
|
|
if len(events) < maxEvents {
|
|
events = append(events, evt)
|
|
}
|
|
},
|
|
}
|
|
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, combatStart, opts)
|
|
|
|
writeJSON(w, http.StatusOK, simulateCombatResponse{
|
|
HeroID: req.HeroID,
|
|
HeroName: baseHero.Name,
|
|
EnemyType: enemy.Slug,
|
|
EnemyName: enemyName,
|
|
EnemyLevel: enemy.Level,
|
|
Survived: survived,
|
|
ElapsedMs: elapsed.Milliseconds(),
|
|
InitialHeroHp: initialHeroHp,
|
|
InitialHeroMaxHp: initialHeroMaxHp,
|
|
InitialEnemyHp: initialEnemyHp,
|
|
InitialEnemyMaxHp: initialEnemyMaxHp,
|
|
FinalHeroHP: hero.HP,
|
|
FinalEnemyHP: enemy.HP,
|
|
WallClockDelayMs: wallClockDelay.Milliseconds(),
|
|
TickRateMs: tickRate.Milliseconds(),
|
|
Events: events,
|
|
})
|
|
}
|
|
|
|
// ── 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": version.Version,
|
|
"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",
|
|
})
|
|
}
|
|
|
|
// ContentListEnemies returns all rows from the enemies table.
|
|
// GET /admin/content/enemies
|
|
func (h *AdminHandler) ContentListEnemies(w http.ResponseWriter, r *http.Request) {
|
|
cs := storage.NewContentStore(h.pool)
|
|
rows, err := cs.ListEnemyRows(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("list enemies", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to list enemies",
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"enemies": rows,
|
|
})
|
|
}
|
|
|
|
// ContentUpdateEnemy overwrites one enemy template by type and hot-reloads in-memory templates.
|
|
// PUT /admin/content/enemies/{enemyType}
|
|
func (h *AdminHandler) ContentUpdateEnemy(w http.ResponseWriter, r *http.Request) {
|
|
typ := strings.TrimSpace(chi.URLParam(r, "enemyType"))
|
|
if typ == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing enemyType"})
|
|
return
|
|
}
|
|
var e model.Enemy
|
|
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
|
|
return
|
|
}
|
|
e.Slug = typ
|
|
e.HP = e.MaxHP
|
|
// Backward-compatible defaults for admin clients that still send legacy enemy payloads.
|
|
if cur, ok := model.EnemyBySlug(typ); ok {
|
|
if e.BaseLevel <= 0 {
|
|
e.BaseLevel = cur.BaseLevel
|
|
}
|
|
if e.LevelVariance <= 0 {
|
|
e.LevelVariance = cur.LevelVariance
|
|
}
|
|
if e.MaxHeroLevelDiff <= 0 {
|
|
e.MaxHeroLevelDiff = cur.MaxHeroLevelDiff
|
|
}
|
|
if e.HPPerLevel == 0 {
|
|
e.HPPerLevel = cur.HPPerLevel
|
|
}
|
|
if e.AttackPerLevel == 0 {
|
|
e.AttackPerLevel = cur.AttackPerLevel
|
|
}
|
|
if e.DefensePerLevel == 0 {
|
|
e.DefensePerLevel = cur.DefensePerLevel
|
|
}
|
|
if e.XPPerLevel == 0 {
|
|
e.XPPerLevel = cur.XPPerLevel
|
|
}
|
|
if e.GoldPerLevel == 0 {
|
|
e.GoldPerLevel = cur.GoldPerLevel
|
|
}
|
|
}
|
|
if e.LevelVariance <= 0 {
|
|
e.LevelVariance = 0.30
|
|
}
|
|
if e.MaxHeroLevelDiff <= 0 {
|
|
e.MaxHeroLevelDiff = 5
|
|
}
|
|
cs := storage.NewContentStore(h.pool)
|
|
if err := cs.UpdateEnemyByType(r.Context(), typ, e); err != nil {
|
|
h.logger.Error("update enemy", "type", typ, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
m, err := cs.LoadEnemyTemplates(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("reload enemy templates after update", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "saved but failed to reload templates",
|
|
})
|
|
return
|
|
}
|
|
model.SetEnemyTemplates(m)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
"count": len(m),
|
|
})
|
|
}
|
|
|
|
// ReloadEnemyTemplates loads enemies from DB into model.EnemyTemplates (hot load).
|
|
// POST /admin/content/enemies/reload
|
|
func (h *AdminHandler) ReloadEnemyTemplates(w http.ResponseWriter, r *http.Request) {
|
|
cs := storage.NewContentStore(h.pool)
|
|
m, err := cs.LoadEnemyTemplates(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("load enemy templates", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to load enemies",
|
|
})
|
|
return
|
|
}
|
|
model.SetEnemyTemplates(m)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "reloaded",
|
|
"count": len(m),
|
|
})
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
// applyNewPlayerHeroDefaults matches CreateHeroWithSpawn field-wise (stats, gold, counters, subscription)
|
|
// while keeping identity fields. Caller should load gear from DB before/after as needed.
|
|
func applyNewPlayerHeroDefaults(hero *model.Hero) {
|
|
resetHeroToLevel1(hero)
|
|
hero.Gold = 100
|
|
hero.Potions = 0
|
|
hero.ReviveCount = 0
|
|
hero.TotalKills = 0
|
|
hero.EliteKills = 0
|
|
hero.TotalDeaths = 0
|
|
hero.KillsSinceDeath = 0
|
|
hero.LegendaryDrops = 0
|
|
hero.SubscriptionActive = false
|
|
hero.SubscriptionExpiresAt = nil
|
|
hero.ExcursionPhase = model.ExcursionNone
|
|
hero.RestKind = model.RestKindNone
|
|
hero.TownPause = nil
|
|
}
|
|
|
|
// 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)
|
|
}
|