package handler import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" "math" "net/http" "runtime" "strconv" "strings" "time" "github.com/denisovdennis/autohero/internal/constants" "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.AddToInventory(r.Context(), heroID, &clone); 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": "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.AddToInventory(r.Context(), heroID, item); 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": "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 } if err := h.gearStore.EquipInventoryItem(r.Context(), heroID, req.ItemID); 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.DeleteHeroItem(r.Context(), heroID, 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: constants.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) }