Compare commits

..

No commits in common. '283b062d080ce9cfe4d61ff244ddd5cb1584fd92' and 'f593691e45bbfb44f933dcb0bca6dbe246937f6c' have entirely different histories.

@ -1,16 +1,5 @@
{ {
"releases": [ "releases": [
{
"version": "0.3.1-dev",
"title": "AutoHero — 0.3.1",
"items": [
"Graphics optimization! We've found a bastard. Minimap was causing huge recalc.",
"Auto potions (now hero will use them even in the online battle",
"Formulae for atk reworked",
"Reduced merchant and monster meet chance",
"Something else"
]
},
{ {
"version": "0.3.0-dev", "version": "0.3.0-dev",
"title": "AutoHero — 0.3.0", "title": "AutoHero — 0.3.0",

@ -11,8 +11,10 @@ import (
) )
type GearStore interface { type GearStore interface {
AddToInventory(ctx context.Context, heroID int64, item *model.GearItem) error CreateItem(ctx context.Context, item *model.GearItem) error
EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error DeleteGearItem(ctx context.Context, itemID int64) 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 {
@ -85,12 +87,35 @@ 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 { if deps.GearStore != nil && item.ID != 0 {
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) err := deps.GearStore.EquipItem(ctx, hero.ID, item.Slot, item.ID)
cancel() cancel()
if err != nil { if err != nil {
if prev == nil { if prev == nil {
@ -111,8 +136,6 @@ 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{
@ -132,6 +155,13 @@ 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
@ -148,25 +178,26 @@ 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) err := deps.GearStore.AddToInventory(ctxInv, hero.ID, item.ID)
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
} }
} }

@ -78,10 +78,15 @@ func RollTownMerchantOfferGold(ilvl int, rarity model.Rarity, townLevel int) int
perIlvl := tuning.EffectiveMerchantTownGearPricePerIlvl() perIlvl := tuning.EffectiveMerchantTownGearPricePerIlvl()
variance := tuning.EffectiveMerchantTownGearPriceVariancePct() variance := tuning.EffectiveMerchantTownGearPriceVariancePct()
ilvlPart := float64(ilvl) * float64(perIlvl) * townMerchantRarityPriceMul(rarity) ilvlPart := float64(ilvl) * float64(perIlvl) * townMerchantRarityPriceMul(rarity)
mean := float64(anchor) + ilvlPart curve := float64(ilvl*ilvl) / 6.0
mean := float64(anchor) + ilvlPart + curve
if mean < 1 {
mean = 1
}
v := float64(variance) / 100.0 v := float64(variance) / 100.0
if v < 0 {
v = 0
}
if v > 0.45 { if v > 0.45 {
v = 0.45 v = 0.45
} }
@ -89,7 +94,7 @@ func RollTownMerchantOfferGold(ilvl int, rarity model.Rarity, townLevel int) int
factor := (1.0 - v) + rand.Float64()*(2*v) factor := (1.0 - v) + rand.Float64()*(2*v)
cost := int64(mean*factor + 0.5) cost := int64(mean*factor + 0.5)
if cost < 1 { if cost < 1 {
cost = int64(mean) cost = 1
} }
return cost return cost
} }
@ -104,10 +109,20 @@ 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) err = gs.EquipItem(ctxEq, hero.ID, toCreate.Slot, toCreate.ID)
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,7 +444,13 @@ func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) {
} }
clone := *src clone := *src
clone.ID = 0 clone.ID = 0
if err := h.gearStore.AddToInventory(r.Context(), heroID, &clone); err != nil { if err := h.gearStore.CreateItem(r.Context(), &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
} }
@ -486,7 +492,12 @@ 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.AddToInventory(r.Context(), heroID, item); err != nil { if err := h.gearStore.CreateItem(r.Context(), 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
} }
@ -514,7 +525,12 @@ 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
} }
if err := h.gearStore.EquipInventoryItem(r.Context(), heroID, req.ItemID); err != nil { item, err := h.gearStore.GetItem(r.Context(), req.ItemID)
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",
@ -583,7 +599,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.DeleteHeroItem(r.Context(), heroID, itemID); err != nil { if err := h.gearStore.DeleteGearItem(r.Context(), 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 == nil { if h.gearStore == nil || item.ID == 0 {
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) return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID)
} }
// 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 { if h.gearStore == nil || item == nil || item.ID == 0 {
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) return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID)
} }
// grantMerchantLoot rolls one random gear piece; auto-equips if better. // grantMerchantLoot rolls one random gear piece; auto-equips if better.
@ -416,6 +416,14 @@ 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
@ -456,6 +464,13 @@ 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,
@ -464,10 +479,13 @@ 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) err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID)
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{
@ -944,11 +962,7 @@ 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)
refLevel := townLv items := game.RollTownMerchantStockItemsForSlots(townLv, n, slots)
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,78 +19,11 @@ 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, `
@ -167,20 +100,10 @@ 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 hg.item_id, hg.slot, SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl,
COALESCE(hg.form_id, g.form_id), g.base_primary, g.primary_stat, g.stat_type,
COALESCE(hg.name, g.name), g.speed_modifier, g.crit_chance, g.agility_bonus,
COALESCE(hg.subtype, g.subtype), g.set_name, g.special_effect
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
@ -213,40 +136,21 @@ func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.Eq
return gear, nil return gear, nil
} }
// EquipItem equips a newly created item (not yet in inventory) into the given slot. // EquipItem equips a gear item into the given slot for a hero (upsert).
// Any previously equipped item is moved to the backpack. // Any previously equipped item in that slot is moved to the backpack (unless it is the same gear_id).
// Returns ErrInventoryFull if the previous item cannot be stashed. // If the new item was in the backpack, it is removed and remaining slots are reindexed (0..n-1).
func (s *GearStore) EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error { // Returns ErrInventoryFull if the previous item cannot be stashed (transaction rolled back).
if item == nil { func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, gearID int64) error {
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 ( var prevGearID int64
prevGearID int64
prevItemID int64
prevOverrides gearOverrideRow
)
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect `, heroID, string(slot)).Scan(&prevGearID)
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
@ -254,262 +158,73 @@ func (s *GearStore) EquipItem(ctx context.Context, heroID int64, item *model.Gea
} 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
}
}
overrides := overrideRowFromItem(item) if _, err := tx.Exec(ctx, `
err = tx.QueryRow(ctx, ` INSERT INTO hero_gear (hero_id, slot, gear_id)
INSERT INTO hero_gear ( VALUES ($1, $2, $3)
hero_id, slot, gear_id, item_id, ON CONFLICT (hero_id, slot) DO UPDATE SET gear_id = EXCLUDED.gear_id
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, `, heroID, string(slot), gearID); err != nil {
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 return err
} }
} if hasPrev && prevGearID != gearID {
if err := tx.Commit(ctx); err != nil { if err := addToInventoryTx(ctx, tx, heroID, prevGearID); 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 return err
} }
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("equip inventory commit: %w", err) return fmt.Errorf("equip gear item commit: %w", err)
} }
return nil return nil
} }
func inventoryCountTx(ctx context.Context, tx pgx.Tx, heroID int64) (int, error) { func addToInventoryTx(ctx context.Context, tx pgx.Tx, heroID, gearID int64) 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 0, fmt.Errorf("inventory count: %w", err) return 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 ( INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
hero_id, slot_index, gear_id, item_id, VALUES ($1, $2, $3)
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, `, heroID, n, gearID); err != nil {
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
} }
// compactInventoryAfterRemovingItem deletes one backpack row if present; if a row // compactInventoryAfterRemovingGear 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 compactInventoryAfterRemovingItem(ctx context.Context, tx pgx.Tx, heroID, itemID int64) error { func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error {
cmd, err := tx.Exec(ctx, ` cmd, err := tx.Exec(ctx, `
DELETE FROM hero_inventory WHERE hero_id = $1 AND item_id = $2 DELETE FROM hero_inventory WHERE hero_id = $1 AND gear_id = $2
`, heroID, itemID) `, heroID, gearID)
if err != nil { if err != nil {
return fmt.Errorf("remove item from inventory: %w", err) return fmt.Errorf("remove equipped gear 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, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, SELECT gear_id FROM hero_inventory WHERE hero_id = $1 ORDER BY slot_index ASC
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 row invRow var id int64
if err := rows.Scan( if err := rows.Scan(&id); err != nil {
&row.gearID, &row.itemID, return fmt.Errorf("scan inventory gear_id: %w", err)
&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) ids = append(ids, id)
} }
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)
@ -518,30 +233,18 @@ func compactInventoryAfterRemovingItem(ctx context.Context, tx pgx.Tx, heroID, i
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, row := range items { for i, gid := range ids {
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory ( INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
hero_id, slot_index, gear_id, item_id, VALUES ($1, $2, $3)
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, `, heroID, i, gid); err != nil {
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. // WipeAllGearForHero removes every equipped and backpack item for the hero and deletes the underlying gear rows.
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 {
@ -549,19 +252,47 @@ 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 archetype row by id. Prefer DeleteHeroItem for owned gear. // 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 { 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 {
@ -573,47 +304,6 @@ 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).
@ -624,22 +314,10 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
var ( var gearID int64
gearID int64
itemID int64
overrides gearOverrideRow
)
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect `, heroID, string(slot)).Scan(&gearID)
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
} }
@ -647,7 +325,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, itemID, overrides); err != nil { if err := addToInventoryTx(ctx, tx, heroID, gearID); err != nil {
return err return err
} }
@ -666,20 +344,10 @@ 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 hi.item_id, g.slot, SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl,
COALESCE(hi.form_id, g.form_id), g.base_primary, g.primary_stat, g.stat_type,
COALESCE(hi.name, g.name), g.speed_modifier, g.crit_chance, g.agility_bonus,
COALESCE(hi.subtype, g.subtype), g.set_name, g.special_effect
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
@ -714,10 +382,7 @@ 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 int64, item *model.GearItem) error { func (s *GearStore) AddToInventory(ctx context.Context, heroID, gearID int64) 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
@ -727,28 +392,10 @@ func (s *GearStore) AddToInventory(ctx context.Context, heroID int64, item *mode
if n >= model.MaxInventorySlots { if n >= model.MaxInventorySlots {
return ErrInventoryFull return ErrInventoryFull
} }
gearID, err := s.resolveArchetypeID(ctx, item) _, err := s.pool.Exec(ctx, `
if err != nil { INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
return err VALUES ($1, $2, $3)
} `, 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)
} }
@ -771,50 +418,28 @@ func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, item
if item == nil || i >= model.MaxInventorySlots { if item == nil || i >= model.MaxInventorySlots {
continue continue
} }
gearID, err := s.resolveArchetypeID(ctx, item) gid := item.ID
if err != nil { if gid == 0 {
return err
}
overrides := overrideRowFromItem(item)
if item.ID == 0 {
err := tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
INSERT INTO hero_inventory ( INSERT INTO gear (slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
hero_id, slot_index, gear_id, item_id, stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect)
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
speed_modifier, crit_chance, agility_bonus, set_name, special_effect RETURNING id
) VALUES ( `,
$1, $2, $3, nextval('public.hero_item_id_seq'), string(item.Slot), item.FormID, item.Name, item.Subtype,
$4, $5, $6, $7, $8, $9, $10, $11, string(item.Rarity), item.Ilvl, item.BasePrimary, item.PrimaryStat,
$12, $13, $14, $15, $16 item.StatType, item.SpeedModifier, item.CritChance, item.AgilityBonus,
) item.SetName, item.SpecialEffect,
RETURNING item_id ).Scan(&gid)
`, 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 insert: %w", err) return fmt.Errorf("replace inventory create gear: %w", err)
} }
continue item.ID = gid
} }
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory ( INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
hero_id, slot_index, gear_id, item_id, VALUES ($1, $2, $3)
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, `, heroID, i, gid); err != nil {
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)
} }
} }
@ -823,7 +448,3 @@ 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
}{ }{
{"Iron Sword"}, {"Worn Shortsword"},
{"Steel Sword"}, {"Traveler's Blade"},
{"Longsword"}, {"Notched Sword"},
{"Excalibur"}, {"Training Sword"},
} }
sw := swords[rand.Intn(len(swords))] sw := swords[rand.Intn(len(swords))]
starterWeapon := &model.GearItem{ starterWeapon := &model.GearItem{
@ -461,7 +461,10 @@ 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.EquipItem(ctx, heroID, starterWeapon); err != nil { if err := s.gearStore.CreateItem(ctx, 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)
} }
@ -473,9 +476,9 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e
agilityBon int agilityBon int
defense int defense int
}{ }{
{"Leather Armor", "gear.form.chest.light", "light", 1.05, 2, 3}, {"Worn Leather Jack", "gear.form.chest.leather", "light", 1.05, 2, 3},
{"Chainmail", "gear.form.chest.medium", "medium", 1.0, 0, 4}, {"Rusty Hauberk", "gear.form.chest.mail", "medium", 1.0, 0, 4},
{"Iron Plate", "gear.form.chest.heavy", "heavy", 0.7, 0, 5}, {"Worn Plate", "gear.form.chest.plate", "heavy", 0.7, 0, 5},
} }
ar := armors[rand.Intn(len(armors))] ar := armors[rand.Intn(len(armors))]
starterArmor := &model.GearItem{ starterArmor := &model.GearItem{
@ -491,7 +494,10 @@ 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.EquipItem(ctx, heroID, starterArmor); err != nil { if err := s.gearStore.CreateItem(ctx, 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)
} }
@ -514,7 +520,10 @@ 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.EquipItem(ctx, heroID, starterWeapon); err != nil { if err := s.gearStore.CreateItem(ctx, 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)
} }
@ -531,7 +540,10 @@ func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error {
SpeedModifier: 1.05, SpeedModifier: 1.05,
AgilityBonus: 3, AgilityBonus: 3,
} }
if err := s.gearStore.EquipItem(ctx, heroID, starterArmor); err != nil { if err := s.gearStore.CreateItem(ctx, 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.04, MonsterEncounterWeightBase: 0.15,
MonsterEncounterWeightWildBonus: 0.18, MonsterEncounterWeightWildBonus: 0.18,
MerchantEncounterWeightBase: 0.002, MerchantEncounterWeightBase: 0.02,
MerchantEncounterWeightRoadBonus: 0.008, MerchantEncounterWeightRoadBonus: 0.05,
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.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'); 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');
-- --

@ -1,168 +0,0 @@
BEGIN;
CREATE SEQUENCE IF NOT EXISTS public.hero_item_id_seq;
ALTER TABLE public.hero_gear
ADD COLUMN IF NOT EXISTS item_id bigint,
ADD COLUMN IF NOT EXISTS form_id text,
ADD COLUMN IF NOT EXISTS name text,
ADD COLUMN IF NOT EXISTS subtype text,
ADD COLUMN IF NOT EXISTS rarity text,
ADD COLUMN IF NOT EXISTS ilvl integer,
ADD COLUMN IF NOT EXISTS base_primary integer,
ADD COLUMN IF NOT EXISTS primary_stat integer,
ADD COLUMN IF NOT EXISTS stat_type text,
ADD COLUMN IF NOT EXISTS speed_modifier double precision,
ADD COLUMN IF NOT EXISTS crit_chance double precision,
ADD COLUMN IF NOT EXISTS agility_bonus integer,
ADD COLUMN IF NOT EXISTS set_name text,
ADD COLUMN IF NOT EXISTS special_effect text;
ALTER TABLE public.hero_inventory
ADD COLUMN IF NOT EXISTS item_id bigint,
ADD COLUMN IF NOT EXISTS form_id text,
ADD COLUMN IF NOT EXISTS name text,
ADD COLUMN IF NOT EXISTS subtype text,
ADD COLUMN IF NOT EXISTS rarity text,
ADD COLUMN IF NOT EXISTS ilvl integer,
ADD COLUMN IF NOT EXISTS base_primary integer,
ADD COLUMN IF NOT EXISTS primary_stat integer,
ADD COLUMN IF NOT EXISTS stat_type text,
ADD COLUMN IF NOT EXISTS speed_modifier double precision,
ADD COLUMN IF NOT EXISTS crit_chance double precision,
ADD COLUMN IF NOT EXISTS agility_bonus integer,
ADD COLUMN IF NOT EXISTS set_name text,
ADD COLUMN IF NOT EXISTS special_effect text;
UPDATE public.hero_gear
SET item_id = nextval('public.hero_item_id_seq')
WHERE item_id IS NULL;
UPDATE public.hero_inventory
SET item_id = nextval('public.hero_item_id_seq')
WHERE item_id IS NULL;
ALTER TABLE public.hero_gear
ALTER COLUMN item_id SET DEFAULT nextval('public.hero_item_id_seq');
ALTER TABLE public.hero_inventory
ALTER COLUMN item_id SET DEFAULT nextval('public.hero_item_id_seq');
ALTER TABLE public.hero_inventory
DROP CONSTRAINT IF EXISTS hero_inventory_gear_id_key;
ALTER TABLE public.hero_gear
ADD CONSTRAINT hero_gear_item_id_key UNIQUE (item_id);
ALTER TABLE public.hero_inventory
ADD CONSTRAINT hero_inventory_item_id_key UNIQUE (item_id);
WITH canonical AS (
SELECT DISTINCT ON (slot, name, form_id, subtype)
id, slot, name, form_id, subtype
FROM gear
WHERE id <= 2317
ORDER BY slot, name, form_id, subtype, id
)
UPDATE public.hero_gear hg
SET form_id = g.form_id,
name = g.name,
subtype = g.subtype,
rarity = g.rarity,
ilvl = g.ilvl,
base_primary = g.base_primary,
primary_stat = g.primary_stat,
stat_type = g.stat_type,
speed_modifier = g.speed_modifier,
crit_chance = g.crit_chance,
agility_bonus = g.agility_bonus,
set_name = g.set_name,
special_effect = g.special_effect,
gear_id = c.id
FROM gear g
JOIN canonical c ON c.slot = g.slot AND c.name = g.name AND c.form_id = g.form_id AND c.subtype = g.subtype
WHERE hg.gear_id = g.id;
WITH fallback AS (
SELECT DISTINCT ON (slot, name)
id, slot, name
FROM gear
WHERE id <= 2317
ORDER BY slot, name, id
)
UPDATE public.hero_gear hg
SET form_id = g.form_id,
name = g.name,
subtype = g.subtype,
rarity = g.rarity,
ilvl = g.ilvl,
base_primary = g.base_primary,
primary_stat = g.primary_stat,
stat_type = g.stat_type,
speed_modifier = g.speed_modifier,
crit_chance = g.crit_chance,
agility_bonus = g.agility_bonus,
set_name = g.set_name,
special_effect = g.special_effect,
gear_id = f.id
FROM gear g
JOIN fallback f ON f.slot = g.slot AND f.name = g.name
WHERE hg.gear_id = g.id AND hg.gear_id > 2317;
WITH canonical AS (
SELECT DISTINCT ON (slot, name, form_id, subtype)
id, slot, name, form_id, subtype
FROM gear
WHERE id <= 2317
ORDER BY slot, name, form_id, subtype, id
)
UPDATE public.hero_inventory hi
SET form_id = g.form_id,
name = g.name,
subtype = g.subtype,
rarity = g.rarity,
ilvl = g.ilvl,
base_primary = g.base_primary,
primary_stat = g.primary_stat,
stat_type = g.stat_type,
speed_modifier = g.speed_modifier,
crit_chance = g.crit_chance,
agility_bonus = g.agility_bonus,
set_name = g.set_name,
special_effect = g.special_effect,
gear_id = c.id
FROM gear g
JOIN canonical c ON c.slot = g.slot AND c.name = g.name AND c.form_id = g.form_id AND c.subtype = g.subtype
WHERE hi.gear_id = g.id;
WITH fallback AS (
SELECT DISTINCT ON (slot, name)
id, slot, name
FROM gear
WHERE id <= 2317
ORDER BY slot, name, id
)
UPDATE public.hero_inventory hi
SET form_id = g.form_id,
name = g.name,
subtype = g.subtype,
rarity = g.rarity,
ilvl = g.ilvl,
base_primary = g.base_primary,
primary_stat = g.primary_stat,
stat_type = g.stat_type,
speed_modifier = g.speed_modifier,
crit_chance = g.crit_chance,
agility_bonus = g.agility_bonus,
set_name = g.set_name,
special_effect = g.special_effect,
gear_id = f.id
FROM gear g
JOIN fallback f ON f.slot = g.slot AND f.name = g.name
WHERE hi.gear_id = g.id AND hi.gear_id > 2317;
DELETE FROM hero_gear WHERE gear_id > 2317;
DELETE FROM hero_inventory WHERE gear_id > 2317;
DELETE FROM gear WHERE id > 2317;
COMMIT;
Loading…
Cancel
Save