|
|
package model
|
|
|
|
|
|
import (
|
|
|
"math/rand"
|
|
|
"time"
|
|
|
|
|
|
"github.com/denisovdennis/autohero/internal/tuning"
|
|
|
)
|
|
|
|
|
|
// Rarity represents item rarity tiers. Shared across weapons, armor, and loot.
|
|
|
type Rarity string
|
|
|
|
|
|
const (
|
|
|
RarityCommon Rarity = "common"
|
|
|
RarityUncommon Rarity = "uncommon"
|
|
|
RarityRare Rarity = "rare"
|
|
|
RarityEpic Rarity = "epic"
|
|
|
RarityLegendary Rarity = "legendary"
|
|
|
)
|
|
|
|
|
|
// GoldRange defines minimum and maximum gold drops per rarity.
|
|
|
type GoldRange struct {
|
|
|
Min int64
|
|
|
Max int64
|
|
|
}
|
|
|
|
|
|
// LootDrop represents a single item or gold drop from defeating an enemy.
|
|
|
type LootDrop struct {
|
|
|
ItemType string `json:"itemType"` // "gold", "potion", or EquipmentSlot ("main_hand", "chest", "head", etc.)
|
|
|
ItemID int64 `json:"itemId,omitempty"` // ID of the weapon or armor, 0 for gold
|
|
|
ItemName string `json:"itemName,omitempty"` // display name when equipped / dropped
|
|
|
Rarity Rarity `json:"rarity"`
|
|
|
GoldAmount int64 `json:"goldAmount,omitempty"` // gold value of this drop
|
|
|
}
|
|
|
|
|
|
// LootHistory records a loot drop event for audit/analytics.
|
|
|
type LootHistory struct {
|
|
|
ID int64 `json:"id"`
|
|
|
HeroID int64 `json:"heroId"`
|
|
|
EnemyType string `json:"enemyType"`
|
|
|
ItemType string `json:"itemType"`
|
|
|
ItemID int64 `json:"itemId,omitempty"`
|
|
|
Rarity Rarity `json:"rarity"`
|
|
|
GoldAmount int64 `json:"goldAmount"`
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
}
|
|
|
|
|
|
// RollRarity rolls a random rarity based on the drop chance table.
|
|
|
// It uses a cumulative probability approach, checking from rarest to most common.
|
|
|
func RollRarity() Rarity {
|
|
|
return RarityFromRoll(rand.Float64())
|
|
|
}
|
|
|
|
|
|
// RarityFromRoll maps a uniform [0,1) value to a rarity tier (spec §8.1 drop bands).
|
|
|
func RarityFromRoll(roll float64) Rarity {
|
|
|
cfg := tuning.Get()
|
|
|
if roll < cfg.LootChanceLegendary {
|
|
|
return RarityLegendary
|
|
|
}
|
|
|
if roll < cfg.LootChanceLegendary+cfg.LootChanceEpic {
|
|
|
return RarityEpic
|
|
|
}
|
|
|
if roll < cfg.LootChanceLegendary+cfg.LootChanceEpic+cfg.LootChanceRare {
|
|
|
return RarityRare
|
|
|
}
|
|
|
if roll < cfg.LootChanceLegendary+cfg.LootChanceEpic+cfg.LootChanceRare+cfg.LootChanceUncommon {
|
|
|
return RarityUncommon
|
|
|
}
|
|
|
return RarityCommon
|
|
|
}
|
|
|
|
|
|
// RollGold returns a random gold amount for the given rarity.
|
|
|
func RollGold(rarity Rarity) int64 {
|
|
|
return RollGoldWithRNG(rarity, nil)
|
|
|
}
|
|
|
|
|
|
// RollGoldWithRNG returns spec §8.2 gold for a rarity tier; if rng is nil, uses the global RNG.
|
|
|
func RollGoldWithRNG(rarity Rarity, rng *rand.Rand) int64 {
|
|
|
cfg := tuning.Get()
|
|
|
gr, ok := GoldRangeForRarity(cfg, rarity)
|
|
|
if !ok {
|
|
|
return 0
|
|
|
}
|
|
|
if gr.Max <= gr.Min {
|
|
|
return gr.Min
|
|
|
}
|
|
|
var n int64
|
|
|
if rng == nil {
|
|
|
n = gr.Min + rand.Int63n(gr.Max-gr.Min+1)
|
|
|
} else {
|
|
|
n = gr.Min + rng.Int63n(gr.Max-gr.Min+1)
|
|
|
}
|
|
|
out := int64(float64(n) * cfg.GoldLootScale)
|
|
|
if out < 1 {
|
|
|
out = 1
|
|
|
}
|
|
|
return out
|
|
|
}
|
|
|
|
|
|
func GoldRangeForRarity(cfg tuning.Values, rarity Rarity) (GoldRange, bool) {
|
|
|
switch rarity {
|
|
|
case RarityCommon:
|
|
|
return GoldRange{Min: cfg.GoldCommonMin, Max: cfg.GoldCommonMax}, true
|
|
|
case RarityUncommon:
|
|
|
return GoldRange{Min: cfg.GoldUncommonMin, Max: cfg.GoldUncommonMax}, true
|
|
|
case RarityRare:
|
|
|
return GoldRange{Min: cfg.GoldRareMin, Max: cfg.GoldRareMax}, true
|
|
|
case RarityEpic:
|
|
|
return GoldRange{Min: cfg.GoldEpicMin, Max: cfg.GoldEpicMax}, true
|
|
|
case RarityLegendary:
|
|
|
return GoldRange{Min: cfg.GoldLegendaryMin, Max: cfg.GoldLegendaryMax}, true
|
|
|
default:
|
|
|
return GoldRange{}, false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// equipmentLootSlots maps loot ItemType strings to relative weights.
|
|
|
// All item types now use unified EquipmentSlot names.
|
|
|
var equipmentLootSlots = []struct {
|
|
|
itemType string
|
|
|
weight float64
|
|
|
}{
|
|
|
// Weights must sum to 1.0 so rollEquipmentLootItemType does not bias the last slot.
|
|
|
{string(SlotMainHand), 0.1},
|
|
|
{string(SlotChest), 0.1},
|
|
|
{string(SlotHead), 0.1},
|
|
|
{string(SlotFeet), 0.1},
|
|
|
{string(SlotNeck), 0.1},
|
|
|
{string(SlotHands), 0.1},
|
|
|
{string(SlotLegs), 0.1},
|
|
|
{string(SlotCloak), 0.1},
|
|
|
{string(SlotFinger), 0.1},
|
|
|
{string(SlotWrist), 0.1},
|
|
|
}
|
|
|
|
|
|
func rollEquipmentLootItemType(float01 func() float64) string {
|
|
|
r := float01()
|
|
|
var acc float64
|
|
|
for _, row := range equipmentLootSlots {
|
|
|
acc += row.weight
|
|
|
if r < acc {
|
|
|
return row.itemType
|
|
|
}
|
|
|
}
|
|
|
return equipmentLootSlots[len(equipmentLootSlots)-1].itemType
|
|
|
}
|
|
|
|
|
|
// GenerateLoot builds a loot roll for an enemy (preview / tests).
|
|
|
// Gold: rolled with GoldDropChance×luck (capped at 1); if it succeeds, rarity/amount use spec §8.1–8.2.
|
|
|
// Equipment: one extra roll uses EquipmentDropBase×luck; slot uses equipmentLootSlots weights.
|
|
|
// enemySlug is the unique template id (enemies.type); reserved for per-enemy tuning.
|
|
|
func GenerateLoot(enemySlug string, luckMultiplier float64) []LootDrop {
|
|
|
return GenerateLootWithRNG(enemySlug, luckMultiplier, nil)
|
|
|
}
|
|
|
|
|
|
// GenerateLootWithRNG is GenerateLoot with an optional RNG for deterministic tests.
|
|
|
func GenerateLootWithRNG(enemySlug string, luckMultiplier float64, rng *rand.Rand) []LootDrop {
|
|
|
_ = enemySlug
|
|
|
var drops []LootDrop
|
|
|
|
|
|
float01 := func() float64 {
|
|
|
if rng == nil {
|
|
|
return rand.Float64()
|
|
|
}
|
|
|
return rng.Float64()
|
|
|
}
|
|
|
|
|
|
cfg := tuning.Get()
|
|
|
goldDropChance := cfg.GoldDropChance * luckMultiplier
|
|
|
if goldDropChance > 1 {
|
|
|
goldDropChance = 1
|
|
|
}
|
|
|
if goldDropChance < 0 {
|
|
|
goldDropChance = 0
|
|
|
}
|
|
|
if float01() < goldDropChance {
|
|
|
goldRarity := RarityFromRoll(float01())
|
|
|
goldAmount := RollGoldWithRNG(goldRarity, rng)
|
|
|
if luckMultiplier > 1 {
|
|
|
goldAmount = int64(float64(goldAmount) * luckMultiplier)
|
|
|
}
|
|
|
drops = append(drops, LootDrop{
|
|
|
ItemType: "gold",
|
|
|
Rarity: goldRarity,
|
|
|
GoldAmount: goldAmount,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// Configurable chance to drop a healing potion.
|
|
|
potionRoll := float01()
|
|
|
if potionRoll < cfg.PotionDropChance {
|
|
|
drops = append(drops, LootDrop{
|
|
|
ItemType: "potion",
|
|
|
Rarity: RarityCommon,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
equipRoll := float01()
|
|
|
equipChance := cfg.EquipmentDropBase * luckMultiplier
|
|
|
if equipChance > 1 {
|
|
|
equipChance = 1
|
|
|
}
|
|
|
|
|
|
if equipRoll < equipChance {
|
|
|
itemRarity := RarityFromRoll(float01())
|
|
|
itemType := rollEquipmentLootItemType(float01)
|
|
|
drops = append(drops, LootDrop{
|
|
|
ItemType: itemType,
|
|
|
Rarity: itemRarity,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
return drops
|
|
|
}
|
|
|
|
|
|
func AutoSellPrice(rarity Rarity) int64 {
|
|
|
cfg := tuning.Get()
|
|
|
switch rarity {
|
|
|
case RarityCommon:
|
|
|
return cfg.AutoSellCommon
|
|
|
case RarityUncommon:
|
|
|
return cfg.AutoSellUncommon
|
|
|
case RarityRare:
|
|
|
return cfg.AutoSellRare
|
|
|
case RarityEpic:
|
|
|
return cfg.AutoSellEpic
|
|
|
case RarityLegendary:
|
|
|
return cfg.AutoSellLegendary
|
|
|
default:
|
|
|
return 0
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// SumGoldFromLootDrops sums gold entries from ApplyVictoryRewards output.
|
|
|
func SumGoldFromLootDrops(drops []LootDrop) int64 {
|
|
|
var s int64
|
|
|
for _, d := range drops {
|
|
|
if d.ItemType == "gold" {
|
|
|
s += d.GoldAmount
|
|
|
}
|
|
|
}
|
|
|
return s
|
|
|
}
|
|
|
|
|
|
// LootDropsToLootItems builds combat_end loot lines (equipment/potion; gold is in GoldGained).
|
|
|
func LootDropsToLootItems(drops []LootDrop) []LootItem {
|
|
|
if len(drops) == 0 {
|
|
|
return nil
|
|
|
}
|
|
|
out := make([]LootItem, 0, len(drops))
|
|
|
for _, d := range drops {
|
|
|
switch d.ItemType {
|
|
|
case "gold":
|
|
|
continue
|
|
|
case "potion":
|
|
|
out = append(out, LootItem{
|
|
|
ItemType: "potion",
|
|
|
Name: "Healing potion",
|
|
|
Rarity: string(d.Rarity),
|
|
|
})
|
|
|
default:
|
|
|
if d.ItemName == "" {
|
|
|
continue
|
|
|
}
|
|
|
out = append(out, LootItem{
|
|
|
ItemType: d.ItemType,
|
|
|
Name: d.ItemName,
|
|
|
Rarity: string(d.Rarity),
|
|
|
})
|
|
|
}
|
|
|
}
|
|
|
if len(out) == 0 {
|
|
|
return nil
|
|
|
}
|
|
|
return out
|
|
|
}
|