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.
830 lines
27 KiB
Go
830 lines
27 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"
|
|
)
|
|
|
|
// ErrInventoryFull is returned when the backpack already holds MaxInventorySlots items.
|
|
var ErrInventoryFull = errors.New("inventory full")
|
|
|
|
// GearStore handles all gear CRUD operations against PostgreSQL.
|
|
type GearStore struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
type gearOverrideRow struct {
|
|
FormID *string
|
|
Name *string
|
|
Subtype *string
|
|
Rarity *string
|
|
Ilvl *int
|
|
BasePrimary *int
|
|
PrimaryStat *int
|
|
StatType *string
|
|
SpeedModifier *float64
|
|
CritChance *float64
|
|
AgilityBonus *int
|
|
SetName *string
|
|
SpecialEffect *string
|
|
}
|
|
|
|
// NewGearStore creates a new GearStore backed by the given connection pool.
|
|
func NewGearStore(pool *pgxpool.Pool) *GearStore {
|
|
return &GearStore{pool: pool}
|
|
}
|
|
|
|
func overrideRowFromItem(item *model.GearItem) gearOverrideRow {
|
|
if item == nil {
|
|
return gearOverrideRow{}
|
|
}
|
|
return gearOverrideRow{
|
|
FormID: &item.FormID,
|
|
Name: &item.Name,
|
|
Subtype: &item.Subtype,
|
|
Rarity: ptrString(string(item.Rarity)),
|
|
Ilvl: &item.Ilvl,
|
|
BasePrimary: &item.BasePrimary,
|
|
PrimaryStat: &item.PrimaryStat,
|
|
StatType: &item.StatType,
|
|
SpeedModifier: &item.SpeedModifier,
|
|
CritChance: &item.CritChance,
|
|
AgilityBonus: &item.AgilityBonus,
|
|
SetName: &item.SetName,
|
|
SpecialEffect: &item.SpecialEffect,
|
|
}
|
|
}
|
|
|
|
func (s *GearStore) resolveArchetypeID(ctx context.Context, item *model.GearItem) (int64, error) {
|
|
if item == nil {
|
|
return 0, fmt.Errorf("nil gear item")
|
|
}
|
|
var id int64
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id FROM gear
|
|
WHERE slot = $1 AND name = $2 AND form_id = $3 AND subtype = $4
|
|
ORDER BY id ASC LIMIT 1
|
|
`, string(item.Slot), item.Name, item.FormID, item.Subtype).Scan(&id)
|
|
if err == nil {
|
|
return id, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, fmt.Errorf("resolve archetype: %w", err)
|
|
}
|
|
err = s.pool.QueryRow(ctx, `
|
|
SELECT id FROM gear
|
|
WHERE slot = $1 AND name = $2
|
|
ORDER BY id ASC LIMIT 1
|
|
`, string(item.Slot), item.Name).Scan(&id)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, fmt.Errorf("gear archetype not found for %s/%s", item.Name, item.Subtype)
|
|
}
|
|
return 0, fmt.Errorf("resolve archetype fallback: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// UpdateItem updates an existing row in `gear` by id (all columns except created_at).
|
|
func (s *GearStore) UpdateItem(ctx context.Context, item *model.GearItem) error {
|
|
if item == nil || item.ID <= 0 {
|
|
return fmt.Errorf("invalid gear id")
|
|
}
|
|
cmd, err := s.pool.Exec(ctx, `
|
|
UPDATE gear SET
|
|
slot = $2, form_id = $3, name = $4, subtype = $5, rarity = $6, ilvl = $7,
|
|
base_primary = $8, primary_stat = $9, stat_type = $10,
|
|
speed_modifier = $11, crit_chance = $12, agility_bonus = $13, set_name = $14, special_effect = $15
|
|
WHERE id = $1
|
|
`,
|
|
item.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,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update gear: %w", err)
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return fmt.Errorf("gear not found: %d", item.ID)
|
|
}
|
|
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 hg.item_id, hg.slot,
|
|
COALESCE(hg.form_id, g.form_id),
|
|
COALESCE(hg.name, g.name),
|
|
COALESCE(hg.subtype, g.subtype),
|
|
COALESCE(hg.rarity, g.rarity),
|
|
COALESCE(hg.ilvl, g.ilvl),
|
|
COALESCE(hg.base_primary, g.base_primary),
|
|
COALESCE(hg.primary_stat, g.primary_stat),
|
|
COALESCE(hg.stat_type, g.stat_type),
|
|
COALESCE(hg.speed_modifier, g.speed_modifier),
|
|
COALESCE(hg.crit_chance, g.crit_chance),
|
|
COALESCE(hg.agility_bonus, g.agility_bonus),
|
|
COALESCE(hg.set_name, g.set_name),
|
|
COALESCE(hg.special_effect, 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 newly created item (not yet in inventory) into the given slot.
|
|
// Any previously equipped item is moved to the backpack.
|
|
// Returns ErrInventoryFull if the previous item cannot be stashed.
|
|
func (s *GearStore) EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error {
|
|
if item == nil {
|
|
return fmt.Errorf("nil gear item")
|
|
}
|
|
gearID, err := s.resolveArchetypeID(ctx, item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("equip gear item begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var (
|
|
prevGearID int64
|
|
prevItemID int64
|
|
prevOverrides gearOverrideRow
|
|
)
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
|
|
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect
|
|
FROM hero_gear
|
|
WHERE hero_id = $1 AND slot = $2
|
|
`, heroID, string(item.Slot)).Scan(
|
|
&prevGearID, &prevItemID,
|
|
&prevOverrides.FormID, &prevOverrides.Name, &prevOverrides.Subtype, &prevOverrides.Rarity,
|
|
&prevOverrides.Ilvl, &prevOverrides.BasePrimary, &prevOverrides.PrimaryStat,
|
|
&prevOverrides.StatType, &prevOverrides.SpeedModifier, &prevOverrides.CritChance, &prevOverrides.AgilityBonus,
|
|
&prevOverrides.SetName, &prevOverrides.SpecialEffect,
|
|
)
|
|
hasPrev := true
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
hasPrev = false
|
|
err = nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("equip read previous: %w", err)
|
|
}
|
|
if hasPrev {
|
|
count, err := inventoryCountTx(ctx, tx, heroID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count >= model.MaxInventorySlots {
|
|
return ErrInventoryFull
|
|
}
|
|
}
|
|
|
|
overrides := overrideRowFromItem(item)
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO hero_gear (
|
|
hero_id, slot, gear_id, item_id,
|
|
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, nextval('public.hero_item_id_seq'),
|
|
$4, $5, $6, $7, $8, $9, $10, $11,
|
|
$12, $13, $14, $15, $16
|
|
)
|
|
ON CONFLICT (hero_id, slot) DO UPDATE SET
|
|
gear_id = EXCLUDED.gear_id,
|
|
item_id = EXCLUDED.item_id,
|
|
form_id = EXCLUDED.form_id,
|
|
name = EXCLUDED.name,
|
|
subtype = EXCLUDED.subtype,
|
|
rarity = EXCLUDED.rarity,
|
|
ilvl = EXCLUDED.ilvl,
|
|
base_primary = EXCLUDED.base_primary,
|
|
primary_stat = EXCLUDED.primary_stat,
|
|
stat_type = EXCLUDED.stat_type,
|
|
speed_modifier = EXCLUDED.speed_modifier,
|
|
crit_chance = EXCLUDED.crit_chance,
|
|
agility_bonus = EXCLUDED.agility_bonus,
|
|
set_name = EXCLUDED.set_name,
|
|
special_effect = EXCLUDED.special_effect
|
|
RETURNING item_id
|
|
`, heroID, string(item.Slot), gearID,
|
|
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
|
|
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
|
|
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
|
|
overrides.SetName, overrides.SpecialEffect,
|
|
).Scan(&item.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("equip gear item: %w", err)
|
|
}
|
|
|
|
if hasPrev && prevItemID != item.ID {
|
|
if err := addToInventoryTx(ctx, tx, heroID, prevGearID, prevItemID, prevOverrides); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("equip gear item commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EquipInventoryItem equips an existing inventory item by item_id.
|
|
func (s *GearStore) EquipInventoryItem(ctx context.Context, heroID, itemID int64) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("equip inventory item begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var (
|
|
gearID int64
|
|
slot string
|
|
overrides gearOverrideRow
|
|
prevGearID int64
|
|
prevItemID int64
|
|
prevOv gearOverrideRow
|
|
)
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT hi.gear_id, g.slot,
|
|
hi.form_id, hi.name, hi.subtype, hi.rarity, hi.ilvl, hi.base_primary, hi.primary_stat,
|
|
hi.stat_type, hi.speed_modifier, hi.crit_chance, hi.agility_bonus, hi.set_name, hi.special_effect
|
|
FROM hero_inventory hi
|
|
JOIN gear g ON hi.gear_id = g.id
|
|
WHERE hi.hero_id = $1 AND hi.item_id = $2
|
|
`, heroID, itemID).Scan(
|
|
&gearID, &slot,
|
|
&overrides.FormID, &overrides.Name, &overrides.Subtype, &overrides.Rarity,
|
|
&overrides.Ilvl, &overrides.BasePrimary, &overrides.PrimaryStat,
|
|
&overrides.StatType, &overrides.SpeedModifier, &overrides.CritChance, &overrides.AgilityBonus,
|
|
&overrides.SetName, &overrides.SpecialEffect,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("inventory item not found")
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("equip inventory read item: %w", err)
|
|
}
|
|
|
|
if err := compactInventoryAfterRemovingItem(ctx, tx, heroID, itemID); err != nil {
|
|
return err
|
|
}
|
|
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
|
|
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect
|
|
FROM hero_gear
|
|
WHERE hero_id = $1 AND slot = $2
|
|
`, heroID, slot).Scan(
|
|
&prevGearID, &prevItemID,
|
|
&prevOv.FormID, &prevOv.Name, &prevOv.Subtype, &prevOv.Rarity,
|
|
&prevOv.Ilvl, &prevOv.BasePrimary, &prevOv.PrimaryStat,
|
|
&prevOv.StatType, &prevOv.SpeedModifier, &prevOv.CritChance, &prevOv.AgilityBonus,
|
|
&prevOv.SetName, &prevOv.SpecialEffect,
|
|
)
|
|
hasPrev := true
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
hasPrev = false
|
|
err = nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("equip inventory read previous: %w", err)
|
|
}
|
|
if hasPrev && prevItemID != itemID {
|
|
count, err := inventoryCountTx(ctx, tx, heroID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count >= model.MaxInventorySlots {
|
|
return ErrInventoryFull
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO hero_gear (
|
|
hero_id, slot, gear_id, item_id,
|
|
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, $15, $16, $17
|
|
)
|
|
ON CONFLICT (hero_id, slot) DO UPDATE SET
|
|
gear_id = EXCLUDED.gear_id,
|
|
item_id = EXCLUDED.item_id,
|
|
form_id = EXCLUDED.form_id,
|
|
name = EXCLUDED.name,
|
|
subtype = EXCLUDED.subtype,
|
|
rarity = EXCLUDED.rarity,
|
|
ilvl = EXCLUDED.ilvl,
|
|
base_primary = EXCLUDED.base_primary,
|
|
primary_stat = EXCLUDED.primary_stat,
|
|
stat_type = EXCLUDED.stat_type,
|
|
speed_modifier = EXCLUDED.speed_modifier,
|
|
crit_chance = EXCLUDED.crit_chance,
|
|
agility_bonus = EXCLUDED.agility_bonus,
|
|
set_name = EXCLUDED.set_name,
|
|
special_effect = EXCLUDED.special_effect
|
|
`, heroID, slot, gearID, itemID,
|
|
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
|
|
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
|
|
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
|
|
overrides.SetName, overrides.SpecialEffect,
|
|
); err != nil {
|
|
return fmt.Errorf("equip inventory upsert: %w", err)
|
|
}
|
|
|
|
if hasPrev && prevItemID != itemID {
|
|
if err := addToInventoryTx(ctx, tx, heroID, prevGearID, prevItemID, prevOv); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("equip inventory commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inventoryCountTx(ctx context.Context, tx pgx.Tx, heroID int64) (int, error) {
|
|
var n int
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1
|
|
`, heroID).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("inventory count: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func addToInventoryTx(ctx context.Context, tx pgx.Tx, heroID, gearID, itemID int64, overrides gearOverrideRow) error {
|
|
n, err := inventoryCountTx(ctx, tx, heroID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n >= model.MaxInventorySlots {
|
|
return ErrInventoryFull
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO hero_inventory (
|
|
hero_id, slot_index, gear_id, item_id,
|
|
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, $15, $16, $17
|
|
)
|
|
`, heroID, n, gearID, itemID,
|
|
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
|
|
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
|
|
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
|
|
overrides.SetName, overrides.SpecialEffect,
|
|
); err != nil {
|
|
return fmt.Errorf("add to inventory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compactInventoryAfterRemovingItem deletes one backpack row if present; if a row
|
|
// was removed, rewrites hero_inventory with contiguous slot_index.
|
|
func compactInventoryAfterRemovingItem(ctx context.Context, tx pgx.Tx, heroID, itemID int64) error {
|
|
cmd, err := tx.Exec(ctx, `
|
|
DELETE FROM hero_inventory WHERE hero_id = $1 AND item_id = $2
|
|
`, heroID, itemID)
|
|
if err != nil {
|
|
return fmt.Errorf("remove item from inventory: %w", err)
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return nil
|
|
}
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
|
|
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect
|
|
FROM hero_inventory
|
|
WHERE hero_id = $1
|
|
ORDER BY slot_index ASC
|
|
`, heroID)
|
|
if err != nil {
|
|
return fmt.Errorf("list inventory after remove: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type invRow struct {
|
|
gearID int64
|
|
itemID int64
|
|
overrides gearOverrideRow
|
|
}
|
|
var items []invRow
|
|
for rows.Next() {
|
|
var row invRow
|
|
if err := rows.Scan(
|
|
&row.gearID, &row.itemID,
|
|
&row.overrides.FormID, &row.overrides.Name, &row.overrides.Subtype, &row.overrides.Rarity,
|
|
&row.overrides.Ilvl, &row.overrides.BasePrimary, &row.overrides.PrimaryStat,
|
|
&row.overrides.StatType, &row.overrides.SpeedModifier, &row.overrides.CritChance, &row.overrides.AgilityBonus,
|
|
&row.overrides.SetName, &row.overrides.SpecialEffect,
|
|
); err != nil {
|
|
return fmt.Errorf("scan inventory row: %w", err)
|
|
}
|
|
items = append(items, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("inventory rows: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil {
|
|
return fmt.Errorf("clear inventory for compact: %w", err)
|
|
}
|
|
for i, row := range items {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO hero_inventory (
|
|
hero_id, slot_index, gear_id, item_id,
|
|
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, $15, $16, $17
|
|
)
|
|
`, heroID, i, row.gearID, row.itemID,
|
|
row.overrides.FormID, row.overrides.Name, row.overrides.Subtype, row.overrides.Rarity, row.overrides.Ilvl,
|
|
row.overrides.BasePrimary, row.overrides.PrimaryStat, row.overrides.StatType,
|
|
row.overrides.SpeedModifier, row.overrides.CritChance, row.overrides.AgilityBonus,
|
|
row.overrides.SetName, row.overrides.SpecialEffect,
|
|
); err != nil {
|
|
return fmt.Errorf("reinsert inventory slot %d: %w", i, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WipeAllGearForHero removes every equipped and backpack item for the hero.
|
|
func (s *GearStore) WipeAllGearForHero(ctx context.Context, heroID int64) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("wipe gear begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM hero_gear WHERE hero_id = $1`, heroID); err != nil {
|
|
return fmt.Errorf("wipe hero_gear: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil {
|
|
return fmt.Errorf("wipe hero_inventory: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("wipe gear commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteGearItem removes a gear archetype row by id. Prefer DeleteHeroItem for owned gear.
|
|
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
|
|
}
|
|
|
|
// DeleteHeroItem removes an equipped or inventory item by item_id.
|
|
func (s *GearStore) DeleteHeroItem(ctx context.Context, heroID, itemID int64) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("delete hero item begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var exists bool
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM hero_inventory WHERE hero_id = $1 AND item_id = $2
|
|
)
|
|
`, heroID, itemID).Scan(&exists); err != nil {
|
|
return fmt.Errorf("delete hero item lookup: %w", err)
|
|
}
|
|
if exists {
|
|
if err := compactInventoryAfterRemovingItem(ctx, tx, heroID, itemID); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("delete hero item commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
cmd, err := tx.Exec(ctx, `
|
|
DELETE FROM hero_gear WHERE hero_id = $1 AND item_id = $2
|
|
`, heroID, itemID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete hero gear item: %w", err)
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return fmt.Errorf("delete hero item: no row for id %d", itemID)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("delete hero item commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UnequipSlot moves equipped gear from the given slot into the hero's backpack.
|
|
// Returns ErrInventoryFull if there is no free slot (equipped row is left unchanged).
|
|
// If the slot is empty, returns nil (idempotent).
|
|
func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.EquipmentSlot) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("unequip begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var (
|
|
gearID int64
|
|
itemID int64
|
|
overrides gearOverrideRow
|
|
)
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
|
|
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect
|
|
FROM hero_gear WHERE hero_id = $1 AND slot = $2
|
|
`, heroID, string(slot)).Scan(
|
|
&gearID, &itemID,
|
|
&overrides.FormID, &overrides.Name, &overrides.Subtype, &overrides.Rarity,
|
|
&overrides.Ilvl, &overrides.BasePrimary, &overrides.PrimaryStat,
|
|
&overrides.StatType, &overrides.SpeedModifier, &overrides.CritChance, &overrides.AgilityBonus,
|
|
&overrides.SetName, &overrides.SpecialEffect,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("unequip read slot: %w", err)
|
|
}
|
|
|
|
if err := addToInventoryTx(ctx, tx, heroID, gearID, itemID, overrides); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM hero_gear WHERE hero_id = $1 AND slot = $2
|
|
`, heroID, string(slot)); err != nil {
|
|
return fmt.Errorf("unequip delete hero_gear: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("unequip commit: %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 hi.item_id, g.slot,
|
|
COALESCE(hi.form_id, g.form_id),
|
|
COALESCE(hi.name, g.name),
|
|
COALESCE(hi.subtype, g.subtype),
|
|
COALESCE(hi.rarity, g.rarity),
|
|
COALESCE(hi.ilvl, g.ilvl),
|
|
COALESCE(hi.base_primary, g.base_primary),
|
|
COALESCE(hi.primary_stat, g.primary_stat),
|
|
COALESCE(hi.stat_type, g.stat_type),
|
|
COALESCE(hi.speed_modifier, g.speed_modifier),
|
|
COALESCE(hi.crit_chance, g.crit_chance),
|
|
COALESCE(hi.agility_bonus, g.agility_bonus),
|
|
COALESCE(hi.set_name, g.set_name),
|
|
COALESCE(hi.special_effect, 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 int64, item *model.GearItem) error {
|
|
if item == nil {
|
|
return fmt.Errorf("nil gear item")
|
|
}
|
|
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 ErrInventoryFull
|
|
}
|
|
gearID, err := s.resolveArchetypeID(ctx, item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
overrides := overrideRowFromItem(item)
|
|
err = s.pool.QueryRow(ctx, `
|
|
INSERT INTO hero_inventory (
|
|
hero_id, slot_index, gear_id, item_id,
|
|
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, nextval('public.hero_item_id_seq'),
|
|
$4, $5, $6, $7, $8, $9, $10, $11,
|
|
$12, $13, $14, $15, $16
|
|
)
|
|
RETURNING item_id
|
|
`, heroID, n, gearID,
|
|
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
|
|
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
|
|
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
|
|
overrides.SetName, overrides.SpecialEffect,
|
|
).Scan(&item.ID)
|
|
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
|
|
}
|
|
gearID, err := s.resolveArchetypeID(ctx, item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
overrides := overrideRowFromItem(item)
|
|
if item.ID == 0 {
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO hero_inventory (
|
|
hero_id, slot_index, gear_id, item_id,
|
|
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, nextval('public.hero_item_id_seq'),
|
|
$4, $5, $6, $7, $8, $9, $10, $11,
|
|
$12, $13, $14, $15, $16
|
|
)
|
|
RETURNING item_id
|
|
`, heroID, i, gearID,
|
|
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
|
|
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
|
|
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
|
|
overrides.SetName, overrides.SpecialEffect,
|
|
).Scan(&item.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("replace inventory insert: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO hero_inventory (
|
|
hero_id, slot_index, gear_id, item_id,
|
|
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, $15, $16, $17
|
|
)
|
|
`, heroID, i, gearID, item.ID,
|
|
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
|
|
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
|
|
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
|
|
overrides.SetName, overrides.SpecialEffect,
|
|
); 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
|
|
}
|
|
|
|
func ptrString(v string) *string {
|
|
return &v
|
|
}
|