Skip to main content

proof_engine/procedural/
items.rs

1//! Item generation — bases, affixes, uniques, sets, enchantments, loot drops.
2//!
3//! Provides a complete Diablo-style item generation pipeline:
4//! base items → affix pool → random rolls → unique/set items → enchantments → loot tables.
5
6use super::Rng;
7use std::collections::HashMap;
8
9// ── StatKind ──────────────────────────────────────────────────────────────────
10
11/// Stat kinds that affixes and enchantments can modify.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum StatKind {
14    Damage,
15    Defense,
16    Health,
17    Mana,
18    Speed,
19    CritChance,
20    CritDamage,
21    FireResist,
22    ColdResist,
23    LightningResist,
24    PoisonResist,
25    LifeSteal,
26    ManaSteal,
27    Thorns,
28    GoldFind,
29    MagicFind,
30    CooldownReduction,
31    AttackSpeed,
32    BlockChance,
33    Dodge,
34}
35
36// ── ItemType ──────────────────────────────────────────────────────────────────
37
38/// Category of an item.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum ItemType {
41    Sword,
42    Axe,
43    Mace,
44    Dagger,
45    Staff,
46    Bow,
47    Shield,
48    Helmet,
49    ChestArmor,
50    Gloves,
51    Boots,
52    Belt,
53    Ring,
54    Amulet,
55    Wand,
56    Quiver,
57}
58
59impl ItemType {
60    pub fn is_weapon(&self) -> bool {
61        matches!(self, ItemType::Sword | ItemType::Axe | ItemType::Mace | ItemType::Dagger
62                     | ItemType::Staff | ItemType::Bow | ItemType::Wand | ItemType::Quiver)
63    }
64
65    pub fn is_armor(&self) -> bool {
66        matches!(self, ItemType::Shield | ItemType::Helmet | ItemType::ChestArmor
67                     | ItemType::Gloves | ItemType::Boots | ItemType::Belt)
68    }
69
70    pub fn is_jewelry(&self) -> bool {
71        matches!(self, ItemType::Ring | ItemType::Amulet)
72    }
73}
74
75// ── Rarity ────────────────────────────────────────────────────────────────────
76
77/// Item rarity tier.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
79pub enum Rarity {
80    Common,
81    Magic,
82    Rare,
83    Epic,
84    Legendary,
85}
86
87impl Rarity {
88    pub fn affix_count_range(&self) -> (usize, usize) {
89        match self {
90            Rarity::Common    => (0, 0),
91            Rarity::Magic     => (1, 2),
92            Rarity::Rare      => (2, 4),
93            Rarity::Epic      => (3, 5),
94            Rarity::Legendary => (4, 6),
95        }
96    }
97
98    pub fn color_code(&self) -> &'static str {
99        match self {
100            Rarity::Common    => "white",
101            Rarity::Magic     => "blue",
102            Rarity::Rare      => "yellow",
103            Rarity::Epic      => "purple",
104            Rarity::Legendary => "orange",
105        }
106    }
107}
108
109// ── ItemBase ──────────────────────────────────────────────────────────────────
110
111/// Base definition of an item (before affixes).
112#[derive(Debug, Clone)]
113pub struct ItemBase {
114    pub name:          &'static str,
115    pub item_type:     ItemType,
116    pub base_damage:   f32,
117    pub base_defense:  f32,
118    pub base_value:    u32,
119    pub weight:        f32,
120    pub rarity:        Rarity,
121    pub glyph:         char,
122    /// Minimum item level required to find this base.
123    pub required_level: u32,
124}
125
126impl ItemBase {
127    /// Return the built-in pool of base items.
128    pub fn pool() -> &'static [ItemBase] {
129        &BASE_POOL
130    }
131
132    /// Filter bases appropriate for a given item level.
133    pub fn for_level(level: u32) -> Vec<&'static ItemBase> {
134        BASE_POOL.iter().filter(|b| b.required_level <= level).collect()
135    }
136}
137
138// Static base pool (lazy-initialised via once_cell-free approach with const)
139static BASE_POOL: [ItemBase; 32] = [
140    // Swords
141    ItemBase { name: "Rusty Sword",      item_type: ItemType::Sword,      base_damage: 5.0,  base_defense: 0.0,  base_value: 10,   weight: 3.0, rarity: Rarity::Common,    glyph: '/', required_level: 1  },
142    ItemBase { name: "Short Sword",      item_type: ItemType::Sword,      base_damage: 10.0, base_defense: 0.0,  base_value: 50,   weight: 3.5, rarity: Rarity::Common,    glyph: '/', required_level: 3  },
143    ItemBase { name: "Long Sword",       item_type: ItemType::Sword,      base_damage: 18.0, base_defense: 0.0,  base_value: 150,  weight: 5.0, rarity: Rarity::Common,    glyph: '/', required_level: 8  },
144    ItemBase { name: "Broadsword",       item_type: ItemType::Sword,      base_damage: 25.0, base_defense: 0.0,  base_value: 300,  weight: 6.0, rarity: Rarity::Common,    glyph: '/', required_level: 15 },
145    // Axes
146    ItemBase { name: "Hand Axe",         item_type: ItemType::Axe,        base_damage: 8.0,  base_defense: 0.0,  base_value: 30,   weight: 4.0, rarity: Rarity::Common,    glyph: 'T', required_level: 2  },
147    ItemBase { name: "Battle Axe",       item_type: ItemType::Axe,        base_damage: 20.0, base_defense: 0.0,  base_value: 200,  weight: 7.0, rarity: Rarity::Common,    glyph: 'T', required_level: 10 },
148    // Daggers
149    ItemBase { name: "Dagger",           item_type: ItemType::Dagger,     base_damage: 6.0,  base_defense: 0.0,  base_value: 20,   weight: 1.0, rarity: Rarity::Common,    glyph: '-', required_level: 1  },
150    ItemBase { name: "Stiletto",         item_type: ItemType::Dagger,     base_damage: 14.0, base_defense: 0.0,  base_value: 120,  weight: 1.5, rarity: Rarity::Common,    glyph: '-', required_level: 7  },
151    // Staves
152    ItemBase { name: "Wooden Staff",     item_type: ItemType::Staff,      base_damage: 7.0,  base_defense: 0.0,  base_value: 25,   weight: 4.0, rarity: Rarity::Common,    glyph: '|', required_level: 1  },
153    ItemBase { name: "Arcane Staff",     item_type: ItemType::Staff,      base_damage: 22.0, base_defense: 0.0,  base_value: 400,  weight: 4.5, rarity: Rarity::Common,    glyph: '|', required_level: 12 },
154    // Bows
155    ItemBase { name: "Short Bow",        item_type: ItemType::Bow,        base_damage: 9.0,  base_defense: 0.0,  base_value: 40,   weight: 2.0, rarity: Rarity::Common,    glyph: ')', required_level: 3  },
156    ItemBase { name: "Long Bow",         item_type: ItemType::Bow,        base_damage: 17.0, base_defense: 0.0,  base_value: 180,  weight: 2.5, rarity: Rarity::Common,    glyph: ')', required_level: 9  },
157    // Shields
158    ItemBase { name: "Buckler",          item_type: ItemType::Shield,     base_damage: 0.0,  base_defense: 8.0,  base_value: 40,   weight: 3.0, rarity: Rarity::Common,    glyph: 'o', required_level: 1  },
159    ItemBase { name: "Tower Shield",     item_type: ItemType::Shield,     base_damage: 0.0,  base_defense: 25.0, base_value: 350,  weight: 9.0, rarity: Rarity::Common,    glyph: 'O', required_level: 12 },
160    // Helms
161    ItemBase { name: "Leather Cap",      item_type: ItemType::Helmet,     base_damage: 0.0,  base_defense: 5.0,  base_value: 20,   weight: 1.0, rarity: Rarity::Common,    glyph: 'n', required_level: 1  },
162    ItemBase { name: "Iron Helm",        item_type: ItemType::Helmet,     base_damage: 0.0,  base_defense: 15.0, base_value: 150,  weight: 4.0, rarity: Rarity::Common,    glyph: 'n', required_level: 8  },
163    ItemBase { name: "Great Helm",       item_type: ItemType::Helmet,     base_damage: 0.0,  base_defense: 25.0, base_value: 400,  weight: 6.0, rarity: Rarity::Common,    glyph: 'N', required_level: 16 },
164    // Chest
165    ItemBase { name: "Leather Armor",    item_type: ItemType::ChestArmor, base_damage: 0.0,  base_defense: 10.0, base_value: 50,   weight: 5.0, rarity: Rarity::Common,    glyph: '[', required_level: 1  },
166    ItemBase { name: "Chain Mail",       item_type: ItemType::ChestArmor, base_damage: 0.0,  base_defense: 20.0, base_value: 200,  weight: 10.0,rarity: Rarity::Common,    glyph: '[', required_level: 8  },
167    ItemBase { name: "Plate Armor",      item_type: ItemType::ChestArmor, base_damage: 0.0,  base_defense: 40.0, base_value: 800,  weight: 18.0,rarity: Rarity::Common,    glyph: '[', required_level: 20 },
168    // Gloves
169    ItemBase { name: "Cloth Gloves",     item_type: ItemType::Gloves,     base_damage: 0.0,  base_defense: 3.0,  base_value: 15,   weight: 0.5, rarity: Rarity::Common,    glyph: '(', required_level: 1  },
170    ItemBase { name: "Iron Gauntlets",   item_type: ItemType::Gloves,     base_damage: 0.0,  base_defense: 10.0, base_value: 100,  weight: 2.0, rarity: Rarity::Common,    glyph: '(', required_level: 8  },
171    // Boots
172    ItemBase { name: "Cloth Shoes",      item_type: ItemType::Boots,      base_damage: 0.0,  base_defense: 3.0,  base_value: 15,   weight: 0.5, rarity: Rarity::Common,    glyph: 'U', required_level: 1  },
173    ItemBase { name: "Iron Boots",       item_type: ItemType::Boots,      base_damage: 0.0,  base_defense: 12.0, base_value: 120,  weight: 3.0, rarity: Rarity::Common,    glyph: 'U', required_level: 8  },
174    // Belt
175    ItemBase { name: "Leather Belt",     item_type: ItemType::Belt,       base_damage: 0.0,  base_defense: 4.0,  base_value: 20,   weight: 0.5, rarity: Rarity::Common,    glyph: '=', required_level: 1  },
176    // Rings & Amulets
177    ItemBase { name: "Copper Ring",      item_type: ItemType::Ring,       base_damage: 0.0,  base_defense: 0.0,  base_value: 30,   weight: 0.1, rarity: Rarity::Common,    glyph: '°', required_level: 1  },
178    ItemBase { name: "Silver Ring",      item_type: ItemType::Ring,       base_damage: 0.0,  base_defense: 0.0,  base_value: 100,  weight: 0.1, rarity: Rarity::Common,    glyph: '°', required_level: 5  },
179    ItemBase { name: "Simple Amulet",    item_type: ItemType::Amulet,     base_damage: 0.0,  base_defense: 0.0,  base_value: 50,   weight: 0.2, rarity: Rarity::Common,    glyph: '♦', required_level: 1  },
180    ItemBase { name: "Jade Amulet",      item_type: ItemType::Amulet,     base_damage: 0.0,  base_defense: 0.0,  base_value: 200,  weight: 0.2, rarity: Rarity::Common,    glyph: '♦', required_level: 8  },
181    // Wands
182    ItemBase { name: "Gnarled Wand",     item_type: ItemType::Wand,       base_damage: 8.0,  base_defense: 0.0,  base_value: 40,   weight: 1.5, rarity: Rarity::Common,    glyph: '!', required_level: 2  },
183    // Mace
184    ItemBase { name: "Club",             item_type: ItemType::Mace,       base_damage: 7.0,  base_defense: 0.0,  base_value: 15,   weight: 4.0, rarity: Rarity::Common,    glyph: '\\',required_level: 1  },
185    ItemBase { name: "War Hammer",       item_type: ItemType::Mace,       base_damage: 28.0, base_defense: 0.0,  base_value: 450,  weight: 9.0, rarity: Rarity::Common,    glyph: '\\',required_level: 18 },
186];
187
188// ── Affix ─────────────────────────────────────────────────────────────────────
189
190/// Whether an affix appears before or after the base item name.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum AffixType {
193    Prefix,
194    Suffix,
195}
196
197/// A stat modifier applied by an affix.
198#[derive(Debug, Clone)]
199pub struct Affix {
200    pub name:           &'static str,
201    pub affix_type:     AffixType,
202    pub stat_modifiers: Vec<(StatKind, f32)>,
203    pub required_level: u32,
204    /// Spawn weight (higher = more common).
205    pub weight:         f32,
206    /// Which item types can receive this affix (empty = all).
207    pub allowed_types:  Vec<ItemType>,
208}
209
210impl Affix {
211    fn new_prefix(
212        name: &'static str,
213        mods: Vec<(StatKind, f32)>,
214        req: u32,
215        w: f32,
216    ) -> Self {
217        Self { name, affix_type: AffixType::Prefix, stat_modifiers: mods, required_level: req, weight: w, allowed_types: Vec::new() }
218    }
219
220    fn new_suffix(
221        name: &'static str,
222        mods: Vec<(StatKind, f32)>,
223        req: u32,
224        w: f32,
225    ) -> Self {
226        Self { name, affix_type: AffixType::Suffix, stat_modifiers: mods, required_level: req, weight: w, allowed_types: Vec::new() }
227    }
228}
229
230// ── AffixPool ─────────────────────────────────────────────────────────────────
231
232/// Weighted pool of affixes for rolling item modifiers.
233pub struct AffixPool {
234    prefixes: Vec<Affix>,
235    suffixes: Vec<Affix>,
236}
237
238impl AffixPool {
239    /// Create the built-in pool with 60+ affixes.
240    pub fn default_pool() -> Self {
241        let prefixes = vec![
242            Affix::new_prefix("Sturdy",      vec![(StatKind::Defense,    5.0)],  1,  10.0),
243            Affix::new_prefix("Reinforced",  vec![(StatKind::Defense,   12.0)],  5,  8.0),
244            Affix::new_prefix("Fortified",   vec![(StatKind::Defense,   25.0)],  12, 5.0),
245            Affix::new_prefix("Iron",        vec![(StatKind::Defense,   40.0)],  20, 3.0),
246            Affix::new_prefix("Brutal",      vec![(StatKind::Damage,     5.0)],  1,  10.0),
247            Affix::new_prefix("Vicious",     vec![(StatKind::Damage,    12.0)],  5,  8.0),
248            Affix::new_prefix("Savage",      vec![(StatKind::Damage,    22.0)],  12, 5.0),
249            Affix::new_prefix("Merciless",   vec![(StatKind::Damage,    38.0)],  20, 3.0),
250            Affix::new_prefix("Vitalized",   vec![(StatKind::Health,    20.0)],  1,  10.0),
251            Affix::new_prefix("Vigorous",    vec![(StatKind::Health,    50.0)],  6,  7.0),
252            Affix::new_prefix("Hale",        vec![(StatKind::Health,   100.0)],  14, 4.0),
253            Affix::new_prefix("Juggernaut",  vec![(StatKind::Health,   200.0)],  25, 2.0),
254            Affix::new_prefix("Quick",       vec![(StatKind::Speed,     0.05)],  3,  9.0),
255            Affix::new_prefix("Swift",       vec![(StatKind::Speed,     0.12)],  10, 6.0),
256            Affix::new_prefix("Blazing",     vec![(StatKind::FireResist, 10.0)], 3,  8.0),
257            Affix::new_prefix("Glacial",     vec![(StatKind::ColdResist, 10.0)], 3,  8.0),
258            Affix::new_prefix("Static",      vec![(StatKind::LightningResist, 10.0)], 3, 8.0),
259            Affix::new_prefix("Toxic",       vec![(StatKind::PoisonResist, 10.0)], 3, 8.0),
260            Affix::new_prefix("Arcane",      vec![(StatKind::Mana, 30.0)],       5,  7.0),
261            Affix::new_prefix("Mystic",      vec![(StatKind::Mana, 70.0)],      12,  5.0),
262            Affix::new_prefix("Ethereal",    vec![(StatKind::MagicFind, 5.0)],   8,  6.0),
263            Affix::new_prefix("Spectral",    vec![(StatKind::MagicFind, 15.0)], 18,  4.0),
264            Affix::new_prefix("Lucky",       vec![(StatKind::GoldFind,  10.0)],  1, 10.0),
265            Affix::new_prefix("Gilded",      vec![(StatKind::GoldFind,  25.0)],  8,  6.0),
266            Affix::new_prefix("Spiked",      vec![(StatKind::Thorns,    8.0)],   5,  7.0),
267            Affix::new_prefix("Barbed",      vec![(StatKind::Thorns,   20.0)],  14,  4.0),
268            Affix::new_prefix("Precise",     vec![(StatKind::CritChance, 0.03)], 6,  7.0),
269            Affix::new_prefix("Deadly",      vec![(StatKind::CritChance, 0.07)],15,  4.0),
270            Affix::new_prefix("Lethal",      vec![(StatKind::CritDamage, 0.15)], 8,  6.0),
271            Affix::new_prefix("Annihilating",vec![(StatKind::CritDamage, 0.30)],20,  3.0),
272            Affix::new_prefix("Hasty",       vec![(StatKind::AttackSpeed, 0.05)],4,  8.0),
273            Affix::new_prefix("Rapid",       vec![(StatKind::AttackSpeed, 0.12)],12, 5.0),
274        ];
275
276        let suffixes = vec![
277            Affix::new_suffix("of Might",       vec![(StatKind::Damage,    10.0)], 1,  10.0),
278            Affix::new_suffix("of Power",        vec![(StatKind::Damage,    22.0)], 8,   7.0),
279            Affix::new_suffix("of Devastation",  vec![(StatKind::Damage,    40.0)],18,   4.0),
280            Affix::new_suffix("of Warding",      vec![(StatKind::Defense,    8.0)], 1,  10.0),
281            Affix::new_suffix("of Protection",   vec![(StatKind::Defense,   18.0)], 7,   7.0),
282            Affix::new_suffix("of the Colossus", vec![(StatKind::Defense,   35.0)],16,   4.0),
283            Affix::new_suffix("of Life",         vec![(StatKind::Health,    25.0)], 1,  10.0),
284            Affix::new_suffix("of Vitality",     vec![(StatKind::Health,    60.0)], 8,   7.0),
285            Affix::new_suffix("of Immortality",  vec![(StatKind::Health,   120.0)],18,   3.0),
286            Affix::new_suffix("of the Mind",     vec![(StatKind::Mana,      20.0)], 1,  10.0),
287            Affix::new_suffix("of Intellect",    vec![(StatKind::Mana,      50.0)], 8,   7.0),
288            Affix::new_suffix("of Brilliance",   vec![(StatKind::Mana,     100.0)],18,   4.0),
289            Affix::new_suffix("of Flame",        vec![(StatKind::FireResist, 8.0)], 2,   9.0),
290            Affix::new_suffix("of Frost",        vec![(StatKind::ColdResist, 8.0)], 2,   9.0),
291            Affix::new_suffix("of Thunder",      vec![(StatKind::LightningResist, 8.0)], 2, 9.0),
292            Affix::new_suffix("of Venom",        vec![(StatKind::PoisonResist, 8.0)], 2, 9.0),
293            Affix::new_suffix("of the Hawk",     vec![(StatKind::CritChance, 0.04)], 6,  7.0),
294            Affix::new_suffix("of Precision",    vec![(StatKind::CritChance, 0.08)],15,  4.0),
295            Affix::new_suffix("of Slaughter",    vec![(StatKind::CritDamage, 0.12)], 8,  6.0),
296            Affix::new_suffix("of Carnage",      vec![(StatKind::CritDamage, 0.25)],18,  3.0),
297            Affix::new_suffix("of Speed",        vec![(StatKind::Speed,       0.06)],4,  8.0),
298            Affix::new_suffix("of Haste",        vec![(StatKind::Speed,       0.15)],12, 5.0),
299            Affix::new_suffix("of the Vampire",  vec![(StatKind::LifeSteal,   0.03)],8,  6.0),
300            Affix::new_suffix("of Draining",     vec![(StatKind::ManaSteal,   0.03)],8,  6.0),
301            Affix::new_suffix("of Thorns",       vec![(StatKind::Thorns,     12.0)], 6,  7.0),
302            Affix::new_suffix("of Fortune",      vec![(StatKind::GoldFind,   15.0)], 1, 10.0),
303            Affix::new_suffix("of the Mage",     vec![(StatKind::MagicFind,  10.0)], 6,  7.0),
304            Affix::new_suffix("of Focus",        vec![(StatKind::CooldownReduction, 0.05)], 10, 6.0),
305            Affix::new_suffix("of Blocking",     vec![(StatKind::BlockChance, 0.05)], 5,  7.0),
306            Affix::new_suffix("of Evasion",      vec![(StatKind::Dodge,       0.05)], 5,  7.0),
307            Affix::new_suffix("of Fury",         vec![(StatKind::AttackSpeed, 0.08)], 6,  7.0),
308        ];
309
310        Self { prefixes, suffixes }
311    }
312
313    /// Roll a random prefix for the given item level.
314    pub fn roll_prefix(&self, item_level: u32, rng: &mut Rng) -> Option<&Affix> {
315        let eligible: Vec<(&Affix, f32)> = self.prefixes.iter()
316            .filter(|a| a.required_level <= item_level)
317            .map(|a| (a, a.weight))
318            .collect();
319        rng.pick_weighted(&eligible).copied()
320    }
321
322    /// Roll a random suffix for the given item level.
323    pub fn roll_suffix(&self, item_level: u32, rng: &mut Rng) -> Option<&Affix> {
324        let eligible: Vec<(&Affix, f32)> = self.suffixes.iter()
325            .filter(|a| a.required_level <= item_level)
326            .map(|a| (a, a.weight))
327            .collect();
328        rng.pick_weighted(&eligible).copied()
329    }
330
331    pub fn prefix_count(&self) -> usize { self.prefixes.len() }
332    pub fn suffix_count(&self) -> usize { self.suffixes.len() }
333}
334
335// ── Item ──────────────────────────────────────────────────────────────────────
336
337/// A fully generated item with a base, optional affixes, and computed stats.
338#[derive(Debug, Clone)]
339pub struct Item {
340    pub name:      String,
341    pub base:      &'static ItemBase,
342    pub rarity:    Rarity,
343    pub prefixes:  Vec<String>,
344    pub suffixes:  Vec<String>,
345    /// Final computed stats (base + all modifiers).
346    pub stats:     HashMap<StatKind, f32>,
347    pub item_level: u32,
348    pub is_unique:  bool,
349    pub set_id:     Option<u32>,
350    pub enchantment: Option<Enchantment>,
351    pub sockets:    Vec<Gem>,
352}
353
354impl Item {
355    pub fn final_damage(&self)  -> f32 { *self.stats.get(&StatKind::Damage).unwrap_or(&0.0) }
356    pub fn final_defense(&self) -> f32 { *self.stats.get(&StatKind::Defense).unwrap_or(&0.0) }
357    pub fn final_health(&self)  -> f32 { *self.stats.get(&StatKind::Health).unwrap_or(&0.0) }
358    pub fn final_value(&self)   -> u32 {
359        let rarity_mult = match self.rarity {
360            Rarity::Common    => 1.0,
361            Rarity::Magic     => 1.5,
362            Rarity::Rare      => 2.5,
363            Rarity::Epic      => 5.0,
364            Rarity::Legendary => 15.0,
365        };
366        (self.base.base_value as f32 * rarity_mult) as u32
367    }
368}
369
370// ── ItemLevelCurve ────────────────────────────────────────────────────────────
371
372/// Maps dungeon depth to appropriate item level range.
373pub struct ItemLevelCurve {
374    /// `level = base + depth * rate`
375    pub base:  u32,
376    pub rate:  f32,
377    pub jitter: u32,
378}
379
380impl Default for ItemLevelCurve {
381    fn default() -> Self { Self { base: 1, rate: 2.0, jitter: 3 } }
382}
383
384impl ItemLevelCurve {
385    pub fn new(base: u32, rate: f32, jitter: u32) -> Self { Self { base, rate, jitter } }
386
387    pub fn item_level(&self, depth: u32, rng: &mut Rng) -> u32 {
388        let base = self.base + (depth as f32 * self.rate) as u32;
389        let j    = rng.range_i32(-(self.jitter as i32), self.jitter as i32);
390        (base as i32 + j).max(1) as u32
391    }
392}
393
394// ── ItemGenerator ─────────────────────────────────────────────────────────────
395
396/// Generates random items by rolling bases and affixes.
397pub struct ItemGenerator {
398    pub affix_pool: AffixPool,
399    pub level_curve: ItemLevelCurve,
400}
401
402impl Default for ItemGenerator {
403    fn default() -> Self {
404        Self { affix_pool: AffixPool::default_pool(), level_curve: ItemLevelCurve::default() }
405    }
406}
407
408impl ItemGenerator {
409    pub fn new(affix_pool: AffixPool, level_curve: ItemLevelCurve) -> Self {
410        Self { affix_pool, level_curve }
411    }
412
413    /// Roll a random rarity based on depth.
414    fn roll_rarity(depth: u32, rng: &mut Rng) -> Rarity {
415        let r = rng.next_f32();
416        let legend_chance = (depth as f32 * 0.002).min(0.03);
417        let epic_chance   = (depth as f32 * 0.005).min(0.08);
418        let rare_chance   = (depth as f32 * 0.01 ).min(0.20);
419        let magic_chance  = 0.35_f32;
420        if r < legend_chance              { Rarity::Legendary }
421        else if r < legend_chance + epic_chance   { Rarity::Epic }
422        else if r < legend_chance + epic_chance + rare_chance  { Rarity::Rare }
423        else if r < legend_chance + epic_chance + rare_chance + magic_chance { Rarity::Magic }
424        else { Rarity::Common }
425    }
426
427    /// Generate a random item for the given dungeon depth.
428    pub fn generate(&self, depth: u32, rng: &mut Rng) -> Item {
429        let item_level = self.level_curve.item_level(depth, rng);
430        let rarity     = Self::roll_rarity(depth, rng);
431
432        // Pick a base
433        let candidates = ItemBase::for_level(item_level);
434        let base = if candidates.is_empty() {
435            &BASE_POOL[0]
436        } else {
437            let i = rng.range_usize(candidates.len());
438            candidates[i]
439        };
440
441        // Roll affixes
442        let (min_aff, max_aff) = rarity.affix_count_range();
443        let n_affixes = if max_aff == 0 { 0 } else {
444            rng.range_usize(max_aff - min_aff + 1) + min_aff
445        };
446        let n_prefix = n_affixes / 2 + rng.range_usize(2);
447        let n_suffix = n_affixes.saturating_sub(n_prefix);
448
449        let mut prefix_names = Vec::new();
450        let mut suffix_names = Vec::new();
451        let mut stat_mods: HashMap<StatKind, f32> = HashMap::new();
452
453        // Base stats
454        *stat_mods.entry(StatKind::Damage).or_insert(0.0)  += base.base_damage;
455        *stat_mods.entry(StatKind::Defense).or_insert(0.0) += base.base_defense;
456
457        for _ in 0..n_prefix {
458            if let Some(affix) = self.affix_pool.roll_prefix(item_level, rng) {
459                prefix_names.push(affix.name.to_string());
460                for (stat, val) in &affix.stat_modifiers {
461                    *stat_mods.entry(*stat).or_insert(0.0) += val;
462                }
463            }
464        }
465        for _ in 0..n_suffix {
466            if let Some(affix) = self.affix_pool.roll_suffix(item_level, rng) {
467                suffix_names.push(affix.name.to_string());
468                for (stat, val) in &affix.stat_modifiers {
469                    *stat_mods.entry(*stat).or_insert(0.0) += val;
470                }
471            }
472        }
473
474        // Build name: "Prefix Base of Suffix"
475        let prefix_str = prefix_names.first().map(|s| s.as_str()).unwrap_or("").to_string();
476        let suffix_str = suffix_names.first().map(|s| format!(" {s}")).unwrap_or_default();
477        let name = if prefix_str.is_empty() {
478            format!("{}{}", base.name, suffix_str)
479        } else {
480            format!("{} {}{}", prefix_str, base.name, suffix_str)
481        };
482
483        Item {
484            name,
485            base,
486            rarity,
487            prefixes: prefix_names,
488            suffixes: suffix_names,
489            stats: stat_mods,
490            item_level,
491            is_unique: false,
492            set_id: None,
493            enchantment: None,
494            sockets: Vec::new(),
495        }
496    }
497
498    /// Generate an item with forced rarity.
499    pub fn generate_with_rarity(&self, depth: u32, rarity: Rarity, rng: &mut Rng) -> Item {
500        let item_level = self.level_curve.item_level(depth, rng);
501        let candidates = ItemBase::for_level(item_level);
502        let base = if candidates.is_empty() { &BASE_POOL[0] } else {
503            candidates[rng.range_usize(candidates.len())]
504        };
505
506        let (min_aff, max_aff) = rarity.affix_count_range();
507        let n_affixes = if max_aff == 0 { 0 } else {
508            rng.range_usize(max_aff - min_aff + 1) + min_aff
509        };
510        let n_prefix = n_affixes / 2;
511        let n_suffix = n_affixes - n_prefix;
512
513        let mut prefix_names = Vec::new();
514        let mut suffix_names = Vec::new();
515        let mut stat_mods: HashMap<StatKind, f32> = HashMap::new();
516        *stat_mods.entry(StatKind::Damage).or_insert(0.0)  += base.base_damage;
517        *stat_mods.entry(StatKind::Defense).or_insert(0.0) += base.base_defense;
518
519        for _ in 0..n_prefix {
520            if let Some(affix) = self.affix_pool.roll_prefix(item_level, rng) {
521                prefix_names.push(affix.name.to_string());
522                for (s, v) in &affix.stat_modifiers { *stat_mods.entry(*s).or_insert(0.0) += v; }
523            }
524        }
525        for _ in 0..n_suffix {
526            if let Some(affix) = self.affix_pool.roll_suffix(item_level, rng) {
527                suffix_names.push(affix.name.to_string());
528                for (s, v) in &affix.stat_modifiers { *stat_mods.entry(*s).or_insert(0.0) += v; }
529            }
530        }
531
532        let prefix_str = prefix_names.first().map(|s| s.as_str()).unwrap_or("").to_string();
533        let suffix_str = suffix_names.first().map(|s| format!(" {s}")).unwrap_or_default();
534        let name = if prefix_str.is_empty() {
535            format!("{}{}", base.name, suffix_str)
536        } else {
537            format!("{} {}{}", prefix_str, base.name, suffix_str)
538        };
539
540        Item { name, base, rarity, prefixes: prefix_names, suffixes: suffix_names, stats: stat_mods,
541               item_level, is_unique: false, set_id: None, enchantment: None, sockets: Vec::new() }
542    }
543}
544
545// ── UniqueItem ────────────────────────────────────────────────────────────────
546
547/// A hand-crafted unique item with fixed stats and lore.
548#[derive(Debug, Clone)]
549pub struct UniqueItem {
550    pub name:       &'static str,
551    pub lore:       &'static str,
552    pub base_type:  ItemType,
553    pub stats:      Vec<(StatKind, f32)>,
554    pub glyph:      char,
555    pub required_level: u32,
556}
557
558impl UniqueItem {
559    /// The full pool of 20 unique items.
560    pub fn pool() -> &'static [UniqueItem] {
561        &UNIQUE_POOL
562    }
563
564    /// Convert to a generatable `Item`.
565    pub fn to_item(&self) -> Item {
566        let base = BASE_POOL.iter()
567            .find(|b| b.item_type == self.base_type)
568            .unwrap_or(&BASE_POOL[0]);
569        let mut stats: HashMap<StatKind, f32> = self.stats.iter().cloned().collect();
570        *stats.entry(StatKind::Damage).or_insert(0.0)  += base.base_damage;
571        *stats.entry(StatKind::Defense).or_insert(0.0) += base.base_defense;
572        Item {
573            name:      self.name.to_string(),
574            base,
575            rarity:    Rarity::Legendary,
576            prefixes:  Vec::new(),
577            suffixes:  Vec::new(),
578            stats,
579            item_level: self.required_level,
580            is_unique:  true,
581            set_id:     None,
582            enchantment: None,
583            sockets:    Vec::new(),
584        }
585    }
586}
587
588static UNIQUE_POOL: [UniqueItem; 20] = [
589    UniqueItem { name: "Soulrender",       lore: "Forged from the bones of a lich.",                           base_type: ItemType::Sword,      stats: vec![], glyph: '/', required_level: 20 },
590    UniqueItem { name: "Voidcleaver",      lore: "It hums with the void's emptiness.",                        base_type: ItemType::Axe,        stats: vec![], glyph: 'T', required_level: 25 },
591    UniqueItem { name: "Thornmail",        lore: "Every blow returns pain tenfold.",                           base_type: ItemType::ChestArmor, stats: vec![], glyph: '[', required_level: 18 },
592    UniqueItem { name: "Dawnbreaker",      lore: "Radiates light that banishes the undead.",                   base_type: ItemType::Mace,       stats: vec![], glyph: '\\',required_level: 22 },
593    UniqueItem { name: "Ghostwalkers",     lore: "The wearer moves without sound.",                            base_type: ItemType::Boots,      stats: vec![], glyph: 'U', required_level: 15 },
594    UniqueItem { name: "Eclipse Crown",    lore: "Worn by the last emperor of the sun dynasty.",               base_type: ItemType::Helmet,     stats: vec![], glyph: 'N', required_level: 30 },
595    UniqueItem { name: "Wraithblade",      lore: "Phases through armour on critical strikes.",                  base_type: ItemType::Dagger,     stats: vec![], glyph: '-', required_level: 20 },
596    UniqueItem { name: "Ring of Eternity", lore: "Ancient artefact from before the sundering.",                base_type: ItemType::Ring,       stats: vec![], glyph: '°', required_level: 35 },
597    UniqueItem { name: "Stormcaller",      lore: "Lightning arcs between its prongs.",                         base_type: ItemType::Staff,      stats: vec![], glyph: '|', required_level: 24 },
598    UniqueItem { name: "Deathgrip",        lore: "The gauntlets won't let go of what they grasp.",             base_type: ItemType::Gloves,     stats: vec![], glyph: '(', required_level: 18 },
599    UniqueItem { name: "Frostweave",       lore: "Woven from the hair of an ice dragon.",                      base_type: ItemType::ChestArmor, stats: vec![], glyph: '[', required_level: 28 },
600    UniqueItem { name: "Titan's Grip",     lore: "Only the mightiest can lift this war hammer.",               base_type: ItemType::Mace,       stats: vec![], glyph: '\\',required_level: 30 },
601    UniqueItem { name: "Whisperbow",       lore: "Arrows fired are noiseless and fly true.",                   base_type: ItemType::Bow,        stats: vec![], glyph: ')', required_level: 22 },
602    UniqueItem { name: "Amulet of Ages",   lore: "Grants visions of past wearers' memories.",                  base_type: ItemType::Amulet,     stats: vec![], glyph: '♦', required_level: 25 },
603    UniqueItem { name: "Bloodward",        lore: "Each wound fuels the shield's magic.",                       base_type: ItemType::Shield,     stats: vec![], glyph: 'O', required_level: 20 },
604    UniqueItem { name: "Runeshard",        lore: "Splinter of an ancient obelisk of power.",                   base_type: ItemType::Wand,       stats: vec![], glyph: '!', required_level: 22 },
605    UniqueItem { name: "Cinderplate",      lore: "Still warm from the forge of dragons.",                      base_type: ItemType::ChestArmor, stats: vec![], glyph: '[', required_level: 32 },
606    UniqueItem { name: "Soulward Belt",    lore: "Prevents the soul from leaving the body.",                   base_type: ItemType::Belt,       stats: vec![], glyph: '=', required_level: 20 },
607    UniqueItem { name: "Phasehelm",        lore: "Lets the wearer see through walls.",                         base_type: ItemType::Helmet,     stats: vec![], glyph: 'n', required_level: 18 },
608    UniqueItem { name: "Berserker's Axe",  lore: "The wielder enters a battle-rage, heedless of pain.",       base_type: ItemType::Axe,        stats: vec![], glyph: 'T', required_level: 28 },
609];
610
611// ── SetItem / ItemSet ──────────────────────────────────────────────────────────
612
613/// An item belonging to a named set.
614#[derive(Debug, Clone)]
615pub struct SetItem {
616    pub set_id:   u32,
617    pub piece_id: u32,
618    pub name:     &'static str,
619    pub base_type: ItemType,
620    pub stats:    Vec<(StatKind, f32)>,
621}
622
623/// A full named set with progressive set bonuses.
624#[derive(Debug, Clone)]
625pub struct ItemSet {
626    pub id:         u32,
627    pub name:       &'static str,
628    pub pieces:     Vec<SetItem>,
629    /// Bonus at each count threshold: (pieces_worn, Vec<(StatKind, bonus_value)>)
630    pub bonuses:    Vec<(usize, Vec<(StatKind, f32)>)>,
631}
632
633impl ItemSet {
634    /// All pre-defined sets.
635    pub fn all_sets() -> Vec<ItemSet> {
636        vec![
637            ItemSet {
638                id: 1,
639                name: "Dragon's Wrath",
640                pieces: vec![
641                    SetItem { set_id: 1, piece_id: 1, name: "Dragon's Helm",  base_type: ItemType::Helmet,     stats: vec![(StatKind::Defense, 20.0),(StatKind::FireResist, 15.0)] },
642                    SetItem { set_id: 1, piece_id: 2, name: "Dragon's Plate", base_type: ItemType::ChestArmor, stats: vec![(StatKind::Defense, 45.0),(StatKind::FireResist, 20.0)] },
643                    SetItem { set_id: 1, piece_id: 3, name: "Dragon's Claw",  base_type: ItemType::Gloves,     stats: vec![(StatKind::Damage,  15.0),(StatKind::AttackSpeed, 0.1)] },
644                    SetItem { set_id: 1, piece_id: 4, name: "Dragon's Tread", base_type: ItemType::Boots,      stats: vec![(StatKind::Speed, 0.15),(StatKind::FireResist, 10.0)] },
645                ],
646                bonuses: vec![
647                    (2, vec![(StatKind::FireResist, 30.0)]),
648                    (4, vec![(StatKind::Damage, 50.0), (StatKind::Defense, 50.0)]),
649                ],
650            },
651            ItemSet {
652                id: 2,
653                name: "Shadowstep",
654                pieces: vec![
655                    SetItem { set_id: 2, piece_id: 1, name: "Shadow Hood",  base_type: ItemType::Helmet,     stats: vec![(StatKind::Dodge, 0.08),(StatKind::CritChance, 0.05)] },
656                    SetItem { set_id: 2, piece_id: 2, name: "Shadow Wrap",  base_type: ItemType::ChestArmor, stats: vec![(StatKind::Dodge, 0.10),(StatKind::Speed, 0.08)] },
657                    SetItem { set_id: 2, piece_id: 3, name: "Shadow Blade", base_type: ItemType::Dagger,     stats: vec![(StatKind::Damage, 18.0),(StatKind::LifeSteal, 0.04)] },
658                ],
659                bonuses: vec![
660                    (2, vec![(StatKind::CritDamage, 0.25)]),
661                    (3, vec![(StatKind::CritChance, 0.10), (StatKind::Speed, 0.20)]),
662                ],
663            },
664            ItemSet {
665                id: 3,
666                name: "Arcane Conclave",
667                pieces: vec![
668                    SetItem { set_id: 3, piece_id: 1, name: "Conclave Circlet", base_type: ItemType::Helmet,  stats: vec![(StatKind::Mana, 80.0),(StatKind::MagicFind, 10.0)] },
669                    SetItem { set_id: 3, piece_id: 2, name: "Conclave Robe",    base_type: ItemType::ChestArmor, stats: vec![(StatKind::Mana, 120.0),(StatKind::CooldownReduction, 0.10)] },
670                    SetItem { set_id: 3, piece_id: 3, name: "Conclave Focus",   base_type: ItemType::Wand,    stats: vec![(StatKind::Damage, 30.0),(StatKind::Mana, 60.0)] },
671                    SetItem { set_id: 3, piece_id: 4, name: "Conclave Ring",    base_type: ItemType::Ring,    stats: vec![(StatKind::Mana, 40.0),(StatKind::MagicFind, 8.0)] },
672                ],
673                bonuses: vec![
674                    (2, vec![(StatKind::CooldownReduction, 0.15)]),
675                    (4, vec![(StatKind::Mana, 300.0), (StatKind::MagicFind, 25.0)]),
676                ],
677            },
678            ItemSet {
679                id: 4,
680                name: "Ironwall",
681                pieces: vec![
682                    SetItem { set_id: 4, piece_id: 1, name: "Ironwall Helm",   base_type: ItemType::Helmet,     stats: vec![(StatKind::Defense, 30.0),(StatKind::BlockChance, 0.05)] },
683                    SetItem { set_id: 4, piece_id: 2, name: "Ironwall Plate",  base_type: ItemType::ChestArmor, stats: vec![(StatKind::Defense, 60.0),(StatKind::Health, 100.0)] },
684                    SetItem { set_id: 4, piece_id: 3, name: "Ironwall Shield", base_type: ItemType::Shield,     stats: vec![(StatKind::Defense, 40.0),(StatKind::BlockChance, 0.10)] },
685                    SetItem { set_id: 4, piece_id: 4, name: "Ironwall Boots",  base_type: ItemType::Boots,      stats: vec![(StatKind::Defense, 15.0),(StatKind::Thorns, 15.0)] },
686                ],
687                bonuses: vec![
688                    (2, vec![(StatKind::Health, 200.0)]),
689                    (4, vec![(StatKind::Defense, 100.0), (StatKind::Thorns, 40.0)]),
690                ],
691            },
692            ItemSet {
693                id: 5,
694                name: "Nature's Grasp",
695                pieces: vec![
696                    SetItem { set_id: 5, piece_id: 1, name: "Thornweave Hood",  base_type: ItemType::Helmet,     stats: vec![(StatKind::Health, 50.0),(StatKind::PoisonResist, 20.0)] },
697                    SetItem { set_id: 5, piece_id: 2, name: "Thornweave Vest",  base_type: ItemType::ChestArmor, stats: vec![(StatKind::Defense, 20.0),(StatKind::PoisonResist, 20.0)] },
698                    SetItem { set_id: 5, piece_id: 3, name: "Barkskin Gloves",  base_type: ItemType::Gloves,     stats: vec![(StatKind::Thorns, 20.0),(StatKind::Health, 30.0)] },
699                ],
700                bonuses: vec![
701                    (2, vec![(StatKind::PoisonResist, 30.0)]),
702                    (3, vec![(StatKind::Thorns, 60.0), (StatKind::Health, 150.0)]),
703                ],
704            },
705        ]
706    }
707
708    /// Compute active bonuses for a given count of equipped pieces.
709    pub fn active_bonuses(&self, equipped_count: usize) -> Vec<(StatKind, f32)> {
710        self.bonuses.iter()
711            .filter(|(threshold, _)| equipped_count >= *threshold)
712            .flat_map(|(_, mods)| mods.iter().cloned())
713            .collect()
714    }
715}
716
717// ── Gem ───────────────────────────────────────────────────────────────────────
718
719/// Gems that can be socketed into items.
720#[derive(Debug, Clone, Copy, PartialEq, Eq)]
721pub enum Gem {
722    Ruby,
723    Sapphire,
724    Emerald,
725    Diamond,
726    Obsidian,
727}
728
729impl Gem {
730    /// Bonus granted when socketed into a weapon.
731    pub fn weapon_bonus(&self) -> (StatKind, f32) {
732        match self {
733            Gem::Ruby     => (StatKind::Damage,    15.0),
734            Gem::Sapphire => (StatKind::Mana,      30.0),
735            Gem::Emerald  => (StatKind::LifeSteal,  0.04),
736            Gem::Diamond  => (StatKind::CritChance, 0.05),
737            Gem::Obsidian => (StatKind::Thorns,    20.0),
738        }
739    }
740
741    /// Bonus granted when socketed into armour.
742    pub fn armor_bonus(&self) -> (StatKind, f32) {
743        match self {
744            Gem::Ruby     => (StatKind::FireResist,      15.0),
745            Gem::Sapphire => (StatKind::ColdResist,      15.0),
746            Gem::Emerald  => (StatKind::PoisonResist,    15.0),
747            Gem::Diamond  => (StatKind::Defense,         20.0),
748            Gem::Obsidian => (StatKind::LightningResist, 15.0),
749        }
750    }
751
752    /// Bonus granted when socketed into jewellery.
753    pub fn jewelry_bonus(&self) -> (StatKind, f32) {
754        match self {
755            Gem::Ruby     => (StatKind::Health,      50.0),
756            Gem::Sapphire => (StatKind::Mana,        50.0),
757            Gem::Emerald  => (StatKind::GoldFind,    20.0),
758            Gem::Diamond  => (StatKind::MagicFind,   15.0),
759            Gem::Obsidian => (StatKind::CritDamage,   0.20),
760        }
761    }
762
763    pub fn socket_bonus(&self, item_type: ItemType) -> (StatKind, f32) {
764        if item_type.is_weapon()  { self.weapon_bonus() }
765        else if item_type.is_jewelry() { self.jewelry_bonus() }
766        else                      { self.armor_bonus() }
767    }
768
769    pub fn name(&self) -> &'static str {
770        match self {
771            Gem::Ruby     => "Ruby",
772            Gem::Sapphire => "Sapphire",
773            Gem::Emerald  => "Emerald",
774            Gem::Diamond  => "Diamond",
775            Gem::Obsidian => "Obsidian",
776        }
777    }
778}
779
780// ── Enchantment ───────────────────────────────────────────────────────────────
781
782/// Extra magical property not covered by standard affixes.
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum EnchantmentKind {
785    SoulBound,       // item is bound to the character
786    Cursed,          // item imposes a penalty that cannot be removed without unequipping
787    ElementalInfusion(ElementKind),
788    Ethereal,        // unusually light; ignores weight
789    Masterwork,      // +10% to all stats
790    Resonant,        // bonus scales with level
791}
792
793#[derive(Debug, Clone, Copy, PartialEq, Eq)]
794pub enum ElementKind { Fire, Ice, Lightning, Poison, Arcane }
795
796/// An enchantment applied to an item.
797#[derive(Debug, Clone)]
798pub struct Enchantment {
799    pub kind:        EnchantmentKind,
800    pub description: String,
801    pub stat_bonus:  Vec<(StatKind, f32)>,
802}
803
804impl Enchantment {
805    pub fn soul_bound() -> Self {
806        Self { kind: EnchantmentKind::SoulBound, description: "Bound to soul — cannot be traded.".into(), stat_bonus: vec![] }
807    }
808    pub fn cursed() -> Self {
809        Self { kind: EnchantmentKind::Cursed, description: "Cursed — cannot be removed without a dispel.".into(),
810               stat_bonus: vec![(StatKind::Speed, -0.10)] }
811    }
812    pub fn elemental(elem: ElementKind) -> Self {
813        let (desc, stat) = match elem {
814            ElementKind::Fire      => ("Infused with fire — deals bonus fire damage.",      (StatKind::Damage, 12.0)),
815            ElementKind::Ice       => ("Infused with ice — slows targets.",                 (StatKind::ColdResist, 20.0)),
816            ElementKind::Lightning => ("Crackling with lightning — chance to stun.",         (StatKind::LightningResist, 20.0)),
817            ElementKind::Poison    => ("Dripping venom — poisons on hit.",                   (StatKind::PoisonResist, 20.0)),
818            ElementKind::Arcane    => ("Humming with arcane power — amplifies spells.",      (StatKind::Mana, 60.0)),
819        };
820        Self { kind: EnchantmentKind::ElementalInfusion(elem), description: desc.into(), stat_bonus: vec![stat] }
821    }
822    pub fn masterwork() -> Self {
823        Self { kind: EnchantmentKind::Masterwork, description: "Masterwork craftsmanship (+10% all stats).".into(),
824               stat_bonus: vec![] } // handled multiplicatively at application
825    }
826    pub fn ethereal() -> Self {
827        Self { kind: EnchantmentKind::Ethereal, description: "Weightless — no encumbrance penalty.".into(), stat_bonus: vec![] }
828    }
829    pub fn resonant() -> Self {
830        Self { kind: EnchantmentKind::Resonant, description: "Power grows with the wielder.".into(),
831               stat_bonus: vec![(StatKind::Damage, 5.0), (StatKind::Defense, 5.0)] }
832    }
833
834    /// Apply this enchantment to a stat map.
835    pub fn apply_to_stats(&self, stats: &mut HashMap<StatKind, f32>) {
836        for (stat, val) in &self.stat_bonus {
837            *stats.entry(*stat).or_insert(0.0) += val;
838        }
839        if self.kind == EnchantmentKind::Masterwork {
840            for v in stats.values_mut() { *v *= 1.1; }
841        }
842    }
843}
844
845// ── LootDropper ───────────────────────────────────────────────────────────────
846
847/// Enemy kind for loot table lookup.
848#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
849pub enum EnemyKind {
850    Minion,
851    Soldier,
852    Elite,
853    Champion,
854    Boss,
855    MegaBoss,
856}
857
858/// Drop rate configuration per enemy tier.
859#[derive(Debug, Clone)]
860pub struct DropConfig {
861    pub base_drop_chance: f32,    // 0..1 probability of dropping anything
862    pub common_weight:    f32,
863    pub magic_weight:     f32,
864    pub rare_weight:      f32,
865    pub epic_weight:      f32,
866    pub legendary_weight: f32,
867    pub max_drops:        usize,
868}
869
870impl DropConfig {
871    fn for_enemy(kind: EnemyKind) -> Self {
872        match kind {
873            EnemyKind::Minion    => DropConfig { base_drop_chance: 0.20, common_weight: 80.0, magic_weight: 15.0, rare_weight: 4.0, epic_weight: 0.8, legendary_weight: 0.2, max_drops: 1 },
874            EnemyKind::Soldier   => DropConfig { base_drop_chance: 0.40, common_weight: 70.0, magic_weight: 20.0, rare_weight: 8.0, epic_weight: 1.5, legendary_weight: 0.5, max_drops: 2 },
875            EnemyKind::Elite     => DropConfig { base_drop_chance: 0.70, common_weight: 50.0, magic_weight: 30.0, rare_weight:15.0, epic_weight: 4.0, legendary_weight: 1.0, max_drops: 3 },
876            EnemyKind::Champion  => DropConfig { base_drop_chance: 0.90, common_weight: 30.0, magic_weight: 35.0, rare_weight:25.0, epic_weight: 8.0, legendary_weight: 2.0, max_drops: 4 },
877            EnemyKind::Boss      => DropConfig { base_drop_chance: 1.00, common_weight: 10.0, magic_weight: 25.0, rare_weight:35.0, epic_weight:20.0, legendary_weight: 10.0, max_drops: 5 },
878            EnemyKind::MegaBoss  => DropConfig { base_drop_chance: 1.00, common_weight:  5.0, magic_weight: 15.0, rare_weight:30.0, epic_weight:30.0, legendary_weight: 20.0, max_drops: 8 },
879        }
880    }
881
882    fn roll_rarity(&self, rng: &mut Rng) -> Rarity {
883        let options = [
884            (Rarity::Common,    self.common_weight),
885            (Rarity::Magic,     self.magic_weight),
886            (Rarity::Rare,      self.rare_weight),
887            (Rarity::Epic,      self.epic_weight),
888            (Rarity::Legendary, self.legendary_weight),
889        ];
890        rng.pick_weighted(&options).copied().unwrap_or(Rarity::Common)
891    }
892}
893
894/// Produces item drops from enemies based on type and depth.
895pub struct LootDropper {
896    pub generator: ItemGenerator,
897}
898
899impl Default for LootDropper {
900    fn default() -> Self { Self { generator: ItemGenerator::default() } }
901}
902
903impl LootDropper {
904    pub fn new(generator: ItemGenerator) -> Self { Self { generator } }
905
906    /// Generate loot for an enemy kill.
907    pub fn drop(&self, enemy_kind: EnemyKind, depth: u32, rng: &mut Rng) -> Vec<Item> {
908        let config = DropConfig::for_enemy(enemy_kind);
909        let mut drops = Vec::new();
910
911        // Check if anything drops at all
912        if !rng.chance(config.base_drop_chance) { return drops; }
913
914        let n_drops = rng.range_usize(config.max_drops) + 1;
915        for _ in 0..n_drops {
916            let rarity = config.roll_rarity(rng);
917            let item = self.generator.generate_with_rarity(depth, rarity, rng);
918            drops.push(item);
919        }
920        drops
921    }
922
923    /// Drop chance for a boss — always drops, usually rare+.
924    pub fn drop_boss(&self, depth: u32, rng: &mut Rng) -> Vec<Item> {
925        self.drop(EnemyKind::Boss, depth, rng)
926    }
927
928    /// Roll for a unique item drop (low probability).
929    pub fn try_unique_drop(&self, depth: u32, rng: &mut Rng) -> Option<Item> {
930        let chance = (depth as f32 * 0.005).min(0.05);
931        if !rng.chance(chance) { return None; }
932        let pool = UniqueItem::pool();
933        let eligible: Vec<&UniqueItem> = pool.iter()
934            .filter(|u| u.required_level <= depth * 2 + 5)
935            .collect();
936        if eligible.is_empty() { return None; }
937        let u = eligible[rng.range_usize(eligible.len())];
938        Some(u.to_item())
939    }
940}
941
942// ── Tests ─────────────────────────────────────────────────────────────────────
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    fn rng() -> Rng { Rng::new(42) }
949
950    #[test]
951    fn item_base_pool_nonempty() {
952        assert!(!ItemBase::pool().is_empty());
953    }
954
955    #[test]
956    fn item_base_for_level_filters() {
957        let bases = ItemBase::for_level(1);
958        assert!(!bases.is_empty(), "should have level-1 bases");
959        assert!(bases.iter().all(|b| b.required_level <= 1));
960    }
961
962    #[test]
963    fn affix_pool_has_enough_affixes() {
964        let pool = AffixPool::default_pool();
965        assert!(pool.prefix_count() >= 30, "should have 30+ prefixes, got {}", pool.prefix_count());
966        assert!(pool.suffix_count() >= 30, "should have 30+ suffixes, got {}", pool.suffix_count());
967    }
968
969    #[test]
970    fn item_generator_produces_item() {
971        let mut r = rng();
972        let gen = ItemGenerator::default();
973        let item = gen.generate(5, &mut r);
974        assert!(!item.name.is_empty());
975    }
976
977    #[test]
978    fn item_generator_legend_at_high_depth() {
979        let mut r = rng();
980        let gen = ItemGenerator::default();
981        // At depth 50, stats should be higher due to level curve
982        let item = gen.generate(50, &mut r);
983        assert!(item.item_level >= 1);
984    }
985
986    #[test]
987    fn rarity_affix_counts_match() {
988        for rarity in [Rarity::Common, Rarity::Magic, Rarity::Rare, Rarity::Epic, Rarity::Legendary] {
989            let (min, max) = rarity.affix_count_range();
990            assert!(min <= max, "min > max for {:?}", rarity);
991        }
992    }
993
994    #[test]
995    fn loot_dropper_boss_always_drops() {
996        let dropper = LootDropper::default();
997        for seed in 0..20u64 {
998            let mut r = Rng::new(seed);
999            let drops = dropper.drop_boss(10, &mut r);
1000            assert!(!drops.is_empty(), "boss should always drop at seed {seed}");
1001        }
1002    }
1003
1004    #[test]
1005    fn unique_item_pool_has_20() {
1006        assert_eq!(UniqueItem::pool().len(), 20);
1007    }
1008
1009    #[test]
1010    fn unique_item_converts_to_item() {
1011        let pool = UniqueItem::pool();
1012        let item = pool[0].to_item();
1013        assert!(item.is_unique);
1014        assert_eq!(item.rarity, Rarity::Legendary);
1015    }
1016
1017    #[test]
1018    fn item_sets_all_five() {
1019        let sets = ItemSet::all_sets();
1020        assert_eq!(sets.len(), 5);
1021    }
1022
1023    #[test]
1024    fn set_bonus_scales_with_count() {
1025        let sets = ItemSet::all_sets();
1026        let set = &sets[0]; // Dragon's Wrath
1027        let bonus_2 = set.active_bonuses(2);
1028        let bonus_4 = set.active_bonuses(4);
1029        assert!(!bonus_2.is_empty());
1030        assert!(bonus_4.len() >= bonus_2.len(), "more pieces should give at least as many bonuses");
1031    }
1032
1033    #[test]
1034    fn gems_have_correct_bonus_by_type() {
1035        let (stat, _) = Gem::Ruby.socket_bonus(ItemType::Sword);
1036        assert_eq!(stat, StatKind::Damage, "Ruby in weapon should boost damage");
1037        let (stat, _) = Gem::Ruby.socket_bonus(ItemType::ChestArmor);
1038        assert_eq!(stat, StatKind::FireResist, "Ruby in armor should boost fire resist");
1039    }
1040}