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
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
|
|
}
|