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.

1071 lines
32 KiB
Go

package storage
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math/rand"
"strconv"
"strings"
"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.hero_model_variant,
h.hp, h.max_hp, h.attack, h.defense, h.speed,
h.strength, h.constitution, h.agility, h.luck,
h.state,
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.town_pause,
h.last_online_at, h.changelog_ack_version,
h.ws_disconnected_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, nil) 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 {
if errors.Is(err, pgx.ErrNoRows) {
return 0, 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.
// 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) {
return s.ListHeroesFiltered(ctx, limit, offset, "")
}
// ListHeroesFiltered returns a paginated hero list with optional query across
// hero name, DB id, and Telegram id.
func (s *HeroStore) ListHeroesFiltered(ctx context.Context, limit, offset int, query string) ([]*model.Hero, error) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
base := heroSelectQuery
var args []any
if q := strings.TrimSpace(query); q != "" {
// Pure numeric query: exact match by DB id or telegram id (single hero in normal cases).
if n, err := strconv.ParseInt(q, 10, 64); err == nil && n > 0 {
base += ` WHERE (h.id = $1 OR h.telegram_id = $1)`
args = append(args, n)
} else {
search := "%" + strings.ToLower(q) + "%"
base += ` WHERE LOWER(h.name) LIKE $1`
args = append(args, search)
}
}
base += ` ORDER BY h.updated_at DESC LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` OFFSET $` + fmt.Sprintf("%d", len(args)+2)
args = append(args, limit, offset)
rows, err := s.pool.Query(ctx, base, args...)
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
}
// ListPayments returns payment rows ordered by created_at DESC.
func (s *HeroStore) ListPayments(ctx context.Context, heroID int64, limit, offset int) ([]*model.Payment, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
query := `
SELECT id, hero_id, type, buff_type, amount_rub, status, created_at, completed_at
FROM payments
`
args := []any{}
if heroID > 0 {
query += ` WHERE hero_id = $1`
args = append(args, heroID)
}
query += ` ORDER BY created_at DESC LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` OFFSET $` + fmt.Sprintf("%d", len(args)+2)
args = append(args, limit, offset)
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list payments: %w", err)
}
defer rows.Close()
out := make([]*model.Payment, 0, limit)
for rows.Next() {
var p model.Payment
var pType string
var status string
if err := rows.Scan(&p.ID, &p.HeroID, &pType, &p.BuffType, &p.AmountRUB, &status, &p.CreatedAt, &p.CompletedAt); err != nil {
return nil, fmt.Errorf("scan payment: %w", err)
}
p.Type = model.PaymentType(pType)
p.Status = model.PaymentStatus(status)
out = append(out, &p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments rows: %w", err)
}
return out, nil
}
// GetPaymentByID loads one payment row by ID.
func (s *HeroStore) GetPaymentByID(ctx context.Context, paymentID int64) (*model.Payment, error) {
var p model.Payment
var pType string
var status string
err := s.pool.QueryRow(ctx, `
SELECT id, hero_id, type, buff_type, amount_rub, status, created_at, completed_at
FROM payments
WHERE id = $1
`, paymentID).Scan(&p.ID, &p.HeroID, &pType, &p.BuffType, &p.AmountRUB, &status, &p.CreatedAt, &p.CompletedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get payment: %w", err)
}
p.Type = model.PaymentType(pType)
p.Status = model.PaymentStatus(status)
return &p, 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.
// 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
}
// insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear.
func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error {
now := time.Now()
hero.CreatedAt = now
hero.UpdatedAt = now
hero.ModelVariant = randomHeroModelVariant()
buffChargesJSON := marshalBuffCharges(hero.BuffCharges)
query := `
INSERT INTO heroes (
telegram_id, name,
hero_model_variant,
hp, max_hp, attack, defense, speed,
strength, constitution, agility, luck,
state,
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,
current_town_id, destination_town_id
) 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,
$34, $35
) RETURNING id
`
err := s.pool.QueryRow(ctx, query,
hero.TelegramID, hero.Name,
hero.ModelVariant,
hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State),
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,
hero.CurrentTownID, hero.DestinationTownID,
).Scan(&hero.ID)
if err != nil {
return fmt.Errorf("insert hero: %w", err)
}
return nil
}
// Create inserts a new hero into the database with legacy default starter gear (dagger + leather).
func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
if err := s.insertNewHeroRow(ctx, hero); err != nil {
return err
}
if err := s.createDefaultGear(ctx, hero.ID); err != nil {
return fmt.Errorf("create default gear: %w", err)
}
return nil
}
func (s *HeroStore) randomTownCoords(ctx context.Context) (id int64, wx, wy float64, err error) {
err = s.pool.QueryRow(ctx, `
SELECT id, world_x, world_y FROM towns ORDER BY random() LIMIT 1`,
).Scan(&id, &wx, &wy)
if err != nil {
return 0, 0, 0, fmt.Errorf("random town: %w", err)
}
return id, wx, wy, nil
}
func (s *HeroStore) randomOutgoingTownID(ctx context.Context, fromTownID int64) (int64, error) {
var to int64
err := s.pool.QueryRow(ctx, `
SELECT to_town_id FROM roads WHERE from_town_id = $1 ORDER BY random() LIMIT 1`, fromTownID,
).Scan(&to)
return to, err
}
func (s *HeroStore) pickBirthTownAndDestination(ctx context.Context) (birthID, destID int64, bx, by float64, err error) {
for i := 0; i < 12; i++ {
birthID, bx, by, err = s.randomTownCoords(ctx)
if err != nil {
return 0, 0, 0, 0, err
}
destID, err = s.randomOutgoingTownID(ctx, birthID)
if err == nil && destID != 0 && destID != birthID {
return birthID, destID, bx, by, nil
}
}
err = s.pool.QueryRow(ctx, `
SELECT r.from_town_id, r.to_town_id, tf.world_x, tf.world_y
FROM roads r
JOIN towns tf ON tf.id = r.from_town_id
ORDER BY random() LIMIT 1`,
).Scan(&birthID, &destID, &bx, &by)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("pick spawn on random road: %w", err)
}
return birthID, destID, bx, by, nil
}
// ApplyRandomSpawn assigns a random birth town, road destination, and position (same logic as new-hero spawn).
func (s *HeroStore) ApplyRandomSpawn(ctx context.Context, hero *model.Hero) error {
birthID, destID, bx, by, err := s.pickBirthTownAndDestination(ctx)
if err != nil {
return err
}
birth := birthID
dest := destID
hero.PositionX = bx
hero.PositionY = by
hero.CurrentTownID = &birth
hero.DestinationTownID = &dest
return nil
}
// ApplyRandomStarterGear equips a new hero with the same random ilvl-1 sword and chest as CreateHeroWithSpawn.
func (s *HeroStore) ApplyRandomStarterGear(ctx context.Context, heroID int64) error {
return s.createRandomStarterGear(ctx, heroID)
}
// CreateHeroWithSpawn creates a new hero after the player chose a name: random birth town,
// 100 gold, random common ilvl-1 sword and armor, destination a town reachable by road.
func (s *HeroStore) CreateHeroWithSpawn(ctx context.Context, telegramID int64, name string) (*model.Hero, error) {
birthID, destID, bx, by, err := s.pickBirthTownAndDestination(ctx)
if err != nil {
return nil, err
}
birth := birthID
dest := destID
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: 100,
XP: 0,
Level: 1,
PositionX: bx,
PositionY: by,
CurrentTownID: &birth,
DestinationTownID: &dest,
BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(),
}
if err := s.insertNewHeroRow(ctx, hero); err != nil {
return nil, err
}
if err := s.createRandomStarterGear(ctx, hero.ID); err != nil {
return nil, fmt.Errorf("create starter gear: %w", err)
}
return s.GetByID(ctx, hero.ID)
}
func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) error {
swords := []struct {
name string
}{
{"Iron Sword"},
{"Steel Sword"},
{"Longsword"},
{"Excalibur"},
}
sw := swords[rand.Intn(len(swords))]
starterWeapon := &model.GearItem{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand.sword",
Name: sw.name,
Subtype: "sword",
Rarity: model.RarityCommon,
Ilvl: 1,
BasePrimary: 4,
PrimaryStat: 4,
StatType: "attack",
SpeedModifier: 1.0,
CritChance: 0.05,
}
if err := s.gearStore.EquipItem(ctx, heroID, starterWeapon); err != nil {
return fmt.Errorf("equip starter sword: %w", err)
}
armors := []struct {
name string
formID string
subtype string
speed float64
agilityBon int
defense int
}{
{"Leather Armor", "gear.form.chest.light", "light", 1.05, 2, 3},
{"Chainmail", "gear.form.chest.medium", "medium", 1.0, 0, 4},
{"Iron Plate", "gear.form.chest.heavy", "heavy", 0.7, 0, 5},
}
ar := armors[rand.Intn(len(armors))]
starterArmor := &model.GearItem{
Slot: model.SlotChest,
FormID: ar.formID,
Name: ar.name,
Subtype: ar.subtype,
Rarity: model.RarityCommon,
Ilvl: 1,
BasePrimary: ar.defense,
PrimaryStat: ar.defense,
StatType: "defense",
SpeedModifier: ar.speed,
AgilityBonus: ar.agilityBon,
}
if err := s.gearStore.EquipItem(ctx, heroID, starterArmor); err != nil {
return fmt.Errorf("equip starter armor: %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.EquipItem(ctx, heroID, starterWeapon); 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.EquipItem(ctx, heroID, starterArmor); 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
hero_model_variant = $1,
hp = $2, max_hp = $3,
attack = $4, defense = $5, speed = $6,
strength = $7, constitution = $8, agility = $9, luck = $10,
state = $11,
gold = $12, xp = $13, level = $14,
revive_count = $15, subscription_active = $16, subscription_expires_at = $17,
buff_free_charges_remaining = $18, buff_quota_period_end = $19, buff_charges = $20,
position_x = $21, position_y = $22, potions = $23,
total_kills = $24, elite_kills = $25, total_deaths = $26,
kills_since_death = $27, legendary_drops = $28,
last_online_at = $29,
updated_at = $30,
destination_town_id = $31,
current_town_id = $32,
town_pause = $33
WHERE id = $34
`
townPauseJSON := marshalTownPause(hero.TownPause)
tag, err := s.pool.Exec(ctx, query,
hero.ModelVariant,
hero.HP, hero.MaxHP,
hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State),
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,
townPauseJSON,
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.Debug("saved hero", "hero", hero)
return nil
}
// SetChangelogAckVersion records that the player has seen the changelog for the given server version.
func (s *HeroStore) SetChangelogAckVersion(ctx context.Context, heroID int64, v string) error {
_, err := s.pool.Exec(ctx, `
UPDATE heroes SET changelog_ack_version = $1, updated_at = now() WHERE id = $2
`, v, heroID)
if err != nil {
return fmt.Errorf("set changelog ack: %w", err)
}
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
}
// SetWsDisconnectedAt records when the player's last WebSocket session ended.
func (s *HeroStore) SetWsDisconnectedAt(ctx context.Context, heroID int64, t time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE heroes SET ws_disconnected_at = $1, updated_at = now() WHERE id = $2`, t, heroID)
if err != nil {
return fmt.Errorf("set ws_disconnected_at: %w", err)
}
return nil
}
// ClearWsDisconnectedAt clears the offline marker after the client has synced (e.g. hero/init).
func (s *HeroStore) ClearWsDisconnectedAt(ctx context.Context, heroID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE heroes SET ws_disconnected_at = NULL, updated_at = now() WHERE id = $1`, heroID)
if err != nil {
return fmt.Errorf("clear ws_disconnected_at: %w", err)
}
return nil
}
// ListHeroesForEngineBootstrap returns heroes that should be loaded into the game engine after a cold start:
// session ended (ws_disconnected_at set) and simulatable world state. Limit caps memory use.
func (s *HeroStore) ListHeroesForEngineBootstrap(ctx context.Context) ([]*model.Hero, error) {
query := heroSelectQuery + `
WHERE h.ws_disconnected_at IS NOT NULL
ORDER BY id ASC
LIMIT 100
`
rows, err := s.pool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap: %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 for engine bootstrap scan: %w", err)
}
heroes = append(heroes, h)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap rows: %w", err)
}
for _, h := range heroes {
if err := s.loadHeroGear(ctx, h); err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap load gear: %w", err)
}
if err := s.loadHeroInventory(ctx, h); err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap 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
var townPauseRaw []byte
err := rows.Scan(
&h.ID, &h.TelegramID, &h.Name, &h.ModelVariant,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state,
&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, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&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)
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
h.TownPause = unmarshalTownPause(townPauseRaw)
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
var townPauseRaw []byte
err := row.Scan(
&h.ID, &h.TelegramID, &h.Name, &h.ModelVariant,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state,
&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, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&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)
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
h.TownPause = unmarshalTownPause(townPauseRaw)
return &h, nil
}
func marshalTownPause(p *model.TownPausePersisted) []byte {
if p == nil {
return nil
}
b, err := json.Marshal(p)
if err != nil {
return nil
}
return b
}
func unmarshalTownPause(raw []byte) *model.TownPausePersisted {
if len(raw) == 0 {
return nil
}
var p model.TownPausePersisted
if err := json.Unmarshal(raw, &p); err != nil {
return nil
}
return &p
}
// 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"`
ModelVariant int `json:"modelVariant"`
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 < 1 {
limit = 1
}
if limit > 5 {
limit = 5
}
rows, err := s.pool.Query(ctx, `
SELECT id, name, level, hero_model_variant, position_x, position_y
FROM heroes
WHERE id != $1
AND sqrt(power(position_x - $2, 2) + power(position_y - $3, 2)) <= $4
ORDER BY last_online_at DESC
LIMIT $5
`, heroID, 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.ModelVariant, &h.PositionX, &h.PositionY); err != nil {
return nil, fmt.Errorf("scan nearby hero: %w", err)
}
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
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 randomHeroModelVariant() int {
return rand.Intn(model.HeroModelVariantMax-model.HeroModelVariantMin+1) + model.HeroModelVariantMin
}
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
}