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.

1730 lines
50 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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.55.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.