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.

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