Skip to main content

proof_engine/character/
skills.rs

1// src/character/skills.rs
2// Skills, abilities, skill trees, cooldowns, and combos.
3
4use std::collections::HashMap;
5use crate::character::stats::{StatKind, StatModifier};
6
7// ---------------------------------------------------------------------------
8// SkillId
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct SkillId(pub u64);
13
14// ---------------------------------------------------------------------------
15// SkillType
16// ---------------------------------------------------------------------------
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum SkillType {
20    Active,
21    Passive,
22    Toggle,
23    Aura,
24    Reaction,
25    Ultimate,
26}
27
28// ---------------------------------------------------------------------------
29// Element & HealTarget & BuffTarget
30// ---------------------------------------------------------------------------
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum Element {
34    Physical,
35    Fire,
36    Ice,
37    Lightning,
38    Holy,
39    Dark,
40    Arcane,
41    Poison,
42    Nature,
43    Wind,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum HealTarget {
48    Self_,
49    SingleAlly,
50    AllAllies,
51    AreaAllies { radius: u32 },
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum BuffTarget {
56    Self_,
57    SingleAlly,
58    SingleEnemy,
59    AllAllies,
60    AllEnemies,
61    Area { radius: u32 },
62}
63
64// ---------------------------------------------------------------------------
65// SkillEffect — what a skill does when cast
66// ---------------------------------------------------------------------------
67
68#[derive(Debug, Clone)]
69pub enum SkillEffect {
70    Damage {
71        base_damage: f32,
72        ratio: f32,
73        element: Element,
74        aoe_radius: f32,
75        pierces: bool,
76    },
77    Heal {
78        base_heal: f32,
79        ratio: f32,
80        target: HealTarget,
81    },
82    Buff {
83        modifiers: Vec<StatModifier>,
84        duration_secs: f32,
85        target: BuffTarget,
86    },
87    Debuff {
88        modifiers: Vec<StatModifier>,
89        duration_secs: f32,
90        target: BuffTarget,
91    },
92    Summon {
93        entity_type: String,
94        count: u32,
95        duration_secs: f32,
96    },
97    Teleport {
98        range: f32,
99        blink: bool, // true = instant, false = cast-time warp
100    },
101    Zone {
102        radius: f32,
103        duration_secs: f32,
104        tick_interval: f32,
105        tick_effect: Box<SkillEffect>,
106    },
107    Projectile {
108        speed: f32,
109        pierce_count: u32,
110        split_count: u32,
111        element: Element,
112        damage: f32,
113    },
114    Chain {
115        max_targets: u32,
116        jump_range: f32,
117        damage_reduction: f32,
118        element: Element,
119        base_damage: f32,
120    },
121    Shield {
122        absorb_amount: f32,
123        duration_secs: f32,
124    },
125    Drain {
126        stat: DrainTarget,
127        amount: f32,
128        return_fraction: f32,
129    },
130    Composite(Vec<SkillEffect>),
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum DrainTarget {
135    Hp,
136    Mp,
137    Stamina,
138}
139
140impl SkillEffect {
141    pub fn is_damaging(&self) -> bool {
142        matches!(self, SkillEffect::Damage { .. } | SkillEffect::Projectile { .. } | SkillEffect::Chain { .. })
143    }
144
145    pub fn is_healing(&self) -> bool {
146        matches!(self, SkillEffect::Heal { .. })
147    }
148}
149
150// ---------------------------------------------------------------------------
151// SkillCost
152// ---------------------------------------------------------------------------
153
154#[derive(Debug, Clone, Default)]
155pub struct SkillCost {
156    pub mana: f32,
157    pub stamina: f32,
158    pub hp: f32,
159    pub cooldown_secs: f32,
160    pub cast_time_secs: f32,
161    pub channel_time_secs: f32,
162    pub skill_point_cost: u32,
163}
164
165impl SkillCost {
166    pub fn free() -> Self {
167        Self::default()
168    }
169
170    pub fn mana_cost(mana: f32, cooldown: f32) -> Self {
171        Self { mana, cooldown_secs: cooldown, ..Default::default() }
172    }
173
174    pub fn stamina_cost(stamina: f32, cooldown: f32) -> Self {
175        Self { stamina, cooldown_secs: cooldown, ..Default::default() }
176    }
177
178    pub fn with_cast_time(mut self, cast_time: f32) -> Self {
179        self.cast_time_secs = cast_time;
180        self
181    }
182}
183
184// ---------------------------------------------------------------------------
185// SkillRequirement
186// ---------------------------------------------------------------------------
187
188#[derive(Debug, Clone)]
189pub enum SkillRequirement {
190    Level(u32),
191    SkillRank { skill_id: SkillId, min_rank: u32 },
192    Stat { kind: StatKind, min_value: f32 },
193    ClassArchetype(String),
194}
195
196impl SkillRequirement {
197    pub fn check_level(&self, level: u32) -> bool {
198        match self {
199            SkillRequirement::Level(required) => level >= *required,
200            _ => true, // Other requirements checked elsewhere
201        }
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Skill
207// ---------------------------------------------------------------------------
208
209#[derive(Debug, Clone)]
210pub struct Skill {
211    pub id: SkillId,
212    pub name: String,
213    pub description: String,
214    pub icon_char: char,
215    pub skill_type: SkillType,
216    pub max_rank: u32,
217    pub requirements: Vec<SkillRequirement>,
218    pub effects_per_rank: Vec<SkillEffect>,
219    pub cost_per_rank: Vec<SkillCost>,
220    pub passive_modifiers: Vec<StatModifier>,
221    pub tags: Vec<String>,
222}
223
224impl Skill {
225    pub fn new(id: SkillId, name: impl Into<String>, skill_type: SkillType) -> Self {
226        Self {
227            id,
228            name: name.into(),
229            description: String::new(),
230            icon_char: '*',
231            skill_type,
232            max_rank: 5,
233            requirements: Vec::new(),
234            effects_per_rank: Vec::new(),
235            cost_per_rank: Vec::new(),
236            passive_modifiers: Vec::new(),
237            tags: Vec::new(),
238        }
239    }
240
241    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
242        self.description = desc.into();
243        self
244    }
245
246    pub fn with_icon(mut self, c: char) -> Self {
247        self.icon_char = c;
248        self
249    }
250
251    pub fn with_max_rank(mut self, rank: u32) -> Self {
252        self.max_rank = rank;
253        self
254    }
255
256    pub fn add_requirement(mut self, req: SkillRequirement) -> Self {
257        self.requirements.push(req);
258        self
259    }
260
261    pub fn add_rank_effect(mut self, effect: SkillEffect) -> Self {
262        self.effects_per_rank.push(effect);
263        self
264    }
265
266    pub fn add_rank_cost(mut self, cost: SkillCost) -> Self {
267        self.cost_per_rank.push(cost);
268        self
269    }
270
271    pub fn add_passive(mut self, modifier: StatModifier) -> Self {
272        self.passive_modifiers.push(modifier);
273        self
274    }
275
276    pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
277        self.tags.push(tag.into());
278        self
279    }
280
281    pub fn effect_at_rank(&self, rank: u32) -> Option<&SkillEffect> {
282        let idx = (rank as usize).saturating_sub(1).min(self.effects_per_rank.len().saturating_sub(1));
283        self.effects_per_rank.get(idx)
284    }
285
286    pub fn cost_at_rank(&self, rank: u32) -> Option<&SkillCost> {
287        let idx = (rank as usize).saturating_sub(1).min(self.cost_per_rank.len().saturating_sub(1));
288        self.cost_per_rank.get(idx)
289    }
290
291    pub fn cooldown_at_rank(&self, rank: u32) -> f32 {
292        self.cost_at_rank(rank).map(|c| c.cooldown_secs).unwrap_or(0.0)
293    }
294}
295
296// ---------------------------------------------------------------------------
297// SkillNode — a node in a skill tree
298// ---------------------------------------------------------------------------
299
300#[derive(Debug, Clone)]
301pub struct SkillNode {
302    pub skill: Skill,
303    pub position: (u8, u8),
304    pub unlocked: bool,
305    pub rank: u32,
306    pub prereqs: Vec<usize>, // indices into the tree's skill list
307}
308
309impl SkillNode {
310    pub fn new(skill: Skill, position: (u8, u8)) -> Self {
311        Self { skill, position, unlocked: false, rank: 0, prereqs: Vec::new() }
312    }
313
314    pub fn with_prereqs(mut self, prereqs: Vec<usize>) -> Self {
315        self.prereqs = prereqs;
316        self
317    }
318
319    pub fn is_available(&self, tree: &SkillTree) -> bool {
320        if self.prereqs.is_empty() { return true; }
321        self.prereqs.iter().all(|&idx| {
322            tree.nodes.get(idx).map(|n| n.rank > 0).unwrap_or(false)
323        })
324    }
325}
326
327// ---------------------------------------------------------------------------
328// SkillTree
329// ---------------------------------------------------------------------------
330
331#[derive(Debug, Clone)]
332pub struct SkillTree {
333    pub name: String,
334    pub nodes: Vec<SkillNode>,
335    pub connections: Vec<(usize, usize)>,
336}
337
338impl SkillTree {
339    pub fn new(name: impl Into<String>) -> Self {
340        Self { name: name.into(), nodes: Vec::new(), connections: Vec::new() }
341    }
342
343    pub fn add_node(mut self, node: SkillNode) -> Self {
344        self.nodes.push(node);
345        self
346    }
347
348    pub fn add_connection(mut self, from: usize, to: usize) -> Self {
349        self.connections.push((from, to));
350        self
351    }
352
353    pub fn total_points_spent(&self) -> u32 {
354        self.nodes.iter().map(|n| n.rank).sum()
355    }
356
357    pub fn find_by_id(&self, id: SkillId) -> Option<(usize, &SkillNode)> {
358        self.nodes.iter().enumerate().find(|(_, n)| n.skill.id == id)
359    }
360
361    pub fn find_by_id_mut(&mut self, id: SkillId) -> Option<(usize, &mut SkillNode)> {
362        self.nodes.iter_mut().enumerate().find(|(_, n)| n.skill.id == id)
363    }
364
365    pub fn available_nodes(&self) -> Vec<usize> {
366        (0..self.nodes.len())
367            .filter(|&i| self.nodes[i].is_available(self))
368            .collect()
369    }
370}
371
372// ---------------------------------------------------------------------------
373// SkillBook — a character's known skills
374// ---------------------------------------------------------------------------
375
376#[derive(Debug, Clone, Default)]
377pub struct SkillBook {
378    pub known: HashMap<SkillId, (Skill, u32)>, // skill_id -> (skill, rank)
379}
380
381impl SkillBook {
382    pub fn new() -> Self {
383        Self { known: HashMap::new() }
384    }
385
386    pub fn learn(&mut self, skill: Skill) -> bool {
387        if self.known.contains_key(&skill.id) { return false; }
388        self.known.insert(skill.id, (skill, 1));
389        true
390    }
391
392    pub fn upgrade(&mut self, skill_id: SkillId) -> bool {
393        if let Some((skill, rank)) = self.known.get_mut(&skill_id) {
394            if *rank < skill.max_rank {
395                *rank += 1;
396                return true;
397            }
398        }
399        false
400    }
401
402    pub fn forget(&mut self, skill_id: SkillId) -> Option<Skill> {
403        self.known.remove(&skill_id).map(|(s, _)| s)
404    }
405
406    pub fn rank_of(&self, skill_id: SkillId) -> u32 {
407        self.known.get(&skill_id).map(|(_, r)| *r).unwrap_or(0)
408    }
409
410    pub fn knows(&self, skill_id: SkillId) -> bool {
411        self.known.contains_key(&skill_id)
412    }
413
414    pub fn all_skills(&self) -> impl Iterator<Item = (&Skill, u32)> {
415        self.known.values().map(|(s, r)| (s, *r))
416    }
417
418    pub fn passive_skills(&self) -> impl Iterator<Item = (&Skill, u32)> {
419        self.known.values()
420            .filter(|(s, _)| s.skill_type == SkillType::Passive)
421            .map(|(s, r)| (s, *r))
422    }
423
424    pub fn active_skills(&self) -> impl Iterator<Item = (&Skill, u32)> {
425        self.known.values()
426            .filter(|(s, _)| s.skill_type == SkillType::Active || s.skill_type == SkillType::Ultimate)
427            .map(|(s, r)| (s, *r))
428    }
429
430    pub fn can_afford_upgrade(&self, skill_id: SkillId, skill_points: u32) -> bool {
431        if let Some((skill, rank)) = self.known.get(&skill_id) {
432            if *rank >= skill.max_rank { return false; }
433            let cost = skill.cost_at_rank(*rank + 1)
434                .map(|c| c.skill_point_cost)
435                .unwrap_or(1);
436            skill_points >= cost
437        } else {
438            false
439        }
440    }
441}
442
443// ---------------------------------------------------------------------------
444// Ability — an active skill bound to a hotkey
445// ---------------------------------------------------------------------------
446
447#[derive(Debug, Clone)]
448pub struct Ability {
449    pub skill_id: SkillId,
450    pub hotkey: u8,
451    pub override_icon: Option<char>,
452    pub override_name: Option<String>,
453}
454
455impl Ability {
456    pub fn new(skill_id: SkillId, hotkey: u8) -> Self {
457        Self { skill_id, hotkey, override_icon: None, override_name: None }
458    }
459}
460
461// ---------------------------------------------------------------------------
462// AbilityBar — 12-slot action bar
463// ---------------------------------------------------------------------------
464
465#[derive(Debug, Clone)]
466pub struct AbilityBar {
467    pub slots: [Option<Ability>; 12],
468}
469
470impl AbilityBar {
471    pub fn new() -> Self {
472        Self { slots: [const { None }; 12] }
473    }
474
475    pub fn bind(&mut self, slot: usize, ability: Ability) -> Option<Ability> {
476        if slot >= 12 { return None; }
477        let old = self.slots[slot].take();
478        self.slots[slot] = Some(ability);
479        old
480    }
481
482    pub fn unbind(&mut self, slot: usize) -> Option<Ability> {
483        if slot >= 12 { return None; }
484        self.slots[slot].take()
485    }
486
487    pub fn get(&self, slot: usize) -> Option<&Ability> {
488        self.slots.get(slot).and_then(|s| s.as_ref())
489    }
490
491    pub fn find_by_skill(&self, skill_id: SkillId) -> Option<usize> {
492        self.slots.iter().position(|s| s.as_ref().map(|a| a.skill_id) == Some(skill_id))
493    }
494
495    pub fn occupied_count(&self) -> usize {
496        self.slots.iter().filter(|s| s.is_some()).count()
497    }
498}
499
500impl Default for AbilityBar {
501    fn default() -> Self {
502        Self::new()
503    }
504}
505
506// ---------------------------------------------------------------------------
507// CooldownTracker
508// ---------------------------------------------------------------------------
509
510#[derive(Debug, Clone, Default)]
511pub struct CooldownTracker {
512    pub timers: HashMap<SkillId, f32>,
513}
514
515impl CooldownTracker {
516    pub fn new() -> Self {
517        Self { timers: HashMap::new() }
518    }
519
520    pub fn start(&mut self, skill_id: SkillId, duration: f32) {
521        self.timers.insert(skill_id, duration);
522    }
523
524    pub fn remaining(&self, skill_id: SkillId) -> f32 {
525        *self.timers.get(&skill_id).unwrap_or(&0.0)
526    }
527
528    pub fn is_ready(&self, skill_id: SkillId) -> bool {
529        self.remaining(skill_id) <= 0.0
530    }
531
532    pub fn tick(&mut self, dt: f32) {
533        for timer in self.timers.values_mut() {
534            *timer = (*timer - dt).max(0.0);
535        }
536    }
537
538    pub fn reduce(&mut self, skill_id: SkillId, amount: f32) {
539        if let Some(t) = self.timers.get_mut(&skill_id) {
540            *t = (*t - amount).max(0.0);
541        }
542    }
543
544    pub fn reset(&mut self, skill_id: SkillId) {
545        self.timers.remove(&skill_id);
546    }
547
548    pub fn reset_all(&mut self) {
549        self.timers.clear();
550    }
551
552    pub fn apply_cdr(&mut self, cdr_percent: f32) {
553        // Cooldown Reduction: remaining time = remaining * (1 - cdr)
554        let mult = (1.0 - cdr_percent / 100.0).max(0.0);
555        for timer in self.timers.values_mut() {
556            *timer *= mult;
557        }
558    }
559}
560
561// ---------------------------------------------------------------------------
562// Combo System — chained skill sequences
563// ---------------------------------------------------------------------------
564
565#[derive(Debug, Clone)]
566pub struct Combo {
567    pub name: String,
568    pub trigger_sequence: Vec<SkillId>,
569    pub bonus_effect: SkillEffect,
570    pub window_ms: f32,
571    pub reset_on_damage: bool,
572}
573
574impl Combo {
575    pub fn new(name: impl Into<String>, sequence: Vec<SkillId>, bonus: SkillEffect, window_ms: f32) -> Self {
576        Self {
577            name: name.into(),
578            trigger_sequence: sequence,
579            bonus_effect: bonus,
580            window_ms,
581            reset_on_damage: false,
582        }
583    }
584
585    pub fn matches(&self, recent: &[SkillId]) -> bool {
586        if recent.len() < self.trigger_sequence.len() { return false; }
587        let start = recent.len() - self.trigger_sequence.len();
588        &recent[start..] == self.trigger_sequence.as_slice()
589    }
590}
591
592#[derive(Debug, Clone)]
593pub struct ComboSystem {
594    pub combos: Vec<Combo>,
595    pub recent_skills: Vec<SkillId>,
596    pub last_skill_time: f32,
597    pub current_time: f32,
598    pub max_history: usize,
599}
600
601impl ComboSystem {
602    pub fn new() -> Self {
603        Self {
604            combos: Vec::new(),
605            recent_skills: Vec::new(),
606            last_skill_time: 0.0,
607            current_time: 0.0,
608            max_history: 8,
609        }
610    }
611
612    pub fn add_combo(&mut self, combo: Combo) {
613        self.combos.push(combo);
614    }
615
616    pub fn tick(&mut self, dt: f32) {
617        self.current_time += dt;
618    }
619
620    pub fn register_skill_use(&mut self, skill_id: SkillId) {
621        // Reset if window expired
622        if let Some(last) = self.recent_skills.last() {
623            let _ = last;
624            let elapsed_ms = (self.current_time - self.last_skill_time) * 1000.0;
625            let max_window = self.combos.iter().map(|c| c.window_ms).fold(0.0f32, f32::max);
626            if elapsed_ms > max_window && max_window > 0.0 {
627                self.recent_skills.clear();
628            }
629        }
630        self.recent_skills.push(skill_id);
631        self.last_skill_time = self.current_time;
632        if self.recent_skills.len() > self.max_history {
633            self.recent_skills.remove(0);
634        }
635    }
636
637    pub fn check_combos(&self) -> Vec<&Combo> {
638        let elapsed_ms = (self.current_time - self.last_skill_time) * 1000.0;
639        self.combos.iter()
640            .filter(|c| {
641                c.matches(&self.recent_skills) && elapsed_ms <= c.window_ms
642            })
643            .collect()
644    }
645
646    pub fn reset(&mut self) {
647        self.recent_skills.clear();
648    }
649}
650
651impl Default for ComboSystem {
652    fn default() -> Self {
653        Self::new()
654    }
655}
656
657// ---------------------------------------------------------------------------
658// Skill Presets — class archetypes with pre-built skill trees
659// ---------------------------------------------------------------------------
660
661pub struct SkillPresets;
662
663impl SkillPresets {
664    pub fn warrior_tree() -> SkillTree {
665        let slash = Skill::new(SkillId(1001), "Power Slash", SkillType::Active)
666            .with_description("A powerful slash dealing heavy physical damage.")
667            .with_icon('/')
668            .with_max_rank(5)
669            .add_rank_effect(SkillEffect::Damage { base_damage: 30.0, ratio: 1.5, element: Element::Physical, aoe_radius: 0.0, pierces: false })
670            .add_rank_cost(SkillCost::stamina_cost(20.0, 4.0))
671            .add_rank_effect(SkillEffect::Damage { base_damage: 45.0, ratio: 1.7, element: Element::Physical, aoe_radius: 0.0, pierces: false })
672            .add_rank_cost(SkillCost::stamina_cost(20.0, 3.8))
673            .add_rank_effect(SkillEffect::Damage { base_damage: 60.0, ratio: 1.9, element: Element::Physical, aoe_radius: 0.0, pierces: false })
674            .add_rank_cost(SkillCost::stamina_cost(22.0, 3.5))
675            .add_rank_effect(SkillEffect::Damage { base_damage: 80.0, ratio: 2.1, element: Element::Physical, aoe_radius: 0.0, pierces: false })
676            .add_rank_cost(SkillCost::stamina_cost(25.0, 3.2))
677            .add_rank_effect(SkillEffect::Damage { base_damage: 100.0, ratio: 2.5, element: Element::Physical, aoe_radius: 0.0, pierces: false })
678            .add_rank_cost(SkillCost::stamina_cost(30.0, 3.0))
679            .add_tag("melee");
680
681        let whirlwind = Skill::new(SkillId(1002), "Whirlwind", SkillType::Active)
682            .with_description("Spin and deal AoE damage to all nearby enemies.")
683            .with_icon('✦')
684            .with_max_rank(5)
685            .add_requirement(SkillRequirement::SkillRank { skill_id: SkillId(1001), min_rank: 2 })
686            .add_rank_effect(SkillEffect::Damage { base_damage: 20.0, ratio: 1.0, element: Element::Physical, aoe_radius: 3.0, pierces: false })
687            .add_rank_cost(SkillCost::stamina_cost(35.0, 8.0))
688            .add_rank_effect(SkillEffect::Damage { base_damage: 30.0, ratio: 1.2, element: Element::Physical, aoe_radius: 3.5, pierces: false })
689            .add_rank_cost(SkillCost::stamina_cost(35.0, 7.5))
690            .add_rank_effect(SkillEffect::Damage { base_damage: 45.0, ratio: 1.4, element: Element::Physical, aoe_radius: 4.0, pierces: false })
691            .add_rank_cost(SkillCost::stamina_cost(38.0, 7.0))
692            .add_rank_effect(SkillEffect::Damage { base_damage: 60.0, ratio: 1.6, element: Element::Physical, aoe_radius: 4.5, pierces: false })
693            .add_rank_cost(SkillCost::stamina_cost(40.0, 6.5))
694            .add_rank_effect(SkillEffect::Damage { base_damage: 80.0, ratio: 2.0, element: Element::Physical, aoe_radius: 5.0, pierces: false })
695            .add_rank_cost(SkillCost::stamina_cost(45.0, 6.0))
696            .add_tag("melee").add_tag("aoe");
697
698        let battle_cry = Skill::new(SkillId(1003), "Battle Cry", SkillType::Active)
699            .with_description("Rally allies, granting bonus attack for 30 seconds.")
700            .with_icon('!')
701            .with_max_rank(3)
702            .add_rank_effect(SkillEffect::Buff {
703                modifiers: vec![StatModifier::percent("battle_cry", StatKind::PhysicalAttack, 0.1)],
704                duration_secs: 30.0,
705                target: BuffTarget::AllAllies,
706            })
707            .add_rank_cost(SkillCost::stamina_cost(30.0, 60.0))
708            .add_tag("support");
709
710        let iron_skin = Skill::new(SkillId(1004), "Iron Skin", SkillType::Passive)
711            .with_description("Passive increase to Defense.")
712            .with_icon('Ω')
713            .with_max_rank(5)
714            .add_passive(StatModifier::flat("iron_skin", StatKind::Defense, 5.0));
715
716        let berserker_rage = Skill::new(SkillId(1005), "Berserker Rage", SkillType::Toggle)
717            .with_description("Enter a rage state: more damage, less defense.")
718            .with_icon('Ψ')
719            .with_max_rank(1)
720            .add_requirement(SkillRequirement::Level(10))
721            .add_rank_effect(SkillEffect::Composite(vec![
722                SkillEffect::Buff {
723                    modifiers: vec![StatModifier::percent("berserk", StatKind::PhysicalAttack, 0.3)],
724                    duration_secs: f32::MAX,
725                    target: BuffTarget::Self_,
726                },
727                SkillEffect::Debuff {
728                    modifiers: vec![StatModifier::percent("berserk_def", StatKind::Defense, -0.2)],
729                    duration_secs: f32::MAX,
730                    target: BuffTarget::Self_,
731                },
732            ]))
733            .add_rank_cost(SkillCost::free());
734
735        SkillTree::new("Warrior")
736            .add_node(SkillNode::new(slash, (2, 0)))
737            .add_node(SkillNode::new(whirlwind, (2, 1)).with_prereqs(vec![0]))
738            .add_node(SkillNode::new(battle_cry, (1, 1)))
739            .add_node(SkillNode::new(iron_skin, (3, 0)))
740            .add_node(SkillNode::new(berserker_rage, (2, 2)).with_prereqs(vec![1]))
741            .add_connection(0, 1)
742            .add_connection(1, 4)
743    }
744
745    pub fn mage_tree() -> SkillTree {
746        let fireball = Skill::new(SkillId(2001), "Fireball", SkillType::Active)
747            .with_description("Hurl a flaming orb at your enemies.")
748            .with_icon('o')
749            .with_max_rank(5)
750            .add_rank_effect(SkillEffect::Projectile { speed: 15.0, pierce_count: 0, split_count: 0, element: Element::Fire, damage: 40.0 })
751            .add_rank_cost(SkillCost::mana_cost(25.0, 3.0).with_cast_time(0.8))
752            .add_rank_effect(SkillEffect::Projectile { speed: 15.0, pierce_count: 0, split_count: 0, element: Element::Fire, damage: 60.0 })
753            .add_rank_cost(SkillCost::mana_cost(25.0, 2.8).with_cast_time(0.75))
754            .add_rank_effect(SkillEffect::Projectile { speed: 17.0, pierce_count: 0, split_count: 1, element: Element::Fire, damage: 80.0 })
755            .add_rank_cost(SkillCost::mana_cost(28.0, 2.5).with_cast_time(0.7))
756            .add_rank_effect(SkillEffect::Projectile { speed: 17.0, pierce_count: 0, split_count: 1, element: Element::Fire, damage: 100.0 })
757            .add_rank_cost(SkillCost::mana_cost(30.0, 2.3).with_cast_time(0.65))
758            .add_rank_effect(SkillEffect::Projectile { speed: 20.0, pierce_count: 0, split_count: 2, element: Element::Fire, damage: 130.0 })
759            .add_rank_cost(SkillCost::mana_cost(35.0, 2.0).with_cast_time(0.6))
760            .add_tag("fire").add_tag("ranged");
761
762        let ice_shard = Skill::new(SkillId(2002), "Ice Shard", SkillType::Active)
763            .with_description("Launch a shard of ice that pierces through enemies.")
764            .with_icon('*')
765            .with_max_rank(5)
766            .add_rank_effect(SkillEffect::Projectile { speed: 20.0, pierce_count: 2, split_count: 0, element: Element::Ice, damage: 30.0 })
767            .add_rank_cost(SkillCost::mana_cost(20.0, 2.5))
768            .add_rank_effect(SkillEffect::Projectile { speed: 20.0, pierce_count: 3, split_count: 0, element: Element::Ice, damage: 45.0 })
769            .add_rank_cost(SkillCost::mana_cost(22.0, 2.3))
770            .add_rank_effect(SkillEffect::Projectile { speed: 22.0, pierce_count: 3, split_count: 0, element: Element::Ice, damage: 60.0 })
771            .add_rank_cost(SkillCost::mana_cost(24.0, 2.1))
772            .add_rank_effect(SkillEffect::Projectile { speed: 22.0, pierce_count: 4, split_count: 0, element: Element::Ice, damage: 80.0 })
773            .add_rank_cost(SkillCost::mana_cost(26.0, 1.9))
774            .add_rank_effect(SkillEffect::Projectile { speed: 25.0, pierce_count: 5, split_count: 0, element: Element::Ice, damage: 100.0 })
775            .add_rank_cost(SkillCost::mana_cost(30.0, 1.7))
776            .add_tag("ice").add_tag("ranged");
777
778        let arcane_shield = Skill::new(SkillId(2003), "Arcane Shield", SkillType::Active)
779            .with_description("Create a barrier that absorbs incoming damage.")
780            .with_icon('Ω')
781            .with_max_rank(3)
782            .add_rank_effect(SkillEffect::Shield { absorb_amount: 100.0, duration_secs: 10.0 })
783            .add_rank_cost(SkillCost::mana_cost(40.0, 30.0).with_cast_time(0.5))
784            .add_rank_effect(SkillEffect::Shield { absorb_amount: 175.0, duration_secs: 12.0 })
785            .add_rank_cost(SkillCost::mana_cost(40.0, 28.0).with_cast_time(0.4))
786            .add_rank_effect(SkillEffect::Shield { absorb_amount: 280.0, duration_secs: 15.0 })
787            .add_rank_cost(SkillCost::mana_cost(45.0, 25.0).with_cast_time(0.3));
788
789        let mana_mastery = Skill::new(SkillId(2004), "Mana Mastery", SkillType::Passive)
790            .with_description("Reduces mana cost of all spells.")
791            .with_icon('M')
792            .with_max_rank(5)
793            .add_passive(StatModifier::percent("mana_mastery", StatKind::MaxMp, 0.05));
794
795        let blink = Skill::new(SkillId(2005), "Blink", SkillType::Active)
796            .with_description("Instantly teleport a short distance.")
797            .with_icon('→')
798            .with_max_rank(3)
799            .add_requirement(SkillRequirement::Level(8))
800            .add_rank_effect(SkillEffect::Teleport { range: 8.0, blink: true })
801            .add_rank_cost(SkillCost::mana_cost(30.0, 15.0))
802            .add_rank_effect(SkillEffect::Teleport { range: 12.0, blink: true })
803            .add_rank_cost(SkillCost::mana_cost(28.0, 12.0))
804            .add_rank_effect(SkillEffect::Teleport { range: 16.0, blink: true })
805            .add_rank_cost(SkillCost::mana_cost(25.0, 10.0));
806
807        let chain_lightning = Skill::new(SkillId(2006), "Chain Lightning", SkillType::Active)
808            .with_description("Lightning that jumps between enemies.")
809            .with_icon('~')
810            .with_max_rank(5)
811            .add_requirement(SkillRequirement::Level(15))
812            .add_rank_effect(SkillEffect::Chain { max_targets: 3, jump_range: 5.0, damage_reduction: 0.2, element: Element::Lightning, base_damage: 50.0 })
813            .add_rank_cost(SkillCost::mana_cost(45.0, 8.0).with_cast_time(1.0))
814            .add_rank_effect(SkillEffect::Chain { max_targets: 4, jump_range: 5.5, damage_reduction: 0.18, element: Element::Lightning, base_damage: 70.0 })
815            .add_rank_cost(SkillCost::mana_cost(47.0, 7.5).with_cast_time(0.9))
816            .add_rank_effect(SkillEffect::Chain { max_targets: 5, jump_range: 6.0, damage_reduction: 0.15, element: Element::Lightning, base_damage: 90.0 })
817            .add_rank_cost(SkillCost::mana_cost(50.0, 7.0).with_cast_time(0.8))
818            .add_rank_effect(SkillEffect::Chain { max_targets: 6, jump_range: 6.5, damage_reduction: 0.12, element: Element::Lightning, base_damage: 115.0 })
819            .add_rank_cost(SkillCost::mana_cost(53.0, 6.5).with_cast_time(0.75))
820            .add_rank_effect(SkillEffect::Chain { max_targets: 8, jump_range: 7.0, damage_reduction: 0.10, element: Element::Lightning, base_damage: 145.0 })
821            .add_rank_cost(SkillCost::mana_cost(58.0, 6.0).with_cast_time(0.7))
822            .add_tag("lightning").add_tag("aoe");
823
824        SkillTree::new("Mage")
825            .add_node(SkillNode::new(fireball, (1, 0)))
826            .add_node(SkillNode::new(ice_shard, (3, 0)))
827            .add_node(SkillNode::new(arcane_shield, (2, 0)))
828            .add_node(SkillNode::new(mana_mastery, (2, 1)))
829            .add_node(SkillNode::new(blink, (2, 2)))
830            .add_node(SkillNode::new(chain_lightning, (1, 2)).with_prereqs(vec![0]))
831            .add_connection(0, 5)
832            .add_connection(2, 3)
833            .add_connection(3, 4)
834    }
835
836    pub fn rogue_tree() -> SkillTree {
837        let backstab = Skill::new(SkillId(3001), "Backstab", SkillType::Active)
838            .with_description("Deal massive damage from stealth or behind the target.")
839            .with_icon('↑')
840            .with_max_rank(5)
841            .add_rank_effect(SkillEffect::Damage { base_damage: 50.0, ratio: 2.0, element: Element::Physical, aoe_radius: 0.0, pierces: false })
842            .add_rank_cost(SkillCost::stamina_cost(25.0, 6.0))
843            .add_rank_effect(SkillEffect::Damage { base_damage: 70.0, ratio: 2.3, element: Element::Physical, aoe_radius: 0.0, pierces: false })
844            .add_rank_cost(SkillCost::stamina_cost(25.0, 5.5))
845            .add_rank_effect(SkillEffect::Damage { base_damage: 95.0, ratio: 2.6, element: Element::Physical, aoe_radius: 0.0, pierces: false })
846            .add_rank_cost(SkillCost::stamina_cost(27.0, 5.0))
847            .add_rank_effect(SkillEffect::Damage { base_damage: 125.0, ratio: 3.0, element: Element::Physical, aoe_radius: 0.0, pierces: false })
848            .add_rank_cost(SkillCost::stamina_cost(28.0, 4.5))
849            .add_rank_effect(SkillEffect::Damage { base_damage: 160.0, ratio: 3.5, element: Element::Physical, aoe_radius: 0.0, pierces: false })
850            .add_rank_cost(SkillCost::stamina_cost(30.0, 4.0))
851            .add_tag("melee").add_tag("stealth");
852
853        let poison_blade = Skill::new(SkillId(3002), "Poison Blade", SkillType::Active)
854            .with_description("Coat your blade in poison, applying DoT.")
855            .with_icon('¥')
856            .with_max_rank(5)
857            .add_rank_effect(SkillEffect::Zone {
858                radius: 0.0,
859                duration_secs: 10.0,
860                tick_interval: 1.0,
861                tick_effect: Box::new(SkillEffect::Damage { base_damage: 8.0, ratio: 0.3, element: Element::Poison, aoe_radius: 0.0, pierces: false }),
862            })
863            .add_rank_cost(SkillCost::stamina_cost(15.0, 12.0))
864            .add_tag("poison");
865
866        let evasion_skill = Skill::new(SkillId(3003), "Evasion", SkillType::Passive)
867            .with_description("Permanently increases evasion.")
868            .with_icon('E')
869            .with_max_rank(5)
870            .add_passive(StatModifier::flat("evasion_skill", StatKind::Evasion, 3.0));
871
872        let shadowstep = Skill::new(SkillId(3004), "Shadowstep", SkillType::Active)
873            .with_description("Teleport behind a target enemy.")
874            .with_icon('↓')
875            .with_max_rank(3)
876            .add_requirement(SkillRequirement::Level(12))
877            .add_rank_effect(SkillEffect::Teleport { range: 6.0, blink: true })
878            .add_rank_cost(SkillCost::stamina_cost(35.0, 20.0))
879            .add_tag("movement");
880
881        SkillTree::new("Rogue")
882            .add_node(SkillNode::new(backstab, (2, 0)))
883            .add_node(SkillNode::new(poison_blade, (1, 0)))
884            .add_node(SkillNode::new(evasion_skill, (3, 0)))
885            .add_node(SkillNode::new(shadowstep, (2, 1)).with_prereqs(vec![0]))
886            .add_connection(0, 3)
887    }
888
889    pub fn healer_tree() -> SkillTree {
890        let holy_light = Skill::new(SkillId(4001), "Holy Light", SkillType::Active)
891            .with_description("Call down a beam of healing light on a target.")
892            .with_icon('+')
893            .with_max_rank(5)
894            .add_rank_effect(SkillEffect::Heal { base_heal: 60.0, ratio: 1.5, target: HealTarget::SingleAlly })
895            .add_rank_cost(SkillCost::mana_cost(30.0, 4.0).with_cast_time(1.0))
896            .add_rank_effect(SkillEffect::Heal { base_heal: 90.0, ratio: 1.7, target: HealTarget::SingleAlly })
897            .add_rank_cost(SkillCost::mana_cost(30.0, 3.8).with_cast_time(0.9))
898            .add_rank_effect(SkillEffect::Heal { base_heal: 120.0, ratio: 2.0, target: HealTarget::SingleAlly })
899            .add_rank_cost(SkillCost::mana_cost(32.0, 3.5).with_cast_time(0.8))
900            .add_rank_effect(SkillEffect::Heal { base_heal: 160.0, ratio: 2.3, target: HealTarget::SingleAlly })
901            .add_rank_cost(SkillCost::mana_cost(35.0, 3.3).with_cast_time(0.7))
902            .add_rank_effect(SkillEffect::Heal { base_heal: 200.0, ratio: 2.8, target: HealTarget::SingleAlly })
903            .add_rank_cost(SkillCost::mana_cost(38.0, 3.0).with_cast_time(0.6));
904
905        let renew = Skill::new(SkillId(4002), "Renew", SkillType::Active)
906            .with_description("Apply a regeneration aura to an ally.")
907            .with_icon('R')
908            .with_max_rank(5)
909            .add_rank_effect(SkillEffect::Zone {
910                radius: 0.0,
911                duration_secs: 15.0,
912                tick_interval: 1.0,
913                tick_effect: Box::new(SkillEffect::Heal { base_heal: 10.0, ratio: 0.2, target: HealTarget::SingleAlly }),
914            })
915            .add_rank_cost(SkillCost::mana_cost(20.0, 15.0));
916
917        let mass_heal = Skill::new(SkillId(4003), "Mass Heal", SkillType::Active)
918            .with_description("Heal all allies in range.")
919            .with_icon('H')
920            .with_max_rank(3)
921            .add_requirement(SkillRequirement::Level(15))
922            .add_rank_effect(SkillEffect::Heal { base_heal: 80.0, ratio: 1.2, target: HealTarget::AllAllies })
923            .add_rank_cost(SkillCost::mana_cost(70.0, 30.0).with_cast_time(2.0))
924            .add_rank_effect(SkillEffect::Heal { base_heal: 120.0, ratio: 1.5, target: HealTarget::AllAllies })
925            .add_rank_cost(SkillCost::mana_cost(70.0, 28.0).with_cast_time(1.8))
926            .add_rank_effect(SkillEffect::Heal { base_heal: 180.0, ratio: 2.0, target: HealTarget::AllAllies })
927            .add_rank_cost(SkillCost::mana_cost(75.0, 25.0).with_cast_time(1.5));
928
929        let divine_favor = Skill::new(SkillId(4004), "Divine Favor", SkillType::Passive)
930            .with_description("Increases healing output.")
931            .with_icon('†')
932            .with_max_rank(5)
933            .add_passive(StatModifier::flat("divine_favor", StatKind::Wisdom, 2.0));
934
935        SkillTree::new("Healer")
936            .add_node(SkillNode::new(holy_light, (2, 0)))
937            .add_node(SkillNode::new(renew, (1, 0)))
938            .add_node(SkillNode::new(mass_heal, (2, 1)).with_prereqs(vec![0]))
939            .add_node(SkillNode::new(divine_favor, (3, 0)))
940            .add_connection(0, 2)
941    }
942
943    pub fn summoner_tree() -> SkillTree {
944        let summon_wolf = Skill::new(SkillId(5001), "Summon Wolf", SkillType::Active)
945            .with_description("Summon a wolf companion to fight for you.")
946            .with_icon('W')
947            .with_max_rank(5)
948            .add_rank_effect(SkillEffect::Summon { entity_type: "wolf".to_string(), count: 1, duration_secs: 60.0 })
949            .add_rank_cost(SkillCost::mana_cost(50.0, 30.0).with_cast_time(2.0))
950            .add_rank_effect(SkillEffect::Summon { entity_type: "wolf".to_string(), count: 1, duration_secs: 90.0 })
951            .add_rank_cost(SkillCost::mana_cost(50.0, 28.0).with_cast_time(1.8))
952            .add_rank_effect(SkillEffect::Summon { entity_type: "dire_wolf".to_string(), count: 1, duration_secs: 120.0 })
953            .add_rank_cost(SkillCost::mana_cost(55.0, 25.0).with_cast_time(1.5))
954            .add_rank_effect(SkillEffect::Summon { entity_type: "dire_wolf".to_string(), count: 2, duration_secs: 150.0 })
955            .add_rank_cost(SkillCost::mana_cost(60.0, 23.0).with_cast_time(1.3))
956            .add_rank_effect(SkillEffect::Summon { entity_type: "shadow_wolf".to_string(), count: 2, duration_secs: 180.0 })
957            .add_rank_cost(SkillCost::mana_cost(70.0, 20.0).with_cast_time(1.0));
958
959        let summon_golem = Skill::new(SkillId(5002), "Summon Stone Golem", SkillType::Active)
960            .with_description("Summon a powerful stone golem tank.")
961            .with_icon('G')
962            .with_max_rank(3)
963            .add_requirement(SkillRequirement::Level(10))
964            .add_rank_effect(SkillEffect::Summon { entity_type: "stone_golem".to_string(), count: 1, duration_secs: 120.0 })
965            .add_rank_cost(SkillCost::mana_cost(80.0, 60.0).with_cast_time(3.0))
966            .add_rank_effect(SkillEffect::Summon { entity_type: "iron_golem".to_string(), count: 1, duration_secs: 150.0 })
967            .add_rank_cost(SkillCost::mana_cost(85.0, 55.0).with_cast_time(2.5))
968            .add_rank_effect(SkillEffect::Summon { entity_type: "crystal_golem".to_string(), count: 1, duration_secs: 200.0 })
969            .add_rank_cost(SkillCost::mana_cost(90.0, 50.0).with_cast_time(2.0));
970
971        let bond = Skill::new(SkillId(5003), "Empathic Bond", SkillType::Passive)
972            .with_description("Your summons gain more of your stats.")
973            .with_icon('∞')
974            .with_max_rank(5)
975            .add_passive(StatModifier::flat("bond", StatKind::Charisma, 3.0));
976
977        SkillTree::new("Summoner")
978            .add_node(SkillNode::new(summon_wolf, (1, 0)))
979            .add_node(SkillNode::new(summon_golem, (3, 0)))
980            .add_node(SkillNode::new(bond, (2, 0)))
981            .add_connection(0, 1)
982    }
983
984    /// Build default combos for a warrior
985    pub fn warrior_combos() -> Vec<Combo> {
986        vec![
987            Combo::new(
988                "Unstoppable Force",
989                vec![SkillId(1001), SkillId(1001), SkillId(1002)],
990                SkillEffect::Damage { base_damage: 200.0, ratio: 3.0, element: Element::Physical, aoe_radius: 5.0, pierces: true },
991                2000.0,
992            ),
993            Combo::new(
994                "Warcry Slash",
995                vec![SkillId(1003), SkillId(1001)],
996                SkillEffect::Damage { base_damage: 50.0, ratio: 1.5, element: Element::Physical, aoe_radius: 0.0, pierces: false },
997                1500.0,
998            ),
999        ]
1000    }
1001
1002    pub fn mage_combos() -> Vec<Combo> {
1003        vec![
1004            Combo::new(
1005                "Frozen Inferno",
1006                vec![SkillId(2002), SkillId(2001)],
1007                SkillEffect::Damage { base_damage: 120.0, ratio: 2.5, element: Element::Arcane, aoe_radius: 3.0, pierces: false },
1008                1500.0,
1009            ),
1010        ]
1011    }
1012}
1013
1014// ---------------------------------------------------------------------------
1015// Tests
1016// ---------------------------------------------------------------------------
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021
1022    #[test]
1023    fn test_skill_id_equality() {
1024        assert_eq!(SkillId(1), SkillId(1));
1025        assert_ne!(SkillId(1), SkillId(2));
1026    }
1027
1028    #[test]
1029    fn test_skill_effect_at_rank() {
1030        let tree = SkillPresets::warrior_tree();
1031        let node = &tree.nodes[0]; // Power Slash
1032        let eff = node.skill.effect_at_rank(1);
1033        assert!(eff.is_some());
1034        assert!(eff.unwrap().is_damaging());
1035    }
1036
1037    #[test]
1038    fn test_skill_book_learn() {
1039        let mut book = SkillBook::new();
1040        let skill = Skill::new(SkillId(1), "Test", SkillType::Active);
1041        assert!(book.learn(skill.clone()));
1042        assert!(!book.learn(skill)); // Can't learn twice
1043    }
1044
1045    #[test]
1046    fn test_skill_book_upgrade() {
1047        let mut book = SkillBook::new();
1048        let skill = Skill::new(SkillId(1), "Test", SkillType::Active).with_max_rank(3);
1049        book.learn(skill);
1050        assert!(book.upgrade(SkillId(1)));
1051        assert_eq!(book.rank_of(SkillId(1)), 2);
1052    }
1053
1054    #[test]
1055    fn test_skill_book_max_rank() {
1056        let mut book = SkillBook::new();
1057        let skill = Skill::new(SkillId(1), "Test", SkillType::Active).with_max_rank(1);
1058        book.learn(skill);
1059        assert!(!book.upgrade(SkillId(1))); // Already at max rank 1
1060    }
1061
1062    #[test]
1063    fn test_ability_bar_bind_unbind() {
1064        let mut bar = AbilityBar::new();
1065        bar.bind(0, Ability::new(SkillId(1), 0));
1066        assert!(bar.get(0).is_some());
1067        bar.unbind(0);
1068        assert!(bar.get(0).is_none());
1069    }
1070
1071    #[test]
1072    fn test_cooldown_tracker() {
1073        let mut tracker = CooldownTracker::new();
1074        tracker.start(SkillId(1), 5.0);
1075        assert!(!tracker.is_ready(SkillId(1)));
1076        tracker.tick(3.0);
1077        assert!((tracker.remaining(SkillId(1)) - 2.0).abs() < 0.001);
1078        tracker.tick(2.0);
1079        assert!(tracker.is_ready(SkillId(1)));
1080    }
1081
1082    #[test]
1083    fn test_cooldown_tracker_reduce() {
1084        let mut tracker = CooldownTracker::new();
1085        tracker.start(SkillId(1), 10.0);
1086        tracker.reduce(SkillId(1), 5.0);
1087        assert!((tracker.remaining(SkillId(1)) - 5.0).abs() < 0.001);
1088    }
1089
1090    #[test]
1091    fn test_combo_matches() {
1092        let combo = Combo::new(
1093            "Test",
1094            vec![SkillId(1), SkillId(2), SkillId(3)],
1095            SkillEffect::Damage { base_damage: 100.0, ratio: 1.0, element: Element::Physical, aoe_radius: 0.0, pierces: false },
1096            2000.0,
1097        );
1098        let seq = vec![SkillId(5), SkillId(1), SkillId(2), SkillId(3)];
1099        assert!(combo.matches(&seq));
1100        let bad_seq = vec![SkillId(1), SkillId(2)];
1101        assert!(!combo.matches(&bad_seq));
1102    }
1103
1104    #[test]
1105    fn test_combo_system_detects_combo() {
1106        let mut sys = ComboSystem::new();
1107        sys.add_combo(Combo::new(
1108            "Test",
1109            vec![SkillId(1001), SkillId(1002)],
1110            SkillEffect::Damage { base_damage: 50.0, ratio: 1.0, element: Element::Physical, aoe_radius: 0.0, pierces: false },
1111            2000.0,
1112        ));
1113        sys.register_skill_use(SkillId(1001));
1114        sys.register_skill_use(SkillId(1002));
1115        let found = sys.check_combos();
1116        assert_eq!(found.len(), 1);
1117    }
1118
1119    #[test]
1120    fn test_skill_tree_warrior_available() {
1121        let tree = SkillPresets::warrior_tree();
1122        let available = tree.available_nodes();
1123        assert!(!available.is_empty());
1124    }
1125
1126    #[test]
1127    fn test_skill_tree_total_points() {
1128        let tree = SkillPresets::mage_tree();
1129        assert_eq!(tree.total_points_spent(), 0);
1130    }
1131}