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.

1766 lines
54 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"log/slog"
"math/rand"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"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
}
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 !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
}
if h.engine != nil {
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: "Wandering Merchant",
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 := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
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.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: 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)
}
// 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.