|
|
package handler
|
|
|
|
|
|
import (
|
|
|
"context"
|
|
|
"encoding/json"
|
|
|
"fmt"
|
|
|
"log/slog"
|
|
|
"math/rand"
|
|
|
"net/http"
|
|
|
"strconv"
|
|
|
"strings"
|
|
|
"sync"
|
|
|
"time"
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
|
"github.com/denisovdennis/autohero/internal/game"
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
|
"github.com/denisovdennis/autohero/internal/storage"
|
|
|
"github.com/denisovdennis/autohero/internal/world"
|
|
|
)
|
|
|
|
|
|
// maxLootHistory is the number of recent loot entries kept per hero in memory.
|
|
|
const maxLootHistory = 50
|
|
|
|
|
|
// encounterCombatCooldown limits how often the server grants a combat encounter.
|
|
|
// Client polls roughly every walk segment (~2.5–5.5s); 16s minimum spacing ≈ 4× lower fight rate.
|
|
|
const encounterCombatCooldown = 16 * time.Second
|
|
|
|
|
|
type GameHandler struct {
|
|
|
engine *game.Engine
|
|
|
store *storage.HeroStore
|
|
|
logStore *storage.LogStore
|
|
|
hub *Hub
|
|
|
questStore *storage.QuestStore
|
|
|
gearStore *storage.GearStore
|
|
|
achievementStore *storage.AchievementStore
|
|
|
taskStore *storage.DailyTaskStore
|
|
|
logger *slog.Logger
|
|
|
world *world.Service
|
|
|
lootMu sync.RWMutex
|
|
|
lootCache map[int64][]model.LootHistory // keyed by hero ID
|
|
|
serverStartedAt time.Time
|
|
|
|
|
|
encounterMu sync.Mutex
|
|
|
lastCombatEncounterAt map[int64]time.Time // per-hero; in-memory only
|
|
|
}
|
|
|
|
|
|
type encounterEnemyResponse struct {
|
|
|
ID int64 `json:"id"`
|
|
|
Name string `json:"name"`
|
|
|
HP int `json:"hp"`
|
|
|
MaxHP int `json:"maxHp"`
|
|
|
Attack int `json:"attack"`
|
|
|
Defense int `json:"defense"`
|
|
|
Speed float64 `json:"speed"`
|
|
|
EnemyType model.EnemyType `json:"enemyType"`
|
|
|
}
|
|
|
|
|
|
func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, worldSvc *world.Service, logger *slog.Logger, serverStartedAt time.Time, questStore *storage.QuestStore, gearStore *storage.GearStore, achievementStore *storage.AchievementStore, taskStore *storage.DailyTaskStore, hub *Hub) *GameHandler {
|
|
|
h := &GameHandler{
|
|
|
engine: engine,
|
|
|
store: store,
|
|
|
logStore: logStore,
|
|
|
hub: hub,
|
|
|
questStore: questStore,
|
|
|
gearStore: gearStore,
|
|
|
achievementStore: achievementStore,
|
|
|
taskStore: taskStore,
|
|
|
logger: logger,
|
|
|
world: worldSvc,
|
|
|
lootCache: make(map[int64][]model.LootHistory),
|
|
|
serverStartedAt: serverStartedAt,
|
|
|
lastCombatEncounterAt: make(map[int64]time.Time),
|
|
|
}
|
|
|
engine.SetOnEnemyDeath(h.onEnemyDeath)
|
|
|
return h
|
|
|
}
|
|
|
|
|
|
// addLog is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS.
|
|
|
func (h *GameHandler) 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)
|
|
|
return
|
|
|
}
|
|
|
if h.hub != nil {
|
|
|
h.hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: message})
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// onEnemyDeath is called by the engine when an enemy is defeated.
|
|
|
// Delegates to processVictoryRewards for canonical reward logic.
|
|
|
func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now time.Time) {
|
|
|
h.processVictoryRewards(hero, enemy, now)
|
|
|
}
|
|
|
|
|
|
// processVictoryRewards is the single source of truth for post-kill rewards.
|
|
|
// It awards XP, generates loot (gold is guaranteed via GenerateLoot — no separate
|
|
|
// enemy.GoldReward add), processes equipment drops (auto-equip, else stash up to
|
|
|
// MaxInventorySlots, else discard + adventure log), runs the level-up loop,
|
|
|
// sets hero state to walking, and records loot history.
|
|
|
// Returns the drops for API response building.
|
|
|
func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop {
|
|
|
oldLevel := hero.Level
|
|
|
hero.XP += enemy.XPReward
|
|
|
levelsGained := 0
|
|
|
for hero.LevelUp() {
|
|
|
levelsGained++
|
|
|
}
|
|
|
hero.State = model.StateWalking
|
|
|
|
|
|
luckMult := game.LuckMultiplier(hero, now)
|
|
|
drops := model.GenerateLoot(enemy.Type, luckMult)
|
|
|
|
|
|
ctxTown, cancelTown := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
inTown := h.isHeroInTown(ctxTown, hero.PositionX, hero.PositionY)
|
|
|
cancelTown()
|
|
|
|
|
|
h.lootMu.Lock()
|
|
|
defer h.lootMu.Unlock()
|
|
|
|
|
|
for i := range drops {
|
|
|
drop := &drops[i]
|
|
|
|
|
|
switch drop.ItemType {
|
|
|
case "gold":
|
|
|
hero.Gold += drop.GoldAmount
|
|
|
|
|
|
case "potion":
|
|
|
hero.Potions++
|
|
|
|
|
|
default:
|
|
|
// All equipment drops go through the unified gear system.
|
|
|
slot := model.EquipmentSlot(drop.ItemType)
|
|
|
family := model.PickGearFamily(slot)
|
|
|
if family != nil {
|
|
|
ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
|
|
|
item := model.NewGearItem(family, ilvl, drop.Rarity)
|
|
|
|
|
|
// Persist the gear item to DB.
|
|
|
if h.gearStore != nil {
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
if err := h.gearStore.CreateItem(ctx, item); err != nil {
|
|
|
h.logger.Warn("failed to create gear item", "slot", slot, "error", err)
|
|
|
cancel()
|
|
|
if inTown {
|
|
|
sellPrice := model.AutoSellPrices[drop.Rarity]
|
|
|
hero.Gold += sellPrice
|
|
|
drop.GoldAmount = sellPrice
|
|
|
} else {
|
|
|
drop.GoldAmount = 0
|
|
|
}
|
|
|
goto recordLoot
|
|
|
}
|
|
|
cancel()
|
|
|
}
|
|
|
|
|
|
drop.ItemID = item.ID
|
|
|
drop.ItemName = item.Name
|
|
|
|
|
|
equipped := h.tryAutoEquipGear(hero, item, now)
|
|
|
if equipped {
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name))
|
|
|
} else {
|
|
|
hero.EnsureInventorySlice()
|
|
|
if len(hero.Inventory) >= model.MaxInventorySlots {
|
|
|
ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
if h.gearStore != nil && item.ID != 0 {
|
|
|
if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil {
|
|
|
h.logger.Warn("failed to delete gear (inventory full)", "gear_id", item.ID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
cancelDel()
|
|
|
drop.ItemID = 0
|
|
|
drop.ItemName = ""
|
|
|
drop.GoldAmount = 0
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
|
|
|
} else {
|
|
|
ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
var err error
|
|
|
if h.gearStore != nil {
|
|
|
err = h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID)
|
|
|
}
|
|
|
cancelInv()
|
|
|
if err != nil {
|
|
|
h.logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err)
|
|
|
ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
if h.gearStore != nil && item.ID != 0 {
|
|
|
_ = h.gearStore.DeleteGearItem(ctxDel, item.ID)
|
|
|
}
|
|
|
cancelDel()
|
|
|
drop.ItemID = 0
|
|
|
drop.ItemName = ""
|
|
|
drop.GoldAmount = 0
|
|
|
} else {
|
|
|
hero.Inventory = append(hero.Inventory, item)
|
|
|
drop.GoldAmount = 0
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} else if inTown {
|
|
|
sellPrice := model.AutoSellPrices[drop.Rarity]
|
|
|
hero.Gold += sellPrice
|
|
|
drop.GoldAmount = sellPrice
|
|
|
}
|
|
|
}
|
|
|
|
|
|
recordLoot:
|
|
|
entry := model.LootHistory{
|
|
|
HeroID: hero.ID,
|
|
|
EnemyType: string(enemy.Type),
|
|
|
ItemType: drop.ItemType,
|
|
|
ItemID: drop.ItemID,
|
|
|
Rarity: drop.Rarity,
|
|
|
GoldAmount: drop.GoldAmount,
|
|
|
CreatedAt: now,
|
|
|
}
|
|
|
h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry)
|
|
|
if len(h.lootCache[hero.ID]) > maxLootHistory {
|
|
|
h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-maxLootHistory:]
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Log the victory.
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, enemy.GoldReward))
|
|
|
|
|
|
// Log level-ups.
|
|
|
for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ {
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Leveled up to %d!", l))
|
|
|
}
|
|
|
|
|
|
// Stat tracking for achievements.
|
|
|
hero.TotalKills++
|
|
|
hero.KillsSinceDeath++
|
|
|
if enemy.IsElite {
|
|
|
hero.EliteKills++
|
|
|
}
|
|
|
// Track legendary drops for achievement conditions.
|
|
|
for _, drop := range drops {
|
|
|
if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" {
|
|
|
hero.LegendaryDrops++
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Quest progress hooks (fire-and-forget, errors logged but not fatal).
|
|
|
h.progressQuestsAfterKill(hero.ID, enemy)
|
|
|
|
|
|
// Achievement check (fire-and-forget).
|
|
|
h.checkAchievementsAfterKill(hero)
|
|
|
|
|
|
// Daily/weekly task progress (fire-and-forget).
|
|
|
h.progressTasksAfterKill(hero.ID, enemy, drops)
|
|
|
|
|
|
return drops
|
|
|
}
|
|
|
|
|
|
// resolveTelegramID extracts the Telegram user ID from auth context,
|
|
|
// falling back to a "telegramId" query param for dev mode (when auth middleware is disabled).
|
|
|
// On localhost, defaults to telegram_id=1 when no ID is provided.
|
|
|
func resolveTelegramID(r *http.Request) (int64, bool) {
|
|
|
if id, ok := TelegramIDFromContext(r.Context()); ok {
|
|
|
return id, true
|
|
|
}
|
|
|
// Dev fallback: accept telegramId query param.
|
|
|
idStr := r.URL.Query().Get("telegramId")
|
|
|
if idStr != "" {
|
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
|
if err == nil {
|
|
|
return id, true
|
|
|
}
|
|
|
}
|
|
|
// Localhost fallback: default to telegram_id 1 for testing.
|
|
|
host := r.Host
|
|
|
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "192.168.0.53") {
|
|
|
return 1, true
|
|
|
}
|
|
|
return 0, false
|
|
|
}
|
|
|
|
|
|
// GetHero returns the current hero state.
|
|
|
// GET /api/v1/hero
|
|
|
func (h *GameHandler) GetHero(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.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero", "telegram_id", telegramID, "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
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
needsSave := false
|
|
|
if h.engine == nil || !h.engine.IsTimePaused() {
|
|
|
needsSave = hero.EnsureBuffChargesPopulated(now)
|
|
|
if hero.ApplyBuffQuotaRollover(now) {
|
|
|
needsSave = true
|
|
|
}
|
|
|
}
|
|
|
if needsSave {
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
writeJSON(w, http.StatusOK, hero)
|
|
|
}
|
|
|
|
|
|
// ActivateBuff activates a buff on the hero.
|
|
|
// POST /api/v1/hero/buff/{buffType}
|
|
|
func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) {
|
|
|
buffTypeStr := chi.URLParam(r, "buffType")
|
|
|
|
|
|
bt, ok := model.ValidBuffType(buffTypeStr)
|
|
|
if !ok {
|
|
|
h.logger.Warn("invalid buff type requested", "buff_type", buffTypeStr)
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "invalid buff type: " + buffTypeStr,
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
telegramID, ok := resolveTelegramID(r)
|
|
|
if !ok {
|
|
|
h.logger.Warn("buff request missing telegramId", "buff_type", buffTypeStr)
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "missing telegramId",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero for buff", "telegram_id", telegramID, "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
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
hero.EnsureBuffChargesPopulated(now)
|
|
|
|
|
|
consumed := false
|
|
|
if !hero.SubscriptionActive {
|
|
|
if err := consumeFreeBuffCharge(hero, bt, now); err != nil {
|
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
|
"error": err.Error(),
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
consumed = true
|
|
|
}
|
|
|
|
|
|
ab := game.ApplyBuff(hero, bt, now)
|
|
|
if ab == nil {
|
|
|
if consumed {
|
|
|
refundFreeBuffCharge(hero, bt)
|
|
|
}
|
|
|
h.logger.Error("ApplyBuff returned nil", "hero_id", hero.ID, "buff_type", bt)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to apply buff",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Error("failed to save hero after buff", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to save hero",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
h.logger.Info("buff activated",
|
|
|
"hero_id", hero.ID,
|
|
|
"buff", bt,
|
|
|
"expires_at", ab.ExpiresAt,
|
|
|
)
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Activated %s", ab.Buff.Name))
|
|
|
|
|
|
// Daily/weekly task progress: use_buff.
|
|
|
if h.taskStore != nil {
|
|
|
taskCtx, taskCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
defer taskCancel()
|
|
|
_ = h.taskStore.EnsureHeroTasks(taskCtx, hero.ID, now)
|
|
|
if err := h.taskStore.IncrementTaskProgress(taskCtx, hero.ID, "use_buff", 1); err != nil {
|
|
|
h.logger.Warn("task use_buff progress failed", "hero_id", hero.ID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
"buff": ab,
|
|
|
"heroBuffs": hero.Buffs,
|
|
|
"hero": hero,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// ReviveHero revives an effectively dead hero (StateDead or HP <= 0) with base HP.
|
|
|
// POST /api/v1/hero/revive
|
|
|
func (h *GameHandler) ReviveHero(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.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero for revive", "telegram_id", telegramID, "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 hero.State != model.StateDead && hero.HP > 0 {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "hero is alive (state is not dead and hp > 0)",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if !hero.SubscriptionActive && hero.ReviveCount >= 2 {
|
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
|
"error": "free revive limit reached (subscribe for unlimited revives)",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// Track death stats (the hero is dead, this is the first time we process it server-side).
|
|
|
hero.TotalDeaths++
|
|
|
hero.KillsSinceDeath = 0
|
|
|
|
|
|
hero.HP = hero.MaxHP / 2
|
|
|
if hero.HP < 1 {
|
|
|
hero.HP = 1
|
|
|
}
|
|
|
hero.State = model.StateWalking
|
|
|
now := time.Now()
|
|
|
hero.Buffs = model.RemoveBuffType(hero.Buffs, model.BuffResurrection)
|
|
|
hero.Debuffs = nil
|
|
|
hero.ReviveCount++
|
|
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Error("failed to save hero after revive", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to save hero",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP)
|
|
|
h.addLog(hero.ID, "Hero revived")
|
|
|
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
writeJSON(w, http.StatusOK, hero)
|
|
|
}
|
|
|
|
|
|
// RequestEncounter picks a backend-generated enemy for the hero's current level.
|
|
|
// POST /api/v1/hero/encounter
|
|
|
func (h *GameHandler) RequestEncounter(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.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero for encounter", "telegram_id", telegramID, "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 hero.State == model.StateDead || hero.HP <= 0 {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "hero is dead",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// Check if hero is inside a town (no combat in towns)
|
|
|
posX := hero.PositionX
|
|
|
posY := hero.PositionY
|
|
|
if px := r.URL.Query().Get("posX"); px != "" {
|
|
|
if v, err := strconv.ParseFloat(px, 64); err == nil {
|
|
|
posX = v
|
|
|
}
|
|
|
}
|
|
|
if py := r.URL.Query().Get("posY"); py != "" {
|
|
|
if v, err := strconv.ParseFloat(py, 64); err == nil {
|
|
|
posY = v
|
|
|
}
|
|
|
}
|
|
|
if h.isHeroInTown(r.Context(), posX, posY) {
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
|
"type": "no_encounter",
|
|
|
"reason": "in_town",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
h.encounterMu.Lock()
|
|
|
if t, ok := h.lastCombatEncounterAt[hero.ID]; ok && now.Sub(t) < encounterCombatCooldown {
|
|
|
h.encounterMu.Unlock()
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
|
"type": "no_encounter",
|
|
|
"reason": "cooldown",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
h.encounterMu.Unlock()
|
|
|
|
|
|
// 10% chance to encounter a wandering NPC instead of an enemy.
|
|
|
if rand.Float64() < 0.10 {
|
|
|
cost := int64(20 + hero.Level*5)
|
|
|
h.addLog(hero.ID, "Encountered a Wandering Merchant on the road")
|
|
|
h.encounterMu.Lock()
|
|
|
h.lastCombatEncounterAt[hero.ID] = now
|
|
|
h.encounterMu.Unlock()
|
|
|
writeJSON(w, http.StatusOK, model.NPCEventResponse{
|
|
|
Type: "npc_event",
|
|
|
NPC: model.NPCEventNPC{
|
|
|
Name: "Wandering Merchant",
|
|
|
Role: "alms",
|
|
|
},
|
|
|
Cost: cost,
|
|
|
Reward: "random_equipment",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
enemy := pickEnemyForLevel(hero.Level)
|
|
|
h.encounterMu.Lock()
|
|
|
h.lastCombatEncounterAt[hero.ID] = now
|
|
|
h.encounterMu.Unlock()
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name))
|
|
|
writeJSON(w, http.StatusOK, encounterEnemyResponse{
|
|
|
ID: time.Now().UnixNano(),
|
|
|
Name: enemy.Name,
|
|
|
HP: enemy.MaxHP,
|
|
|
MaxHP: enemy.MaxHP,
|
|
|
Attack: enemy.Attack,
|
|
|
Defense: enemy.Defense,
|
|
|
Speed: enemy.Speed,
|
|
|
EnemyType: enemy.Type,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// isHeroInTown checks if the position is within any town's radius.
|
|
|
func (h *GameHandler) isHeroInTown(ctx context.Context, posX, posY float64) bool {
|
|
|
towns, err := h.questStore.ListTowns(ctx)
|
|
|
if err != nil || len(towns) == 0 {
|
|
|
return false
|
|
|
}
|
|
|
for _, t := range towns {
|
|
|
dx := posX - t.WorldX
|
|
|
dy := posY - t.WorldY
|
|
|
if dx*dx+dy*dy <= t.Radius*t.Radius {
|
|
|
return true
|
|
|
}
|
|
|
}
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
// pickEnemyForLevel delegates to the canonical implementation in the game package.
|
|
|
func pickEnemyForLevel(level int) model.Enemy {
|
|
|
return game.PickEnemyForLevel(level)
|
|
|
}
|
|
|
|
|
|
// tryAutoEquipGear uses the in-memory combat rating comparison to decide whether
|
|
|
// to equip a new gear item. If it improves combat rating by >= 3%, equips it
|
|
|
// (persisting to DB if gearStore is available). Returns true if equipped.
|
|
|
func (h *GameHandler) tryAutoEquipGear(hero *model.Hero, item *model.GearItem, now time.Time) bool {
|
|
|
if !game.TryAutoEquipInMemory(hero, item, now) {
|
|
|
return false
|
|
|
}
|
|
|
h.persistGearEquip(hero.ID, item)
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
// persistGearEquip saves the equip to the hero_gear table if gearStore is available.
|
|
|
func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) {
|
|
|
if h.gearStore == nil || item.ID == 0 {
|
|
|
return
|
|
|
}
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
defer cancel()
|
|
|
if err := h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID); err != nil {
|
|
|
h.logger.Warn("failed to persist gear equip", "hero_id", heroID, "slot", item.Slot, "error", err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats.
|
|
|
func pickEnemyByType(level int, t model.EnemyType) model.Enemy {
|
|
|
tmpl, ok := model.EnemyTemplates[t]
|
|
|
if !ok {
|
|
|
tmpl = model.EnemyTemplates[model.EnemyWolf]
|
|
|
}
|
|
|
return game.ScaleEnemyTemplate(tmpl, level)
|
|
|
}
|
|
|
|
|
|
type victoryRequest struct {
|
|
|
EnemyType string `json:"enemyType"`
|
|
|
HeroHP int `json:"heroHp"`
|
|
|
PositionX float64 `json:"positionX"`
|
|
|
PositionY float64 `json:"positionY"`
|
|
|
}
|
|
|
|
|
|
type victoryDropJSON struct {
|
|
|
ItemType string `json:"itemType"`
|
|
|
ItemID int64 `json:"itemId,omitempty"`
|
|
|
ItemName string `json:"itemName,omitempty"`
|
|
|
Rarity string `json:"rarity"`
|
|
|
GoldAmount int64 `json:"goldAmount"`
|
|
|
}
|
|
|
|
|
|
type victoryResponse struct {
|
|
|
Hero *model.Hero `json:"hero"`
|
|
|
Drops []victoryDropJSON `json:"drops"`
|
|
|
}
|
|
|
|
|
|
// ReportVictory applies authoritative combat rewards after a client-resolved kill.
|
|
|
// POST /api/v1/hero/victory
|
|
|
// Hero HP after the fight is taken from the client and remains persisted across fights.
|
|
|
func (h *GameHandler) ReportVictory(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 victoryRequest
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "invalid request body: " + err.Error(),
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if req.EnemyType == "" {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "enemyType is required",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
et := model.EnemyType(req.EnemyType)
|
|
|
if _, ok := model.EnemyTemplates[et]; !ok {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "unknown enemyType: " + req.EnemyType,
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero for victory", "telegram_id", telegramID, "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 hero.State == model.StateDead || hero.HP <= 0 {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "hero is dead",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
hpAfterFight := req.HeroHP
|
|
|
if hpAfterFight < 1 {
|
|
|
hpAfterFight = 1
|
|
|
}
|
|
|
if hpAfterFight > hero.MaxHP {
|
|
|
hpAfterFight = hero.MaxHP
|
|
|
}
|
|
|
if hpAfterFight > hero.HP {
|
|
|
h.logger.Warn("client reported HP higher than server HP, clamping",
|
|
|
"hero_id", hero.ID,
|
|
|
"client_hp", req.HeroHP,
|
|
|
"server_hp", hero.HP,
|
|
|
)
|
|
|
hpAfterFight = hero.HP
|
|
|
}
|
|
|
|
|
|
enemy := pickEnemyByType(hero.Level, et)
|
|
|
|
|
|
drops := h.processVictoryRewards(hero, &enemy, now)
|
|
|
|
|
|
outDrops := make([]victoryDropJSON, 0, len(drops))
|
|
|
for _, drop := range drops {
|
|
|
outDrops = append(outDrops, victoryDropJSON{
|
|
|
ItemType: drop.ItemType,
|
|
|
ItemID: drop.ItemID,
|
|
|
ItemName: drop.ItemName,
|
|
|
Rarity: string(drop.Rarity),
|
|
|
GoldAmount: drop.GoldAmount,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// Level-up does NOT restore HP (spec §3.3). Always persist the post-fight HP.
|
|
|
hero.HP = hpAfterFight
|
|
|
hero.PositionX = req.PositionX
|
|
|
hero.PositionY = req.PositionY
|
|
|
|
|
|
// Update online status for shared world.
|
|
|
nowPtr := time.Now()
|
|
|
hero.LastOnlineAt = &nowPtr
|
|
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Error("failed to save hero after victory", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to save hero",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
writeJSON(w, http.StatusOK, victoryResponse{
|
|
|
Hero: hero,
|
|
|
Drops: outDrops,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// offlineReport describes what happened while the hero was offline.
|
|
|
type offlineReport struct {
|
|
|
OfflineSeconds int `json:"offlineSeconds"`
|
|
|
MonstersKilled int `json:"monstersKilled"`
|
|
|
XPGained int64 `json:"xpGained"`
|
|
|
GoldGained int64 `json:"goldGained"`
|
|
|
LevelsGained int `json:"levelsGained"`
|
|
|
PotionsUsed int `json:"potionsUsed"`
|
|
|
PotionsFound int `json:"potionsFound"`
|
|
|
HPBefore int `json:"hpBefore"`
|
|
|
Message string `json:"message"`
|
|
|
Log []string `json:"log"`
|
|
|
}
|
|
|
|
|
|
// buildOfflineReport constructs an offline report from real adventure log entries
|
|
|
// written by the offline simulator (and catch-up). Parses log messages to count
|
|
|
// defeats, XP, gold, levels, and deaths.
|
|
|
func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero, offlineDuration time.Duration) *offlineReport {
|
|
|
if offlineDuration < 30*time.Second {
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
// Query log entries since hero was last updated (with a small buffer).
|
|
|
since := hero.UpdatedAt.Add(-5 * time.Minute)
|
|
|
entries, err := h.logStore.GetSince(ctx, hero.ID, since, 200)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get offline log entries", "hero_id", hero.ID, "error", err)
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
if len(entries) == 0 {
|
|
|
// No offline activity recorded.
|
|
|
if hero.State == model.StateDead {
|
|
|
return &offlineReport{
|
|
|
OfflineSeconds: int(offlineDuration.Seconds()),
|
|
|
HPBefore: 0,
|
|
|
Message: "Your hero remains dead. Revive to continue progression.",
|
|
|
Log: []string{},
|
|
|
}
|
|
|
}
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
report := &offlineReport{
|
|
|
OfflineSeconds: int(offlineDuration.Seconds()),
|
|
|
HPBefore: hero.HP,
|
|
|
Log: make([]string, 0, len(entries)),
|
|
|
}
|
|
|
|
|
|
for _, entry := range entries {
|
|
|
report.Log = append(report.Log, entry.Message)
|
|
|
|
|
|
// Parse structured log messages to populate summary counters.
|
|
|
// Messages written by the offline simulator follow known patterns.
|
|
|
if matched, _ := parseDefeatedLog(entry.Message); matched {
|
|
|
report.MonstersKilled++
|
|
|
}
|
|
|
if xp, gold, ok := parseGainsLog(entry.Message); ok {
|
|
|
report.XPGained += xp
|
|
|
report.GoldGained += gold
|
|
|
}
|
|
|
if isLevelUpLog(entry.Message) {
|
|
|
report.LevelsGained++
|
|
|
}
|
|
|
if isDeathLog(entry.Message) {
|
|
|
// Death was recorded
|
|
|
}
|
|
|
if isPotionLog(entry.Message) {
|
|
|
report.PotionsUsed++
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if hero.State == model.StateDead {
|
|
|
report.Message = "Your hero died while offline. Revive to continue progression."
|
|
|
} else if report.MonstersKilled > 0 {
|
|
|
report.Message = "Your hero fought while you were away!"
|
|
|
} else {
|
|
|
report.Message = "Your hero rested while you were away."
|
|
|
}
|
|
|
|
|
|
return report
|
|
|
}
|
|
|
|
|
|
// catchUpOfflineGap simulates the gap between hero.UpdatedAt and serverStartedAt.
|
|
|
// This covers the period when the server was down and the offline simulator wasn't running.
|
|
|
// Returns true if any simulation was performed.
|
|
|
func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) bool {
|
|
|
if h.engine != nil && h.engine.IsTimePaused() {
|
|
|
return false
|
|
|
}
|
|
|
gapDuration := h.serverStartedAt.Sub(hero.UpdatedAt)
|
|
|
if gapDuration < 30*time.Second {
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
// Cap at 8 hours.
|
|
|
if gapDuration > 8*time.Hour {
|
|
|
gapDuration = 8 * time.Hour
|
|
|
}
|
|
|
|
|
|
// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3).
|
|
|
if (hero.State == model.StateDead || hero.HP <= 0) && gapDuration > 1*time.Hour {
|
|
|
hero.HP = hero.MaxHP / 2
|
|
|
if hero.HP < 1 {
|
|
|
hero.HP = 1
|
|
|
}
|
|
|
hero.State = model.StateWalking
|
|
|
hero.Debuffs = nil
|
|
|
h.addLog(hero.ID, "Auto-revived after 1 hour")
|
|
|
}
|
|
|
|
|
|
totalFights := int(gapDuration.Seconds()) / 10
|
|
|
if totalFights <= 0 {
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
performed := false
|
|
|
|
|
|
for i := 0; i < totalFights; i++ {
|
|
|
if hero.HP <= 0 || hero.State == model.StateDead {
|
|
|
break
|
|
|
}
|
|
|
|
|
|
var rg *game.RoadGraph
|
|
|
if h.engine != nil {
|
|
|
rg = h.engine.RoadGraph()
|
|
|
}
|
|
|
survived, enemy, xpGained, goldGained := game.SimulateOneFight(hero, now, nil, rg, func(msg string) {
|
|
|
h.addLog(hero.ID, msg)
|
|
|
})
|
|
|
performed = true
|
|
|
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name))
|
|
|
if survived {
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, xpGained, goldGained))
|
|
|
} else {
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Died fighting %s", enemy.Name))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return performed
|
|
|
}
|
|
|
|
|
|
// parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern.
|
|
|
func parseDefeatedLog(msg string) (bool, string) {
|
|
|
if len(msg) > 9 && msg[:9] == "Defeated " {
|
|
|
return true, msg[9:]
|
|
|
}
|
|
|
return false, ""
|
|
|
}
|
|
|
|
|
|
// parseGainsLog parses "Defeated X, gained N XP and M gold" to extract XP and gold.
|
|
|
func parseGainsLog(msg string) (xp int64, gold int64, ok bool) {
|
|
|
// Pattern: "Defeated ..., gained %d XP and %d gold"
|
|
|
// Find ", gained " as the separator since enemy names may contain spaces.
|
|
|
const sep = ", gained "
|
|
|
idx := -1
|
|
|
for i := 0; i <= len(msg)-len(sep); i++ {
|
|
|
if msg[i:i+len(sep)] == sep {
|
|
|
idx = i
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
if idx < 0 {
|
|
|
return 0, 0, false
|
|
|
}
|
|
|
tail := msg[idx+len(sep):]
|
|
|
n, _ := fmt.Sscanf(tail, "%d XP and %d gold", &xp, &gold)
|
|
|
if n >= 2 {
|
|
|
return xp, gold, true
|
|
|
}
|
|
|
return 0, 0, false
|
|
|
}
|
|
|
|
|
|
// isLevelUpLog checks if a message is a level-up log.
|
|
|
func isLevelUpLog(msg string) bool {
|
|
|
return len(msg) > 12 && msg[:12] == "Leveled up t"
|
|
|
}
|
|
|
|
|
|
// isDeathLog checks if a message is a death log.
|
|
|
func isDeathLog(msg string) bool {
|
|
|
return len(msg) > 14 && msg[:14] == "Died fighting "
|
|
|
}
|
|
|
|
|
|
// isPotionLog checks if a message is a potion usage log.
|
|
|
func isPotionLog(msg string) bool {
|
|
|
return len(msg) > 20 && msg[:20] == "Used healing potion,"
|
|
|
}
|
|
|
|
|
|
// InitHero returns the hero for the given Telegram user, creating one with defaults if needed.
|
|
|
// Also simulates offline progress based on time since last update.
|
|
|
// GET /api/v1/hero/init
|
|
|
func (h *GameHandler) InitHero(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.store.GetOrCreate(r.Context(), telegramID, "Hero")
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to init hero", "telegram_id", telegramID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to init hero",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
simFrozen := h.engine != nil && h.engine.IsTimePaused()
|
|
|
if !simFrozen {
|
|
|
chargesInit := hero.EnsureBuffChargesPopulated(now)
|
|
|
quotaRolled := hero.ApplyBuffQuotaRollover(now)
|
|
|
if chargesInit || quotaRolled {
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Catch-up simulation: cover the gap between hero.UpdatedAt and serverStartedAt
|
|
|
// (the period when the server was down and the offline simulator wasn't running).
|
|
|
offlineDuration := time.Since(hero.UpdatedAt)
|
|
|
var catchUpPerformed bool
|
|
|
if !simFrozen && hero.UpdatedAt.Before(h.serverStartedAt) && hero.State == model.StateWalking && hero.HP > 0 {
|
|
|
catchUpPerformed = h.catchUpOfflineGap(r.Context(), hero)
|
|
|
}
|
|
|
|
|
|
// Build offline report from real adventure log entries (written by the
|
|
|
// offline simulator and/or the catch-up above).
|
|
|
report := h.buildOfflineReport(r.Context(), hero, offlineDuration)
|
|
|
|
|
|
if catchUpPerformed {
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Error("failed to save hero after catch-up sim", "hero_id", hero.ID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3).
|
|
|
if !simFrozen && (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > 1*time.Hour {
|
|
|
hero.HP = hero.MaxHP / 2
|
|
|
if hero.HP < 1 {
|
|
|
hero.HP = 1
|
|
|
}
|
|
|
hero.State = model.StateWalking
|
|
|
hero.Debuffs = nil
|
|
|
h.addLog(hero.ID, "Auto-revived after 1 hour")
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Error("failed to save hero after auto-revive", "hero_id", hero.ID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
needsName := hero.Name == "" || hero.Name == "Hero"
|
|
|
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
|
|
|
// Build towns with NPCs for the frontend map.
|
|
|
townsWithNPCs := h.buildTownsWithNPCs(r.Context())
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
"hero": hero,
|
|
|
"needsName": needsName,
|
|
|
"offlineReport": report,
|
|
|
"mapRef": h.world.RefForLevel(hero.Level),
|
|
|
"towns": townsWithNPCs,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
|
|
|
// buildTownsWithNPCs loads all towns and their NPCs, returning a slice of
|
|
|
// TownWithNPCs suitable for the frontend map render.
|
|
|
func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNPCs {
|
|
|
towns, err := h.questStore.ListTowns(ctx)
|
|
|
if err != nil {
|
|
|
h.logger.Warn("failed to load towns for init response", "error", err)
|
|
|
return []model.TownWithNPCs{}
|
|
|
}
|
|
|
|
|
|
allNPCs, err := h.questStore.ListAllNPCs(ctx)
|
|
|
if err != nil {
|
|
|
h.logger.Warn("failed to load npcs for init response", "error", err)
|
|
|
return []model.TownWithNPCs{}
|
|
|
}
|
|
|
|
|
|
// Group NPCs by town ID for O(1) lookup.
|
|
|
npcsByTown := make(map[int64][]model.NPC, len(towns))
|
|
|
for _, n := range allNPCs {
|
|
|
npcsByTown[n.TownID] = append(npcsByTown[n.TownID], n)
|
|
|
}
|
|
|
|
|
|
result := make([]model.TownWithNPCs, 0, len(towns))
|
|
|
for _, t := range towns {
|
|
|
tw := model.TownWithNPCs{
|
|
|
ID: t.ID,
|
|
|
Name: t.Name,
|
|
|
Biome: t.Biome,
|
|
|
WorldX: t.WorldX,
|
|
|
WorldY: t.WorldY,
|
|
|
Radius: t.Radius,
|
|
|
Size: model.TownSizeFromRadius(t.Radius),
|
|
|
NPCs: make([]model.NPCView, 0),
|
|
|
}
|
|
|
for _, n := range npcsByTown[t.ID] {
|
|
|
tw.NPCs = append(tw.NPCs, model.NPCView{
|
|
|
ID: n.ID,
|
|
|
Name: n.Name,
|
|
|
Type: n.Type,
|
|
|
WorldX: t.WorldX + n.OffsetX,
|
|
|
WorldY: t.WorldY + n.OffsetY,
|
|
|
})
|
|
|
}
|
|
|
result = append(result, tw)
|
|
|
}
|
|
|
return result
|
|
|
}
|
|
|
|
|
|
// heroNameRequest is the JSON body for the set-name endpoint.
|
|
|
type heroNameRequest struct {
|
|
|
Name string `json:"name"`
|
|
|
}
|
|
|
|
|
|
// isValidHeroName checks that a name is 2-16 chars, only latin/cyrillic letters and digits,
|
|
|
// no leading/trailing spaces.
|
|
|
func isValidHeroName(name string) bool {
|
|
|
if len(name) < 2 || len(name) > 16 {
|
|
|
return false
|
|
|
}
|
|
|
if name[0] == ' ' || name[len(name)-1] == ' ' {
|
|
|
return false
|
|
|
}
|
|
|
for _, r := range name {
|
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
|
|
continue
|
|
|
}
|
|
|
if (r >= '0' && r <= '9') {
|
|
|
continue
|
|
|
}
|
|
|
// Cyrillic block: U+0400 to U+04FF
|
|
|
if r >= 0x0400 && r <= 0x04FF {
|
|
|
continue
|
|
|
}
|
|
|
return false
|
|
|
}
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
// SetHeroName sets the hero's name (first-time setup only).
|
|
|
// POST /api/v1/hero/name
|
|
|
func (h *GameHandler) SetHeroName(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 heroNameRequest
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "invalid request body",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if !isValidHeroName(req.Name) {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "invalid name: must be 2-16 characters, letters (latin/cyrillic) and digits only",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero for name", "telegram_id", telegramID, "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
|
|
|
}
|
|
|
|
|
|
// Only allow name change if it hasn't been set yet.
|
|
|
if hero.Name != "" && hero.Name != "Hero" {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "name already set",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if err := h.store.SaveName(r.Context(), hero.ID, req.Name); err != nil {
|
|
|
// Check for unique constraint violation (pgx wraps the error with code 23505).
|
|
|
errStr := err.Error()
|
|
|
if containsUniqueViolation(errStr) {
|
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
|
"error": "HERO_NAME_TAKEN",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
h.logger.Error("failed to save hero name", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to save name",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
hero.Name = req.Name
|
|
|
h.logger.Info("hero name set", "hero_id", hero.ID, "name", req.Name)
|
|
|
|
|
|
now := time.Now()
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
writeJSON(w, http.StatusOK, hero)
|
|
|
}
|
|
|
|
|
|
// containsUniqueViolation checks if an error message indicates a PostgreSQL unique violation.
|
|
|
func containsUniqueViolation(errStr string) bool {
|
|
|
// pgx includes the SQLSTATE code 23505 for unique_violation.
|
|
|
for _, marker := range []string{"23505", "unique", "UNIQUE", "duplicate key"} {
|
|
|
for i := 0; i <= len(errStr)-len(marker); i++ {
|
|
|
if errStr[i:i+len(marker)] == marker {
|
|
|
return true
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
// buffRefillRequest is the JSON body for the purchase-buff-refill endpoint.
|
|
|
type buffRefillRequest struct {
|
|
|
BuffType string `json:"buffType"`
|
|
|
}
|
|
|
|
|
|
// PurchaseBuffRefill purchases a buff charge refill.
|
|
|
// POST /api/v1/hero/purchase-buff-refill
|
|
|
func (h *GameHandler) PurchaseBuffRefill(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 buffRefillRequest
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "invalid request body",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
bt, ok := model.ValidBuffType(req.BuffType)
|
|
|
if !ok {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "invalid buff type: " + req.BuffType,
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero for buff refill", "telegram_id", telegramID, "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
|
|
|
}
|
|
|
|
|
|
// Determine price.
|
|
|
priceRUB := model.BuffRefillPriceRUB
|
|
|
paymentType := model.PaymentBuffReplenish
|
|
|
if bt == model.BuffResurrection {
|
|
|
priceRUB = model.ResurrectionRefillPriceRUB
|
|
|
paymentType = model.PaymentResurrectionReplenish
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
// Create a payment record and auto-complete it (no real payment gateway yet).
|
|
|
payment := &model.Payment{
|
|
|
HeroID: hero.ID,
|
|
|
Type: paymentType,
|
|
|
BuffType: string(bt),
|
|
|
AmountRUB: priceRUB,
|
|
|
Status: model.PaymentCompleted,
|
|
|
CreatedAt: now,
|
|
|
CompletedAt: &now,
|
|
|
}
|
|
|
if err := h.store.CreatePayment(r.Context(), payment); err != nil {
|
|
|
h.logger.Error("failed to create payment", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to process payment",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// Refill the specific buff's charges to max.
|
|
|
hero.ResetBuffCharges(&bt, now)
|
|
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Error("failed to save hero after buff refill", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to save hero",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
h.logger.Info("purchased buff refill",
|
|
|
"hero_id", hero.ID,
|
|
|
"buff_type", bt,
|
|
|
"price_rub", priceRUB,
|
|
|
)
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt))
|
|
|
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
writeJSON(w, http.StatusOK, hero)
|
|
|
}
|
|
|
|
|
|
// PurchaseSubscription purchases a weekly subscription (x2 buffs, x2 revives).
|
|
|
// POST /api/v1/hero/purchase-subscription
|
|
|
func (h *GameHandler) PurchaseSubscription(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.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("subscription: load hero failed", "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
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
payment := &model.Payment{
|
|
|
HeroID: hero.ID,
|
|
|
Type: "subscription_weekly",
|
|
|
AmountRUB: model.SubscriptionWeeklyPriceRUB,
|
|
|
Status: model.PaymentCompleted,
|
|
|
CreatedAt: now,
|
|
|
CompletedAt: &now,
|
|
|
}
|
|
|
if err := h.store.CreatePayment(r.Context(), payment); err != nil {
|
|
|
h.logger.Error("subscription: payment failed", "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to process payment"})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
hero.ActivateSubscription(now)
|
|
|
|
|
|
// Upgrade buff charges to subscriber limits immediately.
|
|
|
hero.EnsureBuffChargesPopulated(now)
|
|
|
for bt := range model.BuffFreeChargesPerType {
|
|
|
state := hero.GetBuffCharges(bt, now)
|
|
|
subMax := hero.MaxBuffCharges(bt)
|
|
|
if state.Remaining < subMax {
|
|
|
state.Remaining = subMax
|
|
|
hero.BuffCharges[string(bt)] = state
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Error("subscription: save failed", "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt)
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPriceRUB))
|
|
|
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
"hero": hero,
|
|
|
"expiresAt": hero.SubscriptionExpiresAt,
|
|
|
"priceRub": model.SubscriptionWeeklyPriceRUB,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// GetLoot returns the hero's recent loot history.
|
|
|
// GET /api/v1/hero/loot
|
|
|
func (h *GameHandler) GetLoot(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.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil || hero == nil {
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
"loot": []model.LootHistory{},
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
h.lootMu.RLock()
|
|
|
loot := h.lootCache[hero.ID]
|
|
|
h.lootMu.RUnlock()
|
|
|
|
|
|
if loot == nil {
|
|
|
loot = []model.LootHistory{}
|
|
|
}
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
"loot": loot,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// UsePotion consumes a healing potion, restoring 30% of maxHP.
|
|
|
// POST /api/v1/hero/use-potion
|
|
|
func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
|
|
|
telegramID, ok := resolveTelegramID(r)
|
|
|
if !ok {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "missing telegramId",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
storeHero, err := h.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil || storeHero == nil {
|
|
|
h.logger.Error("failed to get hero for potion", "telegram_id", telegramID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to load hero",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
var hero = h.engine.GetMovements(storeHero.ID).Hero
|
|
|
if hero == nil {
|
|
|
writeJSON(w, http.StatusNotFound, map[string]string{
|
|
|
"error": "hero not found",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if hero.Potions <= 0 {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "no potions available",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if hero.State == model.StateDead || hero.HP <= 0 {
|
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
|
"error": "hero is dead",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// Heal 30% of maxHP, capped at maxHP.
|
|
|
healAmount := hero.MaxHP * 30 / 100
|
|
|
if healAmount < 1 {
|
|
|
healAmount = 1
|
|
|
}
|
|
|
hero.HP += healAmount
|
|
|
if hero.HP > hero.MaxHP {
|
|
|
hero.HP = hero.MaxHP
|
|
|
}
|
|
|
hero.Potions--
|
|
|
|
|
|
if err := h.store.Save(r.Context(), hero); err != nil {
|
|
|
h.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to save hero",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount))
|
|
|
|
|
|
now := time.Now()
|
|
|
hero.RefreshDerivedCombatStats(now)
|
|
|
writeJSON(w, http.StatusOK, hero)
|
|
|
}
|
|
|
|
|
|
// GetAdventureLog returns the hero's recent adventure log entries.
|
|
|
// GET /api/v1/hero/log
|
|
|
func (h *GameHandler) GetAdventureLog(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.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil || hero == nil {
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
"log": []storage.LogEntry{},
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
limitStr := r.URL.Query().Get("limit")
|
|
|
limit := 50
|
|
|
if limitStr != "" {
|
|
|
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
|
|
|
limit = parsed
|
|
|
}
|
|
|
}
|
|
|
|
|
|
entries, err := h.logStore.GetRecent(r.Context(), hero.ID, limit)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get adventure log", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to load adventure log",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
"log": entries,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// GetWeapons returns gear catalog entries for the main_hand slot (backward-compatible).
|
|
|
// GET /api/v1/weapons
|
|
|
func (h *GameHandler) GetWeapons(w http.ResponseWriter, r *http.Request) {
|
|
|
var items []model.GearFamily
|
|
|
for _, gf := range model.GearCatalog {
|
|
|
if gf.Slot == model.SlotMainHand {
|
|
|
items = append(items, gf)
|
|
|
}
|
|
|
}
|
|
|
writeJSON(w, http.StatusOK, items)
|
|
|
}
|
|
|
|
|
|
// GetArmor returns gear catalog entries for the chest slot (backward-compatible).
|
|
|
// GET /api/v1/armor
|
|
|
func (h *GameHandler) GetArmor(w http.ResponseWriter, r *http.Request) {
|
|
|
var items []model.GearFamily
|
|
|
for _, gf := range model.GearCatalog {
|
|
|
if gf.Slot == model.SlotChest {
|
|
|
items = append(items, gf)
|
|
|
}
|
|
|
}
|
|
|
writeJSON(w, http.StatusOK, items)
|
|
|
}
|
|
|
|
|
|
// GetGearCatalog returns the full unified gear catalog.
|
|
|
// GET /api/v1/gear/catalog
|
|
|
func (h *GameHandler) GetGearCatalog(w http.ResponseWriter, r *http.Request) {
|
|
|
writeJSON(w, http.StatusOK, model.GearCatalog)
|
|
|
}
|
|
|
|
|
|
// GetHeroGear returns all equipped gear for the hero organized by slot.
|
|
|
// GET /api/v1/hero/gear
|
|
|
func (h *GameHandler) GetHeroGear(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.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero for gear", "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
|
|
|
}
|
|
|
|
|
|
writeJSON(w, http.StatusOK, hero.Gear)
|
|
|
}
|
|
|
|
|
|
// NearbyHeroes returns other heroes near the requesting hero for shared world rendering.
|
|
|
// GET /api/v1/hero/nearby
|
|
|
func (h *GameHandler) NearbyHeroes(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.store.GetByTelegramID(r.Context(), telegramID)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get hero for nearby", "telegram_id", telegramID, "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
|
|
|
}
|
|
|
|
|
|
// Default radius: 500 units, max 50 heroes.
|
|
|
radius := 500.0
|
|
|
if rStr := r.URL.Query().Get("radius"); rStr != "" {
|
|
|
if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 {
|
|
|
radius = parsed
|
|
|
}
|
|
|
}
|
|
|
if radius > 2000 {
|
|
|
radius = 2000
|
|
|
}
|
|
|
|
|
|
nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, hero.PositionX, hero.PositionY, radius, 50)
|
|
|
if err != nil {
|
|
|
h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err)
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
|
"error": "failed to load nearby heroes",
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
"heroes": nearby,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
w.WriteHeader(status)
|
|
|
json.NewEncoder(w).Encode(v)
|
|
|
}
|
|
|
|
|
|
// checkAchievementsAfterKill runs achievement condition checks and applies rewards.
|
|
|
func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) {
|
|
|
if h.achievementStore == nil {
|
|
|
return
|
|
|
}
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
|
defer cancel()
|
|
|
|
|
|
newlyUnlocked, err := h.achievementStore.CheckAndUnlock(ctx, hero)
|
|
|
if err != nil {
|
|
|
h.logger.Warn("achievement check failed", "hero_id", hero.ID, "error", err)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
for _, a := range newlyUnlocked {
|
|
|
// Apply reward.
|
|
|
switch a.RewardType {
|
|
|
case "gold":
|
|
|
hero.Gold += int64(a.RewardAmount)
|
|
|
case "potion":
|
|
|
hero.Potions += a.RewardAmount
|
|
|
}
|
|
|
h.addLog(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// progressTasksAfterKill increments daily/weekly task progress after a kill.
|
|
|
func (h *GameHandler) progressTasksAfterKill(heroID int64, enemy *model.Enemy, drops []model.LootDrop) {
|
|
|
if h.taskStore == nil {
|
|
|
return
|
|
|
}
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
|
defer cancel()
|
|
|
|
|
|
// Ensure tasks exist for current period.
|
|
|
if err := h.taskStore.EnsureHeroTasks(ctx, heroID, time.Now()); err != nil {
|
|
|
h.logger.Warn("task ensure failed", "hero_id", heroID, "error", err)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// kill_count tasks.
|
|
|
if err := h.taskStore.IncrementTaskProgress(ctx, heroID, "kill_count", 1); err != nil {
|
|
|
h.logger.Warn("task kill_count progress failed", "hero_id", heroID, "error", err)
|
|
|
}
|
|
|
|
|
|
// elite_kill tasks.
|
|
|
if enemy.IsElite {
|
|
|
if err := h.taskStore.IncrementTaskProgress(ctx, heroID, "elite_kill", 1); err != nil {
|
|
|
h.logger.Warn("task elite_kill progress failed", "hero_id", heroID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// collect_gold tasks: sum gold gained from all drops.
|
|
|
var goldGained int64
|
|
|
for _, drop := range drops {
|
|
|
if drop.ItemType == "gold" {
|
|
|
goldGained += drop.GoldAmount
|
|
|
} else if drop.GoldAmount > 0 {
|
|
|
goldGained += drop.GoldAmount // auto-sell gold
|
|
|
}
|
|
|
}
|
|
|
if goldGained > 0 {
|
|
|
if err := h.taskStore.IncrementTaskProgress(ctx, heroID, "collect_gold", int(goldGained)); err != nil {
|
|
|
h.logger.Warn("task collect_gold progress failed", "hero_id", heroID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// HandleHeroDeath updates stat tracking when a hero dies.
|
|
|
// Should be called whenever a hero transitions to dead state.
|
|
|
func (h *GameHandler) HandleHeroDeath(hero *model.Hero) {
|
|
|
hero.TotalDeaths++
|
|
|
hero.KillsSinceDeath = 0
|
|
|
}
|
|
|
|
|
|
// progressQuestsAfterKill updates quest progress for kill_count and collect_item quests.
|
|
|
func (h *GameHandler) progressQuestsAfterKill(heroID int64, enemy *model.Enemy) {
|
|
|
if h.questStore == nil {
|
|
|
return
|
|
|
}
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
|
defer cancel()
|
|
|
|
|
|
// kill_count quests: increment with the specific enemy type.
|
|
|
if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", string(enemy.Type), 1); err != nil {
|
|
|
h.logger.Warn("quest kill_count progress failed", "hero_id", heroID, "error", err)
|
|
|
}
|
|
|
|
|
|
// collect_item quests: roll per-quest drop chance.
|
|
|
if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, string(enemy.Type)); err != nil {
|
|
|
h.logger.Warn("quest collect_item progress failed", "hero_id", heroID, "error", err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// NOTE: processExtendedEquipmentDrop and tryAutoEquipExtended have been removed.
|
|
|
// All equipment slot drops are now handled uniformly in processVictoryRewards
|
|
|
// via the unified GearCatalog and tryAutoEquipGear.
|