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.

796 lines
24 KiB
Go

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
}