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.
1051 lines
32 KiB
Go
1051 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.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.town_pause,
|
|
h.last_online_at, h.changelog_ack_version,
|
|
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, 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) {
|
|
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, 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
|
|
}
|
|
|
|
// insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear.
|
|
// Default weapon_id=1 and armor_id=1 satisfy FK to legacy weapons/armor tables.
|
|
func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error {
|
|
now := time.Now()
|
|
|
|
var weaponID int64 = 1
|
|
var armorID int64 = 1
|
|
hero.WeaponID = &weaponID
|
|
hero.ArmorID = &armorID
|
|
hero.CreatedAt = now
|
|
hero.UpdatedAt = now
|
|
|
|
if hero.MoveState == "" {
|
|
hero.MoveState = string(model.StateWalking)
|
|
}
|
|
|
|
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,
|
|
current_town_id, destination_town_id, move_state
|
|
) 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, $36, $37
|
|
) 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,
|
|
hero.CurrentTownID, hero.DestinationTownID, hero.MoveState,
|
|
).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
|
|
}
|
|
|
|
// 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,
|
|
MoveState: string(model.StateWalking),
|
|
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
|
|
}{
|
|
{"Worn Shortsword"},
|
|
{"Traveler's Blade"},
|
|
{"Notched Sword"},
|
|
{"Training Sword"},
|
|
}
|
|
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.CreateItem(ctx, starterWeapon); err != nil {
|
|
return fmt.Errorf("create starter sword: %w", err)
|
|
}
|
|
if err := s.gearStore.EquipItem(ctx, heroID, model.SlotMainHand, starterWeapon.ID); err != nil {
|
|
return fmt.Errorf("equip starter sword: %w", err)
|
|
}
|
|
|
|
armors := []struct {
|
|
name string
|
|
formID string
|
|
subtype string
|
|
speed float64
|
|
agilityBon int
|
|
defense int
|
|
}{
|
|
{"Worn Leather Jack", "gear.form.chest.leather", "light", 1.05, 2, 3},
|
|
{"Rusty Hauberk", "gear.form.chest.mail", "medium", 1.0, 0, 4},
|
|
{"Worn Plate", "gear.form.chest.plate", "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.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
|
|
}
|
|
|
|
// 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,
|
|
town_pause = $35
|
|
WHERE id = $36
|
|
`
|
|
|
|
townPauseJSON := marshalTownPause(hero.TownPause)
|
|
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,
|
|
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.Info("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
|
|
}
|
|
|
|
// ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map,
|
|
// or heroes resting / in town whose DB row has not been updated recently (offline town timers).
|
|
// Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).
|
|
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.hp > 0 AND h.updated_at < $1
|
|
AND (
|
|
(h.state = 'walking'
|
|
AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting')))
|
|
OR h.state IN ('resting', 'in_town')
|
|
)
|
|
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
|
|
var townPauseRaw []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, &townPauseRaw,
|
|
&h.LastOnlineAt, &h.ChangelogAckVersion,
|
|
&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)
|
|
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.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, &townPauseRaw,
|
|
&h.LastOnlineAt, &h.ChangelogAckVersion,
|
|
&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)
|
|
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"`
|
|
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
|
|
}
|