|
|
package model
|
|
|
|
|
|
import (
|
|
|
"math/rand"
|
|
|
"time"
|
|
|
)
|
|
|
|
|
|
// 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"
|
|
|
)
|
|
|
|
|
|
// DropChance maps rarity to its drop probability (0.0 to 1.0).
|
|
|
var DropChance = map[Rarity]float64{
|
|
|
RarityCommon: 0.40,
|
|
|
RarityUncommon: 0.10,
|
|
|
RarityRare: 0.02,
|
|
|
RarityEpic: 0.003,
|
|
|
RarityLegendary: 0.0005,
|
|
|
}
|
|
|
|
|
|
// GoldRange defines minimum and maximum gold drops per rarity.
|
|
|
type GoldRange struct {
|
|
|
Min int64
|
|
|
Max int64
|
|
|
}
|
|
|
|
|
|
// GoldRanges maps rarity to gold drop ranges.
|
|
|
var GoldRanges = map[Rarity]GoldRange{
|
|
|
RarityCommon: {Min: 0, Max: 5},
|
|
|
RarityUncommon: {Min: 6, Max: 20},
|
|
|
RarityRare: {Min: 21, Max: 50},
|
|
|
RarityEpic: {Min: 51, Max: 120},
|
|
|
RarityLegendary: {Min: 121, Max: 300},
|
|
|
}
|
|
|
|
|
|
// 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"`
|
|
|
}
|
|
|
|
|
|
// AutoSellPrices maps rarity to the gold value obtained by auto-selling an
|
|
|
// equipment drop that the hero doesn't need.
|
|
|
var AutoSellPrices = map[Rarity]int64{
|
|
|
RarityCommon: 3,
|
|
|
RarityUncommon: 8,
|
|
|
RarityRare: 20,
|
|
|
RarityEpic: 60,
|
|
|
RarityLegendary: 180,
|
|
|
}
|
|
|
|
|
|
// 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 {
|
|
|
if roll < DropChance[RarityLegendary] {
|
|
|
return RarityLegendary
|
|
|
}
|
|
|
if roll < DropChance[RarityLegendary]+DropChance[RarityEpic] {
|
|
|
return RarityEpic
|
|
|
}
|
|
|
if roll < DropChance[RarityLegendary]+DropChance[RarityEpic]+DropChance[RarityRare] {
|
|
|
return RarityRare
|
|
|
}
|
|
|
if roll < DropChance[RarityLegendary]+DropChance[RarityEpic]+DropChance[RarityRare]+DropChance[RarityUncommon] {
|
|
|
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 {
|
|
|
gr, ok := GoldRanges[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)
|
|
|
}
|
|
|
// MVP balance: reduce gold loot rate vs spec table (plates longer progression).
|
|
|
const goldLootScale = 0.5
|
|
|
return int64(float64(n) * goldLootScale)
|
|
|
}
|
|
|
|
|
|
// 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,
|
|
|
})
|
|
|
|
|
|
// 5% chance to drop a healing potion (heals 30% of maxHP).
|
|
|
potionRoll := float01()
|
|
|
if potionRoll < 0.05 {
|
|
|
drops = append(drops, LootDrop{
|
|
|
ItemType: "potion",
|
|
|
Rarity: RarityCommon,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
equipRoll := float01()
|
|
|
equipChance := 0.15 * 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
|
|
|
}
|