package handler import ( "context" "encoding/json" "errors" "log/slog" "math/rand" "net/http" "strconv" "strings" "sync" "time" "unicode/utf8" hero_actions "github.com/denisovdennis/autohero/internal/hero" "github.com/go-chi/chi/v5" "golang.org/x/text/unicode/norm" "github.com/denisovdennis/autohero/internal/changelog" "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/profanity" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/version" "github.com/denisovdennis/autohero/internal/world" ) type GameHandler struct { engine *game.Engine store *storage.HeroStore logStore *storage.LogStore digestStore *storage.OfflineDigestStore 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"` Level int `json:"level,omitempty"` HP int `json:"hp"` MaxHP int `json:"maxHp"` Attack int `json:"attack"` Defense int `json:"defense"` Speed float64 `json:"speed"` EnemyType string `json:"enemyType"` // slug (enemies.type) } func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, digestStore *storage.OfflineDigestStore, 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, digestStore: digestStore, 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 } // addLogLine is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS. func (h *GameHandler) addLogLine(heroID int64, line model.AdventureLogLine) { if h.logStore == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := h.logStore.Add(ctx, heroID, line); err != nil { h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) return } if h.hub != nil { h.hub.SendToHero(heroID, "adventure_log_line", line) } } // 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) []model.LootDrop { return h.processVictoryRewards(hero, enemy, now) } // processVictoryRewards is the single source of truth for post-kill rewards. // It awards XP, generates loot (gold/equipment via GenerateLoot + tuning; 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 { h.lootMu.Lock() defer h.lootMu.Unlock() return game.ApplyVictoryRewards(hero, enemy, now, game.VictoryRewardDeps{ GearStore: h.gearStore, QuestProgressor: h.questStore, AchievementCheck: h.achievementStore, TaskProgressor: h.taskStore, LogWriter: h.addLogLine, InTown: func(ctx context.Context, posX, posY float64) bool { return h.isHeroInTown(ctx, posX, posY) }, LootRecorder: func(entry model.LootHistory) { h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry) lootHistoryLimit := int(tuning.Get().LootHistoryLimit) if lootHistoryLimit < 1 { lootHistoryLimit = int(tuning.DefaultValues().LootHistoryLimit) } if len(h.lootCache[hero.ID]) > lootHistoryLimit { h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:] } }, Logger: h.logger, }) } // 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) } } if h.engine != nil && !h.engine.IsTimePaused() && h.engine.MergeResidentHeroState(hero) { if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Warn("failed to persist engine-merged hero on get", "hero_id", hero.ID, "error", err) } } hero.RefreshDerivedCombatStats(now) writeHeroJSON(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) if err := consumeFreeBuffCharge(hero, bt, now); err != nil { writeJSON(w, http.StatusForbidden, map[string]string{ "error": err.Error(), }) return } ab := game.ApplyBuff(hero, bt, now) if ab == nil { 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.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseBuffActivated, Args: map[string]any{"buffType": string(bt)}, }, }) // 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) model.AttachDebuffCatalogForClient(hero) 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 } if h.engine == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "error": "game engine unavailable", }) return } hero := h.engine.LiveHeroByTelegramID(telegramID) if hero == nil { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "no active hero session; connect to revive", }) return } if !game.IsEffectivelyDead(hero) { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "hero is alive (state is not dead and hp > 0)", }) return } if err := game.CheckPlayerReviveQuota(hero); err != nil { if errors.Is(err, game.ErrReviveQuotaExceeded) { writeJSON(w, http.StatusForbidden, map[string]string{ "error": "free revive limit reached (subscribe for unlimited revives)", }) return } writeJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) return } now := time.Now() game.ApplyHeroReviveMechanical(hero) game.ApplyPlayerReviveProgressCounters(hero) 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.engine.ApplyAdminHeroRevive(hero) h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP) h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}}) hero.RefreshDerivedCombatStats(now) writeHeroJSON(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) { h.logger.Info("rest encounter: no encounter", "hero_id", hero.ID, "hero_level", hero.Level, "reason", "in_town", "pos_x", posX, "pos_y", posY, ) writeJSON(w, http.StatusOK, map[string]string{ "type": "no_encounter", "reason": "in_town", }) return } now := time.Now() cfg := tuning.Get() h.encounterMu.Lock() if t, ok := h.lastCombatEncounterAt[hero.ID]; ok && now.Sub(t) < time.Duration(cfg.RESTEncounterCooldownMs)*time.Millisecond { remain := time.Duration(cfg.RESTEncounterCooldownMs)*time.Millisecond - now.Sub(t) h.encounterMu.Unlock() h.logger.Info("rest encounter: no encounter", "hero_id", hero.ID, "hero_level", hero.Level, "reason", "cooldown", "cooldown_remaining_ms", remain.Milliseconds(), ) 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() < cfg.RESTEncounterNPCChance { cost := game.WanderingMerchantCost(hero.Level) h.logger.Info("rest encounter: wandering merchant", "hero_id", hero.ID, "hero_level", hero.Level, "cost", cost, "npc_chance", cfg.RESTEncounterNPCChance, ) h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant}}) h.encounterMu.Lock() h.lastCombatEncounterAt[hero.ID] = now h.encounterMu.Unlock() writeJSON(w, http.StatusOK, model.NPCEventResponse{ Type: "npc_event", NPC: model.NPCEventNPC{ Name: "Gillen Porter", NameKey: model.WanderingMerchantNPCKey, Role: "alms", }, Cost: cost, Reward: "random_equipment", }) return } enemy := pickEnemyForHero(hero) mult := game.EnemyEncounterMultiplierBreakdownForHero(hero) h.logger.Info("rest encounter: enemy generated", "hero_id", hero.ID, "hero_level", hero.Level, "enemy_slug", enemy.Slug, "enemy_level", enemy.Level, "enemy_max_hp", enemy.MaxHP, "enemy_attack", enemy.Attack, "enemy_defense", enemy.Defense, "enemy_speed", enemy.Speed, "mult_global_encounter", mult.GlobalEncounterStatMultiplier, "mult_unequipped_config", mult.UnequippedHeroStatMultiplier, "mult_unequipped_applied", mult.UnequippedScalingApplied, ) h.encounterMu.Lock() h.lastCombatEncounterAt[hero.ID] = now h.encounterMu.Unlock() h.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseEncounteredEnemy, Args: map[string]any{"enemyType": enemy.Slug}, }, }) writeJSON(w, http.StatusOK, encounterEnemyResponse{ ID: time.Now().UnixNano(), Name: enemy.Name, Level: enemy.Level, HP: enemy.MaxHP, MaxHP: enemy.MaxHP, Attack: enemy.Attack, Defense: enemy.Defense, Speed: enemy.Speed, EnemyType: enemy.Slug, }) } // 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 } // pickEnemyForHero delegates to the canonical implementation in the game package. func pickEnemyForHero(hero *model.Hero) model.Enemy { return game.PickEnemyForHero(hero) } // tryAutoEquipGear uses the in-memory combat rating comparison to decide whether // to equip a new gear item. If it clears the runtime-configured improvement threshold, 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 { hero.EnsureGearMap() slot := item.Slot var prev *model.GearItem if hero.Gear != nil { prev = hero.Gear[slot] } if !game.TryAutoEquipInMemory(hero, item, now) { return false } err := h.persistGearEquip(hero.ID, item) if err != nil { if prev == nil { delete(hero.Gear, slot) } else { hero.Gear[slot] = prev } hero.RefreshDerivedCombatStats(now) if errors.Is(err, storage.ErrInventoryFull) { h.logger.Warn("persist gear equip skipped: inventory full (free a slot to swap)", "hero_id", hero.ID, "slot", item.Slot) } else { h.logger.Warn("failed to persist gear equip", "hero_id", hero.ID, "slot", item.Slot, "error", err) } return false } if prev != nil && prev.ID != item.ID { hero.EnsureInventorySlice() hero.Inventory = append(hero.Inventory, prev) } return true } // persistGearEquip saves the equip to the hero_gear table if gearStore is available. func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) error { if h.gearStore == nil || item.ID == 0 { return nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID) } // pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats. func pickEnemyByType(level int, slug string) (model.Enemy, bool) { tmpl, ok := model.EnemyBySlug(slug) if !ok { return model.Enemy{}, false } return game.ScaleEnemyTemplate(tmpl, level), true } 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) { if r.Header.Get("X-Allow-Legacy-Victory") != "1" { writeJSON(w, http.StatusGone, map[string]string{ "error": "client-side victory flow removed; combat rewards are server-authoritative", }) return } 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 } if _, ok := model.EnemyBySlug(req.EnemyType); !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, ok := pickEnemyByType(hero.Level, req.EnemyType) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "unknown enemyType: " + req.EnemyType, }) return } 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) model.AttachDebuffCatalogForClient(hero) 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"` Deaths int `json:"deaths"` Revives int `json:"revives"` Loot []model.LootDrop `json:"loot"` HPBefore int `json:"hpBefore"` Message string `json:"message"` } // buildOfflineReportFromDigest builds the API payload from hero_offline_digest (cleared in InitHero). func (h *GameHandler) buildOfflineReportFromDigest(hero *model.Hero, offlineDuration time.Duration, d storage.OfflineDigestRow) *offlineReport { empty := d.MonstersKilled == 0 && d.XPGained == 0 && d.GoldGained == 0 && d.LevelsGained == 0 && d.Deaths == 0 && d.Revives == 0 && len(d.Loot) == 0 if empty { if hero.State == model.StateDead { return &offlineReport{ OfflineSeconds: int(offlineDuration.Seconds()), HPBefore: hero.HP, Message: "Your hero remains dead. Revive to continue progression.", Loot: []model.LootDrop{}, } } return nil } report := &offlineReport{ OfflineSeconds: int(offlineDuration.Seconds()), MonstersKilled: d.MonstersKilled, XPGained: d.XPGained, GoldGained: d.GoldGained, LevelsGained: d.LevelsGained, Deaths: d.Deaths, Revives: d.Revives, Loot: d.Loot, HPBefore: hero.HP, } if report.Loot == nil { report.Loot = []model.LootDrop{} } if hero.State == model.StateDead { report.Message = "Your hero died while offline. Revive to continue progression." } else if d.MonstersKilled > 0 || d.XPGained > 0 || d.GoldGained > 0 { report.Message = "Your hero fought while you were away!" } else if d.Deaths > 0 || d.Revives > 0 { report.Message = "Your hero had a rough time 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 } // Engine already advanced this hero since process start; do not run batch SimulateOneFight (second combat path). if h.engine != nil && h.engine.HeroHasActiveMovement(hero.ID) { 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 } var rg *game.RoadGraph if h.engine != nil { rg = h.engine.RoadGraph() } sim := game.NewOfflineSimulator(h.store, h.logStore, h.questStore, rg, h.logger, nil, nil). WithRewardStores(h.gearStore, h.achievementStore, h.taskStore). WithDigestStore(h.digestStore) if h.engine != nil { sim.WithCombatTickRate(h.engine.TickRate()) } before := hero.UpdatedAt if err := sim.SimulateHeroAt(ctx, hero, h.serverStartedAt, false); err != nil { h.logger.Error("catch-up sim failed", "hero_id", hero.ID, "error", err) return false } return hero.UpdatedAt.After(before) } // 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.GetByTelegramID(r.Context(), telegramID) 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 } if hero == nil { townsWithNPCs := h.buildTownsWithNPCs(r.Context()) pCost, hCost := tuning.EffectiveNPCShopCosts() cfg := tuning.Get() gearBase := cfg.MerchantTownGearCostBase if gearBase <= 0 { gearBase = tuning.DefaultValues().MerchantTownGearCostBase } gearPer := cfg.MerchantTownGearCostPerTownLevel if gearPer < 0 { gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel } rel := changelog.ForVersion(version.Version) showChangelog := rel != nil // no DB row yet → changelog was never ack'd var changelogPayload any if rel != nil { changelogPayload = map[string]any{ "title": rel.Title, "items": rel.Items, } } writeJSON(w, http.StatusOK, map[string]any{ "hero": nil, "needsName": true, "offlineReport": nil, "mapRef": h.world.RefForLevel(1), "towns": townsWithNPCs, "npcCostPotion": pCost, "npcCostHeal": hCost, "merchantTownGearCostBase": gearBase, "merchantTownGearCostPerTownLevel": gearPer, "serverVersion": version.Version, "showChangelog": showChangelog, "changelog": changelogPayload, }) return } hero.XPToNext = model.XPToNextLevel(hero.Level) 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) } } } // Resident heroes: single source of truth is the Engine (same ticks as WS observers). if h.engine != nil && !simFrozen && h.engine.MergeResidentHeroState(hero) { if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Warn("failed to persist engine-merged hero on init", "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) } // Take persisted offline digest (accumulated after WS disconnect + grace) and clear markers. digestRow := storage.OfflineDigestRow{Loot: []model.LootDrop{}} if h.digestStore != nil { row, err := h.digestStore.TakeDelete(r.Context(), hero.ID) if err != nil { h.logger.Error("failed to take offline digest", "hero_id", hero.ID, "error", err) } else { digestRow = row } } if err := h.store.ClearWsDisconnectedAt(r.Context(), hero.ID); err != nil { h.logger.Warn("failed to clear ws_disconnected_at", "hero_id", hero.ID, "error", err) } report := h.buildOfflineReportFromDigest(hero, offlineDuration, digestRow) 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 && game.IsEffectivelyDead(hero) && time.Since(hero.UpdatedAt) > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond { game.ApplyHeroReviveMechanical(hero) h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseAutoReviveHours}}) 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) } else if h.engine != nil { h.engine.ApplyAdminHeroRevive(hero) } } needsName := hero.Name == "" || hero.Name == "Hero" hero.RefreshDerivedCombatStats(now) // Build towns with NPCs for the frontend map. townsWithNPCs := h.buildTownsWithNPCs(r.Context()) pCost, hCost := tuning.EffectiveNPCShopCosts() cfgT := tuning.Get() gearBase := cfgT.MerchantTownGearCostBase if gearBase <= 0 { gearBase = tuning.DefaultValues().MerchantTownGearCostBase } gearPer := cfgT.MerchantTownGearCostPerTownLevel if gearPer < 0 { gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel } model.AttachDebuffCatalogForClient(hero) rel := changelog.ForVersion(version.Version) showChangelog := rel != nil && hero.ChangelogAckVersion != version.Version var changelogPayload any if showChangelog && rel != nil { changelogPayload = map[string]any{ "title": rel.Title, "items": rel.Items, } } writeJSON(w, http.StatusOK, map[string]any{ "hero": hero, "needsName": needsName, "offlineReport": report, "mapRef": h.world.RefForLevel(hero.Level), "towns": townsWithNPCs, "npcCostPotion": pCost, "npcCostHeal": hCost, "merchantTownGearCostBase": gearBase, "merchantTownGearCostPerTownLevel": gearPer, "serverVersion": version.Version, "showChangelog": showChangelog, "changelog": changelogPayload, }) } // AckChangelog marks the current server changelog as seen for this hero. // POST /api/v1/hero/changelog/ack func (h *GameHandler) AckChangelog(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("changelog ack: 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 } if err := h.store.SetChangelogAckVersion(r.Context(), hero.ID, version.Version); err != nil { h.logger.Error("changelog ack: save", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // 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, NameKey: t.NameKey, 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, NameKey: n.NameKey, 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 Unicode characters, only latin/cyrillic letters and digits, // no leading/trailing spaces. Expects trimmed, NFC-normalized input (see SetHeroName). func isValidHeroName(name string) bool { n := utf8.RuneCountInString(name) if n < 2 || n > 16 { return false } if strings.TrimSpace(name) != name { 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 } req.Name = norm.NFC.String(strings.TrimSpace(req.Name)) 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 } if profanity.HeroNameIsProfane(req.Name) { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid name: inappropriate language", }) 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 { hero, err = h.store.CreateHeroWithSpawn(r.Context(), telegramID, req.Name) if err != nil { errStr := err.Error() if containsUniqueViolation(errStr) { writeJSON(w, http.StatusConflict, map[string]string{ "error": "HERO_NAME_TAKEN", }) return } h.logger.Error("failed to create hero", "telegram_id", telegramID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to create hero", }) return } now := time.Now() chargesInit := hero.EnsureBuffChargesPopulated(now) if chargesInit { if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Warn("failed to persist buff charges after create", "hero_id", hero.ID, "error", err) } } hero.RefreshDerivedCombatStats(now) h.logger.Info("hero created with spawn", "hero_id", hero.ID, "name", req.Name) writeHeroJSON(w, http.StatusOK, hero) 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) writeHeroJSON(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.BuffRefillPrice() paymentType := model.PaymentBuffReplenish if bt == model.BuffResurrection { priceRUB = model.ResurrectionRefillPrice() 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.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{Code: model.LogPhrasePurchasedBuffRefill, Args: map[string]any{"buffType": string(bt)}}, }) hero.RefreshDerivedCombatStats(now) writeHeroJSON(w, http.StatusOK, hero) } // PurchaseSubscription purchases the configured subscription duration (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: int(model.SubscriptionWeeklyPrice()), 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.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseSubscribed, Args: map[string]any{ "durationKey": "subscription.week", "priceRub": model.SubscriptionWeeklyPrice(), }, }, }) hero.RefreshDerivedCombatStats(now) model.AttachDebuffCatalogForClient(hero) writeJSON(w, http.StatusOK, map[string]any{ "hero": hero, "expiresAt": hero.SubscriptionExpiresAt, "priceRub": model.SubscriptionWeeklyPrice(), }) } // 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_actions.UsePotionOnHero(hero,true) 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.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{Code: model.LogPhraseUsedHealingPotion, Args: map[string]any{"amount": healAmount}}, }) now := time.Now() hero.RefreshDerivedCombatStats(now) writeHeroJSON(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: 50 units, max 5 heroes. radius := 50.0 if rStr := r.URL.Query().Get("radius"); rStr != "" { if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 { radius = parsed } } if radius > 100 { radius = 100 } posX, posY := hero.PositionX, hero.PositionY if h.engine != nil { if wx, wy, ok := h.engine.HeroWorldPositionForCombat(hero.ID); ok { posX, posY = wx, wy } } nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 5) 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 } if h.engine != nil { h.engine.OverlayResidentWorldPositionsOnNearby(nearby) } 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) } // writeHeroJSON encodes a hero with client-only fields (debuff catalog durations). func writeHeroJSON(w http.ResponseWriter, status int, hero *model.Hero) { model.AttachDebuffCatalogForClient(hero) writeJSON(w, status, hero) } // 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.addLogLine(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseAchievementUnlocked, Args: map[string]any{ "achievementId": a.ID, "rewardAmount": a.RewardAmount, "rewardType": 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", enemy.Slug, enemy.Archetype, 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, enemy.Slug, enemy.Archetype); 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.