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 }{ {string(SlotMainHand), 0.05}, {string(SlotChest), 0.05}, {string(SlotHead), 0.05}, {string(SlotFeet), 0.05}, {string(SlotNeck), 0.05}, {string(SlotHands), 0.05}, {string(SlotLegs), 0.05}, {string(SlotCloak), 0.05}, {string(SlotFinger), 0.05}, {string(SlotWrist), 0.05}, } 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 generates loot drops from defeating an enemy (preview / tests). // Guaranteed gold uses a spec rarity band; optional equipment is independent and does not replace gold. func GenerateLoot(enemyType EnemyType, luckMultiplier float64) []LootDrop { return GenerateLootWithRNG(enemyType, luckMultiplier, nil) } // GenerateLootWithRNG is GenerateLoot with an optional RNG for deterministic tests. func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.Rand) []LootDrop { var drops []LootDrop float01 := func() float64 { if rng == nil { return rand.Float64() } return rng.Float64() } // Gold tier roll (spec §8.1–8.2); independent of whether an item drops later. 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, }) cfg := tuning.Get() // 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 } }