Skip to main content

proof_engine/combat/
inventory.rs

1//! Inventory, equipment, and item system.
2//!
3//! Items are pure data — stats, effects, and metadata. The inventory is
4//! a slot-based container with equipment binding and stat aggregation.
5
6use std::collections::HashMap;
7use super::{Element, CombatStats};
8
9// ── Rarity ────────────────────────────────────────────────────────────────────
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub enum Rarity {
13    Common,
14    Uncommon,
15    Rare,
16    Epic,
17    Legendary,
18    Mythic,
19    Unique,
20}
21
22impl Rarity {
23    pub fn color(self) -> glam::Vec4 {
24        use Rarity::*;
25        match self {
26            Common    => glam::Vec4::new(0.80, 0.80, 0.80, 1.0),
27            Uncommon  => glam::Vec4::new(0.30, 0.90, 0.30, 1.0),
28            Rare      => glam::Vec4::new(0.20, 0.40, 1.00, 1.0),
29            Epic      => glam::Vec4::new(0.65, 0.20, 0.95, 1.0),
30            Legendary => glam::Vec4::new(1.00, 0.65, 0.00, 1.0),
31            Mythic    => glam::Vec4::new(1.00, 0.10, 0.10, 1.0),
32            Unique    => glam::Vec4::new(0.90, 0.75, 0.20, 1.0),
33        }
34    }
35
36    pub fn glyph(self) -> char {
37        use Rarity::*;
38        match self {
39            Common    => '·',
40            Uncommon  => '◆',
41            Rare      => '★',
42            Epic      => '✦',
43            Legendary => '⬡',
44            Mythic    => '⚜',
45            Unique    => '◉',
46        }
47    }
48
49    pub fn stat_multiplier(self) -> f32 {
50        use Rarity::*;
51        match self {
52            Common    => 1.00,
53            Uncommon  => 1.20,
54            Rare      => 1.50,
55            Epic      => 1.90,
56            Legendary => 2.50,
57            Mythic    => 3.50,
58            Unique    => 5.00,
59        }
60    }
61
62    pub fn name(self) -> &'static str {
63        use Rarity::*;
64        match self {
65            Common    => "Common",
66            Uncommon  => "Uncommon",
67            Rare      => "Rare",
68            Epic      => "Epic",
69            Legendary => "Legendary",
70            Mythic    => "Mythic",
71            Unique    => "Unique",
72        }
73    }
74}
75
76// ── ItemCategory ──────────────────────────────────────────────────────────────
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum ItemCategory {
80    Weapon,
81    Offhand,
82    Helm,
83    Chest,
84    Legs,
85    Boots,
86    Gloves,
87    Ring,
88    Amulet,
89    Belt,
90    Consumable,
91    Material,
92    QuestItem,
93    Rune,
94    Gem,
95}
96
97impl ItemCategory {
98    pub fn is_equippable(self) -> bool {
99        !matches!(self, ItemCategory::Consumable | ItemCategory::Material | ItemCategory::QuestItem)
100    }
101
102    pub fn slot(self) -> Option<EquipSlot> {
103        use ItemCategory::*;
104        match self {
105            Weapon    => Some(EquipSlot::MainHand),
106            Offhand   => Some(EquipSlot::OffHand),
107            Helm      => Some(EquipSlot::Head),
108            Chest     => Some(EquipSlot::Chest),
109            Legs      => Some(EquipSlot::Legs),
110            Boots     => Some(EquipSlot::Feet),
111            Gloves    => Some(EquipSlot::Hands),
112            Ring      => Some(EquipSlot::Ring1), // simplified: just ring1
113            Amulet    => Some(EquipSlot::Neck),
114            Belt      => Some(EquipSlot::Belt),
115            _         => None,
116        }
117    }
118}
119
120// ── EquipSlot ─────────────────────────────────────────────────────────────────
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub enum EquipSlot {
124    MainHand,
125    OffHand,
126    Head,
127    Chest,
128    Legs,
129    Feet,
130    Hands,
131    Ring1,
132    Ring2,
133    Neck,
134    Belt,
135}
136
137impl EquipSlot {
138    pub const ALL: &'static [EquipSlot] = &[
139        EquipSlot::MainHand, EquipSlot::OffHand, EquipSlot::Head,
140        EquipSlot::Chest, EquipSlot::Legs, EquipSlot::Feet,
141        EquipSlot::Hands, EquipSlot::Ring1, EquipSlot::Ring2,
142        EquipSlot::Neck, EquipSlot::Belt,
143    ];
144
145    pub fn name(self) -> &'static str {
146        use EquipSlot::*;
147        match self {
148            MainHand => "Main Hand", OffHand => "Off Hand", Head => "Head",
149            Chest    => "Chest",     Legs    => "Legs",     Feet => "Feet",
150            Hands    => "Hands",     Ring1   => "Ring 1",   Ring2 => "Ring 2",
151            Neck     => "Neck",      Belt    => "Belt",
152        }
153    }
154}
155
156// ── StatModifier ──────────────────────────────────────────────────────────────
157
158#[derive(Debug, Clone)]
159pub enum ModifierType {
160    FlatAdd(f32),
161    PercentAdd(f32),  // additive percent (e.g., 10% + 15% = 25%)
162    PercentMul(f32),  // multiplicative (e.g., 1.1 * 1.15)
163    FlatSet(f32),     // override to fixed value
164}
165
166#[derive(Debug, Clone)]
167pub struct StatModifier {
168    pub stat:  StatId,
169    pub kind:  ModifierType,
170    pub source: String,  // for debug/tooltip
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
174pub enum StatId {
175    Attack,
176    Defense,
177    MaxHp,
178    HpRegen,
179    CritChance,
180    CritMult,
181    Penetration,
182    DodgeChance,
183    BlockChance,
184    BlockAmount,
185    MoveSpeed,
186    AttackSpeed,
187    SkillCooldown,
188    ManaMax,
189    ManaRegen,
190    EntropyAmp,
191}
192
193impl StatId {
194    pub fn name(self) -> &'static str {
195        use StatId::*;
196        match self {
197            Attack => "Attack", Defense => "Defense", MaxHp => "Max HP",
198            HpRegen => "HP Regen", CritChance => "Crit Chance",
199            CritMult => "Crit Multiplier", Penetration => "Penetration",
200            DodgeChance => "Dodge Chance", BlockChance => "Block Chance",
201            BlockAmount => "Block Amount", MoveSpeed => "Move Speed",
202            AttackSpeed => "Attack Speed", SkillCooldown => "Skill Cooldown",
203            ManaMax => "Max Mana", ManaRegen => "Mana Regen",
204            EntropyAmp => "Entropy Amplification",
205        }
206    }
207}
208
209// ── ItemAffix ─────────────────────────────────────────────────────────────────
210
211#[derive(Debug, Clone)]
212pub struct ItemAffix {
213    pub name:      String,
214    pub modifiers: Vec<StatModifier>,
215    pub is_prefix: bool,
216}
217
218impl ItemAffix {
219    pub fn new(name: impl Into<String>, is_prefix: bool) -> Self {
220        Self { name: name.into(), modifiers: Vec::new(), is_prefix }
221    }
222
223    pub fn add_modifier(mut self, stat: StatId, kind: ModifierType) -> Self {
224        self.modifiers.push(StatModifier { stat, kind, source: self.name.clone() });
225        self
226    }
227
228    // Common affix presets
229    pub fn of_the_berserker() -> Self {
230        Self::new("of the Berserker", false)
231            .add_modifier(StatId::Attack, ModifierType::PercentAdd(0.25))
232            .add_modifier(StatId::CritChance, ModifierType::FlatAdd(0.08))
233    }
234
235    pub fn of_fortification() -> Self {
236        Self::new("of Fortification", false)
237            .add_modifier(StatId::Defense, ModifierType::PercentAdd(0.30))
238            .add_modifier(StatId::MaxHp, ModifierType::PercentAdd(0.15))
239    }
240
241    pub fn enchanted() -> Self {
242        Self::new("Enchanted", true)
243            .add_modifier(StatId::EntropyAmp, ModifierType::PercentAdd(0.20))
244    }
245
246    pub fn swift() -> Self {
247        Self::new("Swift", true)
248            .add_modifier(StatId::MoveSpeed, ModifierType::PercentAdd(0.15))
249            .add_modifier(StatId::AttackSpeed, ModifierType::PercentAdd(0.10))
250    }
251
252    pub fn arcane() -> Self {
253        Self::new("Arcane", true)
254            .add_modifier(StatId::ManaMax, ModifierType::FlatAdd(50.0))
255            .add_modifier(StatId::ManaRegen, ModifierType::PercentAdd(0.20))
256    }
257}
258
259// ── ItemEffect ────────────────────────────────────────────────────────────────
260
261#[derive(Debug, Clone)]
262pub enum ItemEffect {
263    /// On hit: apply elemental damage
264    OnHitElement { element: Element, amount: f32 },
265    /// On kill: restore HP
266    OnKillHeal { amount: f32 },
267    /// On low HP (<threshold): gain damage boost
268    LowHpBoost { threshold: f32, multiplier: f32 },
269    /// Proc chance effect
270    ProcOnHit { chance: f32, effect: Box<ItemEffect> },
271    /// Aura: affect nearby allies
272    Aura { radius: f32, stat: StatId, value: f32 },
273    /// Thorns: reflect % of damage received
274    Thorns { reflect_pct: f32 },
275    /// Lifesteal
276    Lifesteal { pct: f32 },
277    /// On crit: apply status
278    OnCritStatus { status_name: String, duration: f32 },
279}
280
281// ── Item ──────────────────────────────────────────────────────────────────────
282
283#[derive(Debug, Clone)]
284pub struct Item {
285    pub id:          u64,
286    pub name:        String,
287    pub description: String,
288    pub category:    ItemCategory,
289    pub rarity:      Rarity,
290    pub level:       u32,
291    pub base_stats:  Vec<StatModifier>,
292    pub affixes:     Vec<ItemAffix>,
293    pub effects:     Vec<ItemEffect>,
294    pub stack_size:  u32,
295    pub max_stack:   u32,
296    pub value:       u64,     // gold value
297    pub weight:      f32,
298    pub glyph:       char,
299    pub sockets:     Vec<Option<Item>>,  // gem sockets
300    pub element:     Option<Element>,
301    pub set_id:      Option<u32>,        // item set membership
302}
303
304impl Item {
305    pub fn new(name: impl Into<String>, category: ItemCategory, rarity: Rarity, level: u32) -> Self {
306        let name = name.into();
307        let glyph = match category {
308            ItemCategory::Weapon   => '⚔',
309            ItemCategory::Offhand  => '🛡',
310            ItemCategory::Helm     => '⛑',
311            ItemCategory::Chest    => '🛡',
312            ItemCategory::Boots    => '👟',
313            ItemCategory::Ring     => '◎',
314            ItemCategory::Amulet   => '◉',
315            ItemCategory::Gem      => '◆',
316            ItemCategory::Rune     => '✦',
317            _                      => '·',
318        };
319        Item {
320            id: 0,
321            name,
322            description: String::new(),
323            category,
324            rarity,
325            level,
326            base_stats: Vec::new(),
327            affixes: Vec::new(),
328            effects: Vec::new(),
329            stack_size: 1,
330            max_stack: if matches!(category, ItemCategory::Consumable | ItemCategory::Material) { 99 } else { 1 },
331            value: (level as u64 * 10) * rarity.stat_multiplier() as u64,
332            weight: 1.0,
333            glyph,
334            sockets: Vec::new(),
335            element: None,
336            set_id: None,
337        }
338    }
339
340    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
341        self.description = desc.into();
342        self
343    }
344
345    pub fn with_stat(mut self, stat: StatId, kind: ModifierType) -> Self {
346        self.base_stats.push(StatModifier { stat, kind, source: self.name.clone() });
347        self
348    }
349
350    pub fn with_affix(mut self, affix: ItemAffix) -> Self {
351        self.affixes.push(affix);
352        self
353    }
354
355    pub fn with_effect(mut self, effect: ItemEffect) -> Self {
356        self.effects.push(effect);
357        self
358    }
359
360    pub fn with_element(mut self, el: Element) -> Self {
361        self.element = Some(el);
362        self
363    }
364
365    pub fn add_socket(mut self) -> Self {
366        self.sockets.push(None);
367        self
368    }
369
370    pub fn socket_gem(&mut self, slot: usize, gem: Item) -> bool {
371        if slot < self.sockets.len() {
372            self.sockets[slot] = Some(gem);
373            true
374        } else {
375            false
376        }
377    }
378
379    pub fn display_name(&self) -> String {
380        let prefix = self.affixes.iter()
381            .find(|a| a.is_prefix)
382            .map(|a| format!("{} ", a.name))
383            .unwrap_or_default();
384        let suffix = self.affixes.iter()
385            .find(|a| !a.is_prefix)
386            .map(|a| format!(" {}", a.name))
387            .unwrap_or_default();
388        format!("{}{}{}", prefix, self.name, suffix)
389    }
390
391    pub fn all_modifiers(&self) -> Vec<&StatModifier> {
392        let mut mods: Vec<&StatModifier> = self.base_stats.iter().collect();
393        for affix in &self.affixes {
394            mods.extend(affix.modifiers.iter());
395        }
396        for socket in &self.sockets {
397            if let Some(gem) = socket {
398                mods.extend(gem.base_stats.iter());
399            }
400        }
401        mods
402    }
403
404    pub fn tooltip(&self) -> String {
405        let mut lines = vec![
406            format!("{} [{}]", self.display_name(), self.rarity.name()),
407            format!("Level {} {} | {} glyph", self.level, format!("{:?}", self.category), self.glyph),
408        ];
409        if !self.description.is_empty() {
410            lines.push(self.description.clone());
411        }
412        lines.push(String::from("---"));
413        for m in self.all_modifiers() {
414            let val_str = match &m.kind {
415                ModifierType::FlatAdd(v)    => format!("+{:.0}", v),
416                ModifierType::PercentAdd(v) => format!("+{:.0}%", v * 100.0),
417                ModifierType::PercentMul(v) => format!("×{:.2}", v),
418                ModifierType::FlatSet(v)    => format!("={:.0}", v),
419            };
420            lines.push(format!("  {} {}", val_str, m.stat.name()));
421        }
422        if !self.effects.is_empty() {
423            lines.push("Effects:".to_string());
424            for eff in &self.effects {
425                lines.push(format!("  {:?}", eff));
426            }
427        }
428        lines.push(format!("Value: {} gold | Weight: {:.1}", self.value, self.weight));
429        lines.join("\n")
430    }
431
432    /// Compute net combat stat bonus for a specific stat.
433    pub fn stat_bonus(&self, stat: StatId, base: f32) -> f32 {
434        let mut flat   = 0.0f32;
435        let mut pct_add = 0.0f32;
436        let mut pct_mul = 1.0f32;
437        let mut override_val: Option<f32> = None;
438        for m in self.all_modifiers() {
439            if m.stat == stat {
440                match m.kind {
441                    ModifierType::FlatAdd(v)    => flat += v,
442                    ModifierType::PercentAdd(v) => pct_add += v,
443                    ModifierType::PercentMul(v) => pct_mul *= 1.0 + v,
444                    ModifierType::FlatSet(v)    => override_val = Some(v),
445                }
446            }
447        }
448        if let Some(v) = override_val { return v; }
449        (base + flat) * (1.0 + pct_add) * pct_mul
450    }
451}
452
453// ── ItemStack ─────────────────────────────────────────────────────────────────
454
455#[derive(Debug, Clone)]
456pub struct ItemStack {
457    pub item:     Item,
458    pub quantity: u32,
459}
460
461impl ItemStack {
462    pub fn single(item: Item) -> Self {
463        Self { item, quantity: 1 }
464    }
465
466    pub fn stacked(item: Item, qty: u32) -> Self {
467        let qty = qty.min(item.max_stack);
468        Self { item, quantity: qty }
469    }
470
471    pub fn can_stack_with(&self, other: &ItemStack) -> bool {
472        self.item.id == other.item.id && self.item.max_stack > 1
473    }
474
475    pub fn add_to_stack(&mut self, qty: u32) -> u32 {
476        let space = self.item.max_stack - self.quantity;
477        let added = qty.min(space);
478        self.quantity += added;
479        qty - added  // leftover
480    }
481
482    pub fn remove_from_stack(&mut self, qty: u32) -> u32 {
483        let removed = qty.min(self.quantity);
484        self.quantity -= removed;
485        removed
486    }
487
488    pub fn is_empty(&self) -> bool { self.quantity == 0 }
489}
490
491// ── Inventory ─────────────────────────────────────────────────────────────────
492
493#[derive(Debug, Clone)]
494pub struct Inventory {
495    slots:     Vec<Option<ItemStack>>,
496    capacity:  usize,
497    gold:      u64,
498    weight:    f32,
499    max_weight: f32,
500}
501
502impl Inventory {
503    pub fn new(capacity: usize, max_weight: f32) -> Self {
504        Inventory {
505            slots: vec![None; capacity],
506            capacity,
507            gold: 0,
508            weight: 0.0,
509            max_weight,
510        }
511    }
512
513    pub fn add_item(&mut self, item: Item) -> bool {
514        if self.weight + item.weight > self.max_weight {
515            return false;
516        }
517        // Try stacking first
518        if item.max_stack > 1 {
519            for slot in self.slots.iter_mut().flatten() {
520                if slot.item.id == item.id {
521                    let leftover = slot.add_to_stack(1);
522                    if leftover == 0 {
523                        self.weight += item.weight;
524                        return true;
525                    }
526                }
527            }
528        }
529        // Find empty slot
530        for slot in &mut self.slots {
531            if slot.is_none() {
532                self.weight += item.weight;
533                *slot = Some(ItemStack::single(item));
534                return true;
535            }
536        }
537        false  // No room
538    }
539
540    pub fn remove_item(&mut self, slot_idx: usize) -> Option<Item> {
541        if slot_idx >= self.capacity { return None; }
542        let stack = self.slots[slot_idx].take()?;
543        self.weight -= stack.item.weight;
544        Some(stack.item)
545    }
546
547    pub fn remove_count(&mut self, slot_idx: usize, count: u32) -> u32 {
548        if slot_idx >= self.capacity { return 0; }
549        if let Some(stack) = &mut self.slots[slot_idx] {
550            let removed = stack.remove_from_stack(count);
551            self.weight -= stack.item.weight * removed as f32;
552            if stack.is_empty() { self.slots[slot_idx] = None; }
553            return removed;
554        }
555        0
556    }
557
558    pub fn get(&self, slot_idx: usize) -> Option<&ItemStack> {
559        self.slots.get(slot_idx)?.as_ref()
560    }
561
562    pub fn get_mut(&mut self, slot_idx: usize) -> Option<&mut ItemStack> {
563        self.slots.get_mut(slot_idx)?.as_mut()
564    }
565
566    pub fn find_item_by_id(&self, id: u64) -> Option<usize> {
567        self.slots.iter().position(|s| s.as_ref().map(|st| st.item.id == id).unwrap_or(false))
568    }
569
570    pub fn find_items_by_category(&self, cat: ItemCategory) -> Vec<usize> {
571        self.slots.iter().enumerate()
572            .filter(|(_, s)| s.as_ref().map(|st| st.item.category == cat).unwrap_or(false))
573            .map(|(i, _)| i)
574            .collect()
575    }
576
577    pub fn used_slots(&self) -> usize {
578        self.slots.iter().filter(|s| s.is_some()).count()
579    }
580
581    pub fn free_slots(&self) -> usize {
582        self.capacity - self.used_slots()
583    }
584
585    pub fn is_full(&self) -> bool { self.free_slots() == 0 }
586
587    pub fn total_value(&self) -> u64 {
588        self.slots.iter().flatten()
589            .map(|s| s.item.value * s.quantity as u64)
590            .sum()
591    }
592
593    pub fn add_gold(&mut self, amount: u64) { self.gold += amount; }
594    pub fn remove_gold(&mut self, amount: u64) -> bool {
595        if self.gold >= amount { self.gold -= amount; true } else { false }
596    }
597    pub fn gold(&self) -> u64 { self.gold }
598    pub fn weight(&self) -> f32 { self.weight }
599    pub fn max_weight(&self) -> f32 { self.max_weight }
600    pub fn capacity(&self) -> usize { self.capacity }
601
602    /// Sort inventory: by rarity desc, then by level desc.
603    pub fn sort(&mut self) {
604        let mut items: Vec<ItemStack> = self.slots.iter_mut()
605            .filter_map(|s| s.take())
606            .collect();
607        items.sort_by(|a, b| {
608            b.item.rarity.cmp(&a.item.rarity)
609                .then(b.item.level.cmp(&a.item.level))
610                .then(a.item.name.cmp(&b.item.name))
611        });
612        for (i, item) in items.into_iter().enumerate() {
613            if i < self.capacity { self.slots[i] = Some(item); }
614        }
615    }
616
617    /// Transfer item from this inventory to another.
618    pub fn transfer(&mut self, slot_idx: usize, target: &mut Inventory) -> bool {
619        if let Some(item) = self.remove_item(slot_idx) {
620            if target.add_item(item.clone()) {
621                return true;
622            }
623            // Put back if transfer failed
624            let _ = self.add_item(item);
625        }
626        false
627    }
628
629    pub fn iter(&self) -> impl Iterator<Item = (usize, &ItemStack)> {
630        self.slots.iter().enumerate()
631            .filter_map(|(i, s)| s.as_ref().map(|st| (i, st)))
632    }
633}
634
635// ── Equipment ─────────────────────────────────────────────────────────────────
636
637#[derive(Debug, Clone, Default)]
638pub struct Equipment {
639    pub slots: HashMap<EquipSlot, Item>,
640}
641
642impl Equipment {
643    pub fn new() -> Self {
644        Self { slots: HashMap::new() }
645    }
646
647    pub fn equip(&mut self, item: Item) -> Option<Item> {
648        let slot = item.category.slot()?;
649        let old = self.slots.remove(&slot);
650        self.slots.insert(slot, item);
651        old
652    }
653
654    pub fn unequip(&mut self, slot: EquipSlot) -> Option<Item> {
655        self.slots.remove(&slot)
656    }
657
658    pub fn get(&self, slot: EquipSlot) -> Option<&Item> {
659        self.slots.get(&slot)
660    }
661
662    pub fn is_slot_filled(&self, slot: EquipSlot) -> bool {
663        self.slots.contains_key(&slot)
664    }
665
666    /// Aggregate all stat modifiers across all equipped items.
667    pub fn all_modifiers(&self) -> Vec<&StatModifier> {
668        self.slots.values()
669            .flat_map(|item| item.all_modifiers())
670            .collect()
671    }
672
673    /// Apply equipment bonuses to a CombatStats block.
674    pub fn apply_to_stats(&self, base: &CombatStats) -> CombatStats {
675        let mut result = base.clone();
676        let mods = self.all_modifiers();
677        result.attack       = apply_mods(&mods, StatId::Attack,       base.attack);
678        result.armor        = apply_mods(&mods, StatId::Defense,      base.armor);
679        result.max_hp       = apply_mods(&mods, StatId::MaxHp,        base.max_hp);
680        result.crit_chance  = apply_mods(&mods, StatId::CritChance,   base.crit_chance);
681        result.crit_mult    = apply_mods(&mods, StatId::CritMult,     base.crit_mult);
682        result.penetration  = apply_mods(&mods, StatId::Penetration,  base.penetration);
683        result.dodge_chance = apply_mods(&mods, StatId::DodgeChance,  base.dodge_chance);
684        result.entropy_amp  = apply_mods(&mods, StatId::EntropyAmp,   base.entropy_amp);
685        result
686    }
687
688    /// Total weight of all equipped items.
689    pub fn total_weight(&self) -> f32 {
690        self.slots.values().map(|i| i.weight).sum()
691    }
692
693    /// Display equipped items summary.
694    pub fn summary(&self) -> Vec<String> {
695        EquipSlot::ALL.iter().map(|&slot| {
696            if let Some(item) = self.slots.get(&slot) {
697                format!("[{}] {} ({})", slot.name(), item.display_name(), item.rarity.name())
698            } else {
699                format!("[{}] — empty —", slot.name())
700            }
701        }).collect()
702    }
703}
704
705fn apply_mods(mods: &[&StatModifier], target: StatId, base: f32) -> f32 {
706    let mut flat   = 0.0f32;
707    let mut pct_add = 0.0f32;
708    let mut pct_mul = 1.0f32;
709    let mut override_val: Option<f32> = None;
710    for m in mods {
711        if m.stat == target {
712            match m.kind {
713                ModifierType::FlatAdd(v)    => flat += v,
714                ModifierType::PercentAdd(v) => pct_add += v,
715                ModifierType::PercentMul(v) => pct_mul *= 1.0 + v,
716                ModifierType::FlatSet(v)    => override_val = Some(v),
717            }
718        }
719    }
720    if let Some(v) = override_val { return v; }
721    (base + flat) * (1.0 + pct_add) * pct_mul
722}
723
724// ── LootTable ─────────────────────────────────────────────────────────────────
725
726#[derive(Debug, Clone)]
727pub struct LootEntry {
728    pub item_builder: fn(u32) -> Item,  // fn(level) -> Item
729    pub weight:       f32,
730    pub min_qty:      u32,
731    pub max_qty:      u32,
732    pub min_level:    u32,
733    pub requires_rare_roll: bool,
734}
735
736#[derive(Debug, Clone, Default)]
737pub struct LootTable {
738    pub entries:     Vec<LootEntry>,
739    pub gold_min:    u64,
740    pub gold_max:    u64,
741    pub drop_chance: f32,  // [0,1] — chance any loot drops at all
742    pub item_count_min: u32,
743    pub item_count_max: u32,
744}
745
746impl LootTable {
747    pub fn new() -> Self {
748        LootTable {
749            entries: Vec::new(),
750            gold_min: 0,
751            gold_max: 0,
752            drop_chance: 1.0,
753            item_count_min: 1,
754            item_count_max: 3,
755        }
756    }
757
758    pub fn add_entry(&mut self, builder: fn(u32) -> Item, weight: f32, min_qty: u32, max_qty: u32) {
759        self.entries.push(LootEntry {
760            item_builder: builder, weight, min_qty, max_qty,
761            min_level: 0, requires_rare_roll: false,
762        });
763    }
764
765    pub fn set_gold(&mut self, min: u64, max: u64) {
766        self.gold_min = min;
767        self.gold_max = max;
768    }
769
770    /// Roll the loot table with a pseudo-random seed. Returns items and gold.
771    pub fn roll(&self, level: u32, luck_bonus: f32, seed: u64) -> LootDrop {
772        let mut rng = SimpleRng::new(seed);
773
774        if rng.next_f32() > self.drop_chance * (1.0 + luck_bonus) {
775            return LootDrop::empty();
776        }
777
778        let gold = if self.gold_max > 0 {
779            self.gold_min + rng.next_u64() % (self.gold_max - self.gold_min + 1)
780        } else { 0 };
781
782        let count = if self.item_count_max > self.item_count_min {
783            self.item_count_min + (rng.next_u32() % (self.item_count_max - self.item_count_min + 1))
784        } else {
785            self.item_count_min
786        };
787
788        let total_weight: f32 = self.entries.iter()
789            .filter(|e| level >= e.min_level)
790            .map(|e| e.weight)
791            .sum();
792
793        let mut items = Vec::new();
794        for _ in 0..count {
795            if total_weight <= 0.0 { break; }
796            let roll = rng.next_f32() * total_weight;
797            let mut accum = 0.0;
798            for entry in &self.entries {
799                if level < entry.min_level { continue; }
800                accum += entry.weight;
801                if roll <= accum {
802                    let qty = if entry.max_qty > entry.min_qty {
803                        entry.min_qty + rng.next_u32() % (entry.max_qty - entry.min_qty + 1)
804                    } else {
805                        entry.min_qty
806                    };
807                    for _ in 0..qty {
808                        let mut item = (entry.item_builder)(level);
809                        item.id = rng.next_u64();
810                        items.push(item);
811                    }
812                    break;
813                }
814            }
815        }
816
817        LootDrop { items, gold }
818    }
819}
820
821#[derive(Debug, Clone)]
822pub struct LootDrop {
823    pub items: Vec<Item>,
824    pub gold:  u64,
825}
826
827impl LootDrop {
828    pub fn empty() -> Self { Self { items: Vec::new(), gold: 0 } }
829    pub fn is_empty(&self) -> bool { self.items.is_empty() && self.gold == 0 }
830}
831
832// ── SimpleRng (for loot rolls) ─────────────────────────────────────────────────
833
834struct SimpleRng { state: u64 }
835
836impl SimpleRng {
837    fn new(seed: u64) -> Self { Self { state: seed ^ 0x6a09e667f3bcc908 } }
838
839    fn next_u64(&mut self) -> u64 {
840        self.state ^= self.state << 13;
841        self.state ^= self.state >> 7;
842        self.state ^= self.state << 17;
843        self.state
844    }
845
846    fn next_u32(&mut self) -> u32 { (self.next_u64() >> 32) as u32 }
847
848    fn next_f32(&mut self) -> f32 {
849        (self.next_u64() >> 11) as f32 / (1u64 << 53) as f32
850    }
851}
852
853// ── Tests ─────────────────────────────────────────────────────────────────────
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858
859    fn make_sword(level: u32) -> Item {
860        Item::new("Iron Sword", ItemCategory::Weapon, Rarity::Common, level)
861            .with_stat(StatId::Attack, ModifierType::FlatAdd(10.0))
862    }
863
864    #[test]
865    fn test_item_display_name() {
866        let item = Item::new("Blade", ItemCategory::Weapon, Rarity::Rare, 10)
867            .with_affix(ItemAffix::swift())
868            .with_affix(ItemAffix::of_the_berserker());
869        assert!(item.display_name().contains("Swift"));
870        assert!(item.display_name().contains("Berserker"));
871    }
872
873    #[test]
874    fn test_inventory_add_remove() {
875        let mut inv = Inventory::new(10, 100.0);
876        let sword = make_sword(1);
877        assert!(inv.add_item(sword));
878        assert_eq!(inv.used_slots(), 1);
879        let removed = inv.remove_item(0);
880        assert!(removed.is_some());
881        assert_eq!(inv.used_slots(), 0);
882    }
883
884    #[test]
885    fn test_equipment_stat_apply() {
886        let mut equip = Equipment::new();
887        let item = Item::new("Power Helm", ItemCategory::Helm, Rarity::Rare, 5)
888            .with_stat(StatId::MaxHp, ModifierType::FlatAdd(100.0))
889            .with_stat(StatId::Defense, ModifierType::PercentAdd(0.20));
890        equip.equip(item);
891
892        let base = CombatStats::default();
893        let boosted = equip.apply_to_stats(&base);
894        assert!(boosted.max_hp > base.max_hp);
895        assert!(boosted.armor >= base.armor);
896    }
897
898    #[test]
899    fn test_loot_table_roll() {
900        let mut table = LootTable::new();
901        table.add_entry(|lvl| make_sword(lvl), 1.0, 1, 1);
902        table.set_gold(10, 50);
903        let drop = table.roll(5, 0.0, 12345);
904        // Either got items or didn't (RNG dependent), just check it doesn't panic
905        let _ = drop.is_empty();
906    }
907
908    #[test]
909    fn test_rarity_ordering() {
910        assert!(Rarity::Legendary > Rarity::Rare);
911        assert!(Rarity::Common < Rarity::Epic);
912    }
913}