Compare commits

..

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

@ -1,16 +1,5 @@
{
"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",
"title": "AutoHero — 0.3.0",

@ -11,8 +11,10 @@ import (
)
type GearStore interface {
AddToInventory(ctx context.Context, heroID int64, item *model.GearItem) error
EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error
CreateItem(ctx context.Context, 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 {
@ -85,12 +87,35 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
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()
prev := hero.Gear[item.Slot]
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)
err := deps.GearStore.EquipItem(ctx, hero.ID, item)
err := deps.GearStore.EquipItem(ctx, hero.ID, item.Slot, item.ID)
cancel()
if err != nil {
if prev == nil {
@ -111,8 +136,6 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
goto recordLoot
}
}
drop.ItemID = item.ID
drop.ItemName = item.Name
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
@ -132,6 +155,13 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
hero.EnsureInventorySlice()
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.ItemName = ""
drop.GoldAmount = 0
@ -148,25 +178,26 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
} else {
if deps.GearStore != nil {
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()
if err != nil {
if deps.Logger != nil {
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.ItemName = ""
drop.GoldAmount = 0
} else {
hero.Inventory = append(hero.Inventory, item)
drop.ItemID = item.ID
drop.ItemName = item.Name
drop.GoldAmount = 0
}
} else {
hero.Inventory = append(hero.Inventory, item)
drop.ItemID = item.ID
drop.ItemName = item.Name
drop.GoldAmount = 0
}
}

@ -78,10 +78,15 @@ func RollTownMerchantOfferGold(ilvl int, rarity model.Rarity, townLevel int) int
perIlvl := tuning.EffectiveMerchantTownGearPricePerIlvl()
variance := tuning.EffectiveMerchantTownGearPriceVariancePct()
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
if v < 0 {
v = 0
}
if 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)
cost := int64(mean*factor + 0.5)
if cost < 1 {
cost = int64(mean)
cost = 1
}
return cost
}
@ -104,10 +109,20 @@ func ApplyPreparedTownMerchantPurchase(ctx context.Context, gs *storage.GearStor
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)
err := gs.EquipItem(ctxEq, hero.ID, toCreate)
err = gs.EquipItem(ctxEq, hero.ID, toCreate.Slot, toCreate.ID)
cancelEq()
if err != nil {
ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
_ = gs.DeleteGearItem(ctxDel, toCreate.ID)
cancelDel()
return nil, err
}

@ -444,7 +444,13 @@ func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) {
}
clone := *src
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()})
return
}
@ -486,7 +492,12 @@ func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) {
}
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()})
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"})
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) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"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()})
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()})
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.
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
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
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.

@ -384,12 +384,12 @@ func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) {
// npcPersistGearEquip writes hero_gear when a merchant drop is equipped.
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
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
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.
@ -416,6 +416,14 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
ilvl := model.RollIlvl(refLevel, false)
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()
slot := item.Slot
var prev *model.GearItem
@ -456,6 +464,13 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
if !equipped {
hero.EnsureInventorySlice()
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{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseWanderingAlmsDropped,
@ -464,10 +479,13 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
})
} else {
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()
if err != nil {
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 {
hero.Inventory = append(hero.Inventory, item)
h.addLogLine(hero.ID, model.AdventureLogLine{
@ -944,11 +962,7 @@ func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) {
townLv := game.TownEffectiveLevel(town)
n := tuning.EffectiveMerchantTownStockCount()
slots := model.GearVendorSlots(npc.Type)
refLevel := townLv
if hero.Level < townLv {
refLevel = int(float64(hero.Level) * (1 + (1-float64(hero.Level / townLv))))
}
items := game.RollTownMerchantStockItemsForSlots(refLevel, n, slots)
items := game.RollTownMerchantStockItemsForSlots(townLv, n, slots)
costs := make([]int64, len(items))
for i, it := range items {
if it == nil {

@ -19,78 +19,11 @@ type GearStore struct {
pool *pgxpool.Pool
}
type gearOverrideRow struct {
FormID *string
Name *string
Subtype *string
Rarity *string
Ilvl *int
BasePrimary *int
PrimaryStat *int
StatType *string
SpeedModifier *float64
CritChance *float64
AgilityBonus *int
SetName *string
SpecialEffect *string
}
// NewGearStore creates a new GearStore backed by the given connection pool.
func NewGearStore(pool *pgxpool.Pool) *GearStore {
return &GearStore{pool: pool}
}
func overrideRowFromItem(item *model.GearItem) gearOverrideRow {
if item == nil {
return gearOverrideRow{}
}
return gearOverrideRow{
FormID: &item.FormID,
Name: &item.Name,
Subtype: &item.Subtype,
Rarity: ptrString(string(item.Rarity)),
Ilvl: &item.Ilvl,
BasePrimary: &item.BasePrimary,
PrimaryStat: &item.PrimaryStat,
StatType: &item.StatType,
SpeedModifier: &item.SpeedModifier,
CritChance: &item.CritChance,
AgilityBonus: &item.AgilityBonus,
SetName: &item.SetName,
SpecialEffect: &item.SpecialEffect,
}
}
func (s *GearStore) resolveArchetypeID(ctx context.Context, item *model.GearItem) (int64, error) {
if item == nil {
return 0, fmt.Errorf("nil gear item")
}
var id int64
err := s.pool.QueryRow(ctx, `
SELECT id FROM gear
WHERE slot = $1 AND name = $2 AND form_id = $3 AND subtype = $4
ORDER BY id ASC LIMIT 1
`, string(item.Slot), item.Name, item.FormID, item.Subtype).Scan(&id)
if err == nil {
return id, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("resolve archetype: %w", err)
}
err = s.pool.QueryRow(ctx, `
SELECT id FROM gear
WHERE slot = $1 AND name = $2
ORDER BY id ASC LIMIT 1
`, string(item.Slot), item.Name).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("gear archetype not found for %s/%s", item.Name, item.Subtype)
}
return 0, fmt.Errorf("resolve archetype fallback: %w", err)
}
return id, nil
}
// CreateItem inserts a new gear item into the database and populates item.ID.
func (s *GearStore) CreateItem(ctx context.Context, item *model.GearItem) error {
err := s.pool.QueryRow(ctx, `
@ -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.
func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.EquipmentSlot]*model.GearItem, error) {
rows, err := s.pool.Query(ctx, `
SELECT hg.item_id, hg.slot,
COALESCE(hg.form_id, g.form_id),
COALESCE(hg.name, g.name),
COALESCE(hg.subtype, g.subtype),
COALESCE(hg.rarity, g.rarity),
COALESCE(hg.ilvl, g.ilvl),
COALESCE(hg.base_primary, g.base_primary),
COALESCE(hg.primary_stat, g.primary_stat),
COALESCE(hg.stat_type, g.stat_type),
COALESCE(hg.speed_modifier, g.speed_modifier),
COALESCE(hg.crit_chance, g.crit_chance),
COALESCE(hg.agility_bonus, g.agility_bonus),
COALESCE(hg.set_name, g.set_name),
COALESCE(hg.special_effect, g.special_effect)
SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl,
g.base_primary, g.primary_stat, g.stat_type,
g.speed_modifier, g.crit_chance, g.agility_bonus,
g.set_name, g.special_effect
FROM hero_gear hg
JOIN gear g ON hg.gear_id = g.id
WHERE hg.hero_id = $1
@ -213,40 +136,21 @@ func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.Eq
return gear, nil
}
// EquipItem equips a newly created item (not yet in inventory) into the given slot.
// Any previously equipped item is moved to the backpack.
// Returns ErrInventoryFull if the previous item cannot be stashed.
func (s *GearStore) EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error {
if item == nil {
return fmt.Errorf("nil gear item")
}
gearID, err := s.resolveArchetypeID(ctx, item)
if err != nil {
return err
}
// EquipItem equips a gear item into the given slot for a hero (upsert).
// Any previously equipped item in that slot is moved to the backpack (unless it is the same gear_id).
// If the new item was in the backpack, it is removed and remaining slots are reindexed (0..n-1).
// Returns ErrInventoryFull if the previous item cannot be stashed (transaction rolled back).
func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, gearID int64) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("equip gear item begin: %w", err)
}
defer tx.Rollback(ctx)
var (
prevGearID int64
prevItemID int64
prevOverrides gearOverrideRow
)
var prevGearID int64
err = tx.QueryRow(ctx, `
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect
FROM hero_gear
WHERE hero_id = $1 AND slot = $2
`, heroID, string(item.Slot)).Scan(
&prevGearID, &prevItemID,
&prevOverrides.FormID, &prevOverrides.Name, &prevOverrides.Subtype, &prevOverrides.Rarity,
&prevOverrides.Ilvl, &prevOverrides.BasePrimary, &prevOverrides.PrimaryStat,
&prevOverrides.StatType, &prevOverrides.SpeedModifier, &prevOverrides.CritChance, &prevOverrides.AgilityBonus,
&prevOverrides.SetName, &prevOverrides.SpecialEffect,
)
SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2
`, heroID, string(slot)).Scan(&prevGearID)
hasPrev := true
if errors.Is(err, pgx.ErrNoRows) {
hasPrev = false
@ -254,262 +158,73 @@ func (s *GearStore) EquipItem(ctx context.Context, heroID int64, item *model.Gea
} else if err != nil {
return fmt.Errorf("equip read previous: %w", err)
}
if hasPrev {
count, err := inventoryCountTx(ctx, tx, heroID)
if err != nil {
return err
}
if count >= model.MaxInventorySlots {
return ErrInventoryFull
}
}
overrides := overrideRowFromItem(item)
err = tx.QueryRow(ctx, `
INSERT INTO hero_gear (
hero_id, slot, gear_id, item_id,
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
speed_modifier, crit_chance, agility_bonus, set_name, special_effect
) VALUES (
$1, $2, $3, nextval('public.hero_item_id_seq'),
$4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16
)
ON CONFLICT (hero_id, slot) DO UPDATE SET
gear_id = EXCLUDED.gear_id,
item_id = EXCLUDED.item_id,
form_id = EXCLUDED.form_id,
name = EXCLUDED.name,
subtype = EXCLUDED.subtype,
rarity = EXCLUDED.rarity,
ilvl = EXCLUDED.ilvl,
base_primary = EXCLUDED.base_primary,
primary_stat = EXCLUDED.primary_stat,
stat_type = EXCLUDED.stat_type,
speed_modifier = EXCLUDED.speed_modifier,
crit_chance = EXCLUDED.crit_chance,
agility_bonus = EXCLUDED.agility_bonus,
set_name = EXCLUDED.set_name,
special_effect = EXCLUDED.special_effect
RETURNING item_id
`, heroID, string(item.Slot), gearID,
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
overrides.SetName, overrides.SpecialEffect,
).Scan(&item.ID)
if err != nil {
if _, err := tx.Exec(ctx, `
INSERT INTO hero_gear (hero_id, slot, gear_id)
VALUES ($1, $2, $3)
ON CONFLICT (hero_id, slot) DO UPDATE SET gear_id = EXCLUDED.gear_id
`, heroID, string(slot), gearID); err != nil {
return fmt.Errorf("equip gear item: %w", err)
}
if 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 {
if err := compactInventoryAfterRemovingGear(ctx, tx, heroID, gearID); 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 {
if hasPrev && prevGearID != gearID {
if err := addToInventoryTx(ctx, tx, heroID, prevGearID); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("equip inventory commit: %w", err)
return fmt.Errorf("equip gear item commit: %w", err)
}
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
if err := tx.QueryRow(ctx, `
SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1
`, heroID).Scan(&n); err != nil {
return 0, fmt.Errorf("inventory count: %w", err)
}
return n, nil
}
func addToInventoryTx(ctx context.Context, tx pgx.Tx, heroID, gearID, itemID int64, overrides gearOverrideRow) error {
n, err := inventoryCountTx(ctx, tx, heroID)
if err != nil {
return err
return fmt.Errorf("inventory count: %w", err)
}
if n >= model.MaxInventorySlots {
return ErrInventoryFull
}
if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory (
hero_id, slot_index, gear_id, item_id,
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
speed_modifier, crit_chance, agility_bonus, set_name, special_effect
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17
)
`, heroID, n, gearID, itemID,
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
overrides.SetName, overrides.SpecialEffect,
); err != nil {
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
VALUES ($1, $2, $3)
`, heroID, n, gearID); err != nil {
return fmt.Errorf("add to inventory: %w", err)
}
return nil
}
// 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.
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, `
DELETE FROM hero_inventory WHERE hero_id = $1 AND item_id = $2
`, heroID, itemID)
DELETE FROM hero_inventory WHERE hero_id = $1 AND gear_id = $2
`, heroID, gearID)
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 {
return nil
}
rows, err := tx.Query(ctx, `
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect
FROM hero_inventory
WHERE hero_id = $1
ORDER BY slot_index ASC
SELECT gear_id FROM hero_inventory WHERE hero_id = $1 ORDER BY slot_index ASC
`, heroID)
if err != nil {
return fmt.Errorf("list inventory after remove: %w", err)
}
defer rows.Close()
type invRow struct {
gearID int64
itemID int64
overrides gearOverrideRow
}
var items []invRow
var ids []int64
for rows.Next() {
var row invRow
if err := rows.Scan(
&row.gearID, &row.itemID,
&row.overrides.FormID, &row.overrides.Name, &row.overrides.Subtype, &row.overrides.Rarity,
&row.overrides.Ilvl, &row.overrides.BasePrimary, &row.overrides.PrimaryStat,
&row.overrides.StatType, &row.overrides.SpeedModifier, &row.overrides.CritChance, &row.overrides.AgilityBonus,
&row.overrides.SetName, &row.overrides.SpecialEffect,
); err != nil {
return fmt.Errorf("scan inventory row: %w", err)
var id int64
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("scan inventory gear_id: %w", err)
}
items = append(items, row)
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
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 {
return fmt.Errorf("clear inventory for compact: %w", err)
}
for i, row := range items {
for i, gid := range ids {
if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory (
hero_id, slot_index, gear_id, item_id,
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
speed_modifier, crit_chance, agility_bonus, set_name, special_effect
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17
)
`, heroID, i, row.gearID, row.itemID,
row.overrides.FormID, row.overrides.Name, row.overrides.Subtype, row.overrides.Rarity, row.overrides.Ilvl,
row.overrides.BasePrimary, row.overrides.PrimaryStat, row.overrides.StatType,
row.overrides.SpeedModifier, row.overrides.CritChance, row.overrides.AgilityBonus,
row.overrides.SetName, row.overrides.SpecialEffect,
); err != nil {
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
VALUES ($1, $2, $3)
`, heroID, i, gid); err != nil {
return fmt.Errorf("reinsert inventory slot %d: %w", i, err)
}
}
return nil
}
// 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 {
tx, err := s.pool.Begin(ctx)
if err != nil {
@ -549,19 +252,47 @@ func (s *GearStore) WipeAllGearForHero(ctx context.Context, heroID int64) error
}
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 {
return fmt.Errorf("wipe hero_gear: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil {
return fmt.Errorf("wipe hero_inventory: %w", err)
}
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 {
return fmt.Errorf("wipe gear commit: %w", err)
}
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 {
cmd, err := s.pool.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id)
if err != nil {
@ -573,47 +304,6 @@ func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error {
return nil
}
// DeleteHeroItem removes an equipped or inventory item by item_id.
func (s *GearStore) DeleteHeroItem(ctx context.Context, heroID, itemID int64) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("delete hero item begin: %w", err)
}
defer tx.Rollback(ctx)
var exists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM hero_inventory WHERE hero_id = $1 AND item_id = $2
)
`, heroID, itemID).Scan(&exists); err != nil {
return fmt.Errorf("delete hero item lookup: %w", err)
}
if exists {
if err := compactInventoryAfterRemovingItem(ctx, tx, heroID, itemID); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("delete hero item commit: %w", err)
}
return nil
}
cmd, err := tx.Exec(ctx, `
DELETE FROM hero_gear WHERE hero_id = $1 AND item_id = $2
`, heroID, itemID)
if err != nil {
return fmt.Errorf("delete hero gear item: %w", err)
}
if cmd.RowsAffected() == 0 {
return fmt.Errorf("delete hero item: no row for id %d", itemID)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("delete hero item commit: %w", err)
}
return nil
}
// UnequipSlot moves equipped gear from the given slot into the hero's backpack.
// Returns ErrInventoryFull if there is no free slot (equipped row is left unchanged).
// If the slot is empty, returns nil (idempotent).
@ -624,22 +314,10 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq
}
defer tx.Rollback(ctx)
var (
gearID int64
itemID int64
overrides gearOverrideRow
)
var gearID int64
err = tx.QueryRow(ctx, `
SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect
FROM hero_gear WHERE hero_id = $1 AND slot = $2
`, heroID, string(slot)).Scan(
&gearID, &itemID,
&overrides.FormID, &overrides.Name, &overrides.Subtype, &overrides.Rarity,
&overrides.Ilvl, &overrides.BasePrimary, &overrides.PrimaryStat,
&overrides.StatType, &overrides.SpeedModifier, &overrides.CritChance, &overrides.AgilityBonus,
&overrides.SetName, &overrides.SpecialEffect,
)
SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2
`, heroID, string(slot)).Scan(&gearID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
@ -647,7 +325,7 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq
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
}
@ -666,20 +344,10 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq
// GetHeroInventory loads unequipped gear ordered by slot_index.
func (s *GearStore) GetHeroInventory(ctx context.Context, heroID int64) ([]*model.GearItem, error) {
rows, err := s.pool.Query(ctx, `
SELECT hi.item_id, g.slot,
COALESCE(hi.form_id, g.form_id),
COALESCE(hi.name, g.name),
COALESCE(hi.subtype, g.subtype),
COALESCE(hi.rarity, g.rarity),
COALESCE(hi.ilvl, g.ilvl),
COALESCE(hi.base_primary, g.base_primary),
COALESCE(hi.primary_stat, g.primary_stat),
COALESCE(hi.stat_type, g.stat_type),
COALESCE(hi.speed_modifier, g.speed_modifier),
COALESCE(hi.crit_chance, g.crit_chance),
COALESCE(hi.agility_bonus, g.agility_bonus),
COALESCE(hi.set_name, g.set_name),
COALESCE(hi.special_effect, g.special_effect)
SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl,
g.base_primary, g.primary_stat, g.stat_type,
g.speed_modifier, g.crit_chance, g.agility_bonus,
g.set_name, g.special_effect
FROM hero_inventory hi
JOIN gear g ON hi.gear_id = g.id
WHERE hi.hero_id = $1
@ -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.
func (s *GearStore) AddToInventory(ctx context.Context, heroID int64, item *model.GearItem) error {
if item == nil {
return fmt.Errorf("nil gear item")
}
func (s *GearStore) AddToInventory(ctx context.Context, heroID, gearID int64) error {
var n int
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1
@ -727,28 +392,10 @@ func (s *GearStore) AddToInventory(ctx context.Context, heroID int64, item *mode
if n >= model.MaxInventorySlots {
return ErrInventoryFull
}
gearID, err := s.resolveArchetypeID(ctx, item)
if err != nil {
return err
}
overrides := overrideRowFromItem(item)
err = s.pool.QueryRow(ctx, `
INSERT INTO hero_inventory (
hero_id, slot_index, gear_id, item_id,
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
speed_modifier, crit_chance, agility_bonus, set_name, special_effect
) VALUES (
$1, $2, $3, nextval('public.hero_item_id_seq'),
$4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16
)
RETURNING item_id
`, heroID, n, gearID,
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
overrides.SetName, overrides.SpecialEffect,
).Scan(&item.ID)
_, err := s.pool.Exec(ctx, `
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
VALUES ($1, $2, $3)
`, heroID, n, gearID)
if err != nil {
return fmt.Errorf("add to inventory: %w", err)
}
@ -771,50 +418,28 @@ func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, item
if item == nil || i >= model.MaxInventorySlots {
continue
}
gearID, err := s.resolveArchetypeID(ctx, item)
if err != nil {
return err
}
overrides := overrideRowFromItem(item)
if item.ID == 0 {
gid := item.ID
if gid == 0 {
err := tx.QueryRow(ctx, `
INSERT INTO hero_inventory (
hero_id, slot_index, gear_id, item_id,
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
speed_modifier, crit_chance, agility_bonus, set_name, special_effect
) VALUES (
$1, $2, $3, nextval('public.hero_item_id_seq'),
$4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16
)
RETURNING item_id
`, heroID, i, gearID,
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
overrides.SetName, overrides.SpecialEffect,
).Scan(&item.ID)
INSERT INTO gear (slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat,
stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id
`,
string(item.Slot), item.FormID, item.Name, item.Subtype,
string(item.Rarity), item.Ilvl, item.BasePrimary, item.PrimaryStat,
item.StatType, item.SpeedModifier, item.CritChance, item.AgilityBonus,
item.SetName, item.SpecialEffect,
).Scan(&gid)
if err != nil {
return fmt.Errorf("replace inventory insert: %w", err)
return fmt.Errorf("replace inventory create gear: %w", err)
}
continue
item.ID = gid
}
if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory (
hero_id, slot_index, gear_id, item_id,
form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
speed_modifier, crit_chance, agility_bonus, set_name, special_effect
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17
)
`, heroID, i, gearID, item.ID,
overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl,
overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType,
overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus,
overrides.SetName, overrides.SpecialEffect,
); err != nil {
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
VALUES ($1, $2, $3)
`, heroID, i, gid); err != nil {
return fmt.Errorf("replace inventory insert: %w", err)
}
}
@ -823,7 +448,3 @@ func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, item
}
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 {
name string
}{
{"Iron Sword"},
{"Steel Sword"},
{"Longsword"},
{"Excalibur"},
{"Worn Shortsword"},
{"Traveler's Blade"},
{"Notched Sword"},
{"Training Sword"},
}
sw := swords[rand.Intn(len(swords))]
starterWeapon := &model.GearItem{
@ -461,7 +461,10 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e
SpeedModifier: 1.0,
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)
}
@ -473,9 +476,9 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e
agilityBon int
defense int
}{
{"Leather Armor", "gear.form.chest.light", "light", 1.05, 2, 3},
{"Chainmail", "gear.form.chest.medium", "medium", 1.0, 0, 4},
{"Iron Plate", "gear.form.chest.heavy", "heavy", 0.7, 0, 5},
{"Worn Leather Jack", "gear.form.chest.leather", "light", 1.05, 2, 3},
{"Rusty Hauberk", "gear.form.chest.mail", "medium", 1.0, 0, 4},
{"Worn Plate", "gear.form.chest.plate", "heavy", 0.7, 0, 5},
}
ar := armors[rand.Intn(len(armors))]
starterArmor := &model.GearItem{
@ -491,7 +494,10 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e
SpeedModifier: ar.speed,
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)
}
@ -514,7 +520,10 @@ func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error {
SpeedModifier: 1.3,
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)
}
@ -531,7 +540,10 @@ func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error {
SpeedModifier: 1.05,
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)
}

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