Skip to main content

proof_engine/combat/
abilities.rs

1//! Skill and ability system — cooldowns, mana, resource tracking, ability trees.
2
3use std::collections::HashMap;
4use super::Element;
5
6// ── Resource ──────────────────────────────────────────────────────────────────
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum ResourceType {
10    Mana,
11    Rage,
12    Energy,
13    Focus,
14    Entropy,
15    Charges,
16}
17
18impl ResourceType {
19    pub fn name(self) -> &'static str {
20        match self {
21            ResourceType::Mana    => "Mana",
22            ResourceType::Rage    => "Rage",
23            ResourceType::Energy  => "Energy",
24            ResourceType::Focus   => "Focus",
25            ResourceType::Entropy => "Entropy",
26            ResourceType::Charges => "Charges",
27        }
28    }
29
30    pub fn color(self) -> glam::Vec4 {
31        match self {
32            ResourceType::Mana    => glam::Vec4::new(0.20, 0.40, 1.00, 1.0),
33            ResourceType::Rage    => glam::Vec4::new(1.00, 0.15, 0.10, 1.0),
34            ResourceType::Energy  => glam::Vec4::new(1.00, 0.90, 0.10, 1.0),
35            ResourceType::Focus   => glam::Vec4::new(0.30, 0.90, 0.60, 1.0),
36            ResourceType::Entropy => glam::Vec4::new(0.60, 0.10, 0.80, 1.0),
37            ResourceType::Charges => glam::Vec4::new(0.90, 0.90, 0.90, 1.0),
38        }
39    }
40
41    /// Whether this resource regenerates passively over time.
42    pub fn regenerates(self) -> bool {
43        matches!(self, ResourceType::Mana | ResourceType::Energy | ResourceType::Focus)
44    }
45
46    /// Whether this resource decays when not in combat.
47    pub fn decays_out_of_combat(self) -> bool {
48        matches!(self, ResourceType::Rage | ResourceType::Entropy)
49    }
50}
51
52// ── ResourcePool ──────────────────────────────────────────────────────────────
53
54#[derive(Debug, Clone)]
55pub struct ResourcePool {
56    pub kind:    ResourceType,
57    pub current: f32,
58    pub maximum: f32,
59    pub regen:   f32,    // per second
60    pub decay:   f32,    // per second when decaying
61}
62
63impl ResourcePool {
64    pub fn new(kind: ResourceType, max: f32) -> Self {
65        let regen = match kind {
66            ResourceType::Mana   => max * 0.05,
67            ResourceType::Energy => max * 0.15,
68            ResourceType::Focus  => max * 0.08,
69            _                    => 0.0,
70        };
71        Self { kind, current: max, maximum: max, regen, decay: max * 0.10 }
72    }
73
74    pub fn update(&mut self, dt: f32, in_combat: bool) {
75        if self.kind.regenerates() && !in_combat {
76            self.current = (self.current + self.regen * dt).min(self.maximum);
77        }
78        if self.kind.decays_out_of_combat() && !in_combat {
79            self.current = (self.current - self.decay * dt).max(0.0);
80        }
81    }
82
83    pub fn spend(&mut self, amount: f32) -> bool {
84        if self.current >= amount {
85            self.current -= amount;
86            true
87        } else {
88            false
89        }
90    }
91
92    pub fn restore(&mut self, amount: f32) {
93        self.current = (self.current + amount).min(self.maximum);
94    }
95
96    pub fn fill(&mut self) { self.current = self.maximum; }
97    pub fn empty(&mut self) { self.current = 0.0; }
98
99    pub fn percent(&self) -> f32 {
100        if self.maximum > 0.0 { self.current / self.maximum } else { 0.0 }
101    }
102
103    pub fn is_empty(&self) -> bool { self.current <= 0.0 }
104    pub fn is_full(&self) -> bool { self.current >= self.maximum }
105}
106
107// ── AbilityTag ────────────────────────────────────────────────────────────────
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub enum AbilityTag {
111    Attack,
112    Spell,
113    Movement,
114    Channel,
115    Toggle,
116    Passive,
117    Summon,
118    Utility,
119    Defensive,
120    Ultimate,
121    AoE,
122    SingleTarget,
123    Projectile,
124    Melee,
125    Ranged,
126    Instant,
127    Delayed,
128}
129
130// ── AbilityEffect ─────────────────────────────────────────────────────────────
131
132#[derive(Debug, Clone)]
133pub enum AbilityEffect {
134    /// Deal damage to target
135    Damage { base: f32, element: Element, scaling: f32 },
136    /// Heal target
137    Heal { amount: f32, scaling: f32 },
138    /// Apply a status effect
139    ApplyStatus { name: String, duration: f32, stacks: u32 },
140    /// Teleport caster
141    Teleport { range: f32 },
142    /// Push target
143    Knockback { force: f32, direction_from_caster: bool },
144    /// Pull target
145    Pull { force: f32 },
146    /// Stun target
147    Stun { duration: f32 },
148    /// Apply damage over time
149    DotDamage { dps: f32, element: Element, duration: f32 },
150    /// Shield / absorb
151    Shield { amount: f32, duration: f32 },
152    /// Chain to multiple targets
153    Chain { max_jumps: u32, falloff: f32 },
154    /// AoE explosion at point
155    Explosion { radius: f32, damage: f32, element: Element },
156    /// Summon entity
157    Summon { entity_id: String, duration: f32 },
158    /// Modify resource
159    ModifyResource { kind: ResourceType, amount: f32 },
160    /// Buff stat temporarily
161    StatBuff { stat_name: String, multiplier: f32, duration: f32 },
162}
163
164// ── Ability ───────────────────────────────────────────────────────────────────
165
166#[derive(Debug, Clone)]
167pub struct Ability {
168    pub id:           u32,
169    pub name:         String,
170    pub description:  String,
171    pub tags:         Vec<AbilityTag>,
172    pub effects:      Vec<AbilityEffect>,
173    pub cooldown:     f32,
174    pub cast_time:    f32,
175    pub channel_time: f32,
176    pub resource_cost: Vec<(ResourceType, f32)>,
177    pub range:        f32,
178    pub radius:       f32,
179    pub level:        u32,
180    pub max_level:    u32,
181    pub glyph:        char,
182    pub rank_bonuses: Vec<RankBonus>,
183    pub combo_points_generated: u32,
184    pub combo_points_consumed:  Option<u32>,
185    pub interrupt_flags: InterruptFlags,
186}
187
188#[derive(Debug, Clone)]
189pub struct RankBonus {
190    pub rank: u32,
191    pub description: String,
192    pub effect_modifier: f32,
193}
194
195#[derive(Debug, Clone, Copy, Default)]
196pub struct InterruptFlags {
197    pub interrupted_by_damage:  bool,
198    pub interrupted_by_cc:      bool,
199    pub interrupted_by_movement: bool,
200}
201
202impl Ability {
203    pub fn new(id: u32, name: impl Into<String>) -> Self {
204        Self {
205            id,
206            name: name.into(),
207            description: String::new(),
208            tags: Vec::new(),
209            effects: Vec::new(),
210            cooldown: 0.0,
211            cast_time: 0.0,
212            channel_time: 0.0,
213            resource_cost: Vec::new(),
214            range: 1.0,
215            radius: 0.0,
216            level: 1,
217            max_level: 5,
218            glyph: '◆',
219            rank_bonuses: Vec::new(),
220            combo_points_generated: 0,
221            combo_points_consumed: None,
222            interrupt_flags: InterruptFlags::default(),
223        }
224    }
225
226    pub fn with_description(mut self, d: impl Into<String>) -> Self { self.description = d.into(); self }
227    pub fn with_cooldown(mut self, cd: f32) -> Self { self.cooldown = cd; self }
228    pub fn with_cast_time(mut self, ct: f32) -> Self { self.cast_time = ct; self }
229    pub fn with_cost(mut self, resource: ResourceType, amount: f32) -> Self {
230        self.resource_cost.push((resource, amount)); self
231    }
232    pub fn with_range(mut self, r: f32) -> Self { self.range = r; self }
233    pub fn with_radius(mut self, r: f32) -> Self { self.radius = r; self }
234    pub fn with_tag(mut self, tag: AbilityTag) -> Self { self.tags.push(tag); self }
235    pub fn with_effect(mut self, eff: AbilityEffect) -> Self { self.effects.push(eff); self }
236    pub fn with_glyph(mut self, g: char) -> Self { self.glyph = g; self }
237
238    pub fn has_tag(&self, tag: AbilityTag) -> bool { self.tags.contains(&tag) }
239
240    pub fn is_instant(&self) -> bool { self.cast_time <= 0.0 && self.channel_time <= 0.0 }
241
242    pub fn scaled_damage(&self, base_attack: f32) -> f32 {
243        let level_mult = 1.0 + (self.level as f32 - 1.0) * 0.15;
244        self.effects.iter()
245            .filter_map(|e| match e {
246                AbilityEffect::Damage { base, scaling, .. } => Some(base + scaling * base_attack),
247                _ => None,
248            })
249            .sum::<f32>() * level_mult
250    }
251
252    pub fn tooltip(&self) -> Vec<String> {
253        let mut lines = vec![
254            format!("{} (Rank {})", self.name, self.level),
255            self.description.clone(),
256            format!("Cooldown: {:.1}s | Range: {:.1}", self.cooldown, self.range),
257        ];
258        if self.cast_time > 0.0 {
259            lines.push(format!("Cast time: {:.1}s", self.cast_time));
260        }
261        for (res, cost) in &self.resource_cost {
262            lines.push(format!("Cost: {:.0} {}", cost, res.name()));
263        }
264        for tag in &self.tags {
265            lines.push(format!("[{:?}]", tag));
266        }
267        lines
268    }
269
270    // ── Preset abilities ──────────────────────────────────────────────────────
271
272    pub fn fireball() -> Self {
273        Ability::new(1, "Fireball")
274            .with_description("Hurls a sphere of entropic fire at the target.")
275            .with_cooldown(3.0)
276            .with_cast_time(1.0)
277            .with_cost(ResourceType::Mana, 40.0)
278            .with_range(12.0)
279            .with_radius(3.0)
280            .with_tag(AbilityTag::Spell)
281            .with_tag(AbilityTag::AoE)
282            .with_tag(AbilityTag::Projectile)
283            .with_effect(AbilityEffect::Explosion {
284                radius: 3.0,
285                damage: 80.0,
286                element: Element::Fire,
287            })
288            .with_glyph('♨')
289    }
290
291    pub fn blink() -> Self {
292        Ability::new(2, "Blink")
293            .with_description("Instantly teleport a short distance.")
294            .with_cooldown(8.0)
295            .with_cost(ResourceType::Mana, 25.0)
296            .with_range(8.0)
297            .with_tag(AbilityTag::Movement)
298            .with_tag(AbilityTag::Instant)
299            .with_effect(AbilityEffect::Teleport { range: 8.0 })
300            .with_glyph('⟿')
301    }
302
303    pub fn void_strike() -> Self {
304        Ability::new(3, "Void Strike")
305            .with_description("A heavy melee strike that tears through dimensional barriers.")
306            .with_cooldown(6.0)
307            .with_cast_time(0.3)
308            .with_cost(ResourceType::Rage, 30.0)
309            .with_range(2.0)
310            .with_tag(AbilityTag::Attack)
311            .with_tag(AbilityTag::Melee)
312            .with_tag(AbilityTag::SingleTarget)
313            .with_effect(AbilityEffect::Damage { base: 120.0, element: Element::Void, scaling: 1.8 })
314            .with_effect(AbilityEffect::ApplyStatus { name: "Void Shred".to_string(), duration: 4.0, stacks: 1 })
315            .with_glyph('◈')
316    }
317
318    pub fn temporal_freeze() -> Self {
319        Ability::new(4, "Temporal Freeze")
320            .with_description("Slows time around the target, stunnning them briefly.")
321            .with_cooldown(12.0)
322            .with_cast_time(0.5)
323            .with_cost(ResourceType::Mana, 60.0)
324            .with_range(10.0)
325            .with_tag(AbilityTag::Spell)
326            .with_tag(AbilityTag::SingleTarget)
327            .with_effect(AbilityEffect::Stun { duration: 2.5 })
328            .with_effect(AbilityEffect::DotDamage { dps: 20.0, element: Element::Temporal, duration: 3.0 })
329            .with_glyph('⧗')
330    }
331
332    pub fn entropy_cascade() -> Self {
333        Ability::new(5, "Entropy Cascade")
334            .with_description("Unleash a wave of pure entropy that chains between enemies.")
335            .with_cooldown(20.0)
336            .with_cast_time(1.5)
337            .with_cost(ResourceType::Entropy, 80.0)
338            .with_range(15.0)
339            .with_tag(AbilityTag::Spell)
340            .with_tag(AbilityTag::AoE)
341            .with_tag(AbilityTag::Ultimate)
342            .with_effect(AbilityEffect::Damage { base: 200.0, element: Element::Entropy, scaling: 2.5 })
343            .with_effect(AbilityEffect::Chain { max_jumps: 5, falloff: 0.15 })
344            .with_glyph('∞')
345    }
346
347    pub fn iron_skin() -> Self {
348        Ability::new(6, "Iron Skin")
349            .with_description("Harden your body, gaining a protective shield.")
350            .with_cooldown(15.0)
351            .with_tag(AbilityTag::Defensive)
352            .with_tag(AbilityTag::Instant)
353            .with_effect(AbilityEffect::Shield { amount: 150.0, duration: 8.0 })
354            .with_effect(AbilityEffect::StatBuff { stat_name: "armor".to_string(), multiplier: 1.5, duration: 8.0 })
355            .with_glyph('⚙')
356    }
357}
358
359// ── AbilityState ──────────────────────────────────────────────────────────────
360
361#[derive(Debug, Clone)]
362pub struct AbilityState {
363    pub ability:         Ability,
364    pub cooldown_remaining: f32,
365    pub is_casting:      bool,
366    pub cast_progress:   f32,
367    pub is_channeling:   bool,
368    pub channel_elapsed: f32,
369    pub is_on_gcd:       bool,  // global cooldown
370}
371
372impl AbilityState {
373    pub fn new(ability: Ability) -> Self {
374        Self {
375            ability,
376            cooldown_remaining: 0.0,
377            is_casting: false,
378            cast_progress: 0.0,
379            is_channeling: false,
380            channel_elapsed: 0.0,
381            is_on_gcd: false,
382        }
383    }
384
385    pub fn update(&mut self, dt: f32) {
386        if self.cooldown_remaining > 0.0 {
387            self.cooldown_remaining = (self.cooldown_remaining - dt).max(0.0);
388        }
389        if self.is_casting {
390            self.cast_progress += dt;
391            if self.cast_progress >= self.ability.cast_time {
392                self.is_casting = false;
393                self.cast_progress = 0.0;
394            }
395        }
396        if self.is_channeling {
397            self.channel_elapsed += dt;
398            if self.channel_elapsed >= self.ability.channel_time {
399                self.is_channeling = false;
400                self.channel_elapsed = 0.0;
401            }
402        }
403    }
404
405    pub fn is_ready(&self) -> bool {
406        self.cooldown_remaining <= 0.0 && !self.is_casting && !self.is_channeling && !self.is_on_gcd
407    }
408
409    pub fn trigger(&mut self) {
410        self.cooldown_remaining = self.ability.cooldown;
411        if self.ability.cast_time > 0.0 {
412            self.is_casting = true;
413            self.cast_progress = 0.0;
414        }
415    }
416
417    pub fn interrupt(&mut self) {
418        if self.ability.interrupt_flags.interrupted_by_damage {
419            self.is_casting = false;
420            self.cast_progress = 0.0;
421            self.is_channeling = false;
422            self.channel_elapsed = 0.0;
423        }
424    }
425
426    pub fn cast_percent(&self) -> f32 {
427        if self.ability.cast_time > 0.0 {
428            (self.cast_progress / self.ability.cast_time).min(1.0)
429        } else { 0.0 }
430    }
431
432    pub fn cooldown_percent(&self) -> f32 {
433        if self.ability.cooldown > 0.0 {
434            1.0 - (self.cooldown_remaining / self.ability.cooldown).min(1.0)
435        } else { 1.0 }
436    }
437}
438
439// ── AbilityBar ────────────────────────────────────────────────────────────────
440
441pub const MAX_ABILITY_SLOTS: usize = 12;
442
443#[derive(Debug, Clone)]
444pub struct AbilityBar {
445    pub slots: Vec<Option<AbilityState>>,
446    pub resources: HashMap<ResourceType, ResourcePool>,
447    pub global_cooldown: f32,
448    pub gcd_remaining:   f32,
449    pub combo_points:    u32,
450    pub max_combo_points: u32,
451    pub in_combat:       bool,
452    pub combat_timer:    f32,
453}
454
455impl AbilityBar {
456    pub fn new() -> Self {
457        Self {
458            slots: vec![None; MAX_ABILITY_SLOTS],
459            resources: HashMap::new(),
460            global_cooldown: 1.5,
461            gcd_remaining: 0.0,
462            combo_points: 0,
463            max_combo_points: 5,
464            in_combat: false,
465            combat_timer: 0.0,
466        }
467    }
468
469    pub fn add_resource(&mut self, kind: ResourceType, max: f32) {
470        self.resources.insert(kind, ResourcePool::new(kind, max));
471    }
472
473    pub fn assign(&mut self, slot: usize, ability: Ability) {
474        if slot < MAX_ABILITY_SLOTS {
475            self.slots[slot] = Some(AbilityState::new(ability));
476        }
477    }
478
479    pub fn unassign(&mut self, slot: usize) {
480        if slot < MAX_ABILITY_SLOTS {
481            self.slots[slot] = None;
482        }
483    }
484
485    pub fn update(&mut self, dt: f32) {
486        // Update GCD
487        if self.gcd_remaining > 0.0 {
488            self.gcd_remaining = (self.gcd_remaining - dt).max(0.0);
489            for slot in self.slots.iter_mut().flatten() {
490                slot.is_on_gcd = self.gcd_remaining > 0.0;
491            }
492        }
493
494        // Update cooldowns and cast states
495        for slot in self.slots.iter_mut().flatten() {
496            slot.update(dt);
497        }
498
499        // Update resources
500        for pool in self.resources.values_mut() {
501            pool.update(dt, self.in_combat);
502        }
503
504        // Combat timer (leave combat after 5s of no attacks)
505        if self.in_combat {
506            self.combat_timer -= dt;
507            if self.combat_timer <= 0.0 {
508                self.in_combat = false;
509            }
510        }
511    }
512
513    pub fn can_use(&self, slot: usize) -> Result<(), AbilityCastError> {
514        let state = self.slots.get(slot)
515            .and_then(|s| s.as_ref())
516            .ok_or(AbilityCastError::NoAbilityInSlot)?;
517
518        if !state.is_ready() {
519            return Err(if state.cooldown_remaining > 0.0 {
520                AbilityCastError::OnCooldown { remaining: state.cooldown_remaining }
521            } else {
522                AbilityCastError::AlreadyCasting
523            });
524        }
525
526        for (res_type, cost) in &state.ability.resource_cost {
527            let pool = self.resources.get(res_type)
528                .ok_or(AbilityCastError::NotEnoughResource(*res_type))?;
529            if pool.current < *cost {
530                return Err(AbilityCastError::NotEnoughResource(*res_type));
531            }
532        }
533
534        if let Some(req) = state.ability.combo_points_consumed {
535            if self.combo_points < req {
536                return Err(AbilityCastError::NotEnoughComboPoints { need: req, have: self.combo_points });
537            }
538        }
539
540        Ok(())
541    }
542
543    pub fn use_ability(&mut self, slot: usize) -> Result<AbilityUseResult, AbilityCastError> {
544        self.can_use(slot)?;
545
546        let state = self.slots[slot].as_mut().unwrap();
547        let ability_clone = state.ability.clone();
548        state.trigger();
549
550        // Spend resources
551        for (res_type, cost) in &ability_clone.resource_cost {
552            if let Some(pool) = self.resources.get_mut(res_type) {
553                pool.spend(*cost);
554            }
555        }
556
557        // Spend combo points
558        if let Some(req) = ability_clone.combo_points_consumed {
559            self.combo_points = self.combo_points.saturating_sub(req);
560        }
561
562        // Generate combo points
563        self.combo_points = (self.combo_points + ability_clone.combo_points_generated)
564            .min(self.max_combo_points);
565
566        // Trigger GCD if not instant
567        if !ability_clone.is_instant() {
568            self.gcd_remaining = self.global_cooldown;
569        }
570
571        // Enter combat
572        self.in_combat = true;
573        self.combat_timer = 5.0;
574
575        Ok(AbilityUseResult {
576            ability: ability_clone,
577            instant: self.slots[slot].as_ref().unwrap().ability.is_instant(),
578        })
579    }
580
581    pub fn interrupt_all(&mut self) {
582        for slot in self.slots.iter_mut().flatten() {
583            slot.interrupt();
584        }
585    }
586
587    pub fn get_slot(&self, slot: usize) -> Option<&AbilityState> {
588        self.slots.get(slot)?.as_ref()
589    }
590
591    pub fn resource(&self, kind: ResourceType) -> Option<&ResourcePool> {
592        self.resources.get(&kind)
593    }
594
595    pub fn all_on_cooldown(&self) -> bool {
596        self.slots.iter().flatten().all(|s| !s.is_ready())
597    }
598}
599
600#[derive(Debug, Clone)]
601pub struct AbilityUseResult {
602    pub ability: Ability,
603    pub instant: bool,
604}
605
606#[derive(Debug, Clone)]
607pub enum AbilityCastError {
608    NoAbilityInSlot,
609    OnCooldown { remaining: f32 },
610    AlreadyCasting,
611    NotEnoughResource(ResourceType),
612    NotEnoughComboPoints { need: u32, have: u32 },
613    OutOfRange,
614    InvalidTarget,
615}
616
617impl std::fmt::Display for AbilityCastError {
618    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619        match self {
620            Self::NoAbilityInSlot             => write!(f, "No ability in that slot"),
621            Self::OnCooldown { remaining }    => write!(f, "On cooldown ({:.1}s)", remaining),
622            Self::AlreadyCasting              => write!(f, "Already casting"),
623            Self::NotEnoughResource(r)        => write!(f, "Not enough {}", r.name()),
624            Self::NotEnoughComboPoints { need, have } => write!(f, "Need {} combo points, have {}", need, have),
625            Self::OutOfRange                  => write!(f, "Target is out of range"),
626            Self::InvalidTarget               => write!(f, "Invalid target"),
627        }
628    }
629}
630
631// ── AbilityTree ───────────────────────────────────────────────────────────────
632
633#[derive(Debug, Clone)]
634pub struct AbilityNode {
635    pub id:           u32,
636    pub ability:      Ability,
637    pub required_points: u32,
638    pub prerequisites: Vec<u32>,  // node IDs that must be unlocked first
639    pub position:     (f32, f32),  // for UI layout
640    pub unlocked:     bool,
641}
642
643#[derive(Debug, Clone)]
644pub struct AbilityTree {
645    pub name:  String,
646    pub nodes: Vec<AbilityNode>,
647    pub spent_points: u32,
648}
649
650impl AbilityTree {
651    pub fn new(name: impl Into<String>) -> Self {
652        Self { name: name.into(), nodes: Vec::new(), spent_points: 0 }
653    }
654
655    pub fn add_node(&mut self, ability: Ability, required_points: u32, prereqs: Vec<u32>, pos: (f32, f32)) -> u32 {
656        let id = self.nodes.len() as u32;
657        self.nodes.push(AbilityNode {
658            id,
659            ability,
660            required_points,
661            prerequisites: prereqs,
662            position: pos,
663            unlocked: false,
664        });
665        id
666    }
667
668    pub fn can_unlock(&self, node_id: u32, available_points: u32) -> bool {
669        if let Some(node) = self.nodes.iter().find(|n| n.id == node_id) {
670            if node.unlocked { return false; }
671            if available_points < node.required_points + self.spent_points { return false; }
672            node.prerequisites.iter().all(|&prereq_id| {
673                self.nodes.iter().find(|n| n.id == prereq_id).map(|n| n.unlocked).unwrap_or(false)
674            })
675        } else {
676            false
677        }
678    }
679
680    pub fn unlock(&mut self, node_id: u32, available_points: u32) -> bool {
681        if !self.can_unlock(node_id, available_points) { return false; }
682        if let Some(node) = self.nodes.iter_mut().find(|n| n.id == node_id) {
683            node.unlocked = true;
684            self.spent_points += node.required_points;
685            true
686        } else {
687            false
688        }
689    }
690
691    pub fn unlocked_abilities(&self) -> Vec<&Ability> {
692        self.nodes.iter().filter(|n| n.unlocked).map(|n| &n.ability).collect()
693    }
694
695    pub fn available_nodes(&self, available_points: u32) -> Vec<u32> {
696        self.nodes.iter()
697            .filter(|n| !n.unlocked && self.can_unlock(n.id, available_points))
698            .map(|n| n.id)
699            .collect()
700    }
701}
702
703// ── Tests ──────────────────────────────────────────────────────────────────────
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn test_resource_pool_spend() {
711        let mut pool = ResourcePool::new(ResourceType::Mana, 100.0);
712        assert!(pool.spend(40.0));
713        assert!((pool.current - 60.0).abs() < 0.01);
714        assert!(!pool.spend(80.0));  // not enough
715    }
716
717    #[test]
718    fn test_ability_bar_use() {
719        let mut bar = AbilityBar::new();
720        bar.add_resource(ResourceType::Mana, 200.0);
721        bar.assign(0, Ability::fireball());
722        assert!(bar.can_use(0).is_ok());
723        let result = bar.use_ability(0);
724        assert!(result.is_ok());
725        // Ability should now be on cooldown
726        assert!(bar.can_use(0).is_err());
727    }
728
729    #[test]
730    fn test_ability_tree_unlock() {
731        let mut tree = AbilityTree::new("Mage");
732        let root = tree.add_node(Ability::fireball(), 1, vec![], (0.0, 0.0));
733        let branch = tree.add_node(Ability::blink(), 2, vec![root], (1.0, 0.0));
734
735        assert!(tree.unlock(root, 5));
736        assert!(!tree.unlock(branch, 2)); // not enough points spent yet
737        assert!(tree.unlock(branch, 10));
738        assert_eq!(tree.unlocked_abilities().len(), 2);
739    }
740
741    #[test]
742    fn test_cooldown_tracking() {
743        let mut state = AbilityState::new(Ability::fireball());
744        assert!(state.is_ready());
745        state.trigger();
746        assert!(!state.is_ready());
747        state.update(10.0);  // fast-forward past cooldown
748        assert!(state.is_ready());
749    }
750}