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 } // merchantStockRow is one town merchant shelf row (stats + per-item gold fixed at open). type merchantStockRow struct { model.GearItem Cost int64 `json:"cost"` } // 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) } // loadHeroNPCInTown loads the hero, NPC row, town, and checks hero stand position is inside the town radius. // If allowedTypes is non-empty, npc.Type must match one of them. func (h *NPCHandler) loadHeroNPCInTown(ctx context.Context, telegramID, npcID int64, posX, posY float64, allowedTypes ...string) (*model.Hero, *model.NPC, *model.Town, error) { if npcID == 0 { return nil, nil, nil, fmt.Errorf("npcId is required") } hero, err := h.heroStore.GetByTelegramID(ctx, telegramID) if err != nil { return nil, nil, nil, fmt.Errorf("failed to load hero") } if hero == nil { return nil, nil, nil, fmt.Errorf("hero not found") } npc, err := h.questStore.GetNPCByID(ctx, npcID) if err != nil { return nil, nil, nil, fmt.Errorf("failed to load npc") } if npc == nil { return nil, nil, nil, fmt.Errorf("npc not found") } if len(allowedTypes) > 0 { ok := false for _, t := range allowedTypes { if npc.Type == t { ok = true break } } if !ok { return nil, nil, nil, fmt.Errorf("npc type mismatch") } } town, err := h.questStore.GetTown(ctx, npc.TownID) if err != nil { return nil, nil, nil, fmt.Errorf("failed to load town") } if town == nil { return nil, nil, nil, fmt.Errorf("town not found") } if dist2D(posX, posY, town.WorldX, town.WorldY) > town.Radius { return nil, nil, nil, fmt.Errorf("hero is too far from the town") } return hero, npc, town, nil } // 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 model.NPCTypeBounty, model.NPCTypeElder, model.NPCTypeQuestGiver: 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() townOfferLevel := game.TownEffectiveLevel(town) quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, townOfferLevel, 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 model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler: gearCost := tuning.EffectiveTownMerchantGearCost(game.TownEffectiveLevel(town)) actions = append(actions, model.NPCInteractAction{ ActionType: "shop_item", ItemKey: "shop.merchant_gear_rows", ItemName: "Town gear", ItemCost: gearCost, Description: "Stock is rolled when you open the shop (town-tier stats shown before purchase).", }) case "healer": potionCost, healCost := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "shop_item", ItemKey: "shop.healing_potion", ItemName: "Healing Potion", ItemCost: potionCost, Description: "Restores health in combat.", }) 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.LogPhraseMetNPC, 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. // refLevel drives ilvl (hero level for wandering merchant, town tier for static shops). // Cost must already be deducted from hero.Gold. func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time, refLevel int) (*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(refLevel, 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.LogPhraseWanderingAlmsEquipped, Args: map[string]any{ "itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID, }, }, }) } } 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.LogPhraseWanderingAlmsDropped, Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID}, }, }) } 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.LogPhraseWanderingAlmsStashed, Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID}, }, }) } } } 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, hero.Level) 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, hero.Level) 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"` 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 } var hero *model.Hero if req.NPCID != 0 { var err error hero, _, _, err = h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer") if err != nil { msg := err.Error() switch msg { case "hero not found": writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) case "npc not found", "town not found": writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) case "failed to load hero", "failed to load npc", "failed to load town": h.logger.Error("npc heal lookup", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"}) default: writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) } return } } else { var err error 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 } } _, 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.LogPhraseHealedFullTown}}) if h.engine != nil { h.engine.ApplyPersistedHeroSnapshot(hero) } writeHeroJSON(w, http.StatusOK, hero) } // BuyPotion handles POST /api/v1/hero/npc-buy-potion. // A healer NPC sells a healing potion (hero must stand in town near the NPC's town). 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 } 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 } hero, _, _, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer") if err != nil { msg := err.Error() switch msg { case "hero not found": writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) case "npc not found", "town not found": writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) case "failed to load hero", "failed to load npc", "failed to load town": h.logger.Error("buy potion lookup", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"}) default: writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) } 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.LogPhraseBoughtPotionTown}}) if h.engine != nil { h.engine.ApplyPersistedHeroSnapshot(hero) } writeHeroJSON(w, http.StatusOK, hero) } // BuyTownMerchantGear handles POST /api/v1/hero/npc-buy-town-gear. // Purchases one row from the current merchant stock (see POST .../npc-merchant-stock); equips immediately. func (h *NPCHandler) BuyTownMerchantGear(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"` OfferIndex int `json:"offerIndex"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid request body", }) return } hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler) if err != nil { msg := err.Error() switch msg { case "hero not found": writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) case "npc not found", "town not found": writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) case "failed to load hero", "failed to load npc", "failed to load town": h.logger.Error("buy town gear lookup", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"}) default: writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) } return } if h.gearStore == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gear store unavailable"}) return } if h.engine == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"}) return } item, price, ok := h.engine.TakeMerchantOffer(hero.ID, req.NPCID, req.OfferIndex) if !ok || item == nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid or expired shop offer — reopen the merchant", }) return } if hero.Gold < price { h.engine.UnshiftMerchantOffer(hero.ID, npc.ID, town.ID, item, price) writeJSON(w, http.StatusBadRequest, map[string]string{ "error": fmt.Sprintf("not enough gold (need %d, have %d)", price, hero.Gold), }) return } hero.Gold -= price now := time.Now() drop, err := game.ApplyPreparedTownMerchantPurchase(r.Context(), h.gearStore, hero, item, now) if err != nil { hero.Gold += price h.engine.UnshiftMerchantOffer(hero.ID, npc.ID, town.ID, item, price) if errors.Is(err, storage.ErrInventoryFull) { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "inventory full — free a backpack slot to swap gear", }) return } h.logger.Warn("town merchant gear failed", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to grant gear"}) return } if err := h.heroStore.Save(r.Context(), hero); err != nil { h.logger.Error("failed to save hero after town gear", "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.LogPhraseBoughtGearTownMerchant, Args: map[string]any{ "npcKey": npc.NameKey, "townKey": town.NameKey, "slot": drop.ItemType, "rarity": string(drop.Rarity), "itemId": drop.ItemID, }, }, }) h.engine.ApplyPersistedHeroSnapshot(hero) writeHeroJSON(w, http.StatusOK, hero) } // NPCDialogPause handles POST /api/v1/hero/npc-dialog-pause. // While open, the engine freezes town NPC visit narration timers (shop / quest UI). func (h *NPCHandler) NPCDialogPause(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 { Open bool `json:"open"` } 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("npc dialog pause: load hero", "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 { h.engine.SetTownNPCUILock(hero.ID, req.Open) if !req.Open { h.engine.ClearMerchantStock(hero.ID) h.engine.SkipTownNPCNarrationAfterDialog(hero.ID) } } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } // MerchantStock handles POST /api/v1/hero/npc-merchant-stock. // Rolls town-tier gear rows (not persisted until purchase) and caches them on the engine. func (h *NPCHandler) MerchantStock(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 h.engine == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"}) return } hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler) if err != nil { msg := err.Error() switch msg { case "hero not found": writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) case "npc not found", "town not found": writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) case "failed to load hero", "failed to load npc", "failed to load town": h.logger.Error("merchant stock lookup", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"}) default: writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) } return } townLv := game.TownEffectiveLevel(town) n := tuning.EffectiveMerchantTownStockCount() slots := model.GearVendorSlots(npc.Type) items := game.RollTownMerchantStockItemsForSlots(int(float64(hero.Level) * float64(1 + hero.Level / townLv)), n, slots) costs := make([]int64, len(items)) for i, it := range items { if it == nil { continue } costs[i] = game.RollTownMerchantOfferGold(it.Ilvl, it.Rarity, townLv) } h.engine.SetTownNPCUILock(hero.ID, true) h.engine.SetMerchantStock(hero.ID, npc.ID, town.ID, items, costs) rows := make([]merchantStockRow, len(items)) for i, it := range items { if it == nil { continue } rows[i].GearItem = *it rows[i].Cost = costs[i] } writeJSON(w, http.StatusOK, map[string]any{ "items": rows, }) }