You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

176 lines
5.2 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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)
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 [1v, 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
}