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 }