|
|
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 {
|
|
|
return RollTownMerchantStockItemsForSlots(refLevel, count, model.AllEquipmentSlots)
|
|
|
}
|
|
|
|
|
|
// RollTownMerchantStockItemsForSlots rolls gear only from the given slots (e.g. per vendor type).
|
|
|
func RollTownMerchantStockItemsForSlots(refLevel int, count int, slots []model.EquipmentSlot) []*model.GearItem {
|
|
|
if count < 1 {
|
|
|
count = 1
|
|
|
}
|
|
|
if len(slots) == 0 {
|
|
|
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)
|
|
|
mean := float64(anchor) + ilvlPart
|
|
|
|
|
|
v := float64(variance) / 100.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 = int64(mean)
|
|
|
}
|
|
|
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
|
|
|
}
|