package handler import ( "log/slog" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" ) // QuestHandler serves quest system API endpoints. type QuestHandler struct { questStore *storage.QuestStore heroStore *storage.HeroStore logStore *storage.LogStore logger *slog.Logger } // NewQuestHandler creates a new QuestHandler. func NewQuestHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, logStore *storage.LogStore, logger *slog.Logger) *QuestHandler { return &QuestHandler{ questStore: questStore, heroStore: heroStore, logStore: logStore, logger: logger, } } // ListTowns returns all towns. // GET /api/v1/towns func (h *QuestHandler) ListTowns(w http.ResponseWriter, r *http.Request) { towns, err := h.questStore.ListTowns(r.Context()) if err != nil { h.logger.Error("failed to list towns", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to list towns", }) return } writeJSON(w, http.StatusOK, towns) } // ListNPCsByTown returns all NPCs in a town. // GET /api/v1/towns/{townId}/npcs func (h *QuestHandler) ListNPCsByTown(w http.ResponseWriter, r *http.Request) { townIDStr := chi.URLParam(r, "townId") townID, err := strconv.ParseInt(townIDStr, 10, 64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid townId", }) return } npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID) if err != nil { h.logger.Error("failed to list npcs", "town_id", townID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to list npcs", }) return } writeJSON(w, http.StatusOK, npcs) } // ListBuildingsByTown returns all buildings in a town. // GET /api/v1/towns/{townId}/buildings func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Request) { townIDStr := chi.URLParam(r, "townId") townID, err := strconv.ParseInt(townIDStr, 10, 64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid townId", }) return } town, err := h.questStore.GetTown(r.Context(), townID) if err != nil { h.logger.Error("failed to get town for buildings", "town_id", townID, "error", err) 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 } buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID) if err != nil { h.logger.Error("failed to list buildings", "town_id", townID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to list buildings", }) return } views := make([]model.BuildingView, 0, len(buildings)) for _, b := range buildings { views = append(views, model.BuildingView{ ID: b.ID, BuildingType: b.BuildingType, WorldX: town.WorldX + b.OffsetX, WorldY: town.WorldY + b.OffsetY, Facing: b.Facing, FootprintW: b.FootprintW, FootprintH: b.FootprintH, }) } writeJSON(w, http.StatusOK, views) } // ListQuestsByNPC returns quests offered by an NPC. // GET /api/v1/npcs/{npcId}/quests // With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, and rotated on a configured cadence — same rules as npc-interact. // Without telegramId, returns all templates for that NPC (catalog / tools). func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { npcIDStr := chi.URLParam(r, "npcId") npcID, err := strconv.ParseInt(npcIDStr, 10, 64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid npcId", }) return } if tgStr := r.URL.Query().Get("telegramId"); tgStr != "" { tgID, err := strconv.ParseInt(tgStr, 10, 64) if err != nil || tgID == 0 { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid telegramId", }) return } hero, err := h.heroStore.GetByTelegramID(r.Context(), tgID) if err != nil { h.logger.Error("failed to get hero for npc quests", "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 } refreshHours := tuning.EffectiveQuestOfferRefreshHours() if refreshHours <= 0 { refreshHours = 2 } refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second) timeBucket := time.Now().UTC().Unix() / refreshSeconds limit := tuning.EffectiveQuestOffersPerNPC() quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, hero.Level, limit, timeBucket) if err != nil { h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to list quests", }) return } writeJSON(w, http.StatusOK, quests) return } quests, err := h.questStore.ListQuestsByNPC(r.Context(), npcID) if err != nil { h.logger.Error("failed to list quests", "npc_id", npcID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to list quests", }) return } writeJSON(w, http.StatusOK, quests) } // AcceptQuest accepts a quest for the hero. // POST /api/v1/hero/quests/{questId}/accept func (h *QuestHandler) AcceptQuest(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "missing telegramId", }) return } questIDStr := chi.URLParam(r, "questId") questID, err := strconv.ParseInt(questIDStr, 10, 64) if err != nil || questID == 0 { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid questId", }) return } req := struct{ QuestID int64 }{QuestID: questID} hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to get hero for quest accept", "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 } inserted, err := h.questStore.TryAcceptQuest(r.Context(), hero.ID, req.QuestID) if err != nil { h.logger.Error("failed to accept quest", "hero_id", hero.ID, "quest_id", req.QuestID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to accept quest", }) return } if !inserted { writeJSON(w, http.StatusConflict, map[string]string{ "error": "quest already in log", }) return } h.logger.Info("quest accepted", "hero_id", hero.ID, "quest_id", req.QuestID) writeJSON(w, http.StatusOK, map[string]string{ "status": "accepted", }) } // ListHeroQuests returns the hero's quest log. // GET /api/v1/hero/quests func (h *QuestHandler) ListHeroQuests(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "missing telegramId", }) return } hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to get hero for quest list", "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 } quests, err := h.questStore.ListHeroQuests(r.Context(), hero.ID) if err != nil { h.logger.Error("failed to list hero quests", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to list quests", }) return } writeJSON(w, http.StatusOK, quests) } // ClaimQuestReward claims a completed quest's reward. // POST /api/v1/hero/quests/{questId}/claim — questId is hero_quests.id (log row). func (h *QuestHandler) ClaimQuestReward(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "missing telegramId", }) return } questIDStr := chi.URLParam(r, "questId") questID, err := strconv.ParseInt(questIDStr, 10, 64) if err != nil { h.logger.Error("error claiming quest", "error", err) writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid questId", }) return } hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to get hero for quest claim", "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 } reward, err := h.questStore.ClaimQuestReward(r.Context(), hero.ID, questID) if err != nil { h.logger.Warn("failed to claim quest reward", "hero_id", hero.ID, "quest_id", questID, "error", err) writeJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) return } // Apply rewards to hero. hero.XP += reward.XP hero.Gold += reward.Gold hero.Potions += reward.Potions // Run level-up loop. for hero.LevelUp() { } if err := h.heroStore.Save(r.Context(), hero); err != nil { h.logger.Error("failed to save hero after quest claim", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.logger.Info("quest reward claimed", "hero_id", hero.ID, "quest_id", questID, "xp", reward.XP, "gold", reward.Gold, "potions", reward.Potions) writeHeroJSON(w, http.StatusOK, hero) } // AbandonQuest removes a quest from the hero's quest log. // DELETE /api/v1/hero/quests/{questId} — questId is hero_quests.id (log row), not the template id. func (h *QuestHandler) AbandonQuest(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "missing telegramId", }) return } questIDStr := chi.URLParam(r, "questId") questID, err := strconv.ParseInt(questIDStr, 10, 64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid questId", }) return } hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to get hero for quest abandon", "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 err := h.questStore.AbandonQuest(r.Context(), hero.ID, questID); err != nil { h.logger.Warn("failed to abandon quest", "hero_id", hero.ID, "quest_id", questID, "error", err) writeJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) return } h.logger.Info("quest abandoned", "hero_id", hero.ID, "quest_id", questID) writeJSON(w, http.StatusOK, map[string]string{ "status": "abandoned", }) }