package storage import ( "context" "encoding/json" "fmt" "log/slog" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/model" ) // heroSelectQuery is the shared SELECT used by all hero-loading methods. // Gear is loaded separately via GearStore.GetHeroGear after the hero row is loaded. const heroSelectQuery = ` SELECT h.id, h.telegram_id, h.name, h.hp, h.max_hp, h.attack, h.defense, h.speed, h.strength, h.constitution, h.agility, h.luck, h.state, h.weapon_id, h.armor_id, h.gold, h.xp, h.level, h.revive_count, h.subscription_active, h.subscription_expires_at, h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges, h.position_x, h.position_y, h.potions, h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops, h.current_town_id, h.destination_town_id, h.move_state, h.last_online_at, h.created_at, h.updated_at FROM heroes h ` // HeroStore handles all hero CRUD operations against PostgreSQL. type HeroStore struct { pool *pgxpool.Pool gearStore *GearStore logger *slog.Logger } // NewHeroStore creates a new HeroStore backed by the given connection pool. func NewHeroStore(pool *pgxpool.Pool, logger *slog.Logger) *HeroStore { return &HeroStore{ pool: pool, gearStore: NewGearStore(pool), logger: logger, } } // GetHeroIDByTelegramID returns the DB hero ID for a given Telegram user ID. // Returns 0 if not found. func (s *HeroStore) GetHeroIDByTelegramID(ctx context.Context, telegramID int64) (int64, error) { var id int64 err := s.pool.QueryRow(ctx, "SELECT id FROM heroes WHERE telegram_id = $1", telegramID).Scan(&id) if err != nil { return 0, err } return id, nil } // GearStore returns the embedded gear store for direct access by handlers. func (s *HeroStore) GearStore() *GearStore { return s.gearStore } // GetByTelegramID loads a hero by Telegram user ID, including weapon and armor via LEFT JOIN. // Returns (nil, nil) if no hero is found. func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*model.Hero, error) { query := heroSelectQuery + ` WHERE h.telegram_id = $1` row := s.pool.QueryRow(ctx, query, telegramID) hero, err := scanHeroRow(row) if err != nil || hero == nil { return hero, err } if err := s.loadHeroGear(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by telegram_id gear: %w", err) } if err := s.loadHeroInventory(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by telegram_id inventory: %w", err) } if err := s.loadHeroBuffsAndDebuffs(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by telegram_id buffs: %w", err) } return hero, nil } // ListHeroes returns a paginated list of heroes ordered by updated_at DESC. func (s *HeroStore) ListHeroes(ctx context.Context, limit, offset int) ([]*model.Hero, error) { if limit <= 0 { limit = 20 } if limit > 200 { limit = 200 } if offset < 0 { offset = 0 } query := heroSelectQuery + ` ORDER BY h.updated_at DESC LIMIT $1 OFFSET $2` rows, err := s.pool.Query(ctx, query, limit, offset) if err != nil { return nil, fmt.Errorf("list heroes: %w", err) } defer rows.Close() var heroes []*model.Hero for rows.Next() { h, err := scanHeroFromRows(rows) if err != nil { return nil, fmt.Errorf("list heroes scan: %w", err) } heroes = append(heroes, h) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list heroes rows: %w", err) } for _, h := range heroes { if err := s.loadHeroGear(ctx, h); err != nil { return nil, fmt.Errorf("list heroes load gear: %w", err) } if err := s.loadHeroInventory(ctx, h); err != nil { return nil, fmt.Errorf("list heroes load inventory: %w", err) } if err := s.loadHeroBuffsAndDebuffs(ctx, h); err != nil { return nil, fmt.Errorf("list heroes load buffs: %w", err) } } return heroes, nil } // DeleteByID removes a hero by its primary key. Returns nil if the hero didn't exist. func (s *HeroStore) DeleteByID(ctx context.Context, id int64) error { _, err := s.pool.Exec(ctx, `DELETE FROM heroes WHERE id = $1`, id) if err != nil { return fmt.Errorf("delete hero: %w", err) } return nil } // GetByID loads a hero by its primary key, including weapon and armor. // Returns (nil, nil) if not found. func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) { query := heroSelectQuery + ` WHERE h.id = $1` row := s.pool.QueryRow(ctx, query, id) hero, err := scanHeroRow(row) if err != nil || hero == nil { return hero, err } if err := s.loadHeroGear(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by id gear: %w", err) } if err := s.loadHeroInventory(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by id inventory: %w", err) } if err := s.loadHeroBuffsAndDebuffs(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by id buffs: %w", err) } return hero, nil } // Create inserts a new hero into the database. // The hero.ID field is populated from the RETURNING clause. // Default weapon_id=1 (Rusty Dagger) and armor_id=1 (Leather Armor). func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { now := time.Now() // Default equipment IDs. var weaponID int64 = 1 var armorID int64 = 1 hero.WeaponID = &weaponID hero.ArmorID = &armorID hero.CreatedAt = now hero.UpdatedAt = now buffChargesJSON := marshalBuffCharges(hero.BuffCharges) query := ` INSERT INTO heroes ( telegram_id, name, hp, max_hp, attack, defense, speed, strength, constitution, agility, luck, state, weapon_id, armor_id, gold, xp, level, revive_count, subscription_active, subscription_expires_at, buff_free_charges_remaining, buff_quota_period_end, buff_charges, position_x, position_y, potions, total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops, last_online_at, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33 ) RETURNING id ` err := s.pool.QueryRow(ctx, query, hero.TelegramID, hero.Name, hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed, hero.Strength, hero.Constitution, hero.Agility, hero.Luck, string(hero.State), hero.WeaponID, hero.ArmorID, hero.Gold, hero.XP, hero.Level, hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.PositionX, hero.PositionY, hero.Potions, hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops, hero.LastOnlineAt, hero.CreatedAt, hero.UpdatedAt, ).Scan(&hero.ID) if err != nil { return fmt.Errorf("insert hero: %w", err) } // Create default starter gear and equip it. if err := s.createDefaultGear(ctx, hero.ID); err != nil { return fmt.Errorf("create default gear: %w", err) } return nil } // createDefaultGear creates starter weapon (Rusty Dagger) and armor (Leather Armor) // as gear items and equips them for a new hero. func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error { starterWeapon := &model.GearItem{ Slot: model.SlotMainHand, FormID: "gear.form.main_hand.daggers", Name: "Rusty Dagger", Subtype: "daggers", Rarity: model.RarityCommon, Ilvl: 1, BasePrimary: 3, PrimaryStat: 3, StatType: "attack", SpeedModifier: 1.3, CritChance: 0.05, } if err := s.gearStore.CreateItem(ctx, starterWeapon); err != nil { return fmt.Errorf("create starter weapon: %w", err) } if err := s.gearStore.EquipItem(ctx, heroID, model.SlotMainHand, starterWeapon.ID); err != nil { return fmt.Errorf("equip starter weapon: %w", err) } starterArmor := &model.GearItem{ Slot: model.SlotChest, FormID: "gear.form.chest.light", Name: "Leather Armor", Subtype: "light", Rarity: model.RarityCommon, Ilvl: 1, BasePrimary: 3, PrimaryStat: 3, StatType: "defense", SpeedModifier: 1.05, AgilityBonus: 3, } if err := s.gearStore.CreateItem(ctx, starterArmor); err != nil { return fmt.Errorf("create starter armor: %w", err) } if err := s.gearStore.EquipItem(ctx, heroID, model.SlotChest, starterArmor.ID); err != nil { return fmt.Errorf("equip starter armor: %w", err) } return nil } // Save updates a hero's mutable fields in the database. func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error { hero.UpdatedAt = time.Now() buffChargesJSON := marshalBuffCharges(hero.BuffCharges) query := ` UPDATE heroes SET hp = $1, max_hp = $2, attack = $3, defense = $4, speed = $5, strength = $6, constitution = $7, agility = $8, luck = $9, state = $10, weapon_id = $11, armor_id = $12, gold = $13, xp = $14, level = $15, revive_count = $16, subscription_active = $17, subscription_expires_at = $18, buff_free_charges_remaining = $19, buff_quota_period_end = $20, buff_charges = $21, position_x = $22, position_y = $23, potions = $24, total_kills = $25, elite_kills = $26, total_deaths = $27, kills_since_death = $28, legendary_drops = $29, last_online_at = $30, updated_at = $31, destination_town_id = $32, current_town_id = $33, move_state = $34 WHERE id = $35 ` tag, err := s.pool.Exec(ctx, query, hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed, hero.Strength, hero.Constitution, hero.Agility, hero.Luck, string(hero.State), hero.WeaponID, hero.ArmorID, hero.Gold, hero.XP, hero.Level, hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.PositionX, hero.PositionY, hero.Potions, hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops, hero.LastOnlineAt, hero.UpdatedAt, hero.DestinationTownID, hero.CurrentTownID, hero.MoveState, hero.ID, ) if err != nil { return fmt.Errorf("update hero: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("update hero: no rows affected (id=%d)", hero.ID) } if err := s.saveHeroBuffsAndDebuffs(ctx, hero); err != nil { return fmt.Errorf("update hero buffs/debuffs: %w", err) } inv := hero.Inventory if inv == nil { inv = []*model.GearItem{} } if err := s.gearStore.ReplaceHeroInventory(ctx, hero.ID, inv); err != nil { return fmt.Errorf("update hero inventory: %w", err) } s.logger.Info("saved hero", "hero", hero) return nil } // SavePosition is a lightweight UPDATE that persists only the hero's world position. // Called frequently as the hero moves around the map. func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64) error { _, err := s.pool.Exec(ctx, `UPDATE heroes SET position_x = $1, position_y = $2, updated_at = now() WHERE id = $3`, x, y, heroID) if err != nil { return fmt.Errorf("save position: %w", err) } return nil } // GetOrCreate loads a hero by Telegram ID, creating one with default stats if not found. // This is the main entry point used by auth and hero init flows. func (s *HeroStore) GetOrCreate(ctx context.Context, telegramID int64, name string) (*model.Hero, error) { hero, err := s.GetByTelegramID(ctx, telegramID) if err != nil { return nil, fmt.Errorf("get or create hero: %w", err) } if hero != nil { hero.XPToNext = model.XPToNextLevel(hero.Level) return hero, nil } // Create a new hero with default stats. hero = &model.Hero{ TelegramID: telegramID, Name: name, HP: 100, MaxHP: 100, Attack: 10, Defense: 5, Speed: 1.0, Strength: 1, Constitution: 1, Agility: 1, Luck: 1, State: model.StateWalking, Gold: 0, XP: 0, Level: 1, BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriod, } if err := s.Create(ctx, hero); err != nil { return nil, fmt.Errorf("get or create hero: %w", err) } // Reload to get the gear and buff data. hero, err = s.GetByID(ctx, hero.ID) if err != nil { return nil, fmt.Errorf("get or create hero reload: %w", err) } return hero, nil } // ListOfflineHeroes returns heroes that are walking but haven't been updated // recently (i.e. the client is offline). Only loads base hero data without // weapon/armor JOINs — the simulation uses EffectiveAttackAt/EffectiveDefenseAt // which work with base stats and any loaded equipment. func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time.Duration, limit int) ([]*model.Hero, error) { if limit <= 0 { limit = 100 } if limit > 500 { limit = 500 } cutoff := time.Now().Add(-offlineThreshold) query := heroSelectQuery + ` WHERE h.state = 'walking' AND h.hp > 0 AND h.updated_at < $1 AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting')) ORDER BY h.updated_at ASC LIMIT $2 ` rows, err := s.pool.Query(ctx, query, cutoff, limit) if err != nil { return nil, fmt.Errorf("list offline heroes: %w", err) } defer rows.Close() var heroes []*model.Hero for rows.Next() { h, err := scanHeroFromRows(rows) if err != nil { return nil, fmt.Errorf("list offline heroes scan: %w", err) } heroes = append(heroes, h) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list offline heroes rows: %w", err) } for _, h := range heroes { if err := s.loadHeroGear(ctx, h); err != nil { return nil, fmt.Errorf("list offline heroes load gear: %w", err) } if err := s.loadHeroInventory(ctx, h); err != nil { return nil, fmt.Errorf("list offline heroes load inventory: %w", err) } } return heroes, nil } // scanHeroFromRows scans the current row from pgx.Rows into a Hero struct. // Gear is loaded separately via loadHeroGear after scanning. func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) { var h model.Hero var state string var buffChargesRaw []byte err := rows.Scan( &h.ID, &h.TelegramID, &h.Name, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.Strength, &h.Constitution, &h.Agility, &h.Luck, &state, &h.WeaponID, &h.ArmorID, &h.Gold, &h.XP, &h.Level, &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.PositionX, &h.PositionY, &h.Potions, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &h.LastOnlineAt, &h.CreatedAt, &h.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("scan hero from rows: %w", err) } h.BuffCharges = unmarshalBuffCharges(buffChargesRaw) h.State = model.GameState(state) h.Gear = make(map[model.EquipmentSlot]*model.GearItem) return &h, nil } // scanHeroRow scans a single row from the hero query into a Hero struct. // Returns (nil, nil) when the row is pgx.ErrNoRows. // Gear is loaded separately via loadHeroGear after scanning. func scanHeroRow(row pgx.Row) (*model.Hero, error) { var h model.Hero var state string var buffChargesRaw []byte err := row.Scan( &h.ID, &h.TelegramID, &h.Name, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.Strength, &h.Constitution, &h.Agility, &h.Luck, &state, &h.WeaponID, &h.ArmorID, &h.Gold, &h.XP, &h.Level, &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.PositionX, &h.PositionY, &h.Potions, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &h.LastOnlineAt, &h.CreatedAt, &h.UpdatedAt, ) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, fmt.Errorf("scan hero row: %w", err) } h.BuffCharges = unmarshalBuffCharges(buffChargesRaw) h.State = model.GameState(state) h.Gear = make(map[model.EquipmentSlot]*model.GearItem) return &h, nil } // loadHeroGear populates the hero's Gear map from the hero_gear table. func (s *HeroStore) loadHeroGear(ctx context.Context, hero *model.Hero) error { gear, err := s.gearStore.GetHeroGear(ctx, hero.ID) if err != nil { return fmt.Errorf("load hero gear: %w", err) } hero.Gear = gear return nil } // loadHeroInventory populates the hero's backpack from hero_inventory. func (s *HeroStore) loadHeroInventory(ctx context.Context, hero *model.Hero) error { inv, err := s.gearStore.GetHeroInventory(ctx, hero.ID) if err != nil { return fmt.Errorf("load hero inventory: %w", err) } hero.Inventory = inv if hero.Inventory == nil { hero.Inventory = []*model.GearItem{} } return nil } // loadHeroBuffsAndDebuffs populates the hero's Buffs and Debuffs from the // hero_active_buffs / hero_active_debuffs tables, filtering out expired entries. func (s *HeroStore) loadHeroBuffsAndDebuffs(ctx context.Context, hero *model.Hero) error { now := time.Now() // Active buffs. buffRows, err := s.pool.Query(ctx, ` SELECT b.id, b.type, b.name, b.duration_ms, b.magnitude, b.cooldown_ms, hab.applied_at, hab.expires_at FROM hero_active_buffs hab JOIN buffs b ON hab.buff_id = b.id WHERE hab.hero_id = $1 AND hab.expires_at > $2 ORDER BY hab.applied_at `, hero.ID, now) if err != nil { return fmt.Errorf("load active buffs: %w", err) } defer buffRows.Close() for buffRows.Next() { var ab model.ActiveBuff var durationMs, cooldownMs int64 if err := buffRows.Scan( &ab.Buff.ID, &ab.Buff.Type, &ab.Buff.Name, &durationMs, &ab.Buff.Magnitude, &cooldownMs, &ab.AppliedAt, &ab.ExpiresAt, ); err != nil { return fmt.Errorf("scan active buff: %w", err) } ab.Buff.Duration = time.Duration(durationMs) * time.Millisecond ab.Buff.CooldownDuration = time.Duration(cooldownMs) * time.Millisecond hero.Buffs = append(hero.Buffs, ab) } if err := buffRows.Err(); err != nil { return fmt.Errorf("load active buffs rows: %w", err) } // Active debuffs. debuffRows, err := s.pool.Query(ctx, ` SELECT d.id, d.type, d.name, d.duration_ms, d.magnitude, had.applied_at, had.expires_at FROM hero_active_debuffs had JOIN debuffs d ON had.debuff_id = d.id WHERE had.hero_id = $1 AND had.expires_at > $2 ORDER BY had.applied_at `, hero.ID, now) if err != nil { return fmt.Errorf("load active debuffs: %w", err) } defer debuffRows.Close() for debuffRows.Next() { var ad model.ActiveDebuff var durationMs int64 if err := debuffRows.Scan( &ad.Debuff.ID, &ad.Debuff.Type, &ad.Debuff.Name, &durationMs, &ad.Debuff.Magnitude, &ad.AppliedAt, &ad.ExpiresAt, ); err != nil { return fmt.Errorf("scan active debuff: %w", err) } ad.Debuff.Duration = time.Duration(durationMs) * time.Millisecond hero.Debuffs = append(hero.Debuffs, ad) } if err := debuffRows.Err(); err != nil { return fmt.Errorf("load active debuffs rows: %w", err) } return nil } // saveHeroBuffsAndDebuffs replaces the hero's active buff/debuff rows in the DB. // Expired entries are pruned. Uses a transaction for consistency. func (s *HeroStore) saveHeroBuffsAndDebuffs(ctx context.Context, hero *model.Hero) error { now := time.Now() tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("save buffs/debuffs begin tx: %w", err) } defer tx.Rollback(ctx) // Replace active buffs. if _, err := tx.Exec(ctx, `DELETE FROM hero_active_buffs WHERE hero_id = $1`, hero.ID); err != nil { return fmt.Errorf("delete active buffs: %w", err) } for _, ab := range hero.Buffs { if ab.IsExpired(now) { continue } _, err := tx.Exec(ctx, ` INSERT INTO hero_active_buffs (hero_id, buff_id, applied_at, expires_at) VALUES ($1, (SELECT id FROM buffs WHERE type = $2 LIMIT 1), $3, $4) `, hero.ID, string(ab.Buff.Type), ab.AppliedAt, ab.ExpiresAt) if err != nil { return fmt.Errorf("insert active buff %s: %w", ab.Buff.Type, err) } } // Replace active debuffs. if _, err := tx.Exec(ctx, `DELETE FROM hero_active_debuffs WHERE hero_id = $1`, hero.ID); err != nil { return fmt.Errorf("delete active debuffs: %w", err) } for _, ad := range hero.Debuffs { if ad.IsExpired(now) { continue } _, err := tx.Exec(ctx, ` INSERT INTO hero_active_debuffs (hero_id, debuff_id, applied_at, expires_at) VALUES ($1, (SELECT id FROM debuffs WHERE type = $2 LIMIT 1), $3, $4) `, hero.ID, string(ad.Debuff.Type), ad.AppliedAt, ad.ExpiresAt) if err != nil { return fmt.Errorf("insert active debuff %s: %w", ad.Debuff.Type, err) } } return tx.Commit(ctx) } // marshalBuffCharges converts the in-memory buff charges map to JSON bytes for // storage in the JSONB column. Returns "{}" for nil/empty maps. func marshalBuffCharges(m map[string]model.BuffChargeState) []byte { if len(m) == 0 { return []byte("{}") } b, err := json.Marshal(m) if err != nil { return []byte("{}") } return b } // unmarshalBuffCharges parses raw JSON bytes from the buff_charges JSONB column // into the in-memory map. Returns an empty map on nil/empty/invalid input. func unmarshalBuffCharges(raw []byte) map[string]model.BuffChargeState { if len(raw) == 0 { return make(map[string]model.BuffChargeState) } var m map[string]model.BuffChargeState if err := json.Unmarshal(raw, &m); err != nil || m == nil { return make(map[string]model.BuffChargeState) } return m } // HeroSummary is a lightweight projection of a hero for nearby-heroes queries. type HeroSummary struct { ID int64 `json:"id"` Name string `json:"name"` Level int `json:"level"` PositionX float64 `json:"positionX"` PositionY float64 `json:"positionY"` } // UpdateOnlineStatus updates last_online_at and position for shared-world presence. func (s *HeroStore) UpdateOnlineStatus(ctx context.Context, heroID int64, posX, posY float64) error { _, err := s.pool.Exec(ctx, `UPDATE heroes SET last_online_at = now(), position_x = $1, position_y = $2 WHERE id = $3`, posX, posY, heroID, ) if err != nil { return fmt.Errorf("update online status: %w", err) } return nil } // GetNearbyHeroes returns other heroes within radius who were online recently (< 2 min). func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, posY, radius float64, limit int) ([]HeroSummary, error) { if limit <= 0 { limit = 20 } if limit > 100 { limit = 100 } cutoff := time.Now().Add(-2 * time.Minute) rows, err := s.pool.Query(ctx, ` SELECT id, name, level, position_x, position_y FROM heroes WHERE id != $1 AND last_online_at > $2 AND sqrt(power(position_x - $3, 2) + power(position_y - $4, 2)) <= $5 ORDER BY last_online_at DESC LIMIT $6 `, heroID, cutoff, posX, posY, radius, limit) if err != nil { return nil, fmt.Errorf("get nearby heroes: %w", err) } defer rows.Close() var heroes []HeroSummary for rows.Next() { var h HeroSummary if err := rows.Scan(&h.ID, &h.Name, &h.Level, &h.PositionX, &h.PositionY); err != nil { return nil, fmt.Errorf("scan nearby hero: %w", err) } heroes = append(heroes, h) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("nearby heroes rows: %w", err) } if heroes == nil { heroes = []HeroSummary{} } return heroes, nil } // SaveName updates only the hero's name field. Returns an error wrapping // "UNIQUE" if the name violates the case-insensitive uniqueness constraint. func (s *HeroStore) SaveName(ctx context.Context, heroID int64, name string) error { _, err := s.pool.Exec(ctx, `UPDATE heroes SET name = $1, updated_at = now() WHERE id = $2`, name, heroID, ) if err != nil { return fmt.Errorf("save hero name: %w", err) } return nil } // CreatePayment inserts a payment record and returns the generated ID. func (s *HeroStore) CreatePayment(ctx context.Context, p *model.Payment) error { query := ` INSERT INTO payments (hero_id, type, buff_type, amount_rub, status, created_at, completed_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id ` return s.pool.QueryRow(ctx, query, p.HeroID, string(p.Type), p.BuffType, p.AmountRUB, string(p.Status), p.CreatedAt, p.CompletedAt, ).Scan(&p.ID) } func derefStr(p *string) string { if p == nil { return "" } return *p } func derefInt(p *int) int { if p == nil { return 0 } return *p } func derefFloat(p *float64) float64 { if p == nil { return 0 } return *p }