package handler import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "math" "math/rand" "net/http" "strconv" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" ) // NPCHandler serves NPC interaction API endpoints. type NPCHandler struct { questStore *storage.QuestStore heroStore *storage.HeroStore gearStore *storage.GearStore logStore *storage.LogStore logger *slog.Logger } // NewNPCHandler creates a new NPCHandler. func NewNPCHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, gearStore *storage.GearStore, logStore *storage.LogStore, logger *slog.Logger) *NPCHandler { return &NPCHandler{ questStore: questStore, heroStore: heroStore, gearStore: gearStore, logStore: logStore, logger: logger, } } // addLog is a fire-and-forget helper that writes an adventure log entry. func (h *NPCHandler) addLog(heroID int64, message string) { if h.logStore == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := h.logStore.Add(ctx, heroID, message); err != nil { h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) } } // dist2D calculates the Euclidean distance between two 2D points. func dist2D(x1, y1, x2, y2 float64) float64 { dx := x1 - x2 dy := y1 - y2 return math.Sqrt(dx*dx + dy*dy) } // InteractNPC handles POST /api/v1/hero/npc-interact. // The hero interacts with a specific NPC; checks proximity to the NPC's town. func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "missing telegramId", }) return } var req struct { NPCID int64 `json:"npcId"` PositionX float64 `json:"positionX"` PositionY float64 `json:"positionY"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body", }) return } if req.NPCID == 0 { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "npcId is required", }) return } hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to get hero for npc interact", "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 } // Load NPC. npc, err := h.questStore.GetNPCByID(r.Context(), req.NPCID) if err != nil { h.logger.Error("failed to get npc", "npc_id", req.NPCID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to load npc", }) return } if npc == nil { writeJSON(w, http.StatusNotFound, map[string]string{ "error": "npc not found", }) return } // Load the NPC's town. town, err := h.questStore.GetTown(r.Context(), npc.TownID) if err != nil { h.logger.Error("failed to get town for npc", "town_id", npc.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 } // Check proximity: hero must be within the town's radius. d := dist2D(req.PositionX, req.PositionY, town.WorldX, town.WorldY) if d > town.Radius { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "hero is too far from the town", }) return } // Build actions based on NPC type. var actions []model.NPCInteractAction switch npc.Type { case "quest_giver": quests, err := h.questStore.ListQuestsByNPCForHeroLevel(r.Context(), npc.ID, hero.Level) if err != nil { h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to load quests", }) return } for _, q := range quests { actions = append(actions, model.NPCInteractAction{ ActionType: "quest", QuestID: q.ID, QuestTitle: q.Title, Description: q.Description, }) } case "merchant": actions = append(actions, model.NPCInteractAction{ ActionType: "shop_item", ItemName: "Healing Potion", ItemCost: 50, Description: "Restores health. Always handy in a pinch.", }) case "healer": actions = append(actions, model.NPCInteractAction{ ActionType: "heal", ItemName: "Full Heal", ItemCost: 100, Description: "Restore hero to full HP.", }) } // Log the meeting. h.addLog(hero.ID, fmt.Sprintf("Met %s in %s", npc.Name, town.Name)) resp := model.NPCInteractResponse{ NPCName: npc.Name, NPCType: npc.Type, TownName: town.Name, Actions: actions, } if resp.Actions == nil { resp.Actions = []model.NPCInteractAction{} } writeJSON(w, http.StatusOK, resp) } // NearbyNPCs handles GET /api/v1/hero/nearby-npcs. // Returns NPCs within 3 world units of the given position. func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "missing telegramId", }) return } posXStr := r.URL.Query().Get("posX") posYStr := r.URL.Query().Get("posY") posX, errX := strconv.ParseFloat(posXStr, 64) posY, errY := strconv.ParseFloat(posYStr, 64) if errX != nil || errY != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "posX and posY are required numeric parameters", }) return } // Verify hero exists. hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to get hero for nearby npcs", "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 } // Load all towns and NPCs, then filter by distance. towns, err := h.questStore.ListTowns(r.Context()) if err != nil { h.logger.Error("failed to list towns for nearby npcs", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to load towns", }) return } const nearbyRadius = 3.0 var result []model.NearbyNPCEntry for _, town := range towns { npcs, err := h.questStore.ListNPCsByTown(r.Context(), town.ID) if err != nil { h.logger.Warn("failed to list npcs for town", "town_id", town.ID, "error", err) continue } for _, npc := range npcs { npcWorldX := town.WorldX + npc.OffsetX npcWorldY := town.WorldY + npc.OffsetY d := dist2D(posX, posY, npcWorldX, npcWorldY) if d <= nearbyRadius { result = append(result, model.NearbyNPCEntry{ ID: npc.ID, Name: npc.Name, Type: npc.Type, WorldX: npcWorldX, WorldY: npcWorldY, InteractionAvailable: true, }) } } } if result == nil { result = []model.NearbyNPCEntry{} } writeJSON(w, http.StatusOK, result) } // NPCAlms handles POST /api/v1/hero/npc-alms. // The hero gives alms to a wandering merchant in exchange for random equipment. func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "missing telegramId", }) return } var req struct { Accept bool `json:"accept"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // Empty body used to be sent by the web client; treat as accept (mysterious item purchase). if errors.Is(err, io.EOF) { req.Accept = true } else { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body", }) return } } hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to get hero for npc alms", "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 !req.Accept { writeJSON(w, http.StatusOK, model.AlmsResponse{ Accepted: false, Message: "You declined the wandering merchant's offer.", }) return } // Compute cost: 20 + level * 5. cost := int64(20 + hero.Level*5) if hero.Gold < cost { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": fmt.Sprintf("not enough gold (need %d, have %d)", cost, hero.Gold), }) return } hero.Gold -= cost // Generate random equipment drop. slots := model.AllEquipmentSlots slot := slots[rand.Intn(len(slots))] family := model.PickGearFamily(slot) var drop *model.LootDrop if family != nil { rarity := model.RollRarity() ilvl := model.RollIlvl(hero.Level, false) item := model.NewGearItem(family, ilvl, rarity) if h.gearStore != nil { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) err := h.gearStore.CreateItem(ctx, item) cancel() if err != nil { h.logger.Warn("failed to create alms gear item", "slot", slot, "error", err) } else { drop = &model.LootDrop{ ItemType: string(slot), ItemID: item.ID, ItemName: item.Name, Rarity: rarity, } h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant, received %s", item.Name)) } } } if drop == nil { // Fallback: gold refund if we couldn't generate equipment. hero.Gold += cost writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to generate reward", }) return } if err := h.heroStore.Save(r.Context(), hero); err != nil { h.logger.Error("failed to save hero after alms", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } writeJSON(w, http.StatusOK, model.AlmsResponse{ Accepted: true, GoldSpent: cost, ItemDrop: drop, Hero: hero, Message: fmt.Sprintf("You gave %d gold to the wandering merchant and received %s.", cost, drop.ItemName), }) } // HealHero handles POST /api/v1/hero/npc-heal. // A healer NPC restores the hero to full HP for 100 gold. func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "missing telegramId", }) return } var req struct { NPCID int64 `json:"npcId"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body", }) return } hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to get hero for heal", "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 } // Verify NPC is a healer. if req.NPCID != 0 { npc, err := h.questStore.GetNPCByID(r.Context(), req.NPCID) if err != nil { h.logger.Error("failed to get npc for heal", "npc_id", req.NPCID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to load npc", }) return } if npc == nil || npc.Type != "healer" { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "npc is not a healer", }) return } } const healCost int64 = 100 if hero.Gold < healCost { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": fmt.Sprintf("not enough gold (need %d, have %d)", healCost, hero.Gold), }) return } hero.Gold -= healCost hero.HP = hero.MaxHP if err := h.heroStore.Save(r.Context(), hero); err != nil { h.logger.Error("failed to save hero after heal", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.addLog(hero.ID, "Healed to full HP by a town healer") writeJSON(w, http.StatusOK, map[string]any{ "hero": hero, "healed": true, "message": "You have been healed to full HP.", }) } // BuyPotion handles POST /api/v1/hero/npc-buy-potion. // A merchant NPC sells a healing potion for 50 gold. func (h *NPCHandler) BuyPotion(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 buy potion", "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 } const potionCost int64 = 50 if hero.Gold < potionCost { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": fmt.Sprintf("not enough gold (need %d, have %d)", potionCost, hero.Gold), }) return } hero.Gold -= potionCost hero.Potions++ if err := h.heroStore.Save(r.Context(), hero); err != nil { h.logger.Error("failed to save hero after buy potion", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to save hero", }) return } h.addLog(hero.ID, "Purchased a Healing Potion from a merchant") writeJSON(w, http.StatusOK, map[string]any{ "hero": hero, "message": "You purchased a Healing Potion for 50 gold.", }) }