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.

277 lines
7.3 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 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.18.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
}