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 } // 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 } // 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 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). // Any previously equipped item in that slot is moved to the backpack (unless it is the same gear_id). // If the new item was in the backpack, it is removed and remaining slots are reindexed (0..n-1). // Returns ErrInventoryFull if the previous item cannot be stashed (transaction rolled back). func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, gearID int64) error { 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 err = tx.QueryRow(ctx, ` SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2 `, heroID, string(slot)).Scan(&prevGearID) 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 _, err := tx.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 = EXCLUDED.gear_id `, heroID, string(slot), gearID); err != nil { return fmt.Errorf("equip gear item: %w", err) } if err := compactInventoryAfterRemovingGear(ctx, tx, heroID, gearID); err != nil { return err } if hasPrev && prevGearID != gearID { if err := addToInventoryTx(ctx, tx, heroID, prevGearID); err != nil { return err } } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("equip gear item commit: %w", err) } return nil } func addToInventoryTx(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error { var n int if err := tx.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 } if _, err := tx.Exec(ctx, ` INSERT INTO hero_inventory (hero_id, slot_index, gear_id) VALUES ($1, $2, $3) `, heroID, n, gearID); err != nil { return fmt.Errorf("add to inventory: %w", err) } return nil } // compactInventoryAfterRemovingGear deletes one backpack row if present; if a row // was removed, rewrites hero_inventory with contiguous slot_index. func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error { cmd, err := tx.Exec(ctx, ` DELETE FROM hero_inventory WHERE hero_id = $1 AND gear_id = $2 `, heroID, gearID) if err != nil { return fmt.Errorf("remove equipped gear from inventory: %w", err) } if cmd.RowsAffected() == 0 { return nil } rows, err := tx.Query(ctx, ` SELECT gear_id 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() var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return fmt.Errorf("scan inventory gear_id: %w", err) } ids = append(ids, id) } 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, gid := range ids { 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("reinsert inventory slot %d: %w", i, 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 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 err = tx.QueryRow(ctx, ` SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2 `, heroID, string(slot)).Scan(&gearID) 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); 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 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 ErrInventoryFull } _, 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 }