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

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
}