1use super::Rng;
7use std::collections::HashMap;
8
9#[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#[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#[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#[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 pub required_level: u32,
124}
125
126impl ItemBase {
127 pub fn pool() -> &'static [ItemBase] {
129 &BASE_POOL
130 }
131
132 pub fn for_level(level: u32) -> Vec<&'static ItemBase> {
134 BASE_POOL.iter().filter(|b| b.required_level <= level).collect()
135 }
136}
137
138static BASE_POOL: [ItemBase; 32] = [
140 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum AffixType {
193 Prefix,
194 Suffix,
195}
196
197#[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 pub weight: f32,
206 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
230pub struct AffixPool {
234 prefixes: Vec<Affix>,
235 suffixes: Vec<Affix>,
236}
237
238impl AffixPool {
239 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 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 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#[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 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
370pub struct ItemLevelCurve {
374 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
394pub 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 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 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 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 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 *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 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 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#[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 pub fn pool() -> &'static [UniqueItem] {
561 &UNIQUE_POOL
562 }
563
564 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#[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#[derive(Debug, Clone)]
625pub struct ItemSet {
626 pub id: u32,
627 pub name: &'static str,
628 pub pieces: Vec<SetItem>,
629 pub bonuses: Vec<(usize, Vec<(StatKind, f32)>)>,
631}
632
633impl ItemSet {
634 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
721pub enum Gem {
722 Ruby,
723 Sapphire,
724 Emerald,
725 Diamond,
726 Obsidian,
727}
728
729impl Gem {
730 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum EnchantmentKind {
785 SoulBound, Cursed, ElementalInfusion(ElementKind),
788 Ethereal, Masterwork, Resonant, }
792
793#[derive(Debug, Clone, Copy, PartialEq, Eq)]
794pub enum ElementKind { Fire, Ice, Lightning, Poison, Arcane }
795
796#[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![] } }
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 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#[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#[derive(Debug, Clone)]
860pub struct DropConfig {
861 pub base_drop_chance: f32, 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
894pub 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 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 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 pub fn drop_boss(&self, depth: u32, rng: &mut Rng) -> Vec<Item> {
925 self.drop(EnemyKind::Boss, depth, rng)
926 }
927
928 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#[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 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]; 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}