package game import ( "context" "errors" "fmt" "math/rand" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" ) // RollTownMerchantStockItems generates `count` gear rows for town-tier stock (one roll per slot order, unique slots first). func RollTownMerchantStockItems(refLevel int, count int) []*model.GearItem { if count < 1 { count = 1 } slots := model.AllEquipmentSlots if count > len(slots) { count = len(slots) } perm := rand.Perm(len(slots)) out := make([]*model.GearItem, 0, count) for i := 0; i < count; i++ { slot := slots[perm[i]] family := model.PickGearFamily(slot) if family == nil { continue } rarity := model.RollRarity() ilvl := model.RollIlvl(refLevel, false) out = append(out, model.NewGearItem(family, ilvl, rarity)) } for len(out) < count { slot := slots[rand.Intn(len(slots))] family := model.PickGearFamily(slot) if family == nil { continue } rarity := model.RollRarity() ilvl := model.RollIlvl(refLevel, false) out = append(out, model.NewGearItem(family, ilvl, rarity)) } return out } func townMerchantRarityPriceMul(r model.Rarity) float64 { switch r { case model.RarityLegendary: return 3.4 case model.RarityEpic: return 2.25 case model.RarityRare: return 1.65 case model.RarityUncommon: return 1.28 default: return 1.0 } } // RollTownMerchantOfferGold returns a per-item list buy price: town anchor + ilvl (× rarity), then uniform ±variance%. // Inventory sell prices stay on model.AutoSellPrice (runtime autoSell*); they are not derived from this value. func RollTownMerchantOfferGold(ilvl int, rarity model.Rarity, townLevel int) int64 { if ilvl < 1 { ilvl = 1 } anchor := tuning.EffectiveTownMerchantGearCost(townLevel) perIlvl := tuning.EffectiveMerchantTownGearPricePerIlvl() variance := tuning.EffectiveMerchantTownGearPriceVariancePct() ilvlPart := float64(ilvl) * float64(perIlvl) * townMerchantRarityPriceMul(rarity) 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 } // Uniform in [1−v, 1+v] (e.g. v=0.15 → 85%..115% of mean). factor := (1.0 - v) + rand.Float64()*(2*v) cost := int64(mean*factor + 0.5) if cost < 1 { cost = 1 } return cost } // ApplyPreparedTownMerchantPurchase persists a rolled item (id 0) and force-equips it. func ApplyPreparedTownMerchantPurchase(ctx context.Context, gs *storage.GearStore, hero *model.Hero, item *model.GearItem, now time.Time) (*model.LootDrop, error) { if gs == nil || hero == nil || item == nil { return nil, errors.New("nil gear store, hero, or item") } toCreate := model.CloneGearItem(item) if toCreate == nil { 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) cancelEq() if err != nil { ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second) _ = gs.DeleteGearItem(ctxDel, toCreate.ID) cancelDel() return nil, err } ctxLoad, cancelLoad := context.WithTimeout(ctx, 2*time.Second) gear, err := gs.GetHeroGear(ctxLoad, hero.ID) cancelLoad() if err != nil { return nil, fmt.Errorf("reload gear: %w", err) } ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second) inv, err := gs.GetHeroInventory(ctxInv, hero.ID) cancelInv() if err != nil { return nil, fmt.Errorf("reload inventory: %w", err) } hero.Gear = gear hero.Inventory = inv hero.RefreshDerivedCombatStats(now) return &model.LootDrop{ ItemType: string(toCreate.Slot), ItemID: toCreate.ID, ItemName: toCreate.Name, Rarity: toCreate.Rarity, }, nil } // ApplyTownMerchantGearPurchase rolls one gear piece using refLevel for ilvl (town tier), // persists it, and force-equips into the hero slot (previous piece moves to backpack — same as admin EquipItem). func ApplyTownMerchantGearPurchase(ctx context.Context, gs *storage.GearStore, hero *model.Hero, refLevel int, now time.Time) (*model.LootDrop, error) { items := RollTownMerchantStockItems(refLevel, 1) if len(items) == 0 { return nil, errors.New("failed to roll gear family") } return ApplyPreparedTownMerchantPurchase(ctx, gs, hero, items[0], now) } // TownMerchantRollIsUpgrade returns true if equipping the rolled item (hypothetically) raises CombatRatingAt. func TownMerchantRollIsUpgrade(hero *model.Hero, item *model.GearItem, now time.Time) bool { if hero == nil || item == nil { return false } hero.EnsureGearMap() before := hero.CombatRatingAt(now) old := hero.Gear[item.Slot] hero.Gear[item.Slot] = item after := hero.CombatRatingAt(now) hero.Gear[item.Slot] = old return after > before }