master
Denis Ranneft 1 month ago
parent e5336d313f
commit 5373107a03

@ -11,10 +11,8 @@ import (
) )
type GearStore interface { type GearStore interface {
CreateItem(ctx context.Context, item *model.GearItem) error AddToInventory(ctx context.Context, heroID int64, item *model.GearItem) error
DeleteGearItem(ctx context.Context, itemID int64) error EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error
AddToInventory(ctx context.Context, heroID int64, itemID int64) error
EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, itemID int64) error
} }
type QuestProgressor interface { type QuestProgressor interface {
@ -87,35 +85,12 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite) ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
item := model.NewGearItem(family, ilvl, drop.Rarity) item := model.NewGearItem(family, ilvl, drop.Rarity)
if deps.GearStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := deps.GearStore.CreateItem(ctx, item); err != nil {
cancel()
if deps.Logger != nil {
deps.Logger.Warn("failed to create gear item", "slot", slot, "error", err)
}
if inTown {
sellPrice := model.AutoSellPrice(drop.Rarity)
hero.Gold += sellPrice
goldGained += sellPrice
drop.GoldAmount = sellPrice
} else {
drop.GoldAmount = 0
}
goto recordLoot
}
cancel()
}
drop.ItemID = item.ID
drop.ItemName = item.Name
hero.EnsureGearMap() hero.EnsureGearMap()
prev := hero.Gear[item.Slot] prev := hero.Gear[item.Slot]
if TryAutoEquipInMemory(hero, item, now) { if TryAutoEquipInMemory(hero, item, now) {
if deps.GearStore != nil && item.ID != 0 { if deps.GearStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err := deps.GearStore.EquipItem(ctx, hero.ID, item.Slot, item.ID) err := deps.GearStore.EquipItem(ctx, hero.ID, item)
cancel() cancel()
if err != nil { if err != nil {
if prev == nil { if prev == nil {
@ -136,6 +111,8 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
goto recordLoot goto recordLoot
} }
} }
drop.ItemID = item.ID
drop.ItemName = item.Name
if deps.LogWriter != nil { if deps.LogWriter != nil {
deps.LogWriter(hero.ID, model.AdventureLogLine{ deps.LogWriter(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{ Event: &model.AdventureLogEvent{
@ -155,13 +132,6 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
hero.EnsureInventorySlice() hero.EnsureInventorySlice()
if len(hero.Inventory) >= model.MaxInventorySlots { if len(hero.Inventory) >= model.MaxInventorySlots {
if deps.GearStore != nil && item.ID != 0 {
ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
if err := deps.GearStore.DeleteGearItem(ctxDel, item.ID); err != nil && deps.Logger != nil {
deps.Logger.Warn("failed to delete gear (inventory full)", "gear_id", item.ID, "error", err)
}
cancelDel()
}
drop.ItemID = 0 drop.ItemID = 0
drop.ItemName = "" drop.ItemName = ""
drop.GoldAmount = 0 drop.GoldAmount = 0
@ -178,26 +148,25 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
} else { } else {
if deps.GearStore != nil { if deps.GearStore != nil {
ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second) ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second)
err := deps.GearStore.AddToInventory(ctxInv, hero.ID, item.ID) err := deps.GearStore.AddToInventory(ctxInv, hero.ID, item)
cancelInv() cancelInv()
if err != nil { if err != nil {
if deps.Logger != nil { if deps.Logger != nil {
deps.Logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err) deps.Logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err)
} }
ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
if deps.GearStore != nil && item.ID != 0 {
_ = deps.GearStore.DeleteGearItem(ctxDel, item.ID)
}
cancelDel()
drop.ItemID = 0 drop.ItemID = 0
drop.ItemName = "" drop.ItemName = ""
drop.GoldAmount = 0 drop.GoldAmount = 0
} else { } else {
hero.Inventory = append(hero.Inventory, item) hero.Inventory = append(hero.Inventory, item)
drop.ItemID = item.ID
drop.ItemName = item.Name
drop.GoldAmount = 0 drop.GoldAmount = 0
} }
} else { } else {
hero.Inventory = append(hero.Inventory, item) hero.Inventory = append(hero.Inventory, item)
drop.ItemID = item.ID
drop.ItemName = item.Name
drop.GoldAmount = 0 drop.GoldAmount = 0
} }
} }

@ -104,20 +104,10 @@ func ApplyPreparedTownMerchantPurchase(ctx context.Context, gs *storage.GearStor
return nil, errors.New("nil item clone") return nil, errors.New("nil item clone")
} }
ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second)
err := gs.CreateItem(ctxCreate, toCreate)
cancel()
if err != nil {
return nil, fmt.Errorf("create gear: %w", err)
}
ctxEq, cancelEq := context.WithTimeout(ctx, 2*time.Second) ctxEq, cancelEq := context.WithTimeout(ctx, 2*time.Second)
err = gs.EquipItem(ctxEq, hero.ID, toCreate.Slot, toCreate.ID) err := gs.EquipItem(ctxEq, hero.ID, toCreate)
cancelEq() cancelEq()
if err != nil { if err != nil {
ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
_ = gs.DeleteGearItem(ctxDel, toCreate.ID)
cancelDel()
return nil, err return nil, err
} }

@ -444,13 +444,7 @@ func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) {
} }
clone := *src clone := *src
clone.ID = 0 clone.ID = 0
if err := h.gearStore.CreateItem(r.Context(), &clone); err != nil { if err := h.gearStore.AddToInventory(r.Context(), heroID, &clone); err != nil {
h.logger.Error("admin: grant gear clone", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gear copy"})
return
}
if err := h.gearStore.AddToInventory(r.Context(), heroID, clone.ID); err != nil {
_ = h.gearStore.DeleteGearItem(r.Context(), clone.ID)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
@ -492,12 +486,7 @@ func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) {
} }
item := model.NewGearItem(family, req.Ilvl, rarity) item := model.NewGearItem(family, req.Ilvl, rarity)
if err := h.gearStore.CreateItem(r.Context(), item); err != nil { if err := h.gearStore.AddToInventory(r.Context(), heroID, item); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gear item"})
return
}
if err := h.gearStore.AddToInventory(r.Context(), heroID, item.ID); err != nil {
_ = h.gearStore.DeleteGearItem(r.Context(), item.ID)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
@ -525,12 +514,7 @@ func (h *AdminHandler) EquipHeroGear(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return return
} }
item, err := h.gearStore.GetItem(r.Context(), req.ItemID) if err := h.gearStore.EquipInventoryItem(r.Context(), heroID, req.ItemID); err != nil {
if err != nil || item == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "gear item not found"})
return
}
if err := h.gearStore.EquipItem(r.Context(), heroID, item.Slot, item.ID); err != nil {
if errors.Is(err, storage.ErrInventoryFull) { if errors.Is(err, storage.ErrInventoryFull) {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "inventory full — free a backpack slot to swap this piece", "error": "inventory full — free a backpack slot to swap this piece",
@ -599,7 +583,7 @@ func (h *AdminHandler) DeleteHeroGear(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid itemId: " + err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid itemId: " + err.Error()})
return return
} }
if err := h.gearStore.DeleteGearItem(r.Context(), itemID); err != nil { if err := h.gearStore.DeleteHeroItem(r.Context(), heroID, itemID); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }

@ -572,12 +572,12 @@ func (h *GameHandler) tryAutoEquipGear(hero *model.Hero, item *model.GearItem, n
// persistGearEquip saves the equip to the hero_gear table if gearStore is available. // persistGearEquip saves the equip to the hero_gear table if gearStore is available.
func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) error { func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) error {
if h.gearStore == nil || item.ID == 0 { if h.gearStore == nil || item == nil {
return nil return nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID) return h.gearStore.EquipItem(ctx, heroID, item)
} }
// pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats. // pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats.

@ -384,12 +384,12 @@ func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) {
// npcPersistGearEquip writes hero_gear when a merchant drop is equipped. // npcPersistGearEquip writes hero_gear when a merchant drop is equipped.
func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) error { func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) error {
if h.gearStore == nil || item == nil || item.ID == 0 { if h.gearStore == nil || item == nil {
return nil return nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID) return h.gearStore.EquipItem(ctx, heroID, item)
} }
// grantMerchantLoot rolls one random gear piece; auto-equips if better. // grantMerchantLoot rolls one random gear piece; auto-equips if better.
@ -416,14 +416,6 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
ilvl := model.RollIlvl(refLevel, false) ilvl := model.RollIlvl(refLevel, false)
item := model.NewGearItem(family, ilvl, rarity) item := model.NewGearItem(family, ilvl, rarity)
ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second)
err := h.gearStore.CreateItem(ctxCreate, item)
cancel()
if err != nil {
h.logger.Warn("failed to create alms gear item", "slot", family.Slot, "error", err)
return nil, err
}
hero.EnsureGearMap() hero.EnsureGearMap()
slot := item.Slot slot := item.Slot
var prev *model.GearItem var prev *model.GearItem
@ -464,13 +456,6 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
if !equipped { if !equipped {
hero.EnsureInventorySlice() hero.EnsureInventorySlice()
if len(hero.Inventory) >= model.MaxInventorySlots { if len(hero.Inventory) >= model.MaxInventorySlots {
ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
if item.ID != 0 {
if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil {
h.logger.Warn("failed to delete merchant gear (inventory full)", "gear_id", item.ID, "error", err)
}
}
cancelDel()
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{ Event: &model.AdventureLogEvent{
Code: model.LogPhraseWanderingAlmsDropped, Code: model.LogPhraseWanderingAlmsDropped,
@ -479,13 +464,10 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
}) })
} else { } else {
ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second) ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second)
err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID) err := h.gearStore.AddToInventory(ctxInv, hero.ID, item)
cancelInv() cancelInv()
if err != nil { if err != nil {
h.logger.Warn("failed to stash merchant gear", "hero_id", hero.ID, "error", err) h.logger.Warn("failed to stash merchant gear", "hero_id", hero.ID, "error", err)
ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
_ = h.gearStore.DeleteGearItem(ctxDel, item.ID)
cancelDel()
} else { } else {
hero.Inventory = append(hero.Inventory, item) hero.Inventory = append(hero.Inventory, item)
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLogLine(hero.ID, model.AdventureLogLine{
@ -962,7 +944,11 @@ func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) {
townLv := game.TownEffectiveLevel(town) townLv := game.TownEffectiveLevel(town)
n := tuning.EffectiveMerchantTownStockCount() n := tuning.EffectiveMerchantTownStockCount()
slots := model.GearVendorSlots(npc.Type) slots := model.GearVendorSlots(npc.Type)
items := game.RollTownMerchantStockItemsForSlots(int(float64(hero.Level) * float64(1 + hero.Level / townLv)), n, slots) refLevel := townLv
if hero.Level < townLv {
refLevel = int(float64(hero.Level) * (1 + (1-float64(hero.Level / townLv))))
}
items := game.RollTownMerchantStockItemsForSlots(refLevel, n, slots)
costs := make([]int64, len(items)) costs := make([]int64, len(items))
for i, it := range items { for i, it := range items {
if it == nil { if it == nil {

@ -19,11 +19,78 @@ type GearStore struct {
pool *pgxpool.Pool 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. // NewGearStore creates a new GearStore backed by the given connection pool.
func NewGearStore(pool *pgxpool.Pool) *GearStore { func NewGearStore(pool *pgxpool.Pool) *GearStore {
return &GearStore{pool: pool} 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. // CreateItem inserts a new gear item into the database and populates item.ID.
func (s *GearStore) CreateItem(ctx context.Context, item *model.GearItem) error { func (s *GearStore) CreateItem(ctx context.Context, item *model.GearItem) error {
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
@ -100,10 +167,20 @@ func (s *GearStore) GetItem(ctx context.Context, id int64) (*model.GearItem, err
// GetHeroGear returns all equipped gear for a hero, keyed by slot. // 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) { func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.EquipmentSlot]*model.GearItem, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl, SELECT hg.item_id, hg.slot,
g.base_primary, g.primary_stat, g.stat_type, COALESCE(hg.form_id, g.form_id),
g.speed_modifier, g.crit_chance, g.agility_bonus, COALESCE(hg.name, g.name),
g.set_name, g.special_effect 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 FROM hero_gear hg
JOIN gear g ON hg.gear_id = g.id JOIN gear g ON hg.gear_id = g.id
WHERE hg.hero_id = $1 WHERE hg.hero_id = $1
@ -136,21 +213,40 @@ func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.Eq
return gear, nil return gear, nil
} }
// EquipItem equips a gear item into the given slot for a hero (upsert). // EquipItem equips a newly created item (not yet in inventory) into the given slot.
// Any previously equipped item in that slot is moved to the backpack (unless it is the same gear_id). // Any previously equipped item is moved to the backpack.
// 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.
// Returns ErrInventoryFull if the previous item cannot be stashed (transaction rolled back). func (s *GearStore) EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error {
func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, gearID int64) 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) tx, err := s.pool.Begin(ctx)
if err != nil { if err != nil {
return fmt.Errorf("equip gear item begin: %w", err) return fmt.Errorf("equip gear item begin: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
var prevGearID int64 var (
prevGearID int64
prevItemID int64
prevOverrides gearOverrideRow
)
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2 SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
`, heroID, string(slot)).Scan(&prevGearID) 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 hasPrev := true
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
hasPrev = false hasPrev = false
@ -158,73 +254,262 @@ func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.Equi
} else if err != nil { } else if err != nil {
return fmt.Errorf("equip read previous: %w", err) 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
}
}
if _, err := tx.Exec(ctx, ` overrides := overrideRowFromItem(item)
INSERT INTO hero_gear (hero_id, slot, gear_id) err = tx.QueryRow(ctx, `
VALUES ($1, $2, $3) INSERT INTO hero_gear (
ON CONFLICT (hero_id, slot) DO UPDATE SET gear_id = EXCLUDED.gear_id hero_id, slot, gear_id, item_id,
`, heroID, string(slot), gearID); err != nil { 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) return fmt.Errorf("equip gear item: %w", err)
} }
if err := compactInventoryAfterRemovingGear(ctx, tx, heroID, gearID); err != nil {
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 return err
} }
if hasPrev && prevGearID != gearID {
if err := addToInventoryTx(ctx, tx, heroID, prevGearID); err != nil { 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 return err
} }
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("equip gear item commit: %w", err) return fmt.Errorf("equip inventory commit: %w", err)
} }
return nil return nil
} }
func addToInventoryTx(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error { func inventoryCountTx(ctx context.Context, tx pgx.Tx, heroID int64) (int, error) {
var n int var n int
if err := tx.QueryRow(ctx, ` if err := tx.QueryRow(ctx, `
SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1 SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1
`, heroID).Scan(&n); err != nil { `, heroID).Scan(&n); err != nil {
return fmt.Errorf("inventory count: %w", err) 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 { if n >= model.MaxInventorySlots {
return ErrInventoryFull return ErrInventoryFull
} }
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory (hero_id, slot_index, gear_id) INSERT INTO hero_inventory (
VALUES ($1, $2, $3) hero_id, slot_index, gear_id, item_id,
`, heroID, n, gearID); err != nil { 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 fmt.Errorf("add to inventory: %w", err)
} }
return nil return nil
} }
// compactInventoryAfterRemovingGear deletes one backpack row if present; if a row // compactInventoryAfterRemovingItem deletes one backpack row if present; if a row
// was removed, rewrites hero_inventory with contiguous slot_index. // was removed, rewrites hero_inventory with contiguous slot_index.
func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error { func compactInventoryAfterRemovingItem(ctx context.Context, tx pgx.Tx, heroID, itemID int64) error {
cmd, err := tx.Exec(ctx, ` cmd, err := tx.Exec(ctx, `
DELETE FROM hero_inventory WHERE hero_id = $1 AND gear_id = $2 DELETE FROM hero_inventory WHERE hero_id = $1 AND item_id = $2
`, heroID, gearID) `, heroID, itemID)
if err != nil { if err != nil {
return fmt.Errorf("remove equipped gear from inventory: %w", err) return fmt.Errorf("remove item from inventory: %w", err)
} }
if cmd.RowsAffected() == 0 { if cmd.RowsAffected() == 0 {
return nil return nil
} }
rows, err := tx.Query(ctx, ` rows, err := tx.Query(ctx, `
SELECT gear_id FROM hero_inventory WHERE hero_id = $1 ORDER BY slot_index ASC 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) `, heroID)
if err != nil { if err != nil {
return fmt.Errorf("list inventory after remove: %w", err) return fmt.Errorf("list inventory after remove: %w", err)
} }
defer rows.Close() defer rows.Close()
var ids []int64
type invRow struct {
gearID int64
itemID int64
overrides gearOverrideRow
}
var items []invRow
for rows.Next() { for rows.Next() {
var id int64 var row invRow
if err := rows.Scan(&id); err != nil { if err := rows.Scan(
return fmt.Errorf("scan inventory gear_id: %w", err) &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)
} }
ids = append(ids, id) items = append(items, row)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return fmt.Errorf("inventory rows: %w", err) return fmt.Errorf("inventory rows: %w", err)
@ -233,18 +518,30 @@ func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, g
if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil { 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) return fmt.Errorf("clear inventory for compact: %w", err)
} }
for i, gid := range ids { for i, row := range items {
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory (hero_id, slot_index, gear_id) INSERT INTO hero_inventory (
VALUES ($1, $2, $3) hero_id, slot_index, gear_id, item_id,
`, heroID, i, gid); err != nil { 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 fmt.Errorf("reinsert inventory slot %d: %w", i, err)
} }
} }
return nil return nil
} }
// WipeAllGearForHero removes every equipped and backpack item for the hero and deletes the underlying gear rows. // WipeAllGearForHero removes every equipped and backpack item for the hero.
func (s *GearStore) WipeAllGearForHero(ctx context.Context, heroID int64) error { func (s *GearStore) WipeAllGearForHero(ctx context.Context, heroID int64) error {
tx, err := s.pool.Begin(ctx) tx, err := s.pool.Begin(ctx)
if err != nil { if err != nil {
@ -252,47 +549,19 @@ func (s *GearStore) WipeAllGearForHero(ctx context.Context, heroID int64) error
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT gear_id FROM hero_gear WHERE hero_id = $1
UNION
SELECT gear_id FROM hero_inventory WHERE hero_id = $1
`, heroID)
if err != nil {
return fmt.Errorf("wipe gear list ids: %w", err)
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return fmt.Errorf("wipe gear scan id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return fmt.Errorf("wipe gear rows: %w", err)
}
rows.Close()
if _, err := tx.Exec(ctx, `DELETE FROM hero_gear WHERE hero_id = $1`, heroID); err != nil { if _, err := tx.Exec(ctx, `DELETE FROM hero_gear WHERE hero_id = $1`, heroID); err != nil {
return fmt.Errorf("wipe hero_gear: %w", err) return fmt.Errorf("wipe hero_gear: %w", err)
} }
if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil { if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil {
return fmt.Errorf("wipe hero_inventory: %w", err) return fmt.Errorf("wipe hero_inventory: %w", err)
} }
for _, id := range ids {
if _, err := tx.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete gear %d: %w", id, err)
}
}
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("wipe gear commit: %w", err) return fmt.Errorf("wipe gear commit: %w", err)
} }
return nil return nil
} }
// DeleteGearItem removes a gear row by id (e.g. discarded drop not sold). Fails if still equipped. // DeleteGearItem removes a gear archetype row by id. Prefer DeleteHeroItem for owned gear.
func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error { func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error {
cmd, err := s.pool.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id) cmd, err := s.pool.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id)
if err != nil { if err != nil {
@ -304,6 +573,47 @@ func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error {
return nil 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. // 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). // Returns ErrInventoryFull if there is no free slot (equipped row is left unchanged).
// If the slot is empty, returns nil (idempotent). // If the slot is empty, returns nil (idempotent).
@ -314,10 +624,22 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
var gearID int64 var (
gearID int64
itemID int64
overrides gearOverrideRow
)
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2 SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
`, heroID, string(slot)).Scan(&gearID) 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil return nil
} }
@ -325,7 +647,7 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq
return fmt.Errorf("unequip read slot: %w", err) return fmt.Errorf("unequip read slot: %w", err)
} }
if err := addToInventoryTx(ctx, tx, heroID, gearID); err != nil { if err := addToInventoryTx(ctx, tx, heroID, gearID, itemID, overrides); err != nil {
return err return err
} }
@ -344,10 +666,20 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq
// GetHeroInventory loads unequipped gear ordered by slot_index. // GetHeroInventory loads unequipped gear ordered by slot_index.
func (s *GearStore) GetHeroInventory(ctx context.Context, heroID int64) ([]*model.GearItem, error) { func (s *GearStore) GetHeroInventory(ctx context.Context, heroID int64) ([]*model.GearItem, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl, SELECT hi.item_id, g.slot,
g.base_primary, g.primary_stat, g.stat_type, COALESCE(hi.form_id, g.form_id),
g.speed_modifier, g.crit_chance, g.agility_bonus, COALESCE(hi.name, g.name),
g.set_name, g.special_effect 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 FROM hero_inventory hi
JOIN gear g ON hi.gear_id = g.id JOIN gear g ON hi.gear_id = g.id
WHERE hi.hero_id = $1 WHERE hi.hero_id = $1
@ -382,7 +714,10 @@ func (s *GearStore) GetHeroInventory(ctx context.Context, heroID int64) ([]*mode
} }
// AddToInventory inserts gear into the next free backpack slot. Fails if inventory is full. // 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 { 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 var n int
if err := s.pool.QueryRow(ctx, ` if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1 SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1
@ -392,10 +727,28 @@ func (s *GearStore) AddToInventory(ctx context.Context, heroID, gearID int64) er
if n >= model.MaxInventorySlots { if n >= model.MaxInventorySlots {
return ErrInventoryFull return ErrInventoryFull
} }
_, err := s.pool.Exec(ctx, ` gearID, err := s.resolveArchetypeID(ctx, item)
INSERT INTO hero_inventory (hero_id, slot_index, gear_id) if err != nil {
VALUES ($1, $2, $3) return err
`, heroID, n, gearID) }
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 { if err != nil {
return fmt.Errorf("add to inventory: %w", err) return fmt.Errorf("add to inventory: %w", err)
} }
@ -418,28 +771,50 @@ func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, item
if item == nil || i >= model.MaxInventorySlots { if item == nil || i >= model.MaxInventorySlots {
continue continue
} }
gid := item.ID gearID, err := s.resolveArchetypeID(ctx, item)
if gid == 0 { if err != nil {
return err
}
overrides := overrideRowFromItem(item)
if item.ID == 0 {
err := tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
INSERT INTO gear (slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, INSERT INTO hero_inventory (
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect) hero_id, slot_index, gear_id, item_id,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
RETURNING id speed_modifier, crit_chance, agility_bonus, set_name, special_effect
`, ) VALUES (
string(item.Slot), item.FormID, item.Name, item.Subtype, $1, $2, $3, nextval('public.hero_item_id_seq'),
string(item.Rarity), item.Ilvl, item.BasePrimary, item.PrimaryStat, $4, $5, $6, $7, $8, $9, $10, $11,
item.StatType, item.SpeedModifier, item.CritChance, item.AgilityBonus, $12, $13, $14, $15, $16
item.SetName, item.SpecialEffect, )
).Scan(&gid) 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 { if err != nil {
return fmt.Errorf("replace inventory create gear: %w", err) return fmt.Errorf("replace inventory insert: %w", err)
} }
item.ID = gid continue
} }
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory (hero_id, slot_index, gear_id) INSERT INTO hero_inventory (
VALUES ($1, $2, $3) hero_id, slot_index, gear_id, item_id,
`, heroID, i, gid); err != nil { 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) return fmt.Errorf("replace inventory insert: %w", err)
} }
} }
@ -448,3 +823,7 @@ func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, item
} }
return nil return nil
} }
func ptrString(v string) *string {
return &v
}

@ -442,10 +442,10 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e
swords := []struct { swords := []struct {
name string name string
}{ }{
{"Worn Shortsword"}, {"Iron Sword"},
{"Traveler's Blade"}, {"Steel Sword"},
{"Notched Sword"}, {"Longsword"},
{"Training Sword"}, {"Excalibur"},
} }
sw := swords[rand.Intn(len(swords))] sw := swords[rand.Intn(len(swords))]
starterWeapon := &model.GearItem{ starterWeapon := &model.GearItem{
@ -461,10 +461,7 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e
SpeedModifier: 1.0, SpeedModifier: 1.0,
CritChance: 0.05, CritChance: 0.05,
} }
if err := s.gearStore.CreateItem(ctx, starterWeapon); err != nil { if err := s.gearStore.EquipItem(ctx, heroID, starterWeapon); err != nil {
return fmt.Errorf("create starter sword: %w", err)
}
if err := s.gearStore.EquipItem(ctx, heroID, model.SlotMainHand, starterWeapon.ID); err != nil {
return fmt.Errorf("equip starter sword: %w", err) return fmt.Errorf("equip starter sword: %w", err)
} }
@ -476,9 +473,9 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e
agilityBon int agilityBon int
defense int defense int
}{ }{
{"Worn Leather Jack", "gear.form.chest.leather", "light", 1.05, 2, 3}, {"Leather Armor", "gear.form.chest.light", "light", 1.05, 2, 3},
{"Rusty Hauberk", "gear.form.chest.mail", "medium", 1.0, 0, 4}, {"Chainmail", "gear.form.chest.medium", "medium", 1.0, 0, 4},
{"Worn Plate", "gear.form.chest.plate", "heavy", 0.7, 0, 5}, {"Iron Plate", "gear.form.chest.heavy", "heavy", 0.7, 0, 5},
} }
ar := armors[rand.Intn(len(armors))] ar := armors[rand.Intn(len(armors))]
starterArmor := &model.GearItem{ starterArmor := &model.GearItem{
@ -494,10 +491,7 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e
SpeedModifier: ar.speed, SpeedModifier: ar.speed,
AgilityBonus: ar.agilityBon, AgilityBonus: ar.agilityBon,
} }
if err := s.gearStore.CreateItem(ctx, starterArmor); err != nil { if err := s.gearStore.EquipItem(ctx, heroID, starterArmor); err != nil {
return fmt.Errorf("create starter armor: %w", err)
}
if err := s.gearStore.EquipItem(ctx, heroID, model.SlotChest, starterArmor.ID); err != nil {
return fmt.Errorf("equip starter armor: %w", err) return fmt.Errorf("equip starter armor: %w", err)
} }
@ -520,10 +514,7 @@ func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error {
SpeedModifier: 1.3, SpeedModifier: 1.3,
CritChance: 0.05, CritChance: 0.05,
} }
if err := s.gearStore.CreateItem(ctx, starterWeapon); err != nil { if err := s.gearStore.EquipItem(ctx, heroID, starterWeapon); err != nil {
return fmt.Errorf("create starter weapon: %w", err)
}
if err := s.gearStore.EquipItem(ctx, heroID, model.SlotMainHand, starterWeapon.ID); err != nil {
return fmt.Errorf("equip starter weapon: %w", err) return fmt.Errorf("equip starter weapon: %w", err)
} }
@ -540,10 +531,7 @@ func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error {
SpeedModifier: 1.05, SpeedModifier: 1.05,
AgilityBonus: 3, AgilityBonus: 3,
} }
if err := s.gearStore.CreateItem(ctx, starterArmor); err != nil { if err := s.gearStore.EquipItem(ctx, heroID, starterArmor); err != nil {
return fmt.Errorf("create starter armor: %w", err)
}
if err := s.gearStore.EquipItem(ctx, heroID, model.SlotChest, starterArmor.ID); err != nil {
return fmt.Errorf("equip starter armor: %w", err) return fmt.Errorf("equip starter armor: %w", err)
} }

@ -323,10 +323,10 @@ func DefaultValues() Values {
MerchantCostBase: 900, MerchantCostBase: 900,
MerchantCostPerLevel: 5, MerchantCostPerLevel: 5,
MerchantTownAutoSellShare: 0.30, MerchantTownAutoSellShare: 0.30,
MonsterEncounterWeightBase: 0.15, MonsterEncounterWeightBase: 0.04,
MonsterEncounterWeightWildBonus: 0.18, MonsterEncounterWeightWildBonus: 0.18,
MerchantEncounterWeightBase: 0.02, MerchantEncounterWeightBase: 0.002,
MerchantEncounterWeightRoadBonus: 0.05, MerchantEncounterWeightRoadBonus: 0.008,
LootChanceCommon: 0.40, LootChanceCommon: 0.40,
LootChanceUncommon: 0.10, LootChanceUncommon: 0.10,
LootChanceRare: 0.02, LootChanceRare: 0.02,

@ -9330,7 +9330,7 @@ INSERT INTO public.road_waypoints VALUES (11334, 50, 319, 3799.9500000000003, 72
-- Data for Name: runtime_config; Type: TABLE DATA; Schema: public; Owner: - -- Data for Name: runtime_config; Type: TABLE DATA; Schema: public; Owner: -
-- --
INSERT INTO public.runtime_config VALUES (true, '{"agilityCoef": 0.09, "goldEpicMax": 120, "goldEpicMin": 51, "goldRareMax": 50, "goldRareMin": 21, "npcCostHeal": 100, "autoSellEpic": 60, "autoSellRare": 20, "baseMoveSpeed": 1, "goldCommonMax": 5, "goldCommonMin": 0, "goldLootScale": 0.5, "levelUpHpBase": 2, "npcCostPotion": 200, "townRestMaxMs": 1200000, "townRestMinMs": 300000, "autoSellCommon": 3, "debuffProcBurn": 0.18, "debuffProcSlow": 0.25, "debuffProcStun": 0.25, "levelUpHpEvery": 4, "lootChanceEpic": 0.003, "lootChanceRare": 0.02, "lowHpThreshold": 0.25, "maxAttackSpeed": 5, "maxRevivesFree": 1, "minAttackSpeed": 0.1, "townNpcPauseMs": 30000, "townNpcRetryMs": 450, "xpCurveMidBase": 1450, "goldUncommonMax": 20, "goldUncommonMin": 6, "ilvlFactorSlope": 0.03, "levelUpAgiEvery": 20, "levelUpAtkEvery": 4, "levelUpConEvery": 14, "levelUpDefEvery": 5, "levelUpStrEvery": 12, "reviveHpPercent": 0.5, "xpCurveLateBase": 23000, "xpCurveMidScale": 1.15, "autoSellUncommon": 8, "debuffProcFreeze": 0.2, "debuffProcPoison": 0.1, "enemyBurstEveryN": 5, "enemyChainEveryN": 6, "enemyDodgeChance": 0.14, "enemyScaleBandHp": 0.062, "enemyScaleBandXp": 0.05, "goldLegendaryMax": 300, "goldLegendaryMin": 121, "levelUpLuckEvery": 100, "lootChanceCommon": 0.4, "lootHistoryLimit": 50, "merchantCostBase": 900, "potionDropChance": 0.05, "townNpcRollMaxMs": 2600, "townNpcRollMinMs": 800, "townNpcWalkSpeed": 3, "xpCurveEarlyBase": 180, "xpCurveLateScale": 1.1, "autoReviveAfterMs": 3600000, "autoSellLegendary": 180, "combatDamageScale": 1.0, "debuffProcIceSlow": 0.2, "enemyRegenDefault": 0.0012, "enemyScaleBandAtk": 0.044, "enemyScaleBandDef": 0.038, "equipmentDropBase": 0.15, "heroCritChanceCap": 0.12, "potionHealPercent": 0.3, "questOffersPerNPC": 2, "roadsideRestMaxMs": 600000, "roadsideRestMinMs": 240000, "townArrivalRadius": 0.5, "xpCurveEarlyScale": 1.28, "adventureWildMaxMs": 2960000, "adventureWildMinMs": 560000, "autoEquipThreshold": 1.03, "buffChargePeriodMs": 86400000, "buffRefillPriceRub": 50, "enemyCritChanceCap": 0.2, "enemyScaleBandGold": 0.05, "heroBlockChanceCap": 0.2, "lootChanceUncommon": 0.1, "luckBuffMultiplier": 2.5, "movementTickRateMs": 1000, "positionSyncRateMs": 10000, "roadsideRestExitHp": 0.7, "roadsideRestGoInMs": 3200, "summonCycleSeconds": 18, "townNpcVisitChance": 0.78, "adventureCooldownMs": 300000, "adventureMaxLateral": 20, "combatDamageRollMax": 1.10, "combatDamageRollMin": 0.60, "enemyScaleOvercapHp": 0.031, "enemyScaleOvercapXp": 0.03, "lootChanceLegendary": 0.0005, "minAttackIntervalMs": 250, "npcCostNearbyRadius": 3, "roadsideRestLateral": 1.15, "summonDamageDivisor": 10, "townRestHpPerSecond": 0.002, "adventureStartChance": 0.0001, "combatPaceMultiplier": 14, "enemyBurstMultiplier": 1.5, "enemyChainMultiplier": 3, "enemyScaleOvercapAtk": 0.024, "enemyScaleOvercapDef": 0.020, "maxRevivesSubscriber": 2, "merchantCostPerLevel": 5, "rarityMultiplierEpic": 1.52, "rarityMultiplierRare": 1.3, "roadsideRestDepthMax": 25, "roadsideRestReturnMs": 3200, "roadsideThoughtMaxMs": 50000, "roadsideThoughtMinMs": 30000, "townNpcLogIntervalMs": 5000, "townNpcStandoffWorld": 0.65, "adventureRestTargetHp": 0.7, "encounterActivityBase": 0.035, "enemyScaleOvercapGold": 0.025, "startAdventurePerTick": 0.00003, "townNpcApproachChance": 1, "townNpcInteractChance": 0.65, "adventureDurationMaxMs": 1200000, "adventureDurationMinMs": 900000, "adventureOutDurationMs": 20000, "enemyCombatDamageScale": 1.0, "enemyCriticalMinChance": 0.15, "enemyRegenBattleLizard": 0.0005, "enemyRegenForestWarden": 0.00010, "enemyRegenSkeletonKing": 0.00003, "potionAutoUseThreshold": 0.3, "questOfferRefreshHours": 2, "rarityMultiplierCommon": 1, "restEncounterNpcChance": 0.1, "subscriptionDurationMs": 604800000, "townAfterNpcRestChance": 0.78, "encounterCooldownBaseMs": 12000, "restEncounterCooldownMs": 30000, "roadsideRestHpPerSecond": 0.003, "rollIlvlEliteBaseChance": 0.4, "adventureDepthWorldUnits": 20, "adventureRestHpPerSecond": 0.004, "enemyCombatDamageRollMax": 1.0, "enemyCombatDamageRollMin": 0.82, "rarityMultiplierUncommon": 1.12, "adventureReturnDurationMs": 20000, "adventureWanderSpeedRatio": 0.85, "heroBlockChancePerDefense": 0.0025, "merchantTownAutoSellShare": 0.3, "rarityMultiplierLegendary": 1.78, "adventureReturnWildnessMin": 0.35, "monsterEncounterWeightBase": 0.62, "resurrectionRefillPriceRub": 150, "rollIlvlElitePlusOneChance": 0.4, "subscriptionWeeklyPriceRub": 199, "merchantEncounterWeightBase": 0.02, "roadsideRestDepthWorldUnits": 12, "adventureEncounterCooldownMs": 6000, "freeBuffActivationsPerPeriod": 2, "enemyAttackIntervalMultiplier": 1.5, "adventureReturnEncounterEnabled": true, "adventureWildernessRampFraction": 0.12, "monsterEncounterWeightWildBonus": 0.18, "merchantEncounterWeightRoadBonus": 0.05, "wanderingMerchantPromptTimeoutMs": 15000, "adventureForwardSpeedWildFraction": 0.07}', '2026-03-31 16:27:14.86085+00'); INSERT INTO public.runtime_config VALUES (true, '{"agilityCoef": 0.09, "goldEpicMax": 120, "goldEpicMin": 51, "goldRareMax": 50, "goldRareMin": 21, "npcCostHeal": 100, "autoSellEpic": 60, "autoSellRare": 20, "baseMoveSpeed": 1, "goldCommonMax": 5, "goldCommonMin": 0, "goldLootScale": 0.5, "levelUpHpBase": 2, "npcCostPotion": 200, "townRestMaxMs": 1200000, "townRestMinMs": 300000, "autoSellCommon": 3, "debuffProcBurn": 0.18, "debuffProcSlow": 0.25, "debuffProcStun": 0.25, "levelUpHpEvery": 4, "lootChanceEpic": 0.003, "lootChanceRare": 0.02, "lowHpThreshold": 0.25, "maxAttackSpeed": 5, "maxRevivesFree": 1, "minAttackSpeed": 0.1, "townNpcPauseMs": 30000, "townNpcRetryMs": 450, "xpCurveMidBase": 1450, "goldUncommonMax": 20, "goldUncommonMin": 6, "ilvlFactorSlope": 0.03, "levelUpAgiEvery": 20, "levelUpAtkEvery": 4, "levelUpConEvery": 14, "levelUpDefEvery": 5, "levelUpStrEvery": 12, "reviveHpPercent": 0.5, "xpCurveLateBase": 23000, "xpCurveMidScale": 1.15, "autoSellUncommon": 8, "debuffProcFreeze": 0.2, "debuffProcPoison": 0.1, "enemyBurstEveryN": 5, "enemyChainEveryN": 6, "enemyDodgeChance": 0.14, "enemyScaleBandHp": 0.062, "enemyScaleBandXp": 0.05, "goldLegendaryMax": 300, "goldLegendaryMin": 121, "levelUpLuckEvery": 100, "lootChanceCommon": 0.4, "lootHistoryLimit": 50, "merchantCostBase": 900, "potionDropChance": 0.05, "townNpcRollMaxMs": 2600, "townNpcRollMinMs": 800, "townNpcWalkSpeed": 3, "xpCurveEarlyBase": 180, "xpCurveLateScale": 1.1, "autoReviveAfterMs": 3600000, "autoSellLegendary": 180, "combatDamageScale": 1.0, "debuffProcIceSlow": 0.2, "enemyRegenDefault": 0.0012, "enemyScaleBandAtk": 0.044, "enemyScaleBandDef": 0.038, "equipmentDropBase": 0.15, "heroCritChanceCap": 0.12, "potionHealPercent": 0.3, "questOffersPerNPC": 2, "roadsideRestMaxMs": 600000, "roadsideRestMinMs": 240000, "townArrivalRadius": 0.5, "xpCurveEarlyScale": 1.28, "adventureWildMaxMs": 2960000, "adventureWildMinMs": 560000, "autoEquipThreshold": 1.03, "buffChargePeriodMs": 86400000, "buffRefillPriceRub": 50, "enemyCritChanceCap": 0.2, "enemyScaleBandGold": 0.05, "heroBlockChanceCap": 0.2, "lootChanceUncommon": 0.1, "luckBuffMultiplier": 2.5, "movementTickRateMs": 1000, "positionSyncRateMs": 10000, "roadsideRestExitHp": 0.7, "roadsideRestGoInMs": 3200, "summonCycleSeconds": 18, "townNpcVisitChance": 0.78, "adventureCooldownMs": 300000, "adventureMaxLateral": 20, "combatDamageRollMax": 1.10, "combatDamageRollMin": 0.60, "enemyScaleOvercapHp": 0.031, "enemyScaleOvercapXp": 0.03, "lootChanceLegendary": 0.0005, "minAttackIntervalMs": 250, "npcCostNearbyRadius": 3, "roadsideRestLateral": 1.15, "summonDamageDivisor": 10, "townRestHpPerSecond": 0.002, "adventureStartChance": 0.0001, "combatPaceMultiplier": 14, "enemyBurstMultiplier": 1.5, "enemyChainMultiplier": 3, "enemyScaleOvercapAtk": 0.024, "enemyScaleOvercapDef": 0.020, "maxRevivesSubscriber": 2, "merchantCostPerLevel": 5, "rarityMultiplierEpic": 1.52, "rarityMultiplierRare": 1.3, "roadsideRestDepthMax": 25, "roadsideRestReturnMs": 3200, "roadsideThoughtMaxMs": 50000, "roadsideThoughtMinMs": 30000, "townNpcLogIntervalMs": 5000, "townNpcStandoffWorld": 0.65, "adventureRestTargetHp": 0.7, "encounterActivityBase": 0.035, "enemyScaleOvercapGold": 0.025, "startAdventurePerTick": 0.00003, "townNpcApproachChance": 1, "townNpcInteractChance": 0.65, "adventureDurationMaxMs": 1200000, "adventureDurationMinMs": 900000, "adventureOutDurationMs": 20000, "enemyCombatDamageScale": 1.0, "enemyCriticalMinChance": 0.15, "enemyRegenBattleLizard": 0.0005, "enemyRegenForestWarden": 0.00010, "enemyRegenSkeletonKing": 0.00003, "potionAutoUseThreshold": 0.3, "questOfferRefreshHours": 2, "rarityMultiplierCommon": 1, "restEncounterNpcChance": 0.1, "subscriptionDurationMs": 604800000, "townAfterNpcRestChance": 0.78, "encounterCooldownBaseMs": 12000, "restEncounterCooldownMs": 30000, "roadsideRestHpPerSecond": 0.003, "rollIlvlEliteBaseChance": 0.4, "adventureDepthWorldUnits": 20, "adventureRestHpPerSecond": 0.004, "enemyCombatDamageRollMax": 1.0, "enemyCombatDamageRollMin": 0.82, "rarityMultiplierUncommon": 1.12, "adventureReturnDurationMs": 20000, "adventureWanderSpeedRatio": 0.85, "heroBlockChancePerDefense": 0.0025, "merchantTownAutoSellShare": 0.3, "rarityMultiplierLegendary": 1.78, "adventureReturnWildnessMin": 0.35, "monsterEncounterWeightBase": 0.04, "resurrectionRefillPriceRub": 150, "rollIlvlElitePlusOneChance": 0.4, "subscriptionWeeklyPriceRub": 199, "merchantEncounterWeightBase": 0.002, "roadsideRestDepthWorldUnits": 12, "adventureEncounterCooldownMs": 6000, "freeBuffActivationsPerPeriod": 2, "enemyAttackIntervalMultiplier": 1.5, "adventureReturnEncounterEnabled": true, "adventureWildernessRampFraction": 0.12, "monsterEncounterWeightWildBonus": 0.18, "merchantEncounterWeightRoadBonus": 0.008, "wanderingMerchantPromptTimeoutMs": 15000, "adventureForwardSpeedWildFraction": 0.07}', '2026-03-31 16:27:14.86085+00');
-- --

Loading…
Cancel
Save