|
|
|
@ -3,8 +3,10 @@ package storage
|
|
|
|
import (
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"encoding/json"
|
|
|
|
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"fmt"
|
|
|
|
"log/slog"
|
|
|
|
"log/slog"
|
|
|
|
|
|
|
|
"math/rand"
|
|
|
|
"strconv"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
"time"
|
|
|
|
@ -51,11 +53,14 @@ func NewHeroStore(pool *pgxpool.Pool, logger *slog.Logger) *HeroStore {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetHeroIDByTelegramID returns the DB hero ID for a given Telegram user ID.
|
|
|
|
// GetHeroIDByTelegramID returns the DB hero ID for a given Telegram user ID.
|
|
|
|
// Returns 0 if not found.
|
|
|
|
// Returns (0, nil) if not found.
|
|
|
|
func (s *HeroStore) GetHeroIDByTelegramID(ctx context.Context, telegramID int64) (int64, error) {
|
|
|
|
func (s *HeroStore) GetHeroIDByTelegramID(ctx context.Context, telegramID int64) (int64, error) {
|
|
|
|
var id int64
|
|
|
|
var id int64
|
|
|
|
err := s.pool.QueryRow(ctx, "SELECT id FROM heroes WHERE telegram_id = $1", telegramID).Scan(&id)
|
|
|
|
err := s.pool.QueryRow(ctx, "SELECT id FROM heroes WHERE telegram_id = $1", telegramID).Scan(&id)
|
|
|
|
if err != nil {
|
|
|
|
if err != nil {
|
|
|
|
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
|
|
|
|
|
|
return 0, nil
|
|
|
|
|
|
|
|
}
|
|
|
|
return 0, err
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return id, nil
|
|
|
|
return id, nil
|
|
|
|
@ -253,13 +258,11 @@ func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error)
|
|
|
|
return hero, nil
|
|
|
|
return hero, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create inserts a new hero into the database.
|
|
|
|
// insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear.
|
|
|
|
// The hero.ID field is populated from the RETURNING clause.
|
|
|
|
// Default weapon_id=1 and armor_id=1 satisfy FK to legacy weapons/armor tables.
|
|
|
|
// Default weapon_id=1 (Rusty Dagger) and armor_id=1 (Leather Armor).
|
|
|
|
func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error {
|
|
|
|
func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
now := time.Now()
|
|
|
|
|
|
|
|
|
|
|
|
// Default equipment IDs.
|
|
|
|
|
|
|
|
var weaponID int64 = 1
|
|
|
|
var weaponID int64 = 1
|
|
|
|
var armorID int64 = 1
|
|
|
|
var armorID int64 = 1
|
|
|
|
hero.WeaponID = &weaponID
|
|
|
|
hero.WeaponID = &weaponID
|
|
|
|
@ -267,6 +270,10 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
|
|
|
|
hero.CreatedAt = now
|
|
|
|
hero.CreatedAt = now
|
|
|
|
hero.UpdatedAt = now
|
|
|
|
hero.UpdatedAt = now
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if hero.MoveState == "" {
|
|
|
|
|
|
|
|
hero.MoveState = string(model.StateWalking)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
buffChargesJSON := marshalBuffCharges(hero.BuffCharges)
|
|
|
|
buffChargesJSON := marshalBuffCharges(hero.BuffCharges)
|
|
|
|
|
|
|
|
|
|
|
|
query := `
|
|
|
|
query := `
|
|
|
|
@ -281,7 +288,8 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
|
|
|
|
position_x, position_y, potions,
|
|
|
|
position_x, position_y, potions,
|
|
|
|
total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops,
|
|
|
|
total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops,
|
|
|
|
last_online_at,
|
|
|
|
last_online_at,
|
|
|
|
created_at, updated_at
|
|
|
|
created_at, updated_at,
|
|
|
|
|
|
|
|
current_town_id, destination_town_id, move_state
|
|
|
|
) VALUES (
|
|
|
|
) VALUES (
|
|
|
|
$1, $2,
|
|
|
|
$1, $2,
|
|
|
|
$3, $4, $5, $6, $7,
|
|
|
|
$3, $4, $5, $6, $7,
|
|
|
|
@ -293,7 +301,8 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
|
|
|
|
$24, $25, $26,
|
|
|
|
$24, $25, $26,
|
|
|
|
$27, $28, $29, $30, $31,
|
|
|
|
$27, $28, $29, $30, $31,
|
|
|
|
$32,
|
|
|
|
$32,
|
|
|
|
$33, $34
|
|
|
|
$33, $34,
|
|
|
|
|
|
|
|
$35, $36, $37
|
|
|
|
) RETURNING id
|
|
|
|
) RETURNING id
|
|
|
|
`
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
@ -309,15 +318,173 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
|
|
|
|
hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops,
|
|
|
|
hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops,
|
|
|
|
hero.LastOnlineAt,
|
|
|
|
hero.LastOnlineAt,
|
|
|
|
hero.CreatedAt, hero.UpdatedAt,
|
|
|
|
hero.CreatedAt, hero.UpdatedAt,
|
|
|
|
|
|
|
|
hero.CurrentTownID, hero.DestinationTownID, hero.MoveState,
|
|
|
|
).Scan(&hero.ID)
|
|
|
|
).Scan(&hero.ID)
|
|
|
|
if err != nil {
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("insert hero: %w", err)
|
|
|
|
return fmt.Errorf("insert hero: %w", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create default starter gear and equip it.
|
|
|
|
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 {
|
|
|
|
if err := s.createDefaultGear(ctx, hero.ID); err != nil {
|
|
|
|
return fmt.Errorf("create default gear: %w", err)
|
|
|
|
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
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -449,52 +616,6 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64
|
|
|
|
return nil
|
|
|
|
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.FreeBuffActivationsPerPeriodRuntime(),
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 need catch-up: walking heroes stale on the map,
|
|
|
|
// 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).
|
|
|
|
// 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).
|
|
|
|
// Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).
|
|
|
|
|