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.
253 lines
8.3 KiB
Go
253 lines
8.3 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
)
|
|
|
|
// GearStore handles all gear CRUD operations against PostgreSQL.
|
|
type GearStore struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewGearStore creates a new GearStore backed by the given connection pool.
|
|
func NewGearStore(pool *pgxpool.Pool) *GearStore {
|
|
return &GearStore{pool: pool}
|
|
}
|
|
|
|
// CreateItem inserts a new gear item into the database and populates item.ID.
|
|
func (s *GearStore) CreateItem(ctx context.Context, item *model.GearItem) error {
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO gear (slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
|
|
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING id
|
|
`,
|
|
string(item.Slot), item.FormID, item.Name, item.Subtype,
|
|
string(item.Rarity), item.Ilvl, item.BasePrimary, item.PrimaryStat,
|
|
item.StatType, item.SpeedModifier, item.CritChance, item.AgilityBonus,
|
|
item.SetName, item.SpecialEffect,
|
|
).Scan(&item.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("create gear item: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetItem loads a single gear item by ID. Returns (nil, nil) if not found.
|
|
func (s *GearStore) GetItem(ctx context.Context, id int64) (*model.GearItem, error) {
|
|
var item model.GearItem
|
|
var slot, rarity string
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, slot, form_id, name, subtype, rarity, ilvl,
|
|
base_primary, primary_stat, stat_type,
|
|
speed_modifier, crit_chance, agility_bonus,
|
|
set_name, special_effect
|
|
FROM gear WHERE id = $1
|
|
`, id).Scan(
|
|
&item.ID, &slot, &item.FormID, &item.Name, &item.Subtype,
|
|
&rarity, &item.Ilvl,
|
|
&item.BasePrimary, &item.PrimaryStat, &item.StatType,
|
|
&item.SpeedModifier, &item.CritChance, &item.AgilityBonus,
|
|
&item.SetName, &item.SpecialEffect,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("get gear item: %w", err)
|
|
}
|
|
item.Slot = model.EquipmentSlot(slot)
|
|
item.Rarity = model.Rarity(rarity)
|
|
return &item, nil
|
|
}
|
|
|
|
// GetHeroGear returns all equipped gear for a hero, keyed by slot.
|
|
func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.EquipmentSlot]*model.GearItem, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl,
|
|
g.base_primary, g.primary_stat, g.stat_type,
|
|
g.speed_modifier, g.crit_chance, g.agility_bonus,
|
|
g.set_name, g.special_effect
|
|
FROM hero_gear hg
|
|
JOIN gear g ON hg.gear_id = g.id
|
|
WHERE hg.hero_id = $1
|
|
`, heroID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get hero gear: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
gear := make(map[model.EquipmentSlot]*model.GearItem)
|
|
for rows.Next() {
|
|
var item model.GearItem
|
|
var slot, rarity string
|
|
if err := rows.Scan(
|
|
&item.ID, &slot, &item.FormID, &item.Name, &item.Subtype,
|
|
&rarity, &item.Ilvl,
|
|
&item.BasePrimary, &item.PrimaryStat, &item.StatType,
|
|
&item.SpeedModifier, &item.CritChance, &item.AgilityBonus,
|
|
&item.SetName, &item.SpecialEffect,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan gear item: %w", err)
|
|
}
|
|
item.Slot = model.EquipmentSlot(slot)
|
|
item.Rarity = model.Rarity(rarity)
|
|
gear[item.Slot] = &item
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("hero gear rows: %w", err)
|
|
}
|
|
return gear, nil
|
|
}
|
|
|
|
// EquipItem equips a gear item into the given slot for a hero (upsert).
|
|
func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, gearID int64) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO hero_gear (hero_id, slot, gear_id)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (hero_id, slot) DO UPDATE SET gear_id = $3
|
|
`, heroID, string(slot), gearID)
|
|
if err != nil {
|
|
return fmt.Errorf("equip gear item: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteGearItem removes a gear row by id (e.g. discarded drop not sold). Fails if still equipped.
|
|
func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error {
|
|
cmd, err := s.pool.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete gear item: %w", err)
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return fmt.Errorf("delete gear item: no row for id %d", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UnequipSlot removes the gear from the given slot for a hero.
|
|
func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.EquipmentSlot) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
DELETE FROM hero_gear WHERE hero_id = $1 AND slot = $2
|
|
`, heroID, string(slot))
|
|
if err != nil {
|
|
return fmt.Errorf("unequip gear slot: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetHeroInventory loads unequipped gear ordered by slot_index.
|
|
func (s *GearStore) GetHeroInventory(ctx context.Context, heroID int64) ([]*model.GearItem, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl,
|
|
g.base_primary, g.primary_stat, g.stat_type,
|
|
g.speed_modifier, g.crit_chance, g.agility_bonus,
|
|
g.set_name, g.special_effect
|
|
FROM hero_inventory hi
|
|
JOIN gear g ON hi.gear_id = g.id
|
|
WHERE hi.hero_id = $1
|
|
ORDER BY hi.slot_index ASC
|
|
`, heroID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get hero inventory: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*model.GearItem
|
|
for rows.Next() {
|
|
var item model.GearItem
|
|
var slot, rarity string
|
|
if err := rows.Scan(
|
|
&item.ID, &slot, &item.FormID, &item.Name, &item.Subtype,
|
|
&rarity, &item.Ilvl,
|
|
&item.BasePrimary, &item.PrimaryStat, &item.StatType,
|
|
&item.SpeedModifier, &item.CritChance, &item.AgilityBonus,
|
|
&item.SetName, &item.SpecialEffect,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan inventory gear: %w", err)
|
|
}
|
|
item.Slot = model.EquipmentSlot(slot)
|
|
item.Rarity = model.Rarity(rarity)
|
|
out = append(out, &item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("hero inventory rows: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AddToInventory inserts gear into the next free backpack slot. Fails if inventory is full.
|
|
func (s *GearStore) AddToInventory(ctx context.Context, heroID, gearID int64) error {
|
|
var n int
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1
|
|
`, heroID).Scan(&n); err != nil {
|
|
return fmt.Errorf("inventory count: %w", err)
|
|
}
|
|
if n >= model.MaxInventorySlots {
|
|
return fmt.Errorf("inventory full")
|
|
}
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
|
|
VALUES ($1, $2, $3)
|
|
`, heroID, n, gearID)
|
|
if err != nil {
|
|
return fmt.Errorf("add to inventory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReplaceHeroInventory deletes all backpack rows for the hero and re-inserts from the slice.
|
|
// Used when persisting offline-simulated heroes with in-memory-only gear rows (id may be 0).
|
|
func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, items []*model.GearItem) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("replace inventory begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil {
|
|
return fmt.Errorf("replace inventory delete: %w", err)
|
|
}
|
|
for i, item := range items {
|
|
if item == nil || i >= model.MaxInventorySlots {
|
|
continue
|
|
}
|
|
gid := item.ID
|
|
if gid == 0 {
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO gear (slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
|
|
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING id
|
|
`,
|
|
string(item.Slot), item.FormID, item.Name, item.Subtype,
|
|
string(item.Rarity), item.Ilvl, item.BasePrimary, item.PrimaryStat,
|
|
item.StatType, item.SpeedModifier, item.CritChance, item.AgilityBonus,
|
|
item.SetName, item.SpecialEffect,
|
|
).Scan(&gid)
|
|
if err != nil {
|
|
return fmt.Errorf("replace inventory create gear: %w", err)
|
|
}
|
|
item.ID = gid
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
|
|
VALUES ($1, $2, $3)
|
|
`, heroID, i, gid); err != nil {
|
|
return fmt.Errorf("replace inventory insert: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("replace inventory commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|