You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

987 lines
30 KiB
Go

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(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,
})
}