package handler import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "math" "math/rand" "net/http" "strconv" "time" "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" ) // NPCHandler serves NPC interaction API endpoints. type NPCHandler struct { questStore *storage.QuestStore heroStore *storage.HeroStore gearStore *storage.GearStore logStore *storage.LogStore logger *slog.Logger engine *game.Engine hub *Hub } // NewNPCHandler creates a new NPCHandler. func NewNPCHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, gearStore *storage.GearStore, logStore *storage.LogStore, logger *slog.Logger, eng *game.Engine, hub *Hub) *NPCHandler { return &NPCHandler{ questStore: questStore, heroStore: heroStore, gearStore: gearStore, logStore: logStore, logger: logger, engine: eng, hub: hub, } } func (h *NPCHandler) sendMerchantLootWS(heroID int64, cost int64, drop *model.LootDrop) { if h.hub == nil || drop == nil { return } h.hub.SendToHero(heroID, "merchant_loot", model.MerchantLootPayload{ GoldSpent: cost, ItemType: drop.ItemType, ItemName: drop.ItemName, Rarity: string(drop.Rarity), GoldAmount: drop.GoldAmount, }) } // addLogLine is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS. func (h *NPCHandler) addLogLine(heroID int64, line model.AdventureLogLine) { if h.logStore == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := h.logStore.Add(ctx, heroID, line); err != nil { h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) return } if h.hub != nil { h.hub.SendToHero(heroID, "adventure_log_line", line) } } // 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": 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, npc.ID, hero.Level, limit, timeBucket) 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 { qk := q.QuestKey if qk == "" { qk = fmt.Sprintf("quest.%d", q.ID) } actions = append(actions, model.NPCInteractAction{ ActionType: "quest", QuestID: q.ID, QuestKey: qk, QuestTitle: q.Title, Description: q.Description, }) } case "merchant": potionCost, _ := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "shop_item", ItemKey: "shop.healing_potion", ItemName: "Healing Potion", ItemCost: potionCost, Description: "Restores health. Always handy in a pinch.", }) case "healer": _, healCost := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "heal", ItemKey: "shop.full_heal", ItemName: "Full Heal", ItemCost: healCost, Description: "Restore hero to full HP.", }) } // Log the meeting. h.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogMetNPC, Args: map[string]any{"npcKey": npc.NameKey, "townKey": town.NameKey}, }, }) resp := model.NPCInteractResponse{ NPCName: npc.Name, NPCNameKey: npc.NameKey, NPCType: npc.Type, TownName: town.Name, TownNameKey: town.NameKey, 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 } cfg := tuning.Get() nearbyRadius := cfg.NPCCostNearbyRadius 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, NameKey: npc.NameKey, Type: npc.Type, WorldX: npcWorldX, WorldY: npcWorldY, InteractionAvailable: true, }) } } } if result == nil { result = []model.NearbyNPCEntry{} } writeJSON(w, http.StatusOK, result) } // npcPersistGearEquip writes hero_gear when a merchant drop is equipped. func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) error { if h.gearStore == nil || item == nil || item.ID == 0 { return nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID) } // grantMerchantLoot rolls one random gear piece; auto-equips if better. // Outside town, unwanted pieces are discarded (gold for sells only in town). // Cost must already be deducted from hero.Gold. func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time) (*model.LootDrop, error) { slots := model.AllEquipmentSlots if h.gearStore == nil { return nil, errors.New("failed to roll gear") } var family *model.GearFamily for _, idx := range rand.Perm(len(slots)) { slot := slots[idx] family = model.PickGearFamily(slot) if family != nil { break } } if family == nil { return nil, errors.New("failed to roll gear") } rarity := model.RollRarity() ilvl := model.RollIlvl(hero.Level, false) item := model.NewGearItem(family, ilvl, rarity) ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second) err := h.gearStore.CreateItem(ctxCreate, item) cancel() if err != nil { h.logger.Warn("failed to create alms gear item", "slot", family.Slot, "error", err) return nil, err } hero.EnsureGearMap() slot := item.Slot var prev *model.GearItem if hero.Gear != nil { prev = hero.Gear[slot] } equipped := game.TryAutoEquipInMemory(hero, item, now) if equipped { if err := h.npcPersistGearEquip(hero.ID, item); err != nil { if prev == nil { delete(hero.Gear, slot) } else { hero.Gear[slot] = prev } hero.RefreshDerivedCombatStats(now) if errors.Is(err, storage.ErrInventoryFull) { h.logger.Warn("merchant gear equip skipped: inventory full", "hero_id", hero.ID, "slot", item.Slot) } else { h.logger.Warn("failed to persist merchant gear equip", "hero_id", hero.ID, "slot", item.Slot, "error", err) } equipped = false } else { if prev != nil && prev.ID != item.ID { hero.EnsureInventorySlice() hero.Inventory = append(hero.Inventory, prev) } h.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogWanderingAlmsEquipped, Args: map[string]any{"itemName": item.Name}, }, }) } } if !equipped { hero.EnsureInventorySlice() if len(hero.Inventory) >= model.MaxInventorySlots { ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second) if item.ID != 0 { if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil { h.logger.Warn("failed to delete merchant gear (inventory full)", "gear_id", item.ID, "error", err) } } cancelDel() h.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogWanderingAlmsDropped, Args: map[string]any{"itemName": item.Name, "rarity": string(item.Rarity)}, }, }) } else { ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second) err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID) cancelInv() if err != nil { h.logger.Warn("failed to stash merchant gear", "hero_id", hero.ID, "error", err) ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second) _ = h.gearStore.DeleteGearItem(ctxDel, item.ID) cancelDel() } else { hero.Inventory = append(hero.Inventory, item) h.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogWanderingAlmsStashed, Args: map[string]any{"itemName": item.Name}, }, }) } } } drop := &model.LootDrop{ ItemType: string(item.Slot), ItemID: item.ID, ItemName: item.Name, Rarity: rarity, } return drop, nil } // ProcessAlmsByHeroID applies wandering merchant rewards for a DB hero id (WebSocket npc_alms_accept). func (h *NPCHandler) ProcessAlmsByHeroID(ctx context.Context, heroID int64) error { hero, err := h.heroStore.GetByID(ctx, heroID) if err != nil { h.logger.Error("failed to get hero for ws npc alms", "hero_id", heroID, "error", err) return errors.New("failed to load hero") } if hero == nil { return errors.New("hero not found") } cost := game.WanderingMerchantCost(hero.Level) if hero.Gold < cost { return fmt.Errorf("not enough gold (need %d, have %d)", cost, hero.Gold) } hero.Gold -= cost now := time.Now() drop, err := h.grantMerchantLoot(ctx, hero, now) if err != nil { hero.Gold += cost return err } hero.RefreshDerivedCombatStats(now) if err := h.heroStore.Save(ctx, hero); err != nil { h.logger.Error("failed to save hero after alms", "hero_id", hero.ID, "error", err) return errors.New("failed to save hero") } if h.engine != nil { h.engine.ApplyHeroAlmsUpdate(hero) } h.sendMerchantLootWS(hero.ID, cost, drop) return nil } // NPCAlms handles POST /api/v1/hero/npc-alms. // The hero pays for one random equipment roll; better items equip, worse are sold for gold. 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 } cost := game.WanderingMerchantCost(hero.Level) 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 now := time.Now() drop, err := h.grantMerchantLoot(r.Context(), hero, now) if err != nil { hero.Gold += cost writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to generate reward", }) return } hero.RefreshDerivedCombatStats(now) 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 } if h.engine != nil { h.engine.ApplyHeroAlmsUpdate(hero) } h.sendMerchantLootWS(hero.ID, cost, drop) msg := fmt.Sprintf("You gave %d gold to the wandering merchant and received %s.", cost, drop.ItemName) writeJSON(w, http.StatusOK, model.AlmsResponse{ Accepted: true, GoldSpent: cost, ItemDrop: drop, Hero: hero, Message: msg, }) } // HealHero handles POST /api/v1/hero/npc-heal. // A healer NPC restores the hero to full HP for the runtime-configured gold cost. 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 } } _, healCost := tuning.EffectiveNPCShopCosts() 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.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogHealedFullTown}}) // Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client. writeHeroJSON(w, http.StatusOK, hero) } // BuyPotion handles POST /api/v1/hero/npc-buy-potion. // A merchant NPC sells a healing potion for the runtime-configured gold cost. 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 } potionCost, _ := tuning.EffectiveNPCShopCosts() 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.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogBoughtPotionTown}}) writeHeroJSON(w, http.StatusOK, hero) }