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