From 5373107a03e7e8667d30c244ab39bdf6192b63ce Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 6 Apr 2026 03:07:10 +0300 Subject: [PATCH] gear fix --- backend/internal/game/rewards.go | 53 +- backend/internal/game/town_merchant_gear.go | 12 +- backend/internal/handler/admin.go | 24 +- backend/internal/handler/game.go | 4 +- backend/internal/handler/npc.go | 30 +- backend/internal/storage/gear_store.go | 581 ++++++++++++++++---- backend/internal/storage/hero_store.go | 34 +- backend/internal/tuning/runtime.go | 6 +- backend/migrations/000001_init.sql | 2 +- 9 files changed, 521 insertions(+), 225 deletions(-) diff --git a/backend/internal/game/rewards.go b/backend/internal/game/rewards.go index 32bbc2c..b4a87f8 100644 --- a/backend/internal/game/rewards.go +++ b/backend/internal/game/rewards.go @@ -11,10 +11,8 @@ import ( ) type GearStore interface { - 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 + AddToInventory(ctx context.Context, heroID int64, item *model.GearItem) error + EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error } type QuestProgressor interface { @@ -87,35 +85,12 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite) 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 && item.ID != 0 { + if deps.GearStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - err := deps.GearStore.EquipItem(ctx, hero.ID, item.Slot, item.ID) + err := deps.GearStore.EquipItem(ctx, hero.ID, item) cancel() if err != nil { if prev == nil { @@ -136,6 +111,8 @@ 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{ @@ -155,13 +132,6 @@ 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 @@ -178,26 +148,25 @@ 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.ID) + err := deps.GearStore.AddToInventory(ctxInv, hero.ID, item) 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 } } diff --git a/backend/internal/game/town_merchant_gear.go b/backend/internal/game/town_merchant_gear.go index 53c826d..9a86a91 100644 --- a/backend/internal/game/town_merchant_gear.go +++ b/backend/internal/game/town_merchant_gear.go @@ -104,20 +104,10 @@ 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.Slot, toCreate.ID) + err := gs.EquipItem(ctxEq, hero.ID, toCreate) cancelEq() if err != nil { - ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second) - _ = gs.DeleteGearItem(ctxDel, toCreate.ID) - cancelDel() return nil, err } diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index fe0a68d..e7ebeaf 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -444,13 +444,7 @@ func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) { } clone := *src clone.ID = 0 - 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) + if err := h.gearStore.AddToInventory(r.Context(), heroID, &clone); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } @@ -492,12 +486,7 @@ func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) { } item := model.NewGearItem(family, req.Ilvl, rarity) - 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) + if err := h.gearStore.AddToInventory(r.Context(), heroID, item); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } @@ -525,12 +514,7 @@ func (h *AdminHandler) EquipHeroGear(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } - 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 err := h.gearStore.EquipInventoryItem(r.Context(), heroID, req.ItemID); 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", @@ -599,7 +583,7 @@ func (h *AdminHandler) DeleteHeroGear(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid itemId: " + err.Error()}) return } - if err := h.gearStore.DeleteGearItem(r.Context(), itemID); err != nil { + if err := h.gearStore.DeleteHeroItem(r.Context(), heroID, itemID); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 508f695..f1368dc 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -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.ID == 0 { + if h.gearStore == nil || item == nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID) + return h.gearStore.EquipItem(ctx, heroID, item) } // pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats. diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index c9f81cf..a0754e8 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -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 || item.ID == 0 { + if h.gearStore == nil || item == nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID) + return h.gearStore.EquipItem(ctx, heroID, item) } // grantMerchantLoot rolls one random gear piece; auto-equips if better. @@ -416,14 +416,6 @@ 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 @@ -464,13 +456,6 @@ 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, @@ -479,13 +464,10 @@ 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.ID) + err := h.gearStore.AddToInventory(ctxInv, hero.ID, item) 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{ @@ -962,7 +944,11 @@ func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) { townLv := game.TownEffectiveLevel(town) n := tuning.EffectiveMerchantTownStockCount() slots := model.GearVendorSlots(npc.Type) - items := game.RollTownMerchantStockItemsForSlots(int(float64(hero.Level) * float64(1 + hero.Level / townLv)), n, slots) + refLevel := townLv + if hero.Level < townLv { + refLevel = int(float64(hero.Level) * (1 + (1-float64(hero.Level / townLv)))) + } + items := game.RollTownMerchantStockItemsForSlots(refLevel, n, slots) costs := make([]int64, len(items)) for i, it := range items { if it == nil { diff --git a/backend/internal/storage/gear_store.go b/backend/internal/storage/gear_store.go index 330cf8f..8566ac2 100644 --- a/backend/internal/storage/gear_store.go +++ b/backend/internal/storage/gear_store.go @@ -19,11 +19,78 @@ 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, ` @@ -100,10 +167,20 @@ func (s *GearStore) GetItem(ctx context.Context, id int64) (*model.GearItem, err // GetHeroGear returns all equipped gear for a hero, keyed by slot. func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.EquipmentSlot]*model.GearItem, error) { rows, err := s.pool.Query(ctx, ` - SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl, - g.base_primary, g.primary_stat, g.stat_type, - g.speed_modifier, g.crit_chance, g.agility_bonus, - g.set_name, g.special_effect + SELECT hg.item_id, hg.slot, + COALESCE(hg.form_id, g.form_id), + COALESCE(hg.name, g.name), + COALESCE(hg.subtype, g.subtype), + COALESCE(hg.rarity, g.rarity), + COALESCE(hg.ilvl, g.ilvl), + COALESCE(hg.base_primary, g.base_primary), + COALESCE(hg.primary_stat, g.primary_stat), + COALESCE(hg.stat_type, g.stat_type), + COALESCE(hg.speed_modifier, g.speed_modifier), + COALESCE(hg.crit_chance, g.crit_chance), + COALESCE(hg.agility_bonus, g.agility_bonus), + COALESCE(hg.set_name, g.set_name), + COALESCE(hg.special_effect, g.special_effect) FROM hero_gear hg JOIN gear g ON hg.gear_id = g.id WHERE hg.hero_id = $1 @@ -136,21 +213,40 @@ func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.Eq return gear, nil } -// EquipItem equips a gear item into the given slot for a hero (upsert). -// Any previously equipped item in that slot is moved to the backpack (unless it is the same gear_id). -// If the new item was in the backpack, it is removed and remaining slots are reindexed (0..n-1). -// Returns ErrInventoryFull if the previous item cannot be stashed (transaction rolled back). -func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, gearID int64) error { +// EquipItem equips a newly created item (not yet in inventory) into the given slot. +// Any previously equipped item is moved to the backpack. +// Returns ErrInventoryFull if the previous item cannot be stashed. +func (s *GearStore) EquipItem(ctx context.Context, heroID int64, item *model.GearItem) error { + if item == nil { + return fmt.Errorf("nil gear item") + } + gearID, err := s.resolveArchetypeID(ctx, item) + if err != nil { + return err + } tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("equip gear item begin: %w", err) } defer tx.Rollback(ctx) - var prevGearID int64 + var ( + prevGearID int64 + prevItemID int64 + prevOverrides gearOverrideRow + ) err = tx.QueryRow(ctx, ` - SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2 - `, heroID, string(slot)).Scan(&prevGearID) + SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, + stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect + FROM hero_gear + WHERE hero_id = $1 AND slot = $2 + `, heroID, string(item.Slot)).Scan( + &prevGearID, &prevItemID, + &prevOverrides.FormID, &prevOverrides.Name, &prevOverrides.Subtype, &prevOverrides.Rarity, + &prevOverrides.Ilvl, &prevOverrides.BasePrimary, &prevOverrides.PrimaryStat, + &prevOverrides.StatType, &prevOverrides.SpeedModifier, &prevOverrides.CritChance, &prevOverrides.AgilityBonus, + &prevOverrides.SetName, &prevOverrides.SpecialEffect, + ) hasPrev := true if errors.Is(err, pgx.ErrNoRows) { hasPrev = false @@ -158,73 +254,262 @@ func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.Equi } 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 + } + } - 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 { + overrides := overrideRowFromItem(item) + err = tx.QueryRow(ctx, ` + INSERT INTO hero_gear ( + hero_id, slot, gear_id, item_id, + form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, + speed_modifier, crit_chance, agility_bonus, set_name, special_effect + ) VALUES ( + $1, $2, $3, nextval('public.hero_item_id_seq'), + $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16 + ) + ON CONFLICT (hero_id, slot) DO UPDATE SET + gear_id = EXCLUDED.gear_id, + item_id = EXCLUDED.item_id, + form_id = EXCLUDED.form_id, + name = EXCLUDED.name, + subtype = EXCLUDED.subtype, + rarity = EXCLUDED.rarity, + ilvl = EXCLUDED.ilvl, + base_primary = EXCLUDED.base_primary, + primary_stat = EXCLUDED.primary_stat, + stat_type = EXCLUDED.stat_type, + speed_modifier = EXCLUDED.speed_modifier, + crit_chance = EXCLUDED.crit_chance, + agility_bonus = EXCLUDED.agility_bonus, + set_name = EXCLUDED.set_name, + special_effect = EXCLUDED.special_effect + RETURNING item_id + `, heroID, string(item.Slot), gearID, + overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl, + overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType, + overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus, + overrides.SetName, overrides.SpecialEffect, + ).Scan(&item.ID) + if err != nil { return fmt.Errorf("equip gear item: %w", err) } - if err := compactInventoryAfterRemovingGear(ctx, tx, heroID, gearID); err != nil { + + if hasPrev && prevItemID != item.ID { + if err := addToInventoryTx(ctx, tx, heroID, prevGearID, prevItemID, prevOverrides); err != nil { + return err + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("equip gear item commit: %w", err) + } + return nil +} + +// EquipInventoryItem equips an existing inventory item by item_id. +func (s *GearStore) EquipInventoryItem(ctx context.Context, heroID, itemID int64) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("equip inventory item begin: %w", err) + } + defer tx.Rollback(ctx) + + var ( + gearID int64 + slot string + overrides gearOverrideRow + prevGearID int64 + prevItemID int64 + prevOv gearOverrideRow + ) + err = tx.QueryRow(ctx, ` + SELECT hi.gear_id, g.slot, + hi.form_id, hi.name, hi.subtype, hi.rarity, hi.ilvl, hi.base_primary, hi.primary_stat, + hi.stat_type, hi.speed_modifier, hi.crit_chance, hi.agility_bonus, hi.set_name, hi.special_effect + FROM hero_inventory hi + JOIN gear g ON hi.gear_id = g.id + WHERE hi.hero_id = $1 AND hi.item_id = $2 + `, heroID, itemID).Scan( + &gearID, &slot, + &overrides.FormID, &overrides.Name, &overrides.Subtype, &overrides.Rarity, + &overrides.Ilvl, &overrides.BasePrimary, &overrides.PrimaryStat, + &overrides.StatType, &overrides.SpeedModifier, &overrides.CritChance, &overrides.AgilityBonus, + &overrides.SetName, &overrides.SpecialEffect, + ) + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("inventory item not found") + } + if err != nil { + return fmt.Errorf("equip inventory read item: %w", err) + } + + if err := compactInventoryAfterRemovingItem(ctx, tx, heroID, itemID); err != nil { return err } - if hasPrev && prevGearID != gearID { - if err := addToInventoryTx(ctx, tx, heroID, prevGearID); err != nil { + + err = tx.QueryRow(ctx, ` + SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, + stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect + FROM hero_gear + WHERE hero_id = $1 AND slot = $2 + `, heroID, slot).Scan( + &prevGearID, &prevItemID, + &prevOv.FormID, &prevOv.Name, &prevOv.Subtype, &prevOv.Rarity, + &prevOv.Ilvl, &prevOv.BasePrimary, &prevOv.PrimaryStat, + &prevOv.StatType, &prevOv.SpeedModifier, &prevOv.CritChance, &prevOv.AgilityBonus, + &prevOv.SetName, &prevOv.SpecialEffect, + ) + hasPrev := true + if errors.Is(err, pgx.ErrNoRows) { + hasPrev = false + err = nil + } else if err != nil { + return fmt.Errorf("equip inventory read previous: %w", err) + } + if hasPrev && prevItemID != itemID { + count, err := inventoryCountTx(ctx, tx, heroID) + if err != nil { + return err + } + if count >= model.MaxInventorySlots { + return ErrInventoryFull + } + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO hero_gear ( + hero_id, slot, gear_id, item_id, + form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, + speed_modifier, crit_chance, agility_bonus, set_name, special_effect + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17 + ) + ON CONFLICT (hero_id, slot) DO UPDATE SET + gear_id = EXCLUDED.gear_id, + item_id = EXCLUDED.item_id, + form_id = EXCLUDED.form_id, + name = EXCLUDED.name, + subtype = EXCLUDED.subtype, + rarity = EXCLUDED.rarity, + ilvl = EXCLUDED.ilvl, + base_primary = EXCLUDED.base_primary, + primary_stat = EXCLUDED.primary_stat, + stat_type = EXCLUDED.stat_type, + speed_modifier = EXCLUDED.speed_modifier, + crit_chance = EXCLUDED.crit_chance, + agility_bonus = EXCLUDED.agility_bonus, + set_name = EXCLUDED.set_name, + special_effect = EXCLUDED.special_effect + `, heroID, slot, gearID, itemID, + overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl, + overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType, + overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus, + overrides.SetName, overrides.SpecialEffect, + ); err != nil { + return fmt.Errorf("equip inventory upsert: %w", err) + } + + if hasPrev && prevItemID != itemID { + if err := addToInventoryTx(ctx, tx, heroID, prevGearID, prevItemID, prevOv); err != nil { return err } } if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("equip gear item commit: %w", err) + return fmt.Errorf("equip inventory commit: %w", err) } return nil } -func addToInventoryTx(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error { +func inventoryCountTx(ctx context.Context, tx pgx.Tx, heroID int64) (int, error) { var n int if err := tx.QueryRow(ctx, ` SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1 `, heroID).Scan(&n); err != nil { - return fmt.Errorf("inventory count: %w", err) + return 0, fmt.Errorf("inventory count: %w", err) + } + return n, nil +} + +func addToInventoryTx(ctx context.Context, tx pgx.Tx, heroID, gearID, itemID int64, overrides gearOverrideRow) error { + n, err := inventoryCountTx(ctx, tx, heroID) + if err != nil { + return err } if n >= model.MaxInventorySlots { return ErrInventoryFull } if _, err := tx.Exec(ctx, ` - INSERT INTO hero_inventory (hero_id, slot_index, gear_id) - VALUES ($1, $2, $3) - `, heroID, n, gearID); err != nil { + INSERT INTO hero_inventory ( + hero_id, slot_index, gear_id, item_id, + form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, + speed_modifier, crit_chance, agility_bonus, set_name, special_effect + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17 + ) + `, heroID, n, gearID, itemID, + overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl, + overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType, + overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus, + overrides.SetName, overrides.SpecialEffect, + ); err != nil { return fmt.Errorf("add to inventory: %w", err) } return nil } -// compactInventoryAfterRemovingGear deletes one backpack row if present; if a row +// compactInventoryAfterRemovingItem deletes one backpack row if present; if a row // was removed, rewrites hero_inventory with contiguous slot_index. -func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error { +func compactInventoryAfterRemovingItem(ctx context.Context, tx pgx.Tx, heroID, itemID int64) error { cmd, err := tx.Exec(ctx, ` - DELETE FROM hero_inventory WHERE hero_id = $1 AND gear_id = $2 - `, heroID, gearID) + DELETE FROM hero_inventory WHERE hero_id = $1 AND item_id = $2 + `, heroID, itemID) if err != nil { - return fmt.Errorf("remove equipped gear from inventory: %w", err) + return fmt.Errorf("remove item from inventory: %w", err) } if cmd.RowsAffected() == 0 { return nil } rows, err := tx.Query(ctx, ` - SELECT gear_id FROM hero_inventory WHERE hero_id = $1 ORDER BY slot_index ASC + SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, + stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect + FROM hero_inventory + WHERE hero_id = $1 + ORDER BY slot_index ASC `, heroID) if err != nil { return fmt.Errorf("list inventory after remove: %w", err) } defer rows.Close() - var ids []int64 + + type invRow struct { + gearID int64 + itemID int64 + overrides gearOverrideRow + } + var items []invRow for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - return fmt.Errorf("scan inventory gear_id: %w", err) + 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) } - ids = append(ids, id) + items = append(items, row) } if err := rows.Err(); err != nil { return fmt.Errorf("inventory rows: %w", err) @@ -233,18 +518,30 @@ func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, g if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil { return fmt.Errorf("clear inventory for compact: %w", err) } - for i, gid := range ids { + for i, row := range items { if _, err := tx.Exec(ctx, ` - INSERT INTO hero_inventory (hero_id, slot_index, gear_id) - VALUES ($1, $2, $3) - `, heroID, i, gid); err != nil { + INSERT INTO hero_inventory ( + hero_id, slot_index, gear_id, item_id, + form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, + speed_modifier, crit_chance, agility_bonus, set_name, special_effect + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17 + ) + `, heroID, i, row.gearID, row.itemID, + row.overrides.FormID, row.overrides.Name, row.overrides.Subtype, row.overrides.Rarity, row.overrides.Ilvl, + row.overrides.BasePrimary, row.overrides.PrimaryStat, row.overrides.StatType, + row.overrides.SpeedModifier, row.overrides.CritChance, row.overrides.AgilityBonus, + row.overrides.SetName, row.overrides.SpecialEffect, + ); err != nil { return fmt.Errorf("reinsert inventory slot %d: %w", i, err) } } return nil } -// WipeAllGearForHero removes every equipped and backpack item for the hero and deletes the underlying gear rows. +// WipeAllGearForHero removes every equipped and backpack item for the hero. func (s *GearStore) WipeAllGearForHero(ctx context.Context, heroID int64) error { tx, err := s.pool.Begin(ctx) if err != nil { @@ -252,47 +549,19 @@ 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 row by id (e.g. discarded drop not sold). Fails if still equipped. +// DeleteGearItem removes a gear archetype row by id. Prefer DeleteHeroItem for owned gear. func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error { cmd, err := s.pool.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id) if err != nil { @@ -304,6 +573,47 @@ 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). @@ -314,10 +624,22 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq } defer tx.Rollback(ctx) - var gearID int64 + var ( + gearID int64 + itemID int64 + overrides gearOverrideRow + ) err = tx.QueryRow(ctx, ` - SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2 - `, heroID, string(slot)).Scan(&gearID) + SELECT gear_id, item_id, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, + stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect + FROM hero_gear WHERE hero_id = $1 AND slot = $2 + `, heroID, string(slot)).Scan( + &gearID, &itemID, + &overrides.FormID, &overrides.Name, &overrides.Subtype, &overrides.Rarity, + &overrides.Ilvl, &overrides.BasePrimary, &overrides.PrimaryStat, + &overrides.StatType, &overrides.SpeedModifier, &overrides.CritChance, &overrides.AgilityBonus, + &overrides.SetName, &overrides.SpecialEffect, + ) if errors.Is(err, pgx.ErrNoRows) { return nil } @@ -325,7 +647,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); err != nil { + if err := addToInventoryTx(ctx, tx, heroID, gearID, itemID, overrides); err != nil { return err } @@ -344,10 +666,20 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq // GetHeroInventory loads unequipped gear ordered by slot_index. func (s *GearStore) GetHeroInventory(ctx context.Context, heroID int64) ([]*model.GearItem, error) { rows, err := s.pool.Query(ctx, ` - SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl, - g.base_primary, g.primary_stat, g.stat_type, - g.speed_modifier, g.crit_chance, g.agility_bonus, - g.set_name, g.special_effect + SELECT hi.item_id, g.slot, + COALESCE(hi.form_id, g.form_id), + COALESCE(hi.name, g.name), + COALESCE(hi.subtype, g.subtype), + COALESCE(hi.rarity, g.rarity), + COALESCE(hi.ilvl, g.ilvl), + COALESCE(hi.base_primary, g.base_primary), + COALESCE(hi.primary_stat, g.primary_stat), + COALESCE(hi.stat_type, g.stat_type), + COALESCE(hi.speed_modifier, g.speed_modifier), + COALESCE(hi.crit_chance, g.crit_chance), + COALESCE(hi.agility_bonus, g.agility_bonus), + COALESCE(hi.set_name, g.set_name), + COALESCE(hi.special_effect, g.special_effect) FROM hero_inventory hi JOIN gear g ON hi.gear_id = g.id WHERE hi.hero_id = $1 @@ -382,7 +714,10 @@ func (s *GearStore) GetHeroInventory(ctx context.Context, heroID int64) ([]*mode } // AddToInventory inserts gear into the next free backpack slot. Fails if inventory is full. -func (s *GearStore) AddToInventory(ctx context.Context, heroID, gearID int64) error { +func (s *GearStore) AddToInventory(ctx context.Context, heroID int64, item *model.GearItem) error { + if item == nil { + return fmt.Errorf("nil gear item") + } var n int if err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1 @@ -392,10 +727,28 @@ func (s *GearStore) AddToInventory(ctx context.Context, heroID, gearID int64) er if n >= model.MaxInventorySlots { return ErrInventoryFull } - _, err := s.pool.Exec(ctx, ` - INSERT INTO hero_inventory (hero_id, slot_index, gear_id) - VALUES ($1, $2, $3) - `, heroID, n, gearID) + gearID, err := s.resolveArchetypeID(ctx, item) + if err != nil { + return err + } + overrides := overrideRowFromItem(item) + err = s.pool.QueryRow(ctx, ` + INSERT INTO hero_inventory ( + hero_id, slot_index, gear_id, item_id, + form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, + speed_modifier, crit_chance, agility_bonus, set_name, special_effect + ) VALUES ( + $1, $2, $3, nextval('public.hero_item_id_seq'), + $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16 + ) + RETURNING item_id + `, heroID, n, gearID, + overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl, + overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType, + overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus, + overrides.SetName, overrides.SpecialEffect, + ).Scan(&item.ID) if err != nil { return fmt.Errorf("add to inventory: %w", err) } @@ -418,28 +771,50 @@ func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, item if item == nil || i >= model.MaxInventorySlots { continue } - gid := item.ID - if gid == 0 { + gearID, err := s.resolveArchetypeID(ctx, item) + if err != nil { + return err + } + overrides := overrideRowFromItem(item) + if item.ID == 0 { err := tx.QueryRow(ctx, ` - INSERT INTO gear (slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, - stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) - RETURNING id - `, - string(item.Slot), item.FormID, item.Name, item.Subtype, - string(item.Rarity), item.Ilvl, item.BasePrimary, item.PrimaryStat, - item.StatType, item.SpeedModifier, item.CritChance, item.AgilityBonus, - item.SetName, item.SpecialEffect, - ).Scan(&gid) + INSERT INTO hero_inventory ( + hero_id, slot_index, gear_id, item_id, + form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, + speed_modifier, crit_chance, agility_bonus, set_name, special_effect + ) VALUES ( + $1, $2, $3, nextval('public.hero_item_id_seq'), + $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16 + ) + RETURNING item_id + `, heroID, i, gearID, + overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl, + overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType, + overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus, + overrides.SetName, overrides.SpecialEffect, + ).Scan(&item.ID) if err != nil { - return fmt.Errorf("replace inventory create gear: %w", err) + return fmt.Errorf("replace inventory insert: %w", err) } - item.ID = gid + continue } if _, err := tx.Exec(ctx, ` - INSERT INTO hero_inventory (hero_id, slot_index, gear_id) - VALUES ($1, $2, $3) - `, heroID, i, gid); err != nil { + INSERT INTO hero_inventory ( + hero_id, slot_index, gear_id, item_id, + form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, + speed_modifier, crit_chance, agility_bonus, set_name, special_effect + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17 + ) + `, heroID, i, gearID, item.ID, + overrides.FormID, overrides.Name, overrides.Subtype, overrides.Rarity, overrides.Ilvl, + overrides.BasePrimary, overrides.PrimaryStat, overrides.StatType, + overrides.SpeedModifier, overrides.CritChance, overrides.AgilityBonus, + overrides.SetName, overrides.SpecialEffect, + ); err != nil { return fmt.Errorf("replace inventory insert: %w", err) } } @@ -448,3 +823,7 @@ func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, item } return nil } + +func ptrString(v string) *string { + return &v +} diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index 4425452..bfa236f 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -442,10 +442,10 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e swords := []struct { name string }{ - {"Worn Shortsword"}, - {"Traveler's Blade"}, - {"Notched Sword"}, - {"Training Sword"}, + {"Iron Sword"}, + {"Steel Sword"}, + {"Longsword"}, + {"Excalibur"}, } sw := swords[rand.Intn(len(swords))] starterWeapon := &model.GearItem{ @@ -461,10 +461,7 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e SpeedModifier: 1.0, CritChance: 0.05, } - 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 { + if err := s.gearStore.EquipItem(ctx, heroID, starterWeapon); err != nil { return fmt.Errorf("equip starter sword: %w", err) } @@ -476,9 +473,9 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e agilityBon int defense int }{ - {"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}, + {"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}, } ar := armors[rand.Intn(len(armors))] starterArmor := &model.GearItem{ @@ -494,10 +491,7 @@ func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) e SpeedModifier: ar.speed, AgilityBonus: ar.agilityBon, } - 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 { + if err := s.gearStore.EquipItem(ctx, heroID, starterArmor); err != nil { return fmt.Errorf("equip starter armor: %w", err) } @@ -520,10 +514,7 @@ func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error { SpeedModifier: 1.3, CritChance: 0.05, } - 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 { + if err := s.gearStore.EquipItem(ctx, heroID, starterWeapon); err != nil { return fmt.Errorf("equip starter weapon: %w", err) } @@ -540,10 +531,7 @@ func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error { SpeedModifier: 1.05, AgilityBonus: 3, } - 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 { + if err := s.gearStore.EquipItem(ctx, heroID, starterArmor); err != nil { return fmt.Errorf("equip starter armor: %w", err) } diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 6397e7f..2060c9e 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -323,10 +323,10 @@ func DefaultValues() Values { MerchantCostBase: 900, MerchantCostPerLevel: 5, MerchantTownAutoSellShare: 0.30, - MonsterEncounterWeightBase: 0.15, + MonsterEncounterWeightBase: 0.04, MonsterEncounterWeightWildBonus: 0.18, - MerchantEncounterWeightBase: 0.02, - MerchantEncounterWeightRoadBonus: 0.05, + MerchantEncounterWeightBase: 0.002, + MerchantEncounterWeightRoadBonus: 0.008, LootChanceCommon: 0.40, LootChanceUncommon: 0.10, LootChanceRare: 0.02, diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql index fd6cb92..40155fa 100644 --- a/backend/migrations/000001_init.sql +++ b/backend/migrations/000001_init.sql @@ -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.62, "resurrectionRefillPriceRub": 150, "rollIlvlElitePlusOneChance": 0.4, "subscriptionWeeklyPriceRub": 199, "merchantEncounterWeightBase": 0.02, "roadsideRestDepthWorldUnits": 12, "adventureEncounterCooldownMs": 6000, "freeBuffActivationsPerPeriod": 2, "enemyAttackIntervalMultiplier": 1.5, "adventureReturnEncounterEnabled": true, "adventureWildernessRampFraction": 0.12, "monsterEncounterWeightWildBonus": 0.18, "merchantEncounterWeightRoadBonus": 0.05, "wanderingMerchantPromptTimeoutMs": 15000, "adventureForwardSpeedWildFraction": 0.07}', '2026-03-31 16:27:14.86085+00'); +INSERT INTO public.runtime_config VALUES (true, '{"agilityCoef": 0.09, "goldEpicMax": 120, "goldEpicMin": 51, "goldRareMax": 50, "goldRareMin": 21, "npcCostHeal": 100, "autoSellEpic": 60, "autoSellRare": 20, "baseMoveSpeed": 1, "goldCommonMax": 5, "goldCommonMin": 0, "goldLootScale": 0.5, "levelUpHpBase": 2, "npcCostPotion": 200, "townRestMaxMs": 1200000, "townRestMinMs": 300000, "autoSellCommon": 3, "debuffProcBurn": 0.18, "debuffProcSlow": 0.25, "debuffProcStun": 0.25, "levelUpHpEvery": 4, "lootChanceEpic": 0.003, "lootChanceRare": 0.02, "lowHpThreshold": 0.25, "maxAttackSpeed": 5, "maxRevivesFree": 1, "minAttackSpeed": 0.1, "townNpcPauseMs": 30000, "townNpcRetryMs": 450, "xpCurveMidBase": 1450, "goldUncommonMax": 20, "goldUncommonMin": 6, "ilvlFactorSlope": 0.03, "levelUpAgiEvery": 20, "levelUpAtkEvery": 4, "levelUpConEvery": 14, "levelUpDefEvery": 5, "levelUpStrEvery": 12, "reviveHpPercent": 0.5, "xpCurveLateBase": 23000, "xpCurveMidScale": 1.15, "autoSellUncommon": 8, "debuffProcFreeze": 0.2, "debuffProcPoison": 0.1, "enemyBurstEveryN": 5, "enemyChainEveryN": 6, "enemyDodgeChance": 0.14, "enemyScaleBandHp": 0.062, "enemyScaleBandXp": 0.05, "goldLegendaryMax": 300, "goldLegendaryMin": 121, "levelUpLuckEvery": 100, "lootChanceCommon": 0.4, "lootHistoryLimit": 50, "merchantCostBase": 900, "potionDropChance": 0.05, "townNpcRollMaxMs": 2600, "townNpcRollMinMs": 800, "townNpcWalkSpeed": 3, "xpCurveEarlyBase": 180, "xpCurveLateScale": 1.1, "autoReviveAfterMs": 3600000, "autoSellLegendary": 180, "combatDamageScale": 1.0, "debuffProcIceSlow": 0.2, "enemyRegenDefault": 0.0012, "enemyScaleBandAtk": 0.044, "enemyScaleBandDef": 0.038, "equipmentDropBase": 0.15, "heroCritChanceCap": 0.12, "potionHealPercent": 0.3, "questOffersPerNPC": 2, "roadsideRestMaxMs": 600000, "roadsideRestMinMs": 240000, "townArrivalRadius": 0.5, "xpCurveEarlyScale": 1.28, "adventureWildMaxMs": 2960000, "adventureWildMinMs": 560000, "autoEquipThreshold": 1.03, "buffChargePeriodMs": 86400000, "buffRefillPriceRub": 50, "enemyCritChanceCap": 0.2, "enemyScaleBandGold": 0.05, "heroBlockChanceCap": 0.2, "lootChanceUncommon": 0.1, "luckBuffMultiplier": 2.5, "movementTickRateMs": 1000, "positionSyncRateMs": 10000, "roadsideRestExitHp": 0.7, "roadsideRestGoInMs": 3200, "summonCycleSeconds": 18, "townNpcVisitChance": 0.78, "adventureCooldownMs": 300000, "adventureMaxLateral": 20, "combatDamageRollMax": 1.10, "combatDamageRollMin": 0.60, "enemyScaleOvercapHp": 0.031, "enemyScaleOvercapXp": 0.03, "lootChanceLegendary": 0.0005, "minAttackIntervalMs": 250, "npcCostNearbyRadius": 3, "roadsideRestLateral": 1.15, "summonDamageDivisor": 10, "townRestHpPerSecond": 0.002, "adventureStartChance": 0.0001, "combatPaceMultiplier": 14, "enemyBurstMultiplier": 1.5, "enemyChainMultiplier": 3, "enemyScaleOvercapAtk": 0.024, "enemyScaleOvercapDef": 0.020, "maxRevivesSubscriber": 2, "merchantCostPerLevel": 5, "rarityMultiplierEpic": 1.52, "rarityMultiplierRare": 1.3, "roadsideRestDepthMax": 25, "roadsideRestReturnMs": 3200, "roadsideThoughtMaxMs": 50000, "roadsideThoughtMinMs": 30000, "townNpcLogIntervalMs": 5000, "townNpcStandoffWorld": 0.65, "adventureRestTargetHp": 0.7, "encounterActivityBase": 0.035, "enemyScaleOvercapGold": 0.025, "startAdventurePerTick": 0.00003, "townNpcApproachChance": 1, "townNpcInteractChance": 0.65, "adventureDurationMaxMs": 1200000, "adventureDurationMinMs": 900000, "adventureOutDurationMs": 20000, "enemyCombatDamageScale": 1.0, "enemyCriticalMinChance": 0.15, "enemyRegenBattleLizard": 0.0005, "enemyRegenForestWarden": 0.00010, "enemyRegenSkeletonKing": 0.00003, "potionAutoUseThreshold": 0.3, "questOfferRefreshHours": 2, "rarityMultiplierCommon": 1, "restEncounterNpcChance": 0.1, "subscriptionDurationMs": 604800000, "townAfterNpcRestChance": 0.78, "encounterCooldownBaseMs": 12000, "restEncounterCooldownMs": 30000, "roadsideRestHpPerSecond": 0.003, "rollIlvlEliteBaseChance": 0.4, "adventureDepthWorldUnits": 20, "adventureRestHpPerSecond": 0.004, "enemyCombatDamageRollMax": 1.0, "enemyCombatDamageRollMin": 0.82, "rarityMultiplierUncommon": 1.12, "adventureReturnDurationMs": 20000, "adventureWanderSpeedRatio": 0.85, "heroBlockChancePerDefense": 0.0025, "merchantTownAutoSellShare": 0.3, "rarityMultiplierLegendary": 1.78, "adventureReturnWildnessMin": 0.35, "monsterEncounterWeightBase": 0.04, "resurrectionRefillPriceRub": 150, "rollIlvlElitePlusOneChance": 0.4, "subscriptionWeeklyPriceRub": 199, "merchantEncounterWeightBase": 0.002, "roadsideRestDepthWorldUnits": 12, "adventureEncounterCooldownMs": 6000, "freeBuffActivationsPerPeriod": 2, "enemyAttackIntervalMultiplier": 1.5, "adventureReturnEncounterEnabled": true, "adventureWildernessRampFraction": 0.12, "monsterEncounterWeightWildBonus": 0.18, "merchantEncounterWeightRoadBonus": 0.008, "wanderingMerchantPromptTimeoutMs": 15000, "adventureForwardSpeedWildFraction": 0.07}', '2026-03-31 16:27:14.86085+00'); --