Skip to main content

proof_engine/editor/
ability_editor.rs

1#[allow(dead_code, unused_variables, unused_mut, unused_imports)]
2
3use glam::{Vec2, Vec3, Vec4, Quat, Mat4};
4use std::collections::{HashMap, VecDeque, HashSet, BTreeMap};
5
6// ============================================================
7// ABILITY TYPES (20+)
8// ============================================================
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum AbilityType {
12    MeleeAttack,
13    RangedAttack,
14    AreaOfEffect,
15    Cone,
16    Line,
17    Nova,
18    Projectile,
19    Beam,
20    Teleport,
21    Summon,
22    Buff,
23    Debuff,
24    Heal,
25    Shield,
26    Terrain,
27    Transform,
28    Stealth,
29    Channel,
30    Charge,
31    Dash,
32    Trap,
33    Mine,
34    Totem,
35    Warcry,
36    Curse,
37    Brand,
38    Stance,
39    Pet,
40    Vortex,
41    ChainLightning,
42}
43
44impl AbilityType {
45    pub fn display_name(&self) -> &'static str {
46        match self {
47            AbilityType::MeleeAttack => "Melee Attack",
48            AbilityType::RangedAttack => "Ranged Attack",
49            AbilityType::AreaOfEffect => "Area of Effect",
50            AbilityType::Cone => "Cone",
51            AbilityType::Line => "Line / Ray",
52            AbilityType::Nova => "Nova",
53            AbilityType::Projectile => "Projectile",
54            AbilityType::Beam => "Beam",
55            AbilityType::Teleport => "Teleport",
56            AbilityType::Summon => "Summon",
57            AbilityType::Buff => "Buff",
58            AbilityType::Debuff => "Debuff",
59            AbilityType::Heal => "Heal",
60            AbilityType::Shield => "Shield",
61            AbilityType::Terrain => "Terrain Alteration",
62            AbilityType::Transform => "Transform",
63            AbilityType::Stealth => "Stealth",
64            AbilityType::Channel => "Channel",
65            AbilityType::Charge => "Charge",
66            AbilityType::Dash => "Dash",
67            AbilityType::Trap => "Trap",
68            AbilityType::Mine => "Mine",
69            AbilityType::Totem => "Totem",
70            AbilityType::Warcry => "Warcry",
71            AbilityType::Curse => "Curse",
72            AbilityType::Brand => "Brand",
73            AbilityType::Stance => "Stance",
74            AbilityType::Pet => "Pet",
75            AbilityType::Vortex => "Vortex",
76            AbilityType::ChainLightning => "Chain Lightning",
77        }
78    }
79
80    pub fn is_movement(&self) -> bool {
81        matches!(self, AbilityType::Teleport | AbilityType::Dash | AbilityType::Charge)
82    }
83
84    pub fn is_damage_dealing(&self) -> bool {
85        matches!(self,
86            AbilityType::MeleeAttack | AbilityType::RangedAttack | AbilityType::AreaOfEffect |
87            AbilityType::Cone | AbilityType::Line | AbilityType::Nova | AbilityType::Projectile |
88            AbilityType::Beam | AbilityType::Charge | AbilityType::ChainLightning | AbilityType::Vortex
89        )
90    }
91
92    pub fn can_have_projectile(&self) -> bool {
93        matches!(self, AbilityType::RangedAttack | AbilityType::Projectile | AbilityType::ChainLightning)
94    }
95
96    pub fn default_targeting(&self) -> TargetingMode {
97        match self {
98            AbilityType::MeleeAttack => TargetingMode::SingleTarget,
99            AbilityType::RangedAttack => TargetingMode::SingleTarget,
100            AbilityType::AreaOfEffect => TargetingMode::GroundTarget { indicator: AreaIndicator::Circle },
101            AbilityType::Cone => TargetingMode::Cone { half_angle_deg: 30.0, distance: 8.0 },
102            AbilityType::Line => TargetingMode::Rectangle { width: 2.0, length: 15.0 },
103            AbilityType::Nova => TargetingMode::Self_,
104            AbilityType::Projectile => TargetingMode::SingleTarget,
105            AbilityType::Beam => TargetingMode::SingleTarget,
106            AbilityType::Teleport => TargetingMode::GroundTarget { indicator: AreaIndicator::Point },
107            AbilityType::Summon => TargetingMode::GroundTarget { indicator: AreaIndicator::Point },
108            AbilityType::Buff | AbilityType::Stance | AbilityType::Transform | AbilityType::Stealth => TargetingMode::Self_,
109            AbilityType::Heal => TargetingMode::SingleTarget,
110            AbilityType::Shield => TargetingMode::Self_,
111            AbilityType::Terrain => TargetingMode::GroundTarget { indicator: AreaIndicator::Circle },
112            AbilityType::Channel => TargetingMode::SingleTarget,
113            AbilityType::Charge | AbilityType::Dash => TargetingMode::SingleTarget,
114            AbilityType::Trap | AbilityType::Mine => TargetingMode::GroundTarget { indicator: AreaIndicator::Point },
115            AbilityType::Totem => TargetingMode::GroundTarget { indicator: AreaIndicator::Point },
116            AbilityType::Warcry | AbilityType::Debuff | AbilityType::Curse => TargetingMode::Self_,
117            AbilityType::Brand => TargetingMode::SingleTarget,
118            AbilityType::Pet => TargetingMode::Self_,
119            AbilityType::Vortex => TargetingMode::GroundTarget { indicator: AreaIndicator::Circle },
120            AbilityType::ChainLightning => TargetingMode::SingleTarget,
121        }
122    }
123}
124
125// ============================================================
126// TARGETING SYSTEM
127// ============================================================
128
129#[derive(Debug, Clone, PartialEq)]
130pub enum TargetingMode {
131    Self_,
132    SingleTarget,
133    MultiTarget { max_targets: u32, radius: f32 },
134    AoECircle { radius: f32 },
135    AoESphere { radius: f32 },
136    Cone { half_angle_deg: f32, distance: f32 },
137    Rectangle { width: f32, length: f32 },
138    Chain { max_bounces: u32, bounce_radius: f32, falloff_per_bounce: f32 },
139    SmartCast { priority: TargetPriority },
140    GroundTarget { indicator: AreaIndicator },
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum TargetPriority {
145    Nearest,
146    LowestHealth,
147    HighestHealth,
148    LowestArmor,
149    MostDangerous,
150    Player,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum AreaIndicator {
155    Circle,
156    Rectangle,
157    Cone,
158    Point,
159    Line,
160    Ring,
161}
162
163impl TargetingMode {
164    pub fn display_name(&self) -> String {
165        match self {
166            TargetingMode::Self_ => "Self".to_string(),
167            TargetingMode::SingleTarget => "Single Target".to_string(),
168            TargetingMode::MultiTarget { max_targets, .. } => format!("Multi-Target ({})", max_targets),
169            TargetingMode::AoECircle { radius } => format!("AoE Circle (r={})", radius),
170            TargetingMode::AoESphere { radius } => format!("AoE Sphere (r={})", radius),
171            TargetingMode::Cone { half_angle_deg, distance } => format!("Cone ({}°, {}u)", half_angle_deg * 2.0, distance),
172            TargetingMode::Rectangle { width, length } => format!("Rectangle ({}×{})", width, length),
173            TargetingMode::Chain { max_bounces, .. } => format!("Chain ({} bounces)", max_bounces),
174            TargetingMode::SmartCast { .. } => "Smart Cast".to_string(),
175            TargetingMode::GroundTarget { .. } => "Ground Target".to_string(),
176        }
177    }
178
179    /// Returns true if a given world-space point is within this targeting shape,
180    /// assuming caster is at `origin`, facing in `forward` direction.
181    pub fn contains_point_2d(&self, origin: Vec2, forward: Vec2, target: Vec2) -> bool {
182        let delta = target - origin;
183        let dist = delta.length();
184        match self {
185            TargetingMode::AoECircle { radius } => dist <= *radius,
186            TargetingMode::AoESphere { radius } => dist <= *radius,
187            TargetingMode::Cone { half_angle_deg, distance } => {
188                if dist > *distance { return false; }
189                let fwd = if forward.length_squared() < 1e-9 { Vec2::new(0.0, 1.0) } else { forward.normalize() };
190                let to_target = if dist < 1e-9 { return true; } else { delta / dist };
191                let dot = fwd.dot(to_target);
192                let angle = dot.acos().to_degrees();
193                angle <= *half_angle_deg
194            }
195            TargetingMode::Rectangle { width, length } => {
196                // Rectangle along forward axis
197                let fwd = if forward.length_squared() < 1e-9 { Vec2::new(0.0, 1.0) } else { forward.normalize() };
198                let right = Vec2::new(-fwd.y, fwd.x);
199                let along = delta.dot(fwd);
200                let across = delta.dot(right).abs();
201                along >= 0.0 && along <= *length && across <= *width * 0.5
202            }
203            TargetingMode::SingleTarget => dist < 0.5,
204            _ => false,
205        }
206    }
207
208    /// Collect all targets from a list that fall within this targeting shape.
209    pub fn gather_targets<'a>(
210        &self,
211        origin: Vec2,
212        forward: Vec2,
213        candidates: &'a [(u64, Vec2)],
214        ground_target: Option<Vec2>,
215    ) -> Vec<(u64, Vec2)> {
216        let effective_origin = match self {
217            TargetingMode::AoECircle { .. } | TargetingMode::AoESphere { .. } |
218            TargetingMode::GroundTarget { .. } => ground_target.unwrap_or(origin),
219            _ => origin,
220        };
221
222        candidates.iter().filter(|(_, pos)| {
223            self.contains_point_2d(effective_origin, forward, *pos)
224        }).cloned().collect()
225    }
226
227    /// Chain targeting: start from a primary target and bounce to nearest within bounce_radius.
228    pub fn chain_targets<'a>(
229        &self,
230        primary_target: (u64, Vec2),
231        all_candidates: &'a [(u64, Vec2)],
232    ) -> Vec<(u64, Vec2)> {
233        if let TargetingMode::Chain { max_bounces, bounce_radius, .. } = self {
234            let mut result = vec![primary_target];
235            let mut hit: HashSet<u64> = HashSet::new();
236            hit.insert(primary_target.0);
237            let mut last_pos = primary_target.1;
238
239            for _ in 0..*max_bounces {
240                let next = all_candidates.iter()
241                    .filter(|(id, _)| !hit.contains(id))
242                    .filter(|(_, pos)| (*pos - last_pos).length() <= *bounce_radius)
243                    .min_by(|(_, pa), (_, pb)| {
244                        let da = (*pa - last_pos).length();
245                        let db = (*pb - last_pos).length();
246                        da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
247                    });
248                if let Some(&(id, pos)) = next {
249                    result.push((id, pos));
250                    hit.insert(id);
251                    last_pos = pos;
252                } else {
253                    break;
254                }
255            }
256            result
257        } else {
258            vec![]
259        }
260    }
261}
262
263// ============================================================
264// DAMAGE TYPES AND FORMULAS
265// ============================================================
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
268pub enum DamageElement {
269    Physical,
270    Fire,
271    Cold,
272    Lightning,
273    Poison,
274    Arcane,
275    Holy,
276    Shadow,
277    Chaos,
278    True_,
279}
280
281impl DamageElement {
282    pub fn display_name(&self) -> &'static str {
283        match self {
284            DamageElement::Physical => "Physical",
285            DamageElement::Fire => "Fire",
286            DamageElement::Cold => "Cold",
287            DamageElement::Lightning => "Lightning",
288            DamageElement::Poison => "Poison",
289            DamageElement::Arcane => "Arcane",
290            DamageElement::Holy => "Holy",
291            DamageElement::Shadow => "Shadow",
292            DamageElement::Chaos => "Chaos",
293            DamageElement::True_ => "True",
294        }
295    }
296
297    pub fn color(&self) -> Vec4 {
298        match self {
299            DamageElement::Physical => Vec4::new(0.8, 0.7, 0.6, 1.0),
300            DamageElement::Fire => Vec4::new(1.0, 0.3, 0.0, 1.0),
301            DamageElement::Cold => Vec4::new(0.3, 0.7, 1.0, 1.0),
302            DamageElement::Lightning => Vec4::new(1.0, 1.0, 0.2, 1.0),
303            DamageElement::Poison => Vec4::new(0.4, 0.9, 0.1, 1.0),
304            DamageElement::Arcane => Vec4::new(0.8, 0.2, 1.0, 1.0),
305            DamageElement::Holy => Vec4::new(1.0, 0.95, 0.7, 1.0),
306            DamageElement::Shadow => Vec4::new(0.3, 0.0, 0.5, 1.0),
307            DamageElement::Chaos => Vec4::new(0.7, 0.0, 0.2, 1.0),
308            DamageElement::True_ => Vec4::new(1.0, 1.0, 1.0, 1.0),
309        }
310    }
311}
312
313/// Stat-based scaling coefficient
314#[derive(Debug, Clone)]
315pub struct ScalingCoeff {
316    pub stat_name: String,
317    pub coefficient: f32,
318    pub exponent: f32, // power scaling: stat^exponent * coefficient
319}
320
321impl ScalingCoeff {
322    pub fn linear(stat: impl Into<String>, coeff: f32) -> Self {
323        ScalingCoeff { stat_name: stat.into(), coefficient: coeff, exponent: 1.0 }
324    }
325
326    pub fn power(stat: impl Into<String>, coeff: f32, exp: f32) -> Self {
327        ScalingCoeff { stat_name: stat.into(), coefficient: coeff, exponent: exp }
328    }
329
330    pub fn compute(&self, stat_value: f32) -> f32 {
331        self.coefficient * stat_value.powf(self.exponent)
332    }
333}
334
335#[derive(Debug, Clone)]
336pub struct DamageFormula {
337    pub element: DamageElement,
338    pub base_min: f32,
339    pub base_max: f32,
340    pub scaling: Vec<ScalingCoeff>,
341    pub crit_chance_base: f32,         // percent
342    pub crit_multiplier_base: f32,     // e.g. 1.5 = 150% damage
343    pub crit_chance_scaling: Vec<ScalingCoeff>,
344    pub crit_multiplier_scaling: Vec<ScalingCoeff>,
345    pub penetration: f32,              // flat resistance penetration
346    pub penetration_percent: f32,      // percent of resistance ignored
347    pub versus_status_bonus: Vec<(StatusEffectType, f32)>, // bonus damage if target has status
348    pub variance: f32,                 // ± fraction of computed damage
349}
350
351impl DamageFormula {
352    pub fn new(element: DamageElement, min: f32, max: f32) -> Self {
353        DamageFormula {
354            element,
355            base_min: min,
356            base_max: max,
357            scaling: Vec::new(),
358            crit_chance_base: 5.0,
359            crit_multiplier_base: 1.5,
360            crit_chance_scaling: Vec::new(),
361            crit_multiplier_scaling: Vec::new(),
362            penetration: 0.0,
363            penetration_percent: 0.0,
364            versus_status_bonus: Vec::new(),
365            variance: 0.1,
366        }
367    }
368
369    pub fn compute_raw(&self, stats: &HashMap<String, f32>, roll_t: f32) -> f32 {
370        // Base damage with variance
371        let base = self.base_min + (self.base_max - self.base_min) * roll_t;
372        let scaling_bonus: f32 = self.scaling.iter().map(|s| {
373            let val = stats.get(&s.stat_name).copied().unwrap_or(0.0);
374            s.compute(val)
375        }).sum();
376        let raw = base + scaling_bonus;
377        let var = 1.0 + (roll_t * 2.0 - 1.0) * self.variance;
378        raw * var
379    }
380
381    pub fn compute_crit_chance(&self, stats: &HashMap<String, f32>) -> f32 {
382        let bonus: f32 = self.crit_chance_scaling.iter().map(|s| {
383            let val = stats.get(&s.stat_name).copied().unwrap_or(0.0);
384            s.compute(val)
385        }).sum();
386        (self.crit_chance_base + bonus).clamp(0.0, 100.0)
387    }
388
389    pub fn compute_crit_multiplier(&self, stats: &HashMap<String, f32>) -> f32 {
390        let bonus: f32 = self.crit_multiplier_scaling.iter().map(|s| {
391            let val = stats.get(&s.stat_name).copied().unwrap_or(0.0);
392            s.compute(val)
393        }).sum();
394        self.crit_multiplier_base + bonus
395    }
396
397    pub fn compute_final_damage(
398        &self,
399        stats: &HashMap<String, f32>,
400        target_resist: f32,
401        active_effects: &[StatusEffectType],
402        roll_t: f32,
403        crit_roll: f32,
404    ) -> DamageResult {
405        let raw = self.compute_raw(stats, roll_t);
406        let crit_chance = self.compute_crit_chance(stats);
407        let crit_mul = self.compute_crit_multiplier(stats);
408
409        let is_crit = crit_roll < crit_chance / 100.0;
410        let crit_factor = if is_crit { crit_mul } else { 1.0 };
411
412        // Status bonus
413        let mut status_mul = 1.0f32;
414        for (effect_type, bonus) in &self.versus_status_bonus {
415            if active_effects.contains(effect_type) {
416                status_mul += bonus;
417            }
418        }
419
420        // Resistance after penetration
421        let effective_resist = if self.element == DamageElement::True_ {
422            0.0
423        } else {
424            let after_flat = (target_resist - self.penetration).max(0.0);
425            let after_pct = after_flat * (1.0 - self.penetration_percent / 100.0);
426            after_pct.min(75.0) // resist cap
427        };
428
429        let after_resist = raw * (1.0 - effective_resist / 100.0);
430        let final_dmg = after_resist * crit_factor * status_mul;
431
432        DamageResult {
433            raw_damage: raw,
434            final_damage: final_dmg,
435            is_crit,
436            element: self.element,
437            effective_resist,
438            status_bonus_applied: status_mul > 1.0,
439        }
440    }
441
442    pub fn average_dps_preview(&self, stats: &HashMap<String, f32>, target_resist: f32) -> f32 {
443        let mut total = 0.0f32;
444        let samples = 20;
445        for i in 0..samples {
446            let t = i as f32 / (samples - 1) as f32;
447            let result = self.compute_final_damage(stats, target_resist, &[], t, t);
448            total += result.final_damage;
449        }
450        total / samples as f32
451    }
452}
453
454#[derive(Debug, Clone)]
455pub struct DamageResult {
456    pub raw_damage: f32,
457    pub final_damage: f32,
458    pub is_crit: bool,
459    pub element: DamageElement,
460    pub effective_resist: f32,
461    pub status_bonus_applied: bool,
462}
463
464// ============================================================
465// STATUS EFFECTS (30+)
466// ============================================================
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
469pub enum StatusEffectType {
470    Burn,
471    Freeze,
472    Chill,
473    Shock,
474    Stun,
475    Slow,
476    Poison,
477    Bleed,
478    Blind,
479    Silence,
480    Root,
481    Knockback,
482    Knockup,
483    Fear,
484    Charm,
485    Haste,
486    Shield,
487    Regen,
488    ManaRegen,
489    Taunt,
490    Confused,
491    Petrified,
492    Invulnerable,
493    Invisible,
494    Cursed,
495    Marked,
496    Wet,
497    Oiled,
498    Shocked,
499    Brittle,
500    Empowered,
501    Weakened,
502    Exposed,
503    Enraged,
504    Exhausted,
505}
506
507impl StatusEffectType {
508    pub fn display_name(&self) -> &'static str {
509        match self {
510            StatusEffectType::Burn => "Burn",
511            StatusEffectType::Freeze => "Freeze",
512            StatusEffectType::Chill => "Chill",
513            StatusEffectType::Shock => "Shock",
514            StatusEffectType::Stun => "Stun",
515            StatusEffectType::Slow => "Slow",
516            StatusEffectType::Poison => "Poison",
517            StatusEffectType::Bleed => "Bleed",
518            StatusEffectType::Blind => "Blind",
519            StatusEffectType::Silence => "Silence",
520            StatusEffectType::Root => "Root",
521            StatusEffectType::Knockback => "Knockback",
522            StatusEffectType::Knockup => "Knockup",
523            StatusEffectType::Fear => "Fear",
524            StatusEffectType::Charm => "Charm",
525            StatusEffectType::Haste => "Haste",
526            StatusEffectType::Shield => "Shield",
527            StatusEffectType::Regen => "Regen",
528            StatusEffectType::ManaRegen => "Mana Regen",
529            StatusEffectType::Taunt => "Taunt",
530            StatusEffectType::Confused => "Confused",
531            StatusEffectType::Petrified => "Petrified",
532            StatusEffectType::Invulnerable => "Invulnerable",
533            StatusEffectType::Invisible => "Invisible",
534            StatusEffectType::Cursed => "Cursed",
535            StatusEffectType::Marked => "Marked",
536            StatusEffectType::Wet => "Wet",
537            StatusEffectType::Oiled => "Oiled",
538            StatusEffectType::Shocked => "Shocked",
539            StatusEffectType::Brittle => "Brittle",
540            StatusEffectType::Empowered => "Empowered",
541            StatusEffectType::Weakened => "Weakened",
542            StatusEffectType::Exposed => "Exposed",
543            StatusEffectType::Enraged => "Enraged",
544            StatusEffectType::Exhausted => "Exhausted",
545        }
546    }
547
548    pub fn color(&self) -> Vec4 {
549        match self {
550            StatusEffectType::Burn => Vec4::new(1.0, 0.3, 0.0, 1.0),
551            StatusEffectType::Freeze | StatusEffectType::Chill => Vec4::new(0.4, 0.8, 1.0, 1.0),
552            StatusEffectType::Shock | StatusEffectType::Shocked => Vec4::new(1.0, 1.0, 0.0, 1.0),
553            StatusEffectType::Stun | StatusEffectType::Petrified => Vec4::new(0.7, 0.5, 0.2, 1.0),
554            StatusEffectType::Slow | StatusEffectType::Root => Vec4::new(0.5, 0.5, 0.2, 1.0),
555            StatusEffectType::Poison | StatusEffectType::Bleed => Vec4::new(0.3, 0.9, 0.1, 1.0),
556            StatusEffectType::Blind | StatusEffectType::Silence => Vec4::new(0.3, 0.3, 0.3, 1.0),
557            StatusEffectType::Knockback | StatusEffectType::Knockup => Vec4::new(1.0, 0.5, 0.8, 1.0),
558            StatusEffectType::Fear | StatusEffectType::Confused => Vec4::new(0.8, 0.2, 0.8, 1.0),
559            StatusEffectType::Charm => Vec4::new(1.0, 0.4, 0.6, 1.0),
560            StatusEffectType::Haste | StatusEffectType::Empowered => Vec4::new(0.2, 1.0, 0.2, 1.0),
561            StatusEffectType::Shield | StatusEffectType::Invulnerable => Vec4::new(0.6, 0.8, 1.0, 1.0),
562            StatusEffectType::Regen | StatusEffectType::ManaRegen => Vec4::new(0.0, 1.0, 0.5, 1.0),
563            StatusEffectType::Invisible => Vec4::new(0.5, 0.5, 0.7, 0.6),
564            StatusEffectType::Cursed | StatusEffectType::Weakened | StatusEffectType::Exposed => Vec4::new(0.8, 0.0, 0.5, 1.0),
565            StatusEffectType::Marked => Vec4::new(1.0, 0.0, 0.0, 1.0),
566            StatusEffectType::Wet => Vec4::new(0.3, 0.5, 1.0, 1.0),
567            StatusEffectType::Oiled => Vec4::new(0.3, 0.2, 0.0, 1.0),
568            StatusEffectType::Brittle => Vec4::new(0.7, 0.7, 0.7, 1.0),
569            StatusEffectType::Enraged => Vec4::new(1.0, 0.1, 0.0, 1.0),
570            StatusEffectType::Exhausted => Vec4::new(0.5, 0.4, 0.3, 1.0),
571            StatusEffectType::Taunt => Vec4::new(0.9, 0.4, 0.0, 1.0),
572        }
573    }
574
575    pub fn is_crowd_control(&self) -> bool {
576        matches!(self,
577            StatusEffectType::Freeze | StatusEffectType::Stun | StatusEffectType::Root |
578            StatusEffectType::Knockback | StatusEffectType::Knockup | StatusEffectType::Fear |
579            StatusEffectType::Charm | StatusEffectType::Petrified | StatusEffectType::Confused
580        )
581    }
582
583    pub fn is_beneficial(&self) -> bool {
584        matches!(self,
585            StatusEffectType::Haste | StatusEffectType::Shield | StatusEffectType::Regen |
586            StatusEffectType::ManaRegen | StatusEffectType::Invulnerable | StatusEffectType::Invisible |
587            StatusEffectType::Empowered | StatusEffectType::Enraged
588        )
589    }
590
591    pub fn is_dot(&self) -> bool {
592        matches!(self, StatusEffectType::Burn | StatusEffectType::Poison | StatusEffectType::Bleed)
593    }
594}
595
596// Handle Stealth as a special display case for color matching
597trait StatusEffectTypeExt {
598    fn stealth_color() -> Vec4;
599}
600
601#[derive(Debug, Clone)]
602pub struct StackRule {
603    pub max_stacks: u32,
604    pub stack_behavior: StackBehavior,
605    pub application_behavior: ApplicationBehavior,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609pub enum StackBehavior {
610    Replace,             // New application overwrites old
611    Refresh,             // Reset duration on new application
612    AddStack,            // Add a stack, each with its own timer
613    AddStackRefreshAll,  // Add stack and refresh all timers
614    Ignore,              // Ignore if already present
615}
616
617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
618pub enum ApplicationBehavior {
619    Always,
620    OnlyIfAbsent,
621    OnlyIfPresent,
622    IncrementOnly,
623}
624
625#[derive(Debug, Clone)]
626pub struct StatusEffectDefinition {
627    pub id: u64,
628    pub effect_type: StatusEffectType,
629    pub name: String,
630    pub description: String,
631    pub base_duration: f32,       // seconds; 0 = permanent until removed
632    pub tick_interval: f32,       // seconds between tick applications (0 = no ticks)
633    pub tick_damage: DamageFormula,
634    pub tick_heal: f32,
635    pub stat_modifiers: Vec<StatusStatMod>,
636    pub stack_rule: StackRule,
637    pub immunity_window: f32,     // seconds of immunity after expiry
638    pub can_be_cleansed: bool,
639    pub can_be_dispelled: bool,
640    pub is_debuff: bool,
641    pub visual_particle: String,
642    pub sound_on_apply: String,
643    pub sound_on_tick: String,
644    pub sound_on_expire: String,
645    pub cc_break_on_damage: bool,
646    pub cc_damage_threshold: f32, // damage needed to break CC (0 = never breaks)
647    pub interactions: Vec<StatusInteraction>,
648}
649
650#[derive(Debug, Clone)]
651pub struct StatusStatMod {
652    pub stat: String,
653    pub flat_delta: f32,
654    pub percent_delta: f32,
655    pub per_stack: bool, // if true, multiply by stack count
656}
657
658#[derive(Debug, Clone)]
659pub struct StatusInteraction {
660    pub when_hit_by: StatusEffectType,
661    pub reaction: StatusReaction,
662}
663
664#[derive(Debug, Clone)]
665pub enum StatusReaction {
666    Explode { damage_formula: DamageFormula },
667    Transform { into: StatusEffectType },
668    Remove,
669    Amplify { factor: f32 },
670}
671
672impl StatusEffectDefinition {
673    pub fn tick(&self, stack_count: u32, stats: &HashMap<String, f32>, t: f32) -> TickResult {
674        let damage = if self.tick_interval > 0.0 {
675            let d = self.tick_damage.compute_raw(stats, t);
676            d * stack_count as f32
677        } else {
678            0.0
679        };
680
681        let heal = self.tick_heal * stack_count as f32;
682
683        TickResult { damage, heal, stack_count }
684    }
685
686    pub fn stat_bonuses(&self, stack_count: u32) -> Vec<(String, f32, f32)> {
687        self.stat_modifiers.iter().map(|m| {
688            let mul = if m.per_stack { stack_count as f32 } else { 1.0 };
689            (m.stat.clone(), m.flat_delta * mul, m.percent_delta * mul)
690        }).collect()
691    }
692}
693
694#[derive(Debug, Clone)]
695pub struct TickResult {
696    pub damage: f32,
697    pub heal: f32,
698    pub stack_count: u32,
699}
700
701// Active instance of a status effect on a target
702#[derive(Debug, Clone)]
703pub struct ActiveStatusEffect {
704    pub definition_id: u64,
705    pub effect_type: StatusEffectType,
706    pub stack_count: u32,
707    pub remaining_duration: f32,
708    pub tick_timer: f32,
709    pub source_id: u64,        // ability ID that applied this
710    pub source_caster_id: u64,
711    pub immunity_remaining: f32,
712}
713
714impl ActiveStatusEffect {
715    pub fn new(def: &StatusEffectDefinition, source_id: u64, caster_id: u64) -> Self {
716        ActiveStatusEffect {
717            definition_id: def.id,
718            effect_type: def.effect_type,
719            stack_count: 1,
720            remaining_duration: def.base_duration,
721            tick_timer: def.tick_interval,
722            source_id,
723            source_caster_id: caster_id,
724            immunity_remaining: 0.0,
725        }
726    }
727
728    pub fn tick_update(&mut self, dt: f32, def: &StatusEffectDefinition, stats: &HashMap<String, f32>) -> Option<TickResult> {
729        if def.base_duration > 0.0 {
730            self.remaining_duration -= dt;
731        }
732
733        if def.tick_interval > 0.0 {
734            self.tick_timer -= dt;
735            if self.tick_timer <= 0.0 {
736                self.tick_timer += def.tick_interval;
737                return Some(def.tick(self.stack_count, stats, 0.5));
738            }
739        }
740
741        None
742    }
743
744    pub fn is_expired(&self, def: &StatusEffectDefinition) -> bool {
745        def.base_duration > 0.0 && self.remaining_duration <= 0.0
746    }
747
748    pub fn apply_stack(&mut self, def: &StatusEffectDefinition) {
749        match def.stack_rule.stack_behavior {
750            StackBehavior::Replace => {
751                self.remaining_duration = def.base_duration;
752                self.stack_count = 1;
753            }
754            StackBehavior::Refresh => {
755                self.remaining_duration = def.base_duration;
756            }
757            StackBehavior::AddStack => {
758                if self.stack_count < def.stack_rule.max_stacks {
759                    self.stack_count += 1;
760                }
761            }
762            StackBehavior::AddStackRefreshAll => {
763                self.remaining_duration = def.base_duration;
764                if self.stack_count < def.stack_rule.max_stacks {
765                    self.stack_count += 1;
766                }
767            }
768            StackBehavior::Ignore => {}
769        }
770    }
771}
772
773// ============================================================
774// COOLDOWN SYSTEM
775// ============================================================
776
777#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
778pub enum CooldownCategory {
779    Global,
780    Melee,
781    Ranged,
782    Magic,
783    Movement,
784    Utility,
785    Custom(u32),
786}
787
788#[derive(Debug, Clone)]
789pub struct CooldownEntry {
790    pub base_cooldown: f32,
791    pub remaining: f32,
792    pub category: CooldownCategory,
793    pub charges: u32,
794    pub max_charges: u32,
795    pub charge_recharge_time: f32,
796    pub charge_timer: f32,
797    pub in_global_cooldown: bool,
798}
799
800impl CooldownEntry {
801    pub fn new(base_cd: f32, category: CooldownCategory, max_charges: u32) -> Self {
802        CooldownEntry {
803            base_cooldown: base_cd,
804            remaining: 0.0,
805            category,
806            charges: max_charges,
807            max_charges,
808            charge_recharge_time: base_cd,
809            charge_timer: 0.0,
810            in_global_cooldown: false,
811        }
812    }
813
814    pub fn is_ready(&self) -> bool {
815        self.remaining <= 0.0 && self.charges > 0 && !self.in_global_cooldown
816    }
817
818    pub fn use_charge(&mut self) {
819        if self.charges > 0 {
820            self.charges -= 1;
821            if self.charges < self.max_charges && self.charge_timer <= 0.0 {
822                self.charge_timer = self.charge_recharge_time;
823            }
824        } else {
825            self.remaining = self.base_cooldown;
826        }
827    }
828
829    pub fn tick(&mut self, dt: f32, gcd_remaining: f32) {
830        self.in_global_cooldown = gcd_remaining > 0.0;
831
832        if self.remaining > 0.0 {
833            self.remaining = (self.remaining - dt).max(0.0);
834        }
835
836        if self.charges < self.max_charges && self.charge_timer > 0.0 {
837            self.charge_timer -= dt;
838            if self.charge_timer <= 0.0 {
839                self.charges += 1;
840                if self.charges < self.max_charges {
841                    self.charge_timer = self.charge_recharge_time;
842                }
843            }
844        }
845    }
846
847    pub fn reduce_with_cdr(&self, cdr_pct: f32) -> f32 {
848        // Diminishing returns CDR formula
849        let effective_cdr = diminishing_returns_cdr_ability(cdr_pct);
850        self.base_cooldown * (1.0 - effective_cdr / 100.0)
851    }
852}
853
854fn diminishing_returns_cdr_ability(cdr_pct: f32) -> f32 {
855    // Soft cap at 40% CDR with hard cap at 50%
856    // Formula: effective = cdr / (1 + cdr/100) * 100
857    let raw = cdr_pct / 100.0;
858    let effective = raw / (1.0 + raw);
859    (effective * 100.0).min(50.0)
860}
861
862#[derive(Debug, Clone)]
863pub struct CooldownManager {
864    pub cooldowns: HashMap<u64, CooldownEntry>, // ability_id -> entry
865    pub global_cooldown_remaining: f32,
866    pub global_cooldown_duration: f32,
867}
868
869impl CooldownManager {
870    pub fn new() -> Self {
871        CooldownManager {
872            cooldowns: HashMap::new(),
873            global_cooldown_remaining: 0.0,
874            global_cooldown_duration: 1.5,
875        }
876    }
877
878    pub fn register_ability(&mut self, ability_id: u64, base_cd: f32, category: CooldownCategory, max_charges: u32) {
879        self.cooldowns.insert(ability_id, CooldownEntry::new(base_cd, category, max_charges));
880    }
881
882    pub fn can_use(&self, ability_id: u64) -> bool {
883        self.cooldowns.get(&ability_id).map(|e| e.is_ready()).unwrap_or(false)
884    }
885
886    pub fn trigger(&mut self, ability_id: u64, triggers_gcd: bool) {
887        if let Some(entry) = self.cooldowns.get_mut(&ability_id) {
888            entry.use_charge();
889        }
890        if triggers_gcd {
891            self.global_cooldown_remaining = self.global_cooldown_duration;
892        }
893    }
894
895    pub fn tick(&mut self, dt: f32) {
896        let gcd = self.global_cooldown_remaining;
897        if self.global_cooldown_remaining > 0.0 {
898            self.global_cooldown_remaining = (self.global_cooldown_remaining - dt).max(0.0);
899        }
900        for entry in self.cooldowns.values_mut() {
901            entry.tick(dt, gcd);
902        }
903    }
904
905    pub fn apply_cdr_to_all(&mut self, cdr_pct: f32) {
906        for entry in self.cooldowns.values_mut() {
907            entry.base_cooldown = entry.reduce_with_cdr(cdr_pct);
908            entry.charge_recharge_time = entry.base_cooldown;
909        }
910    }
911
912    pub fn reset_ability(&mut self, ability_id: u64) {
913        if let Some(entry) = self.cooldowns.get_mut(&ability_id) {
914            entry.remaining = 0.0;
915            entry.charges = entry.max_charges;
916        }
917    }
918
919    pub fn reset_all(&mut self) {
920        for entry in self.cooldowns.values_mut() {
921            entry.remaining = 0.0;
922            entry.charges = entry.max_charges;
923        }
924    }
925}
926
927// ============================================================
928// RESOURCE SYSTEM
929// ============================================================
930
931#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
932pub enum ResourceType {
933    Mana,
934    Stamina,
935    Energy,
936    Rage,
937    ComboPoints,
938    Heat,
939    Charges,
940    Focus,
941    Fury,
942    Essence,
943}
944
945impl ResourceType {
946    pub fn display_name(&self) -> &'static str {
947        match self {
948            ResourceType::Mana => "Mana",
949            ResourceType::Stamina => "Stamina",
950            ResourceType::Energy => "Energy",
951            ResourceType::Rage => "Rage",
952            ResourceType::ComboPoints => "Combo Points",
953            ResourceType::Heat => "Heat",
954            ResourceType::Charges => "Charges",
955            ResourceType::Focus => "Focus",
956            ResourceType::Fury => "Fury",
957            ResourceType::Essence => "Essence",
958        }
959    }
960
961    pub fn color(&self) -> Vec4 {
962        match self {
963            ResourceType::Mana => Vec4::new(0.2, 0.4, 1.0, 1.0),
964            ResourceType::Stamina => Vec4::new(0.1, 0.8, 0.1, 1.0),
965            ResourceType::Energy => Vec4::new(1.0, 1.0, 0.0, 1.0),
966            ResourceType::Rage => Vec4::new(1.0, 0.0, 0.0, 1.0),
967            ResourceType::ComboPoints => Vec4::new(1.0, 0.7, 0.0, 1.0),
968            ResourceType::Heat => Vec4::new(1.0, 0.4, 0.0, 1.0),
969            ResourceType::Charges => Vec4::new(0.5, 0.5, 1.0, 1.0),
970            ResourceType::Focus => Vec4::new(0.3, 1.0, 0.9, 1.0),
971            ResourceType::Fury => Vec4::new(0.8, 0.1, 0.1, 1.0),
972            ResourceType::Essence => Vec4::new(0.7, 0.2, 0.9, 1.0),
973        }
974    }
975
976    pub fn base_max(&self) -> f32 {
977        match self {
978            ResourceType::Mana => 200.0,
979            ResourceType::Stamina => 150.0,
980            ResourceType::Energy => 100.0,
981            ResourceType::Rage => 100.0,
982            ResourceType::ComboPoints => 5.0,
983            ResourceType::Heat => 100.0,
984            ResourceType::Charges => 3.0,
985            ResourceType::Focus => 100.0,
986            ResourceType::Fury => 100.0,
987            ResourceType::Essence => 50.0,
988        }
989    }
990
991    pub fn decays_over_time(&self) -> bool {
992        matches!(self, ResourceType::Rage | ResourceType::Fury | ResourceType::ComboPoints)
993    }
994
995    pub fn regens_over_time(&self) -> bool {
996        matches!(self, ResourceType::Mana | ResourceType::Stamina | ResourceType::Energy | ResourceType::Focus)
997    }
998
999    pub fn generated_by_damage(&self) -> bool {
1000        matches!(self, ResourceType::Rage | ResourceType::Fury | ResourceType::Heat)
1001    }
1002}
1003
1004#[derive(Debug, Clone)]
1005pub struct ResourceState {
1006    pub resource_type: ResourceType,
1007    pub current: f32,
1008    pub maximum: f32,
1009    pub regen_per_second: f32,
1010    pub decay_per_second: f32,
1011    pub overflow_behavior: OverflowBehavior,
1012    pub generation_on_hit: f32,
1013    pub generation_on_kill: f32,
1014    pub drain_on_ability: f32,     // resource drained per ability use (e.g. Stamina drain per attack)
1015    pub minimum: f32,
1016}
1017
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub enum OverflowBehavior {
1020    Clamp,
1021    Wraparound,
1022    Explode, // full bar triggers an effect (e.g. Heat Overload)
1023}
1024
1025impl ResourceState {
1026    pub fn new(rt: ResourceType) -> Self {
1027        let max = rt.base_max();
1028        ResourceState {
1029            resource_type: rt,
1030            current: max,
1031            maximum: max,
1032            regen_per_second: if rt.regens_over_time() { max * 0.05 } else { 0.0 },
1033            decay_per_second: if rt.decays_over_time() { max * 0.1 } else { 0.0 },
1034            overflow_behavior: OverflowBehavior::Clamp,
1035            generation_on_hit: if rt.generated_by_damage() { max * 0.05 } else { 0.0 },
1036            generation_on_kill: if rt.generated_by_damage() { max * 0.1 } else { 0.0 },
1037            drain_on_ability: 0.0,
1038            minimum: 0.0,
1039        }
1040    }
1041
1042    pub fn tick(&mut self, dt: f32) -> Option<ResourceOverflowEvent> {
1043        if self.resource_type.regens_over_time() && self.current < self.maximum {
1044            self.current += self.regen_per_second * dt;
1045        }
1046        if self.resource_type.decays_over_time() && self.current > self.minimum {
1047            self.current -= self.decay_per_second * dt;
1048        }
1049
1050        let old = self.current;
1051        self.current = self.current.clamp(self.minimum, self.maximum);
1052
1053        if old > self.maximum && self.overflow_behavior == OverflowBehavior::Explode {
1054            self.current = self.minimum;
1055            return Some(ResourceOverflowEvent { resource_type: self.resource_type });
1056        }
1057
1058        None
1059    }
1060
1061    pub fn spend(&mut self, amount: f32) -> bool {
1062        if self.current >= amount {
1063            self.current -= amount;
1064            true
1065        } else {
1066            false
1067        }
1068    }
1069
1070    pub fn gain(&mut self, amount: f32) {
1071        match self.overflow_behavior {
1072            OverflowBehavior::Clamp => {
1073                self.current = (self.current + amount).min(self.maximum);
1074            }
1075            OverflowBehavior::Wraparound => {
1076                self.current = (self.current + amount) % (self.maximum + 1.0);
1077            }
1078            OverflowBehavior::Explode => {
1079                self.current += amount;
1080                // Overflow handled in tick()
1081            }
1082        }
1083    }
1084
1085    pub fn fraction(&self) -> f32 {
1086        if self.maximum <= 0.0 { return 0.0; }
1087        (self.current / self.maximum).clamp(0.0, 1.0)
1088    }
1089
1090    pub fn regen_formula_with_stats(&self, spirit: f32, regen_bonus_pct: f32) -> f32 {
1091        // spirit increases regen logarithmically to prevent stat inflation
1092        let spirit_regen = if spirit > 0.0 { (spirit * 0.5).ln().max(0.0) } else { 0.0 };
1093        self.regen_per_second * (1.0 + regen_bonus_pct / 100.0) + spirit_regen
1094    }
1095}
1096
1097#[derive(Debug, Clone)]
1098pub struct ResourceOverflowEvent {
1099    pub resource_type: ResourceType,
1100}
1101
1102// ============================================================
1103// PROJECTILE PARAMETERS
1104// ============================================================
1105
1106#[derive(Debug, Clone)]
1107pub struct ProjectileParams {
1108    pub speed: f32,
1109    pub acceleration: f32,     // positive = accelerating, negative = decelerating
1110    pub gravity: f32,          // downward force
1111    pub max_range: f32,
1112    pub pierce_count: u32,     // how many enemies it can pass through
1113    pub split_count: u32,      // splits into N projectiles on first hit
1114    pub fork_count: u32,       // forks into N on expiry
1115    pub chain_count: u32,      // chains to N additional targets
1116    pub chain_radius: f32,
1117    pub homing: bool,
1118    pub homing_strength: f32,  // radians/second angular tracking
1119    pub aoe_on_impact: bool,
1120    pub aoe_radius: f32,
1121    pub visual_trail: String,
1122    pub impact_effect: String,
1123    pub size: f32,
1124}
1125
1126impl ProjectileParams {
1127    pub fn default_arrow() -> Self {
1128        ProjectileParams {
1129            speed: 25.0,
1130            acceleration: -2.0,
1131            gravity: 0.0,
1132            max_range: 30.0,
1133            pierce_count: 0,
1134            split_count: 0,
1135            fork_count: 0,
1136            chain_count: 0,
1137            chain_radius: 0.0,
1138            homing: false,
1139            homing_strength: 0.0,
1140            aoe_on_impact: false,
1141            aoe_radius: 0.0,
1142            visual_trail: "arrow_trail".to_string(),
1143            impact_effect: "arrow_impact".to_string(),
1144            size: 0.1,
1145        }
1146    }
1147
1148    pub fn default_fireball() -> Self {
1149        ProjectileParams {
1150            speed: 18.0,
1151            acceleration: 0.0,
1152            gravity: -0.5,
1153            max_range: 25.0,
1154            pierce_count: 0,
1155            split_count: 0,
1156            fork_count: 0,
1157            chain_count: 0,
1158            chain_radius: 0.0,
1159            homing: false,
1160            homing_strength: 0.0,
1161            aoe_on_impact: true,
1162            aoe_radius: 3.5,
1163            visual_trail: "fire_trail".to_string(),
1164            impact_effect: "explosion".to_string(),
1165            size: 0.4,
1166        }
1167    }
1168
1169    pub fn simulate_trajectory(&self, origin: Vec3, direction: Vec3, dt: f32, steps: u32) -> Vec<Vec3> {
1170        let mut positions = Vec::new();
1171        let dir = direction.normalize();
1172        let mut pos = origin;
1173        let mut vel = dir * self.speed;
1174        let gravity_vec = Vec3::new(0.0, -self.gravity, 0.0);
1175
1176        for _ in 0..steps {
1177            positions.push(pos);
1178            let speed = vel.length();
1179            if speed > 0.0 {
1180                let drag_dir = vel / speed;
1181                vel += drag_dir * self.acceleration * dt + gravity_vec * dt;
1182            }
1183            pos += vel * dt;
1184
1185            if (pos - origin).length() > self.max_range { break; }
1186        }
1187
1188        positions
1189    }
1190
1191    pub fn homing_update(&self, vel: Vec3, to_target: Vec3, dt: f32) -> Vec3 {
1192        if !self.homing || self.homing_strength <= 0.0 { return vel; }
1193        let speed = vel.length();
1194        if speed < 1e-6 { return vel; }
1195        let current_dir = vel / speed;
1196        let target_dir = if to_target.length() > 1e-6 { to_target.normalize() } else { current_dir };
1197        // Rotate current_dir towards target_dir by homing_strength * dt radians
1198        let angle = current_dir.dot(target_dir).acos().min(self.homing_strength * dt);
1199        // Compute rotation axis
1200        let axis = current_dir.cross(target_dir);
1201        let axis_len = axis.length();
1202        if axis_len < 1e-6 { return vel; }
1203        let rotation = Quat::from_axis_angle(axis / axis_len, angle);
1204        rotation * vel
1205    }
1206}
1207
1208// ============================================================
1209// ABILITY MODIFIERS (TALENTS / UPGRADES)
1210// ============================================================
1211
1212#[derive(Debug, Clone)]
1213pub struct AbilityModifier {
1214    pub id: u64,
1215    pub name: String,
1216    pub description: String,
1217    pub changes: Vec<AbilityChange>,
1218    pub unlock_cost: u32,
1219}
1220
1221#[derive(Debug, Clone)]
1222pub enum AbilityChange {
1223    MultiplyDamage(f32),
1224    AddDamageFlat(f32),
1225    MultiplyRange(f32),
1226    AddRange(f32),
1227    MultiplyCooldown(f32),
1228    MultiplyResourceCost(f32),
1229    AddStatusEffect { effect_id: u64, chance: f32 },
1230    RemoveStatusEffect { effect_id: u64 },
1231    AddProjectile(u32),
1232    AddPierce(u32),
1233    EnableHoming,
1234    MultiplyAoeRadius(f32),
1235    SetTargetingMode(TargetingMode),
1236    MultiplyDuration(f32),
1237    MultiplyHeal(f32),
1238    AddConditionalDamage { condition: String, bonus: f32 },
1239    TransformToElement(DamageElement),
1240    AddCharge,
1241    EnableSplit(u32),
1242}
1243
1244impl AbilityModifier {
1245    pub fn apply_to_definition(&self, def: &mut AbilityDefinition) {
1246        for change in &self.changes {
1247            match change {
1248                AbilityChange::MultiplyDamage(f) => {
1249                    for formula in &mut def.damage_formulas {
1250                        formula.base_min *= f;
1251                        formula.base_max *= f;
1252                    }
1253                }
1254                AbilityChange::AddDamageFlat(v) => {
1255                    for formula in &mut def.damage_formulas {
1256                        formula.base_min += v;
1257                        formula.base_max += v;
1258                    }
1259                }
1260                AbilityChange::MultiplyRange(f) => {
1261                    def.range *= f;
1262                }
1263                AbilityChange::AddRange(v) => {
1264                    def.range += v;
1265                }
1266                AbilityChange::MultiplyCooldown(f) => {
1267                    def.base_cooldown *= f;
1268                }
1269                AbilityChange::MultiplyResourceCost(f) => {
1270                    def.resource_cost *= f;
1271                }
1272                AbilityChange::MultiplyAoeRadius(f) => {
1273                    def.aoe_radius *= f;
1274                }
1275                AbilityChange::MultiplyDuration(f) => {
1276                    def.duration *= f;
1277                }
1278                AbilityChange::MultiplyHeal(f) => {
1279                    def.heal_formula.base_min *= f;
1280                    def.heal_formula.base_max *= f;
1281                }
1282                AbilityChange::AddCharge => {
1283                    def.max_charges += 1;
1284                }
1285                AbilityChange::EnableSplit(n) => {
1286                    if let Some(ref mut proj) = def.projectile_params {
1287                        proj.split_count = *n;
1288                    }
1289                }
1290                AbilityChange::EnableHoming => {
1291                    if let Some(ref mut proj) = def.projectile_params {
1292                        proj.homing = true;
1293                        proj.homing_strength = 2.0;
1294                    }
1295                }
1296                AbilityChange::AddPierce(n) => {
1297                    if let Some(ref mut proj) = def.projectile_params {
1298                        proj.pierce_count += n;
1299                    }
1300                }
1301                AbilityChange::AddProjectile(n) => {
1302                    def.projectile_count += n;
1303                }
1304                _ => {}
1305            }
1306        }
1307    }
1308}
1309
1310// ============================================================
1311// CONDITIONAL MODIFIERS / AURA EFFECTS
1312// ============================================================
1313
1314#[derive(Debug, Clone)]
1315pub struct ConditionalModifier {
1316    pub id: u64,
1317    pub name: String,
1318    pub condition: AbilityCondition,
1319    pub stat_modifier: String,
1320    pub flat_bonus: f32,
1321    pub percent_bonus: f32,
1322    pub duration_limit: Option<f32>, // None = permanent while condition is true
1323}
1324
1325#[derive(Debug, Clone)]
1326pub enum AbilityCondition {
1327    TargetHasStatus(StatusEffectType),
1328    TargetBelowHealthPct(f32),
1329    TargetAboveHealthPct(f32),
1330    CasterBelowHealthPct(f32),
1331    CasterHasStatus(StatusEffectType),
1332    ComboPointsAbove(u32),
1333    InCombat,
1334    OutOfCombat,
1335    AlwaysTrue,
1336    CasterRageAbove(f32),
1337}
1338
1339impl AbilityCondition {
1340    pub fn evaluate(&self, ctx: &CastContext) -> bool {
1341        match self {
1342            AbilityCondition::TargetHasStatus(s) => ctx.target_status_effects.contains(s),
1343            AbilityCondition::TargetBelowHealthPct(pct) => ctx.target_health_pct < *pct,
1344            AbilityCondition::TargetAboveHealthPct(pct) => ctx.target_health_pct > *pct,
1345            AbilityCondition::CasterBelowHealthPct(pct) => ctx.caster_health_pct < *pct,
1346            AbilityCondition::CasterHasStatus(s) => ctx.caster_status_effects.contains(s),
1347            AbilityCondition::ComboPointsAbove(n) => ctx.combo_points >= *n,
1348            AbilityCondition::InCombat => ctx.in_combat,
1349            AbilityCondition::OutOfCombat => !ctx.in_combat,
1350            AbilityCondition::AlwaysTrue => true,
1351            AbilityCondition::CasterRageAbove(r) => ctx.rage >= *r,
1352        }
1353    }
1354}
1355
1356#[derive(Debug, Clone)]
1357pub struct CastContext {
1358    pub caster_id: u64,
1359    pub target_id: Option<u64>,
1360    pub caster_stats: HashMap<String, f32>,
1361    pub target_status_effects: HashSet<StatusEffectType>,
1362    pub caster_status_effects: HashSet<StatusEffectType>,
1363    pub target_health_pct: f32,
1364    pub caster_health_pct: f32,
1365    pub combo_points: u32,
1366    pub rage: f32,
1367    pub in_combat: bool,
1368    pub cast_position: Vec3,
1369    pub target_position: Option<Vec3>,
1370}
1371
1372impl CastContext {
1373    pub fn default() -> Self {
1374        CastContext {
1375            caster_id: 0,
1376            target_id: None,
1377            caster_stats: HashMap::new(),
1378            target_status_effects: HashSet::new(),
1379            caster_status_effects: HashSet::new(),
1380            target_health_pct: 1.0,
1381            caster_health_pct: 1.0,
1382            combo_points: 0,
1383            rage: 0.0,
1384            in_combat: false,
1385            cast_position: Vec3::ZERO,
1386            target_position: None,
1387        }
1388    }
1389}
1390
1391#[derive(Debug, Clone)]
1392pub struct AuraEffect {
1393    pub id: u64,
1394    pub name: String,
1395    pub radius: f32,
1396    pub affects_allies: bool,
1397    pub affects_enemies: bool,
1398    pub stat_mods: Vec<ConditionalModifier>,
1399    pub pulses: bool,
1400    pub pulse_interval: f32,
1401    pub pulse_damage: Option<DamageFormula>,
1402    pub visual_effect: String,
1403}
1404
1405impl AuraEffect {
1406    pub fn targets_in_range<'a>(&self, caster_pos: Vec3, candidates: &'a [(u64, Vec3, bool)]) -> Vec<(u64, Vec3)> {
1407        // candidates = (id, position, is_ally)
1408        candidates.iter().filter(|(_, pos, is_ally)| {
1409            let in_range = (*pos - caster_pos).length() <= self.radius;
1410            let affected = (self.affects_allies && *is_ally) || (self.affects_enemies && !*is_ally);
1411            in_range && affected
1412        }).map(|(id, pos, _)| (*id, *pos)).collect()
1413    }
1414}
1415
1416// ============================================================
1417// ABILITY DEFINITION
1418// ============================================================
1419
1420#[derive(Debug, Clone)]
1421pub struct HealFormula {
1422    pub base_min: f32,
1423    pub base_max: f32,
1424    pub scaling: Vec<ScalingCoeff>,
1425    pub overheal_shield: f32, // fraction of excess heal converted to shield
1426    pub critical_heal_multiplier: f32,
1427    pub critical_heal_chance: f32,
1428}
1429
1430impl HealFormula {
1431    pub fn zero() -> Self {
1432        HealFormula {
1433            base_min: 0.0,
1434            base_max: 0.0,
1435            scaling: Vec::new(),
1436            overheal_shield: 0.0,
1437            critical_heal_multiplier: 1.5,
1438            critical_heal_chance: 0.0,
1439        }
1440    }
1441
1442    pub fn compute(&self, stats: &HashMap<String, f32>, roll_t: f32) -> f32 {
1443        let base = self.base_min + (self.base_max - self.base_min) * roll_t;
1444        let scaling: f32 = self.scaling.iter().map(|s| {
1445            s.compute(stats.get(&s.stat_name).copied().unwrap_or(0.0))
1446        }).sum();
1447        base + scaling
1448    }
1449}
1450
1451#[derive(Debug, Clone)]
1452pub struct AbilityDefinition {
1453    // Identity
1454    pub id: u64,
1455    pub name: String,
1456    pub description: String,
1457    pub flavor_text: String,
1458    pub icon_path: String,
1459    pub ability_type: AbilityType,
1460    // Targeting
1461    pub targeting_mode: TargetingMode,
1462    pub range: f32,
1463    pub aoe_radius: f32,
1464    // Timing
1465    pub cast_time: f32,       // 0 = instant
1466    pub channel_time: f32,    // 0 = not channeled
1467    pub channel_ticks: u32,   // how many damage ticks during channel
1468    pub base_cooldown: f32,
1469    pub max_charges: u32,
1470    pub gcd_category: CooldownCategory,
1471    pub triggers_gcd: bool,
1472    // Resources
1473    pub resource_type: ResourceType,
1474    pub resource_cost: f32,
1475    pub resource_cost_scaling: Vec<ScalingCoeff>,
1476    pub resource_generation: f32, // generates this much of the resource on use
1477    // Damage
1478    pub damage_formulas: Vec<DamageFormula>,
1479    pub projectile_params: Option<ProjectileParams>,
1480    pub projectile_count: u32,
1481    pub spread_angle: f32,     // angle between multi-projectiles
1482    // Healing
1483    pub heal_formula: HealFormula,
1484    // Status effects
1485    pub applied_effects: Vec<AppliedEffect>,
1486    pub required_target_status: Vec<StatusEffectType>, // must have these to be usable
1487    pub consumed_target_status: Vec<StatusEffectType>, // these are removed on cast
1488    // Ability flags
1489    pub duration: f32,         // for buffs/summons/terrain
1490    pub is_passive: bool,
1491    pub interrupt_on_move: bool,
1492    pub usable_while_moving: bool,
1493    pub usable_while_stunned: bool,
1494    pub usable_while_silenced: bool,
1495    pub knockback_force: f32,
1496    pub knockback_direction: KnockbackDirection,
1497    // Modifiers
1498    pub applied_modifiers: Vec<u64>, // AbilityModifier IDs applied
1499    pub conditional_mods: Vec<ConditionalModifier>,
1500    pub unlock_level: u32,
1501    pub talent_tree_id: Option<u64>,
1502    pub talent_node_id: Option<u64>,
1503    // Classification
1504    pub school: String,
1505    // Visual / Audio
1506    pub cast_vfx: String,
1507    pub hit_vfx: String,
1508    pub projectile_vfx: String,
1509    pub cast_sfx: String,
1510    pub hit_sfx: String,
1511    pub interrupt_sfx: String,
1512}
1513
1514#[derive(Debug, Clone)]
1515pub struct AppliedEffect {
1516    pub effect_id: u64,
1517    pub effect_type: StatusEffectType,
1518    pub apply_chance: f32,    // 0-100%
1519    pub to_target: bool,      // true = applied to target, false = applied to caster
1520    pub condition: Option<AbilityCondition>,
1521    pub stacks_applied: u32,
1522    pub duration_override: Option<f32>,
1523}
1524
1525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1526pub enum KnockbackDirection {
1527    AwayFromCaster,
1528    TowardCaster,
1529    Up,
1530    Fixed(i32), // degrees, encoded as integer for Copy
1531}
1532
1533impl AbilityDefinition {
1534    pub fn new(id: u64, name: impl Into<String>, ability_type: AbilityType) -> Self {
1535        let targeting = ability_type.default_targeting();
1536        AbilityDefinition {
1537            id,
1538            name: name.into(),
1539            description: String::new(),
1540            flavor_text: String::new(),
1541            icon_path: String::new(),
1542            ability_type,
1543            targeting_mode: targeting,
1544            range: 5.0,
1545            aoe_radius: 0.0,
1546            cast_time: 0.0,
1547            channel_time: 0.0,
1548            channel_ticks: 0,
1549            base_cooldown: 1.0,
1550            max_charges: 1,
1551            gcd_category: CooldownCategory::Global,
1552            triggers_gcd: true,
1553            resource_type: ResourceType::Mana,
1554            resource_cost: 10.0,
1555            resource_cost_scaling: Vec::new(),
1556            resource_generation: 0.0,
1557            damage_formulas: Vec::new(),
1558            projectile_params: None,
1559            projectile_count: 1,
1560            spread_angle: 0.0,
1561            heal_formula: HealFormula::zero(),
1562            applied_effects: Vec::new(),
1563            required_target_status: Vec::new(),
1564            consumed_target_status: Vec::new(),
1565            duration: 0.0,
1566            is_passive: false,
1567            interrupt_on_move: false,
1568            usable_while_moving: true,
1569            usable_while_stunned: false,
1570            usable_while_silenced: false,
1571            knockback_force: 0.0,
1572            knockback_direction: KnockbackDirection::AwayFromCaster,
1573            applied_modifiers: Vec::new(),
1574            conditional_mods: Vec::new(),
1575            unlock_level: 1,
1576            talent_tree_id: None,
1577            talent_node_id: None,
1578            school: String::new(),
1579            cast_vfx: String::new(),
1580            hit_vfx: String::new(),
1581            projectile_vfx: String::new(),
1582            cast_sfx: String::new(),
1583            hit_sfx: String::new(),
1584            interrupt_sfx: String::new(),
1585        }
1586    }
1587
1588    pub fn compute_resource_cost(&self, stats: &HashMap<String, f32>) -> f32 {
1589        let scale_bonus: f32 = self.resource_cost_scaling.iter().map(|s| {
1590            s.compute(stats.get(&s.stat_name).copied().unwrap_or(0.0))
1591        }).sum();
1592        (self.resource_cost + scale_bonus).max(0.0)
1593    }
1594
1595    pub fn can_cast(&self, ctx: &CastContext, resources: &HashMap<ResourceType, ResourceState>, cooldown_mgr: &CooldownManager) -> CastabilityResult {
1596        // Check cooldown
1597        if !cooldown_mgr.can_use(self.id) {
1598            let cd_entry = cooldown_mgr.cooldowns.get(&self.id);
1599            let remaining = cd_entry.map(|e| e.remaining).unwrap_or(0.0);
1600            return CastabilityResult::OnCooldown { remaining_seconds: remaining };
1601        }
1602
1603        // Check resources
1604        let cost = self.compute_resource_cost(&ctx.caster_stats);
1605        if let Some(res) = resources.get(&self.resource_type) {
1606            if res.current < cost {
1607                return CastabilityResult::InsufficientResources {
1608                    needed: cost,
1609                    available: res.current,
1610                    resource: self.resource_type,
1611                };
1612            }
1613        }
1614
1615        // Check CC
1616        if self.usable_while_stunned == false && ctx.caster_status_effects.contains(&StatusEffectType::Stun) {
1617            return CastabilityResult::CastBlocked { reason: "Stunned".to_string() };
1618        }
1619        if self.usable_while_silenced == false && ctx.caster_status_effects.contains(&StatusEffectType::Silence) {
1620            return CastabilityResult::CastBlocked { reason: "Silenced".to_string() };
1621        }
1622
1623        // Check required target status
1624        for req in &self.required_target_status {
1625            if !ctx.target_status_effects.contains(req) {
1626                return CastabilityResult::RequirementNotMet {
1627                    description: format!("Target must have {}", req.display_name()),
1628                };
1629            }
1630        }
1631
1632        CastabilityResult::Ready
1633    }
1634
1635    pub fn compute_damage_preview(&self, ctx: &CastContext, target_resist: f32) -> Vec<DamagePreview> {
1636        self.damage_formulas.iter().map(|formula| {
1637            let avg = formula.average_dps_preview(&ctx.caster_stats, target_resist);
1638            let crit_chance = formula.compute_crit_chance(&ctx.caster_stats);
1639            let crit_mul = formula.compute_crit_multiplier(&ctx.caster_stats);
1640            let min_hit = formula.compute_final_damage(&ctx.caster_stats, target_resist, &[], 0.0, 1.0);
1641            let max_hit = formula.compute_final_damage(&ctx.caster_stats, target_resist, &[], 1.0, 1.0);
1642            let crit_hit = formula.compute_final_damage(&ctx.caster_stats, target_resist, &[], 0.5, 0.0);
1643            DamagePreview {
1644                element: formula.element,
1645                min_hit: min_hit.final_damage,
1646                max_hit: max_hit.final_damage,
1647                avg_hit: avg,
1648                crit_hit: crit_hit.final_damage,
1649                crit_chance,
1650                crit_multiplier: crit_mul,
1651                effective_resist: min_hit.effective_resist,
1652            }
1653        }).collect()
1654    }
1655
1656    pub fn validate(&self) -> Vec<String> {
1657        let mut errors = Vec::new();
1658        if self.name.is_empty() { errors.push("Name is empty".to_string()); }
1659        if self.base_cooldown < 0.0 { errors.push("Cooldown cannot be negative".to_string()); }
1660        if self.resource_cost < 0.0 { errors.push("Resource cost cannot be negative".to_string()); }
1661        if self.range < 0.0 { errors.push("Range cannot be negative".to_string()); }
1662        if self.max_charges == 0 { errors.push("Max charges must be >= 1".to_string()); }
1663        for formula in &self.damage_formulas {
1664            if formula.base_min > formula.base_max {
1665                errors.push(format!("{:?} damage: min > max", formula.element));
1666            }
1667        }
1668        if self.cast_time < 0.0 { errors.push("Cast time cannot be negative".to_string()); }
1669        errors
1670    }
1671}
1672
1673#[derive(Debug, Clone)]
1674pub struct DamagePreview {
1675    pub element: DamageElement,
1676    pub min_hit: f32,
1677    pub max_hit: f32,
1678    pub avg_hit: f32,
1679    pub crit_hit: f32,
1680    pub crit_chance: f32,
1681    pub crit_multiplier: f32,
1682    pub effective_resist: f32,
1683}
1684
1685#[derive(Debug, Clone)]
1686pub enum CastabilityResult {
1687    Ready,
1688    OnCooldown { remaining_seconds: f32 },
1689    InsufficientResources { needed: f32, available: f32, resource: ResourceType },
1690    CastBlocked { reason: String },
1691    RequirementNotMet { description: String },
1692    OutOfRange { current_distance: f32, required_range: f32 },
1693}
1694
1695impl CastabilityResult {
1696    pub fn is_ready(&self) -> bool {
1697        matches!(self, CastabilityResult::Ready)
1698    }
1699
1700    pub fn display_reason(&self) -> String {
1701        match self {
1702            CastabilityResult::Ready => "Ready".to_string(),
1703            CastabilityResult::OnCooldown { remaining_seconds } => format!("Cooldown: {:.1}s", remaining_seconds),
1704            CastabilityResult::InsufficientResources { needed, available, resource } =>
1705                format!("Need {:.0} {} (have {:.0})", needed, resource.display_name(), available),
1706            CastabilityResult::CastBlocked { reason } => reason.clone(),
1707            CastabilityResult::RequirementNotMet { description } => description.clone(),
1708            CastabilityResult::OutOfRange { current_distance, required_range } =>
1709                format!("Out of range: {:.1}m > {:.1}m", current_distance, required_range),
1710        }
1711    }
1712}
1713
1714// ============================================================
1715// TALENT TREE SYSTEM
1716// ============================================================
1717
1718#[derive(Debug, Clone)]
1719pub struct TalentNode {
1720    pub id: u64,
1721    pub name: String,
1722    pub description: String,
1723    pub ability_id: Option<u64>,    // ability unlocked
1724    pub modifier_id: Option<u64>,   // modifier applied to an ability
1725    pub passive_stat_mods: Vec<(String, f32, f32)>, // (stat_name, flat, percent)
1726    pub point_cost: u32,
1727    pub max_ranks: u32,
1728    pub current_ranks: u32,
1729    pub position: Vec2,             // position in the tree UI
1730    pub icon_path: String,
1731    pub is_keystone: bool,          // major powerful node
1732    pub is_notable: bool,           // medium power node
1733    pub unlock_bonus_at: Option<u32>, // special bonus when fully ranked
1734}
1735
1736impl TalentNode {
1737    pub fn is_fully_ranked(&self) -> bool {
1738        self.current_ranks >= self.max_ranks
1739    }
1740
1741    pub fn can_invest(&self, available_points: u32) -> bool {
1742        !self.is_fully_ranked() && available_points >= self.point_cost
1743    }
1744
1745    pub fn current_stat_mods(&self) -> Vec<(String, f32, f32)> {
1746        let rank_mul = self.current_ranks as f32;
1747        self.passive_stat_mods.iter().map(|(name, flat, pct)| {
1748            (name.clone(), flat * rank_mul, pct * rank_mul)
1749        }).collect()
1750    }
1751}
1752
1753#[derive(Debug, Clone)]
1754pub struct TalentEdge {
1755    pub from_node_id: u64,
1756    pub to_node_id: u64,
1757    pub is_prerequisite: bool,       // true = must have from before unlocking to
1758    pub is_mutual_exclusion: bool,   // true = cannot have both nodes
1759}
1760
1761#[derive(Debug, Clone)]
1762pub struct TalentTreeTier {
1763    pub tier_index: u32,
1764    pub node_ids: Vec<u64>,
1765    pub points_required_to_unlock: u32,
1766    pub tier_mastery_ability: Option<u64>, // unlocked when all tier nodes maxed
1767    pub tier_mastery_bonus: Vec<(String, f32, f32)>,
1768}
1769
1770#[derive(Debug, Clone)]
1771pub struct TalentTree {
1772    pub id: u64,
1773    pub name: String,
1774    pub description: String,
1775    pub class_restriction: Option<String>,
1776    pub nodes: HashMap<u64, TalentNode>,
1777    pub edges: Vec<TalentEdge>,
1778    pub tiers: Vec<TalentTreeTier>,
1779    pub total_points_invested: u32,
1780    pub mastery_bonus_thresholds: Vec<(u32, Vec<(String, f32, f32)>)>, // (points, bonuses)
1781}
1782
1783impl TalentTree {
1784    pub fn new(id: u64, name: impl Into<String>) -> Self {
1785        TalentTree {
1786            id,
1787            name: name.into(),
1788            description: String::new(),
1789            class_restriction: None,
1790            nodes: HashMap::new(),
1791            edges: Vec::new(),
1792            tiers: Vec::new(),
1793            total_points_invested: 0,
1794            mastery_bonus_thresholds: Vec::new(),
1795        }
1796    }
1797
1798    pub fn add_node(&mut self, node: TalentNode) {
1799        self.nodes.insert(node.id, node);
1800    }
1801
1802    pub fn connect(&mut self, from: u64, to: u64, prerequisite: bool) {
1803        self.edges.push(TalentEdge { from_node_id: from, to_node_id: to, is_prerequisite: prerequisite, is_mutual_exclusion: false });
1804    }
1805
1806    pub fn mutually_exclude(&mut self, a: u64, b: u64) {
1807        self.edges.push(TalentEdge { from_node_id: a, to_node_id: b, is_prerequisite: false, is_mutual_exclusion: true });
1808    }
1809
1810    pub fn prerequisites_met(&self, node_id: u64) -> bool {
1811        let prereq_edges: Vec<&TalentEdge> = self.edges.iter()
1812            .filter(|e| e.to_node_id == node_id && e.is_prerequisite)
1813            .collect();
1814        prereq_edges.iter().all(|e| {
1815            self.nodes.get(&e.from_node_id).map(|n| n.current_ranks > 0).unwrap_or(false)
1816        })
1817    }
1818
1819    pub fn mutual_exclusions_clear(&self, node_id: u64) -> bool {
1820        let excl_edges: Vec<&TalentEdge> = self.edges.iter()
1821            .filter(|e| e.is_mutual_exclusion && (e.from_node_id == node_id || e.to_node_id == node_id))
1822            .collect();
1823        excl_edges.iter().all(|e| {
1824            let other_id = if e.from_node_id == node_id { e.to_node_id } else { e.from_node_id };
1825            self.nodes.get(&other_id).map(|n| n.current_ranks == 0).unwrap_or(true)
1826        })
1827    }
1828
1829    pub fn can_invest_in(&self, node_id: u64, available_points: u32) -> bool {
1830        if let Some(node) = self.nodes.get(&node_id) {
1831            node.can_invest(available_points) &&
1832            self.prerequisites_met(node_id) &&
1833            self.mutual_exclusions_clear(node_id)
1834        } else {
1835            false
1836        }
1837    }
1838
1839    pub fn invest_point(&mut self, node_id: u64) -> bool {
1840        if self.can_invest_in(node_id, 1) {
1841            if let Some(node) = self.nodes.get_mut(&node_id) {
1842                node.current_ranks += 1;
1843                self.total_points_invested += node.point_cost;
1844                return true;
1845            }
1846        }
1847        false
1848    }
1849
1850    pub fn refund_node(&mut self, node_id: u64) -> bool {
1851        // Check no dependent nodes are invested
1852        let has_dependents = self.edges.iter().any(|e| {
1853            e.is_prerequisite && e.from_node_id == node_id &&
1854            self.nodes.get(&e.to_node_id).map(|n| n.current_ranks > 0).unwrap_or(false)
1855        });
1856        if has_dependents { return false; }
1857
1858        if let Some(node) = self.nodes.get_mut(&node_id) {
1859            let cost = node.point_cost * node.current_ranks;
1860            self.total_points_invested -= cost;
1861            node.current_ranks = 0;
1862            return true;
1863        }
1864        false
1865    }
1866
1867    pub fn active_mastery_bonuses(&self) -> Vec<&(u32, Vec<(String, f32, f32)>)> {
1868        self.mastery_bonus_thresholds.iter()
1869            .filter(|(threshold, _)| self.total_points_invested >= *threshold)
1870            .collect()
1871    }
1872
1873    pub fn compute_all_stat_mods(&self) -> Vec<(String, f32, f32)> {
1874        let mut totals: HashMap<String, (f32, f32)> = HashMap::new();
1875
1876        for node in self.nodes.values() {
1877            for (name, flat, pct) in node.current_stat_mods() {
1878                let entry = totals.entry(name).or_insert((0.0, 0.0));
1879                entry.0 += flat;
1880                entry.1 += pct;
1881            }
1882        }
1883
1884        for (_, bonuses) in self.active_mastery_bonuses() {
1885            for (name, flat, pct) in bonuses {
1886                let entry = totals.entry(name.clone()).or_insert((0.0, 0.0));
1887                entry.0 += flat;
1888                entry.1 += pct;
1889            }
1890        }
1891
1892        totals.into_iter().map(|(k, (f, p))| (k, f, p)).collect()
1893    }
1894
1895    pub fn tier_completion_pct(&self, tier_idx: u32) -> f32 {
1896        if let Some(tier) = self.tiers.iter().find(|t| t.tier_index == tier_idx) {
1897            let total: u32 = tier.node_ids.iter().map(|id| {
1898                self.nodes.get(id).map(|n| n.max_ranks).unwrap_or(0)
1899            }).sum();
1900            let current: u32 = tier.node_ids.iter().map(|id| {
1901                self.nodes.get(id).map(|n| n.current_ranks).unwrap_or(0)
1902            }).sum();
1903            if total == 0 { return 0.0; }
1904            current as f32 / total as f32
1905        } else {
1906            0.0
1907        }
1908    }
1909
1910    pub fn build_dependency_graph(&self) -> HashMap<u64, Vec<u64>> {
1911        let mut graph: HashMap<u64, Vec<u64>> = HashMap::new();
1912        for node_id in self.nodes.keys() {
1913            graph.entry(*node_id).or_insert_with(Vec::new);
1914        }
1915        for edge in &self.edges {
1916            if edge.is_prerequisite {
1917                graph.entry(edge.from_node_id).or_insert_with(Vec::new).push(edge.to_node_id);
1918            }
1919        }
1920        graph
1921    }
1922
1923    pub fn topological_sort(&self) -> Vec<u64> {
1924        let graph = self.build_dependency_graph();
1925        let mut in_degree: HashMap<u64, u32> = HashMap::new();
1926        for id in self.nodes.keys() { in_degree.insert(*id, 0); }
1927        for edge in &self.edges {
1928            if edge.is_prerequisite {
1929                *in_degree.entry(edge.to_node_id).or_insert(0) += 1;
1930            }
1931        }
1932        let mut queue: VecDeque<u64> = in_degree.iter()
1933            .filter(|(_, &d)| d == 0)
1934            .map(|(id, _)| *id)
1935            .collect();
1936        let mut result = Vec::new();
1937        while let Some(node_id) = queue.pop_front() {
1938            result.push(node_id);
1939            if let Some(deps) = graph.get(&node_id) {
1940                for &dep in deps {
1941                    let deg = in_degree.entry(dep).or_insert(1);
1942                    *deg -= 1;
1943                    if *deg == 0 { queue.push_back(dep); }
1944                }
1945            }
1946        }
1947        result
1948    }
1949}
1950
1951// ============================================================
1952// ABILITY DATABASE
1953// ============================================================
1954
1955#[derive(Debug, Clone)]
1956pub struct AbilityDatabase {
1957    pub abilities: HashMap<u64, AbilityDefinition>,
1958    pub status_effects: HashMap<u64, StatusEffectDefinition>,
1959    pub talent_trees: HashMap<u64, TalentTree>,
1960    pub ability_modifiers: HashMap<u64, AbilityModifier>,
1961    pub aura_effects: HashMap<u64, AuraEffect>,
1962    pub next_id: u64,
1963}
1964
1965impl AbilityDatabase {
1966    pub fn new() -> Self {
1967        AbilityDatabase {
1968            abilities: HashMap::new(),
1969            status_effects: HashMap::new(),
1970            talent_trees: HashMap::new(),
1971            ability_modifiers: HashMap::new(),
1972            aura_effects: HashMap::new(),
1973            next_id: 1,
1974        }
1975    }
1976
1977    pub fn alloc_id(&mut self) -> u64 {
1978        let id = self.next_id;
1979        self.next_id += 1;
1980        id
1981    }
1982
1983    pub fn add_ability(&mut self, ability: AbilityDefinition) {
1984        self.abilities.insert(ability.id, ability);
1985    }
1986
1987    pub fn add_status_effect(&mut self, effect: StatusEffectDefinition) {
1988        self.status_effects.insert(effect.id, effect);
1989    }
1990
1991    pub fn add_talent_tree(&mut self, tree: TalentTree) {
1992        self.talent_trees.insert(tree.id, tree);
1993    }
1994
1995    pub fn abilities_by_type(&self, ability_type: AbilityType) -> Vec<&AbilityDefinition> {
1996        self.abilities.values().filter(|a| a.ability_type == ability_type).collect()
1997    }
1998
1999    pub fn search_abilities(&self, query: &str) -> Vec<&AbilityDefinition> {
2000        let q = query.to_lowercase();
2001        let mut results: Vec<&AbilityDefinition> = self.abilities.values().filter(|a| {
2002            a.name.to_lowercase().contains(&q) ||
2003            a.description.to_lowercase().contains(&q) ||
2004            a.ability_type.display_name().to_lowercase().contains(&q)
2005        }).collect();
2006        results.sort_by(|a, b| a.name.cmp(&b.name));
2007        results
2008    }
2009
2010    pub fn abilities_applying_status(&self, effect_type: StatusEffectType) -> Vec<&AbilityDefinition> {
2011        self.abilities.values().filter(|a| {
2012            a.applied_effects.iter().any(|e| e.effect_type == effect_type)
2013        }).collect()
2014    }
2015
2016    pub fn stats_summary(&self) -> AbilityDbStats {
2017        let mut by_type: HashMap<String, u32> = HashMap::new();
2018        let mut by_resource: HashMap<String, u32> = HashMap::new();
2019        for a in self.abilities.values() {
2020            *by_type.entry(a.ability_type.display_name().to_string()).or_insert(0) += 1;
2021            *by_resource.entry(a.resource_type.display_name().to_string()).or_insert(0) += 1;
2022        }
2023        AbilityDbStats {
2024            total_abilities: self.abilities.len(),
2025            total_status_effects: self.status_effects.len(),
2026            total_talent_trees: self.talent_trees.len(),
2027            by_type,
2028            by_resource,
2029        }
2030    }
2031}
2032
2033#[derive(Debug, Clone)]
2034pub struct AbilityDbStats {
2035    pub total_abilities: usize,
2036    pub total_status_effects: usize,
2037    pub total_talent_trees: usize,
2038    pub by_type: HashMap<String, u32>,
2039    pub by_resource: HashMap<String, u32>,
2040}
2041
2042// ============================================================
2043// FORMULA TESTER
2044// ============================================================
2045
2046#[derive(Debug, Clone)]
2047pub struct FormulaTesterConfig {
2048    pub caster_stats: HashMap<String, f32>,
2049    pub target_armor: f32,
2050    pub target_fire_resist: f32,
2051    pub target_cold_resist: f32,
2052    pub target_lightning_resist: f32,
2053    pub target_poison_resist: f32,
2054    pub target_arcane_resist: f32,
2055    pub target_health_pct: f32,
2056    pub target_status_effects: HashSet<StatusEffectType>,
2057    pub caster_status_effects: HashSet<StatusEffectType>,
2058    pub combo_points: u32,
2059    pub sample_count: u32,
2060}
2061
2062impl FormulaTesterConfig {
2063    pub fn default_lvl_20() -> Self {
2064        let mut stats = HashMap::new();
2065        stats.insert("strength".to_string(), 50.0);
2066        stats.insert("dexterity".to_string(), 40.0);
2067        stats.insert("intelligence".to_string(), 35.0);
2068        stats.insert("vitality".to_string(), 45.0);
2069        stats.insert("attack_power".to_string(), 120.0);
2070        stats.insert("spell_power".to_string(), 80.0);
2071        stats.insert("crit_chance".to_string(), 15.0);
2072        stats.insert("crit_multiplier".to_string(), 1.8);
2073        FormulaTesterConfig {
2074            caster_stats: stats,
2075            target_armor: 200.0,
2076            target_fire_resist: 20.0,
2077            target_cold_resist: 15.0,
2078            target_lightning_resist: 10.0,
2079            target_poison_resist: 5.0,
2080            target_arcane_resist: 0.0,
2081            target_health_pct: 1.0,
2082            target_status_effects: HashSet::new(),
2083            caster_status_effects: HashSet::new(),
2084            combo_points: 0,
2085            sample_count: 1000,
2086        }
2087    }
2088
2089    pub fn resist_for_element(&self, element: DamageElement) -> f32 {
2090        match element {
2091            DamageElement::Physical => self.physical_resist_from_armor(),
2092            DamageElement::Fire => self.target_fire_resist,
2093            DamageElement::Cold => self.target_cold_resist,
2094            DamageElement::Lightning => self.target_lightning_resist,
2095            DamageElement::Poison => self.target_poison_resist,
2096            DamageElement::Arcane => self.target_arcane_resist,
2097            DamageElement::Holy | DamageElement::Shadow | DamageElement::Chaos => 0.0,
2098            DamageElement::True_ => 0.0,
2099        }
2100    }
2101
2102    fn physical_resist_from_armor(&self) -> f32 {
2103        let pct = self.target_armor / (self.target_armor + 300.0);
2104        (pct * 100.0).min(75.0)
2105    }
2106}
2107
2108#[derive(Debug, Clone)]
2109pub struct FormulaTesterResult {
2110    pub ability_name: String,
2111    pub damage_previews: Vec<DamagePreview>,
2112    pub total_avg_damage: f32,
2113    pub total_min_damage: f32,
2114    pub total_max_damage: f32,
2115    pub total_crit_damage: f32,
2116    pub heal_preview: f32,
2117    pub resource_cost_preview: f32,
2118    pub effective_dps: f32,         // total_avg_damage / cooldown
2119    pub samples: Vec<f32>,          // Monte Carlo samples
2120    pub percentile_25: f32,
2121    pub percentile_50: f32,
2122    pub percentile_75: f32,
2123    pub percentile_95: f32,
2124}
2125
2126impl FormulaTesterResult {
2127    pub fn compute_percentiles(samples: &mut Vec<f32>) -> (f32, f32, f32, f32) {
2128        if samples.is_empty() { return (0.0, 0.0, 0.0, 0.0); }
2129        samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2130        let n = samples.len();
2131        let p25 = samples[(n as f32 * 0.25) as usize];
2132        let p50 = samples[(n as f32 * 0.50) as usize];
2133        let p75 = samples[(n as f32 * 0.75) as usize];
2134        let p95 = samples[((n as f32 * 0.95) as usize).min(n - 1)];
2135        (p25, p50, p75, p95)
2136    }
2137}
2138
2139pub fn run_formula_test(ability: &AbilityDefinition, config: &FormulaTesterConfig) -> FormulaTesterResult {
2140    let mut samples: Vec<f32> = Vec::new();
2141    let mut seed: u64 = 99887766;
2142
2143    let lcg_next_local = |seed: &mut u64| -> f32 {
2144        *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
2145        ((*seed >> 32) as f32) / (u32::MAX as f32)
2146    };
2147
2148    let active_statuses: Vec<StatusEffectType> = config.target_status_effects.iter().cloned().collect();
2149
2150    for _ in 0..config.sample_count {
2151        let mut total = 0.0f32;
2152        for formula in &ability.damage_formulas {
2153            let t = lcg_next_local(&mut seed);
2154            let crit_roll = lcg_next_local(&mut seed);
2155            let resist = config.resist_for_element(formula.element);
2156            let result = formula.compute_final_damage(&config.caster_stats, resist, &active_statuses, t, crit_roll);
2157            total += result.final_damage;
2158        }
2159        samples.push(total);
2160    }
2161
2162    let total_sum: f32 = samples.iter().sum();
2163    let avg = total_sum / samples.len() as f32;
2164    let min = samples.iter().copied().fold(f32::INFINITY, f32::min);
2165    let max = samples.iter().copied().fold(f32::NEG_INFINITY, f32::max);
2166
2167    // Crit samples (force is_crit = true)
2168    let total_crit: f32 = ability.damage_formulas.iter().map(|formula| {
2169        let resist = config.resist_for_element(formula.element);
2170        formula.compute_final_damage(&config.caster_stats, resist, &active_statuses, 0.5, 0.0).final_damage
2171    }).sum();
2172
2173    let heal = ability.heal_formula.compute(&config.caster_stats, 0.5);
2174    let resource_cost = ability.compute_resource_cost(&config.caster_stats);
2175    let effective_dps = if ability.base_cooldown > 0.0 { avg / ability.base_cooldown } else { avg };
2176
2177    let damage_previews: Vec<DamagePreview> = {
2178        let ctx = CastContext::default();
2179        ability.damage_formulas.iter().map(|f| {
2180            let resist = config.resist_for_element(f.element);
2181            let avg_p = f.average_dps_preview(&config.caster_stats, resist);
2182            let crit_c = f.compute_crit_chance(&config.caster_stats);
2183            let crit_m = f.compute_crit_multiplier(&config.caster_stats);
2184            let min_r = f.compute_final_damage(&config.caster_stats, resist, &active_statuses, 0.0, 1.0);
2185            let max_r = f.compute_final_damage(&config.caster_stats, resist, &active_statuses, 1.0, 1.0);
2186            let crit_r = f.compute_final_damage(&config.caster_stats, resist, &active_statuses, 0.5, 0.0);
2187            DamagePreview {
2188                element: f.element,
2189                min_hit: min_r.final_damage,
2190                max_hit: max_r.final_damage,
2191                avg_hit: avg_p,
2192                crit_hit: crit_r.final_damage,
2193                crit_chance: crit_c,
2194                crit_multiplier: crit_m,
2195                effective_resist: min_r.effective_resist,
2196            }
2197        }).collect()
2198    };
2199
2200    let mut sorted_samples = samples.clone();
2201    let (p25, p50, p75, p95) = FormulaTesterResult::compute_percentiles(&mut sorted_samples);
2202
2203    FormulaTesterResult {
2204        ability_name: ability.name.clone(),
2205        damage_previews,
2206        total_avg_damage: avg,
2207        total_min_damage: min,
2208        total_max_damage: max,
2209        total_crit_damage: total_crit,
2210        heal_preview: heal,
2211        resource_cost_preview: resource_cost,
2212        effective_dps,
2213        samples,
2214        percentile_25: p25,
2215        percentile_50: p50,
2216        percentile_75: p75,
2217        percentile_95: p95,
2218    }
2219}
2220
2221// ============================================================
2222// STATUS EFFECT TIMELINE PREVIEW
2223// ============================================================
2224
2225#[derive(Debug, Clone)]
2226pub struct TimelineFrame {
2227    pub time: f32,
2228    pub active_effects: Vec<(StatusEffectType, u32)>, // (type, stacks)
2229    pub damage_ticks: Vec<(StatusEffectType, f32)>,
2230    pub heal_ticks: Vec<(StatusEffectType, f32)>,
2231    pub resource_events: Vec<(ResourceType, f32)>,
2232}
2233
2234pub fn simulate_status_timeline(
2235    applied_effects: &[(&StatusEffectDefinition, u32)], // (def, initial_stacks)
2236    caster_stats: &HashMap<String, f32>,
2237    duration_seconds: f32,
2238    tick_rate: f32,
2239) -> Vec<TimelineFrame> {
2240    let mut frames = Vec::new();
2241    let dt = tick_rate;
2242    let steps = (duration_seconds / dt).ceil() as u32;
2243
2244    let mut active: Vec<ActiveStatusEffect> = applied_effects.iter().map(|(def, stacks)| {
2245        let mut inst = ActiveStatusEffect::new(def, 0, 0);
2246        inst.stack_count = *stacks;
2247        inst
2248    }).collect();
2249
2250    let defs: HashMap<u64, &StatusEffectDefinition> = applied_effects.iter().map(|(def, _)| (def.id, *def)).collect();
2251
2252    for step in 0..steps {
2253        let time = step as f32 * dt;
2254        let mut frame = TimelineFrame {
2255            time,
2256            active_effects: Vec::new(),
2257            damage_ticks: Vec::new(),
2258            heal_ticks: Vec::new(),
2259            resource_events: Vec::new(),
2260        };
2261
2262        let mut to_remove: Vec<usize> = Vec::new();
2263        for (i, inst) in active.iter_mut().enumerate() {
2264            if let Some(def) = defs.get(&inst.definition_id) {
2265                if inst.is_expired(def) {
2266                    to_remove.push(i);
2267                    continue;
2268                }
2269                if let Some(tick) = inst.tick_update(dt, def, caster_stats) {
2270                    if tick.damage > 0.0 {
2271                        frame.damage_ticks.push((inst.effect_type, tick.damage));
2272                    }
2273                    if tick.heal > 0.0 {
2274                        frame.heal_ticks.push((inst.effect_type, tick.heal));
2275                    }
2276                }
2277                frame.active_effects.push((inst.effect_type, inst.stack_count));
2278            }
2279        }
2280
2281        // Remove expired in reverse order
2282        for &i in to_remove.iter().rev() {
2283            active.swap_remove(i);
2284        }
2285
2286        frames.push(frame);
2287    }
2288
2289    frames
2290}
2291
2292#[derive(Debug, Clone)]
2293pub struct TimelineSummary {
2294    pub total_dot_damage: f32,
2295    pub total_heal: f32,
2296    pub duration_cc: f32,
2297    pub unique_effects: HashSet<StatusEffectType>,
2298    pub peak_stacks: HashMap<StatusEffectType, u32>,
2299}
2300
2301pub fn summarize_timeline(frames: &[TimelineFrame]) -> TimelineSummary {
2302    let mut total_dot = 0.0f32;
2303    let mut total_heal = 0.0f32;
2304    let mut cc_frames = 0u32;
2305    let mut unique: HashSet<StatusEffectType> = HashSet::new();
2306    let mut peak: HashMap<StatusEffectType, u32> = HashMap::new();
2307
2308    for frame in frames {
2309        for (et, dmg) in &frame.damage_ticks { total_dot += dmg; }
2310        for (et, heal) in &frame.heal_ticks { total_heal += heal; }
2311        for (et, stacks) in &frame.active_effects {
2312            unique.insert(*et);
2313            let entry = peak.entry(*et).or_insert(0);
2314            if *stacks > *entry { *entry = *stacks; }
2315            if et.is_crowd_control() { cc_frames += 1; }
2316        }
2317    }
2318
2319    let dt = if frames.len() > 1 { frames[1].time - frames[0].time } else { 0.1 };
2320    TimelineSummary {
2321        total_dot_damage: total_dot,
2322        total_heal,
2323        duration_cc: cc_frames as f32 * dt,
2324        unique_effects: unique,
2325        peak_stacks: peak,
2326    }
2327}
2328
2329// ============================================================
2330// ABILITY EDITOR STATE
2331// ============================================================
2332
2333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2334pub enum AbilityEditorTab {
2335    AbilityBrowser,
2336    AbilityEditor,
2337    StatusEffectEditor,
2338    TalentTreeEditor,
2339    FormulaTester,
2340    StatusTimeline,
2341    Database,
2342}
2343
2344#[derive(Debug, Clone)]
2345pub struct AbilityEditorState {
2346    pub current_ability: Option<AbilityDefinition>,
2347    pub is_dirty: bool,
2348    pub validation_errors: Vec<String>,
2349    pub undo_stack: VecDeque<AbilityDefinition>,
2350    pub redo_stack: VecDeque<AbilityDefinition>,
2351}
2352
2353impl AbilityEditorState {
2354    pub fn new() -> Self {
2355        AbilityEditorState {
2356            current_ability: None,
2357            is_dirty: false,
2358            validation_errors: Vec::new(),
2359            undo_stack: VecDeque::new(),
2360            redo_stack: VecDeque::new(),
2361        }
2362    }
2363
2364    pub fn open_ability(&mut self, ability: AbilityDefinition) {
2365        self.current_ability = Some(ability);
2366        self.is_dirty = false;
2367        self.validation_errors.clear();
2368    }
2369
2370    pub fn mark_dirty(&mut self) {
2371        self.is_dirty = true;
2372        if let Some(ref a) = self.current_ability {
2373            if self.undo_stack.len() >= 50 { self.undo_stack.pop_front(); }
2374            self.undo_stack.push_back(a.clone());
2375            self.redo_stack.clear();
2376        }
2377    }
2378
2379    pub fn undo(&mut self) {
2380        if let Some(prev) = self.undo_stack.pop_back() {
2381            if let Some(ref cur) = self.current_ability {
2382                if self.redo_stack.len() >= 50 { self.redo_stack.pop_front(); }
2383                self.redo_stack.push_back(cur.clone());
2384            }
2385            self.current_ability = Some(prev);
2386            self.is_dirty = true;
2387        }
2388    }
2389
2390    pub fn redo(&mut self) {
2391        if let Some(next) = self.redo_stack.pop_back() {
2392            if let Some(ref cur) = self.current_ability {
2393                if self.undo_stack.len() >= 50 { self.undo_stack.pop_front(); }
2394                self.undo_stack.push_back(cur.clone());
2395            }
2396            self.current_ability = Some(next);
2397            self.is_dirty = true;
2398        }
2399    }
2400
2401    pub fn validate(&mut self) {
2402        if let Some(ref a) = self.current_ability {
2403            self.validation_errors = a.validate();
2404        }
2405    }
2406}
2407
2408#[derive(Debug, Clone)]
2409pub struct StatusEffectEditorState {
2410    pub current_effect: Option<StatusEffectDefinition>,
2411    pub is_dirty: bool,
2412    pub preview_duration: f32,
2413    pub preview_stats: HashMap<String, f32>,
2414    pub timeline_preview: Vec<TimelineFrame>,
2415}
2416
2417impl StatusEffectEditorState {
2418    pub fn new() -> Self {
2419        StatusEffectEditorState {
2420            current_effect: None,
2421            is_dirty: false,
2422            preview_duration: 5.0,
2423            preview_stats: HashMap::new(),
2424            timeline_preview: Vec::new(),
2425        }
2426    }
2427
2428    pub fn update_timeline_preview(&mut self) {
2429        if let Some(ref def) = self.current_effect {
2430            self.timeline_preview = simulate_status_timeline(
2431                &[(def, 3)],
2432                &self.preview_stats,
2433                self.preview_duration,
2434                0.1,
2435            );
2436        }
2437    }
2438}
2439
2440#[derive(Debug, Clone)]
2441pub struct TalentTreeEditorState {
2442    pub current_tree_id: Option<u64>,
2443    pub selected_node_id: Option<u64>,
2444    pub total_points_available: u32,
2445    pub zoom: f32,
2446    pub pan_offset: Vec2,
2447    pub drag_node: Option<u64>,
2448    pub pending_connection: Option<(u64, bool)>, // (from_node, is_prerequisite)
2449}
2450
2451impl TalentTreeEditorState {
2452    pub fn new() -> Self {
2453        TalentTreeEditorState {
2454            current_tree_id: None,
2455            selected_node_id: None,
2456            total_points_available: 0,
2457            zoom: 1.0,
2458            pan_offset: Vec2::ZERO,
2459            drag_node: None,
2460            pending_connection: None,
2461        }
2462    }
2463
2464    pub fn screen_to_tree_space(&self, screen_pos: Vec2) -> Vec2 {
2465        (screen_pos - self.pan_offset) / self.zoom
2466    }
2467
2468    pub fn tree_to_screen_space(&self, tree_pos: Vec2) -> Vec2 {
2469        tree_pos * self.zoom + self.pan_offset
2470    }
2471
2472    pub fn node_screen_rect(&self, tree_pos: Vec2, node_size: Vec2) -> Vec4 {
2473        let screen = self.tree_to_screen_space(tree_pos);
2474        let sz = node_size * self.zoom;
2475        Vec4::new(screen.x, screen.y, sz.x, sz.y)
2476    }
2477
2478    pub fn hit_test_node(&self, screen_pos: Vec2, nodes: &HashMap<u64, TalentNode>, node_size: Vec2) -> Option<u64> {
2479        let tree_pos = self.screen_to_tree_space(screen_pos);
2480        for (id, node) in nodes {
2481            let rect = node.position;
2482            let half = node_size * 0.5;
2483            if (tree_pos - rect).abs().cmple(half).all() {
2484                return Some(*id);
2485            }
2486        }
2487        None
2488    }
2489}
2490
2491#[derive(Debug, Clone)]
2492pub struct FormulaTesterState {
2493    pub config: FormulaTesterConfig,
2494    pub selected_ability_id: Option<u64>,
2495    pub last_result: Option<FormulaTesterResult>,
2496    pub show_samples_histogram: bool,
2497    pub histogram_bins: u32,
2498    pub compare_ability_id: Option<u64>,
2499    pub compare_result: Option<FormulaTesterResult>,
2500}
2501
2502impl FormulaTesterState {
2503    pub fn new() -> Self {
2504        FormulaTesterState {
2505            config: FormulaTesterConfig::default_lvl_20(),
2506            selected_ability_id: None,
2507            last_result: None,
2508            show_samples_histogram: false,
2509            histogram_bins: 20,
2510            compare_ability_id: None,
2511            compare_result: None,
2512        }
2513    }
2514
2515    pub fn run_test(&mut self, db: &AbilityDatabase) {
2516        if let Some(id) = self.selected_ability_id {
2517            if let Some(ability) = db.abilities.get(&id) {
2518                self.last_result = Some(run_formula_test(ability, &self.config));
2519            }
2520        }
2521        if let Some(id) = self.compare_ability_id {
2522            if let Some(ability) = db.abilities.get(&id) {
2523                self.compare_result = Some(run_formula_test(ability, &self.config));
2524            }
2525        }
2526    }
2527
2528    pub fn build_histogram(&self) -> Vec<(f32, f32, u32)> {
2529        // Returns Vec<(bin_min, bin_max, count)>
2530        if let Some(ref result) = self.last_result {
2531            if result.samples.is_empty() { return Vec::new(); }
2532            let min = result.samples.iter().copied().fold(f32::INFINITY, f32::min);
2533            let max = result.samples.iter().copied().fold(f32::NEG_INFINITY, f32::max);
2534            if (max - min).abs() < 1e-6 { return vec![(min, max, result.samples.len() as u32)]; }
2535            let bin_width = (max - min) / self.histogram_bins as f32;
2536            let mut counts = vec![0u32; self.histogram_bins as usize];
2537            for &s in &result.samples {
2538                let bin = ((s - min) / bin_width).floor() as usize;
2539                let bin = bin.min(self.histogram_bins as usize - 1);
2540                counts[bin] += 1;
2541            }
2542            counts.iter().enumerate().map(|(i, &c)| {
2543                let lo = min + i as f32 * bin_width;
2544                let hi = lo + bin_width;
2545                (lo, hi, c)
2546            }).collect()
2547        } else {
2548            Vec::new()
2549        }
2550    }
2551}
2552
2553#[derive(Debug, Clone)]
2554pub struct AbilityBrowserState {
2555    pub filter_query: String,
2556    pub filter_types: HashSet<AbilityType>,
2557    pub filter_resources: HashSet<ResourceType>,
2558    pub filtered_ids: Vec<u64>,
2559    pub selected_ids: HashSet<u64>,
2560    pub sort_column: AbilitySortColumn,
2561    pub sort_ascending: bool,
2562    pub scroll_offset: f32,
2563    pub row_height: f32,
2564}
2565
2566#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2567pub enum AbilitySortColumn {
2568    Name,
2569    Type,
2570    Cooldown,
2571    ResourceCost,
2572    Damage,
2573    Range,
2574}
2575
2576impl AbilityBrowserState {
2577    pub fn new() -> Self {
2578        AbilityBrowserState {
2579            filter_query: String::new(),
2580            filter_types: HashSet::new(),
2581            filter_resources: HashSet::new(),
2582            filtered_ids: Vec::new(),
2583            selected_ids: HashSet::new(),
2584            sort_column: AbilitySortColumn::Name,
2585            sort_ascending: true,
2586            scroll_offset: 0.0,
2587            row_height: 22.0,
2588        }
2589    }
2590
2591    pub fn refresh(&mut self, db: &AbilityDatabase) {
2592        let q = self.filter_query.to_lowercase();
2593        let mut results: Vec<&AbilityDefinition> = db.abilities.values().filter(|a| {
2594            let name_match = q.is_empty() || a.name.to_lowercase().contains(&q);
2595            let type_match = self.filter_types.is_empty() || self.filter_types.contains(&a.ability_type);
2596            let res_match = self.filter_resources.is_empty() || self.filter_resources.contains(&a.resource_type);
2597            name_match && type_match && res_match
2598        }).collect();
2599
2600        let col = self.sort_column;
2601        let asc = self.sort_ascending;
2602        results.sort_by(|a, b| {
2603            let cmp = match col {
2604                AbilitySortColumn::Name => a.name.cmp(&b.name),
2605                AbilitySortColumn::Type => a.ability_type.display_name().cmp(b.ability_type.display_name()),
2606                AbilitySortColumn::Cooldown => a.base_cooldown.partial_cmp(&b.base_cooldown).unwrap_or(std::cmp::Ordering::Equal),
2607                AbilitySortColumn::ResourceCost => a.resource_cost.partial_cmp(&b.resource_cost).unwrap_or(std::cmp::Ordering::Equal),
2608                AbilitySortColumn::Damage => {
2609                    let da: f32 = a.damage_formulas.iter().map(|f| (f.base_min + f.base_max) / 2.0).sum();
2610                    let db: f32 = b.damage_formulas.iter().map(|f| (f.base_min + f.base_max) / 2.0).sum();
2611                    da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2612                }
2613                AbilitySortColumn::Range => a.range.partial_cmp(&b.range).unwrap_or(std::cmp::Ordering::Equal),
2614            };
2615            if asc { cmp } else { cmp.reverse() }
2616        });
2617
2618        self.filtered_ids = results.iter().map(|a| a.id).collect();
2619    }
2620
2621    pub fn visible_rows(&self, viewport_height: f32) -> usize {
2622        (viewport_height / self.row_height) as usize
2623    }
2624
2625    pub fn scroll_to_selected(&mut self) {
2626        if let Some(&id) = self.selected_ids.iter().next() {
2627            if let Some(pos) = self.filtered_ids.iter().position(|&i| i == id) {
2628                self.scroll_offset = pos as f32 * self.row_height;
2629            }
2630        }
2631    }
2632}
2633
2634// ============================================================
2635// MAIN ABILITY EDITOR
2636// ============================================================
2637
2638#[derive(Debug)]
2639pub struct AbilityEditor {
2640    pub database: AbilityDatabase,
2641    pub active_tab: AbilityEditorTab,
2642    pub ability_editor: AbilityEditorState,
2643    pub status_editor: StatusEffectEditorState,
2644    pub talent_tree_editor: TalentTreeEditorState,
2645    pub formula_tester: FormulaTesterState,
2646    pub browser: AbilityBrowserState,
2647    pub window_size: Vec2,
2648    pub panel_split: f32,
2649    pub status_message: String,
2650    pub status_timer: f32,
2651    pub clipboard_ability: Option<AbilityDefinition>,
2652    pub search_history: VecDeque<String>,
2653    pub cooldown_manager: CooldownManager,
2654    pub resource_states: HashMap<ResourceType, ResourceState>,
2655    pub recently_viewed: VecDeque<u64>,
2656    pub timeline_target_effects: Vec<u64>, // status effect IDs to preview
2657}
2658
2659impl AbilityEditor {
2660    pub fn new() -> Self {
2661        let mut resource_states = HashMap::new();
2662        for rt in [ResourceType::Mana, ResourceType::Stamina, ResourceType::Energy,
2663                   ResourceType::Rage, ResourceType::ComboPoints, ResourceType::Heat,
2664                   ResourceType::Focus, ResourceType::Fury, ResourceType::Essence] {
2665            resource_states.insert(rt, ResourceState::new(rt));
2666        }
2667        AbilityEditor {
2668            database: AbilityDatabase::new(),
2669            active_tab: AbilityEditorTab::AbilityBrowser,
2670            ability_editor: AbilityEditorState::new(),
2671            status_editor: StatusEffectEditorState::new(),
2672            talent_tree_editor: TalentTreeEditorState::new(),
2673            formula_tester: FormulaTesterState::new(),
2674            browser: AbilityBrowserState::new(),
2675            window_size: Vec2::new(1280.0, 720.0),
2676            panel_split: 0.35,
2677            status_message: String::new(),
2678            status_timer: 0.0,
2679            clipboard_ability: None,
2680            search_history: VecDeque::new(),
2681            cooldown_manager: CooldownManager::new(),
2682            resource_states,
2683            recently_viewed: VecDeque::new(),
2684            timeline_target_effects: Vec::new(),
2685        }
2686    }
2687
2688    pub fn set_status(&mut self, msg: impl Into<String>) {
2689        self.status_message = msg.into();
2690        self.status_timer = 3.0;
2691    }
2692
2693    pub fn tick(&mut self, dt: f32) {
2694        if self.status_timer > 0.0 {
2695            self.status_timer = (self.status_timer - dt).max(0.0);
2696        }
2697        self.cooldown_manager.tick(dt);
2698        for res in self.resource_states.values_mut() {
2699            res.tick(dt);
2700        }
2701    }
2702
2703    pub fn create_new_ability(&mut self, ability_type: AbilityType) {
2704        let id = self.database.alloc_id();
2705        let ability = AbilityDefinition::new(id, "New Ability", ability_type);
2706        self.ability_editor.open_ability(ability);
2707        self.active_tab = AbilityEditorTab::AbilityEditor;
2708    }
2709
2710    pub fn open_ability_by_id(&mut self, id: u64) {
2711        if let Some(ability) = self.database.abilities.get(&id).cloned() {
2712            self.ability_editor.open_ability(ability);
2713            self.active_tab = AbilityEditorTab::AbilityEditor;
2714            self.recently_viewed.push_front(id);
2715            if self.recently_viewed.len() > 20 { self.recently_viewed.pop_back(); }
2716        }
2717    }
2718
2719    pub fn save_current_ability(&mut self) -> bool {
2720        if let Some(ability) = self.ability_editor.current_ability.clone() {
2721            self.ability_editor.validate();
2722            if !self.ability_editor.validation_errors.is_empty() {
2723                self.set_status(format!("Cannot save: {} error(s)", self.ability_editor.validation_errors.len()));
2724                return false;
2725            }
2726            let id = ability.id;
2727            self.cooldown_manager.register_ability(id, ability.base_cooldown, ability.gcd_category, ability.max_charges);
2728            self.database.abilities.insert(id, ability);
2729            self.ability_editor.is_dirty = false;
2730            self.browser.refresh(&self.database);
2731            self.set_status("Ability saved.");
2732            true
2733        } else {
2734            false
2735        }
2736    }
2737
2738    pub fn delete_selected_abilities(&mut self) {
2739        let to_delete: Vec<u64> = self.browser.selected_ids.iter().cloned().collect();
2740        for id in &to_delete {
2741            self.database.abilities.remove(id);
2742            self.cooldown_manager.cooldowns.remove(id);
2743        }
2744        self.browser.selected_ids.clear();
2745        self.browser.refresh(&self.database);
2746        self.set_status(format!("Deleted {} ability(s).", to_delete.len()));
2747    }
2748
2749    pub fn duplicate_ability(&mut self, id: u64) {
2750        if let Some(a) = self.database.abilities.get(&id).cloned() {
2751            let new_id = self.database.alloc_id();
2752            let mut new_a = a;
2753            new_a.id = new_id;
2754            new_a.name = format!("{} (Copy)", new_a.name);
2755            self.database.abilities.insert(new_id, new_a);
2756            self.browser.refresh(&self.database);
2757            self.set_status("Ability duplicated.");
2758        }
2759    }
2760
2761    pub fn copy_ability(&mut self) {
2762        if let Some(ref a) = self.ability_editor.current_ability {
2763            self.clipboard_ability = Some(a.clone());
2764            self.set_status("Ability copied to clipboard.");
2765        }
2766    }
2767
2768    pub fn paste_ability(&mut self) {
2769        if let Some(a) = self.clipboard_ability.clone() {
2770            let new_id = self.database.alloc_id();
2771            let mut new_a = a;
2772            new_a.id = new_id;
2773            new_a.name = format!("{} (Paste)", new_a.name);
2774            self.database.abilities.insert(new_id, new_a.clone());
2775            self.ability_editor.open_ability(new_a);
2776            self.browser.refresh(&self.database);
2777            self.set_status("Ability pasted.");
2778        }
2779    }
2780
2781    pub fn apply_modifier_to_current(&mut self, modifier_id: u64) {
2782        if let Some(ref mut ability) = self.ability_editor.current_ability {
2783            if let Some(modifier) = self.database.ability_modifiers.get(&modifier_id).cloned() {
2784                if !ability.applied_modifiers.contains(&modifier_id) {
2785                    modifier.apply_to_definition(ability);
2786                    ability.applied_modifiers.push(modifier_id);
2787                    self.ability_editor.mark_dirty();
2788                    self.set_status(format!("Applied modifier: {}", modifier.name));
2789                }
2790            }
2791        }
2792    }
2793
2794    pub fn run_formula_test_now(&mut self) {
2795        self.formula_tester.run_test(&self.database);
2796    }
2797
2798    pub fn update_status_timeline(&mut self) {
2799        let mut defs_to_preview: Vec<StatusEffectDefinition> = self.timeline_target_effects.iter()
2800            .filter_map(|id| self.database.status_effects.get(id).cloned())
2801            .collect();
2802
2803        if !defs_to_preview.is_empty() {
2804            let pairs: Vec<(&StatusEffectDefinition, u32)> = defs_to_preview.iter().map(|d| (d, 1)).collect();
2805            let stats = HashMap::new();
2806            let frames = simulate_status_timeline(&pairs, &stats, 10.0, 0.1);
2807            // Store in status editor or formula tester as needed
2808        }
2809    }
2810
2811    pub fn layout(&self) -> AbilityEditorLayout {
2812        let left_w = self.window_size.x * self.panel_split;
2813        let right_w = self.window_size.x - left_w - 2.0;
2814        let header_h = 40.0;
2815        let footer_h = 24.0;
2816        let content_h = self.window_size.y - header_h - footer_h;
2817        AbilityEditorLayout {
2818            header: Vec4::new(0.0, 0.0, self.window_size.x, header_h),
2819            left_panel: Vec4::new(0.0, header_h, left_w, content_h),
2820            right_panel: Vec4::new(left_w + 2.0, header_h, right_w, content_h),
2821            footer: Vec4::new(0.0, self.window_size.y - footer_h, self.window_size.x, footer_h),
2822        }
2823    }
2824
2825    pub fn get_ability_tooltip(&self, id: u64) -> Option<AbilityTooltip> {
2826        let a = self.database.abilities.get(&id)?;
2827        let mut lines = Vec::new();
2828        lines.push((a.name.clone(), Vec4::new(1.0, 0.9, 0.5, 1.0)));
2829        lines.push((a.ability_type.display_name().to_string(), Vec4::new(0.7, 0.7, 0.7, 1.0)));
2830        lines.push((format!("Range: {:.1}m | CD: {:.1}s", a.range, a.base_cooldown), Vec4::new(0.8, 0.8, 0.8, 1.0)));
2831        lines.push((format!("Cost: {:.0} {}", a.resource_cost, a.resource_type.display_name()), a.resource_type.color()));
2832        if !a.damage_formulas.is_empty() {
2833            for f in &a.damage_formulas {
2834                lines.push((format!("{}: {:.0}-{:.0}", f.element.display_name(), f.base_min, f.base_max), f.element.color()));
2835            }
2836        }
2837        if a.heal_formula.base_max > 0.0 {
2838            lines.push((format!("Heals: {:.0}-{:.0}", a.heal_formula.base_min, a.heal_formula.base_max), Vec4::new(0.3, 1.0, 0.5, 1.0)));
2839        }
2840        for eff in &a.applied_effects {
2841            let pct = eff.apply_chance;
2842            let target = if eff.to_target { "target" } else { "self" };
2843            lines.push((format!("{:.0}% {}: {} (+{})", pct, target, eff.effect_type.display_name(), eff.stacks_applied), eff.effect_type.color()));
2844        }
2845        if !a.description.is_empty() {
2846            lines.push((a.description.clone(), Vec4::new(0.6, 0.6, 0.9, 1.0)));
2847        }
2848        Some(AbilityTooltip { lines })
2849    }
2850
2851    pub fn search_with_history(&mut self, query: String) {
2852        if !query.is_empty() {
2853            self.search_history.push_front(query.clone());
2854            if self.search_history.len() > 20 { self.search_history.pop_back(); }
2855        }
2856        self.browser.filter_query = query;
2857        self.browser.refresh(&self.database);
2858    }
2859
2860    pub fn export_abilities_json(&self) -> String {
2861        let mut lines = Vec::new();
2862        lines.push("[".to_string());
2863        let mut sorted: Vec<&AbilityDefinition> = self.database.abilities.values().collect();
2864        sorted.sort_by_key(|a| a.id);
2865        for (i, a) in sorted.iter().enumerate() {
2866            let comma = if i + 1 < sorted.len() { "," } else { "" };
2867            let dmg: String = a.damage_formulas.iter().map(|f| {
2868                format!("{{\"element\":\"{}\",\"min\":{:.1},\"max\":{:.1}}}", f.element.display_name(), f.base_min, f.base_max)
2869            }).collect::<Vec<_>>().join(",");
2870            lines.push(format!(
2871                "  {{\"id\":{},\"name\":\"{}\",\"type\":\"{}\",\"cd\":{:.2},\"cost\":{:.1},\"resource\":\"{}\",\"range\":{:.1},\"damage\":[{}]}}{}",
2872                a.id, a.name, a.ability_type.display_name(), a.base_cooldown,
2873                a.resource_cost, a.resource_type.display_name(), a.range, dmg, comma
2874            ));
2875        }
2876        lines.push("]".to_string());
2877        lines.join("\n")
2878    }
2879}
2880
2881#[derive(Debug, Clone)]
2882pub struct AbilityTooltip {
2883    pub lines: Vec<(String, Vec4)>,
2884}
2885
2886#[derive(Debug, Clone)]
2887pub struct AbilityEditorLayout {
2888    pub header: Vec4,
2889    pub left_panel: Vec4,
2890    pub right_panel: Vec4,
2891    pub footer: Vec4,
2892}
2893
2894// ============================================================
2895// DEFAULT ABILITY DATABASE (starter set)
2896// ============================================================
2897
2898pub fn create_starter_ability_database() -> AbilityDatabase {
2899    let mut db = AbilityDatabase::new();
2900
2901    // ---- Status Effects ----
2902
2903    // Burn
2904    let burn_id = db.alloc_id();
2905    let burn_formula = DamageFormula::new(DamageElement::Fire, 5.0, 12.0);
2906    let burn = StatusEffectDefinition {
2907        id: burn_id,
2908        effect_type: StatusEffectType::Burn,
2909        name: "Burn".to_string(),
2910        description: "Dealing fire damage over time.".to_string(),
2911        base_duration: 4.0,
2912        tick_interval: 1.0,
2913        tick_damage: burn_formula,
2914        tick_heal: 0.0,
2915        stat_modifiers: vec![],
2916        stack_rule: StackRule { max_stacks: 3, stack_behavior: StackBehavior::AddStackRefreshAll, application_behavior: ApplicationBehavior::Always },
2917        immunity_window: 0.5,
2918        can_be_cleansed: true,
2919        can_be_dispelled: false,
2920        is_debuff: true,
2921        visual_particle: "fire_burn".to_string(),
2922        sound_on_apply: "burn_start".to_string(),
2923        sound_on_tick: "burn_tick".to_string(),
2924        sound_on_expire: "burn_end".to_string(),
2925        cc_break_on_damage: false,
2926        cc_damage_threshold: 0.0,
2927        interactions: vec![
2928            StatusInteraction {
2929                when_hit_by: StatusEffectType::Wet,
2930                reaction: StatusReaction::Remove,
2931            }
2932        ],
2933    };
2934    db.add_status_effect(burn);
2935
2936    // Freeze
2937    let freeze_id = db.alloc_id();
2938    let freeze_formula = DamageFormula::new(DamageElement::Cold, 0.0, 0.0);
2939    let freeze = StatusEffectDefinition {
2940        id: freeze_id,
2941        effect_type: StatusEffectType::Freeze,
2942        name: "Freeze".to_string(),
2943        description: "Target cannot move or act.".to_string(),
2944        base_duration: 2.0,
2945        tick_interval: 0.0,
2946        tick_damage: freeze_formula,
2947        tick_heal: 0.0,
2948        stat_modifiers: vec![
2949            StatusStatMod { stat: "move_speed".to_string(), flat_delta: 0.0, percent_delta: -100.0, per_stack: false },
2950        ],
2951        stack_rule: StackRule { max_stacks: 1, stack_behavior: StackBehavior::Refresh, application_behavior: ApplicationBehavior::Always },
2952        immunity_window: 3.0,
2953        can_be_cleansed: true,
2954        can_be_dispelled: true,
2955        is_debuff: true,
2956        visual_particle: "freeze_ice".to_string(),
2957        sound_on_apply: "freeze_crack".to_string(),
2958        sound_on_tick: String::new(),
2959        sound_on_expire: "freeze_shatter".to_string(),
2960        cc_break_on_damage: true,
2961        cc_damage_threshold: 50.0,
2962        interactions: vec![
2963            StatusInteraction {
2964                when_hit_by: StatusEffectType::Burn,
2965                reaction: StatusReaction::Explode {
2966                    damage_formula: DamageFormula::new(DamageElement::Physical, 20.0, 35.0),
2967                },
2968            }
2969        ],
2970    };
2971    db.add_status_effect(freeze);
2972
2973    // Haste
2974    let haste_id = db.alloc_id();
2975    let haste_formula = DamageFormula::new(DamageElement::True_, 0.0, 0.0);
2976    let haste = StatusEffectDefinition {
2977        id: haste_id,
2978        effect_type: StatusEffectType::Haste,
2979        name: "Haste".to_string(),
2980        description: "Increased movement and attack speed.".to_string(),
2981        base_duration: 6.0,
2982        tick_interval: 0.0,
2983        tick_damage: haste_formula,
2984        tick_heal: 0.0,
2985        stat_modifiers: vec![
2986            StatusStatMod { stat: "move_speed".to_string(), flat_delta: 0.0, percent_delta: 30.0, per_stack: true },
2987            StatusStatMod { stat: "attack_speed".to_string(), flat_delta: 0.0, percent_delta: 20.0, per_stack: true },
2988        ],
2989        stack_rule: StackRule { max_stacks: 2, stack_behavior: StackBehavior::AddStack, application_behavior: ApplicationBehavior::Always },
2990        immunity_window: 0.0,
2991        can_be_cleansed: false,
2992        can_be_dispelled: true,
2993        is_debuff: false,
2994        visual_particle: "haste_glow".to_string(),
2995        sound_on_apply: "haste_whoosh".to_string(),
2996        sound_on_tick: String::new(),
2997        sound_on_expire: "haste_fade".to_string(),
2998        cc_break_on_damage: false,
2999        cc_damage_threshold: 0.0,
3000        interactions: vec![],
3001    };
3002    db.add_status_effect(haste);
3003
3004    // Poison
3005    let poison_id = db.alloc_id();
3006    let poison_formula = DamageFormula::new(DamageElement::Poison, 3.0, 8.0);
3007    let poison = StatusEffectDefinition {
3008        id: poison_id,
3009        effect_type: StatusEffectType::Poison,
3010        name: "Poison".to_string(),
3011        description: "Deals poison damage over time. Stacks amplify damage.".to_string(),
3012        base_duration: 8.0,
3013        tick_interval: 0.5,
3014        tick_damage: poison_formula,
3015        tick_heal: 0.0,
3016        stat_modifiers: vec![
3017            StatusStatMod { stat: "healing_received".to_string(), flat_delta: 0.0, percent_delta: -20.0, per_stack: false },
3018        ],
3019        stack_rule: StackRule { max_stacks: 5, stack_behavior: StackBehavior::AddStackRefreshAll, application_behavior: ApplicationBehavior::Always },
3020        immunity_window: 0.0,
3021        can_be_cleansed: true,
3022        can_be_dispelled: false,
3023        is_debuff: true,
3024        visual_particle: "poison_bubbles".to_string(),
3025        sound_on_apply: "poison_hiss".to_string(),
3026        sound_on_tick: String::new(),
3027        sound_on_expire: "poison_end".to_string(),
3028        cc_break_on_damage: false,
3029        cc_damage_threshold: 0.0,
3030        interactions: vec![],
3031    };
3032    db.add_status_effect(poison);
3033
3034    // Stun
3035    let stun_id = db.alloc_id();
3036    let stun_formula = DamageFormula::new(DamageElement::True_, 0.0, 0.0);
3037    let stun = StatusEffectDefinition {
3038        id: stun_id,
3039        effect_type: StatusEffectType::Stun,
3040        name: "Stun".to_string(),
3041        description: "Target is completely incapacitated.".to_string(),
3042        base_duration: 1.5,
3043        tick_interval: 0.0,
3044        tick_damage: stun_formula,
3045        tick_heal: 0.0,
3046        stat_modifiers: vec![],
3047        stack_rule: StackRule { max_stacks: 1, stack_behavior: StackBehavior::Refresh, application_behavior: ApplicationBehavior::Always },
3048        immunity_window: 4.0,
3049        can_be_cleansed: true,
3050        can_be_dispelled: true,
3051        is_debuff: true,
3052        visual_particle: "stun_stars".to_string(),
3053        sound_on_apply: "stun_hit".to_string(),
3054        sound_on_tick: String::new(),
3055        sound_on_expire: "stun_clear".to_string(),
3056        cc_break_on_damage: false,
3057        cc_damage_threshold: 0.0,
3058        interactions: vec![],
3059    };
3060    db.add_status_effect(stun);
3061
3062    // Bleed
3063    let bleed_id = db.alloc_id();
3064    let mut bleed_formula = DamageFormula::new(DamageElement::Physical, 4.0, 10.0);
3065    bleed_formula.scaling.push(ScalingCoeff::linear("strength", 0.15));
3066    let bleed = StatusEffectDefinition {
3067        id: bleed_id,
3068        effect_type: StatusEffectType::Bleed,
3069        name: "Bleed".to_string(),
3070        description: "Dealing physical damage over time, scaling with attacker strength.".to_string(),
3071        base_duration: 5.0,
3072        tick_interval: 0.5,
3073        tick_damage: bleed_formula,
3074        tick_heal: 0.0,
3075        stat_modifiers: vec![],
3076        stack_rule: StackRule { max_stacks: 8, stack_behavior: StackBehavior::AddStack, application_behavior: ApplicationBehavior::Always },
3077        immunity_window: 0.0,
3078        can_be_cleansed: true,
3079        can_be_dispelled: false,
3080        is_debuff: true,
3081        visual_particle: "bleed_drips".to_string(),
3082        sound_on_apply: "bleed_slash".to_string(),
3083        sound_on_tick: "bleed_drip".to_string(),
3084        sound_on_expire: "bleed_close".to_string(),
3085        cc_break_on_damage: false,
3086        cc_damage_threshold: 0.0,
3087        interactions: vec![],
3088    };
3089    db.add_status_effect(bleed);
3090
3091    // ---- Abilities ----
3092
3093    // Basic Attack (melee)
3094    let basic_atk_id = db.alloc_id();
3095    let mut basic_atk = AbilityDefinition::new(basic_atk_id, "Basic Attack", AbilityType::MeleeAttack);
3096    basic_atk.description = "A standard melee strike.".to_string();
3097    basic_atk.range = 2.5;
3098    basic_atk.base_cooldown = 0.0;
3099    basic_atk.resource_cost = 0.0;
3100    basic_atk.triggers_gcd = true;
3101    let mut atk_formula = DamageFormula::new(DamageElement::Physical, 10.0, 18.0);
3102    atk_formula.scaling.push(ScalingCoeff::linear("attack_power", 1.0));
3103    atk_formula.crit_chance_base = 5.0;
3104    basic_atk.damage_formulas.push(atk_formula);
3105    basic_atk.applied_effects.push(AppliedEffect {
3106        effect_id: bleed_id,
3107        effect_type: StatusEffectType::Bleed,
3108        apply_chance: 10.0,
3109        to_target: true,
3110        condition: None,
3111        stacks_applied: 1,
3112        duration_override: None,
3113    });
3114    db.add_ability(basic_atk);
3115
3116    // Fireball
3117    let fireball_id = db.alloc_id();
3118    let mut fireball = AbilityDefinition::new(fireball_id, "Fireball", AbilityType::Projectile);
3119    fireball.description = "Hurls a ball of fire that explodes on impact.".to_string();
3120    fireball.range = 25.0;
3121    fireball.aoe_radius = 3.5;
3122    fireball.base_cooldown = 3.0;
3123    fireball.cast_time = 0.8;
3124    fireball.resource_type = ResourceType::Mana;
3125    fireball.resource_cost = 30.0;
3126    fireball.projectile_params = Some(ProjectileParams::default_fireball());
3127    let mut fire_formula = DamageFormula::new(DamageElement::Fire, 40.0, 65.0);
3128    fire_formula.scaling.push(ScalingCoeff::linear("spell_power", 1.2));
3129    fire_formula.crit_chance_base = 8.0;
3130    fire_formula.crit_multiplier_base = 1.8;
3131    fire_formula.versus_status_bonus.push((StatusEffectType::Burn, 0.3));
3132    fireball.damage_formulas.push(fire_formula);
3133    fireball.applied_effects.push(AppliedEffect {
3134        effect_id: burn_id,
3135        effect_type: StatusEffectType::Burn,
3136        apply_chance: 50.0,
3137        to_target: true,
3138        condition: None,
3139        stacks_applied: 1,
3140        duration_override: None,
3141    });
3142    fireball.targeting_mode = TargetingMode::GroundTarget { indicator: AreaIndicator::Circle };
3143    db.add_ability(fireball);
3144
3145    // Frost Nova
3146    let frost_nova_id = db.alloc_id();
3147    let mut frost_nova = AbilityDefinition::new(frost_nova_id, "Frost Nova", AbilityType::Nova);
3148    frost_nova.description = "Releases a burst of frost in all directions, freezing nearby enemies.".to_string();
3149    frost_nova.range = 0.0;
3150    frost_nova.aoe_radius = 6.0;
3151    frost_nova.base_cooldown = 12.0;
3152    frost_nova.resource_cost = 25.0;
3153    frost_nova.targeting_mode = TargetingMode::Self_;
3154    let mut nova_formula = DamageFormula::new(DamageElement::Cold, 15.0, 25.0);
3155    nova_formula.scaling.push(ScalingCoeff::linear("spell_power", 0.6));
3156    frost_nova.damage_formulas.push(nova_formula);
3157    frost_nova.applied_effects.push(AppliedEffect {
3158        effect_id: freeze_id,
3159        effect_type: StatusEffectType::Freeze,
3160        apply_chance: 80.0,
3161        to_target: true,
3162        condition: None,
3163        stacks_applied: 1,
3164        duration_override: None,
3165    });
3166    db.add_ability(frost_nova);
3167
3168    // Haste Buff
3169    let haste_ability_id = db.alloc_id();
3170    let mut haste_ability = AbilityDefinition::new(haste_ability_id, "Swiftness Aura", AbilityType::Buff);
3171    haste_ability.description = "Grants haste to the caster.".to_string();
3172    haste_ability.base_cooldown = 20.0;
3173    haste_ability.resource_cost = 15.0;
3174    haste_ability.resource_type = ResourceType::Mana;
3175    haste_ability.duration = 8.0;
3176    haste_ability.targeting_mode = TargetingMode::Self_;
3177    haste_ability.applied_effects.push(AppliedEffect {
3178        effect_id: haste_id,
3179        effect_type: StatusEffectType::Haste,
3180        apply_chance: 100.0,
3181        to_target: false,
3182        condition: None,
3183        stacks_applied: 1,
3184        duration_override: None,
3185    });
3186    db.add_ability(haste_ability);
3187
3188    // Whirlwind (AoE melee)
3189    let whirlwind_id = db.alloc_id();
3190    let mut whirlwind = AbilityDefinition::new(whirlwind_id, "Whirlwind", AbilityType::AreaOfEffect);
3191    whirlwind.description = "Spin and deal damage to all nearby enemies.".to_string();
3192    whirlwind.range = 0.0;
3193    whirlwind.aoe_radius = 5.0;
3194    whirlwind.base_cooldown = 8.0;
3195    whirlwind.resource_type = ResourceType::Stamina;
3196    whirlwind.resource_cost = 35.0;
3197    whirlwind.cast_time = 0.3;
3198    whirlwind.duration = 1.5;
3199    whirlwind.targeting_mode = TargetingMode::Self_;
3200    let mut ww_formula = DamageFormula::new(DamageElement::Physical, 25.0, 40.0);
3201    ww_formula.scaling.push(ScalingCoeff::linear("attack_power", 0.9));
3202    ww_formula.scaling.push(ScalingCoeff::linear("strength", 0.3));
3203    whirlwind.damage_formulas.push(ww_formula);
3204    whirlwind.applied_effects.push(AppliedEffect {
3205        effect_id: bleed_id,
3206        effect_type: StatusEffectType::Bleed,
3207        apply_chance: 30.0,
3208        to_target: true,
3209        condition: None,
3210        stacks_applied: 2,
3211        duration_override: None,
3212    });
3213    db.add_ability(whirlwind);
3214
3215    // Shadow Step (Dash / Teleport)
3216    let shadow_step_id = db.alloc_id();
3217    let mut shadow_step = AbilityDefinition::new(shadow_step_id, "Shadow Step", AbilityType::Dash);
3218    shadow_step.description = "Teleport behind target and deal damage.".to_string();
3219    shadow_step.range = 15.0;
3220    shadow_step.base_cooldown = 10.0;
3221    shadow_step.resource_type = ResourceType::Energy;
3222    shadow_step.resource_cost = 20.0;
3223    shadow_step.targeting_mode = TargetingMode::SingleTarget;
3224    let mut step_formula = DamageFormula::new(DamageElement::Shadow, 30.0, 50.0);
3225    step_formula.scaling.push(ScalingCoeff::linear("dexterity", 0.5));
3226    step_formula.crit_chance_base = 25.0; // backstab bonus
3227    step_formula.crit_multiplier_base = 2.5;
3228    shadow_step.damage_formulas.push(step_formula);
3229    shadow_step.applied_effects.push(AppliedEffect {
3230        effect_id: stun_id,
3231        effect_type: StatusEffectType::Stun,
3232        apply_chance: 20.0,
3233        to_target: true,
3234        condition: None,
3235        stacks_applied: 1,
3236        duration_override: Some(0.75),
3237    });
3238    db.add_ability(shadow_step);
3239
3240    // Rain of Arrows (Channel)
3241    let rain_arrows_id = db.alloc_id();
3242    let mut rain_arrows = AbilityDefinition::new(rain_arrows_id, "Rain of Arrows", AbilityType::Channel);
3243    rain_arrows.description = "Channels a barrage of arrows onto a target area.".to_string();
3244    rain_arrows.range = 30.0;
3245    rain_arrows.aoe_radius = 5.0;
3246    rain_arrows.base_cooldown = 15.0;
3247    rain_arrows.cast_time = 0.0;
3248    rain_arrows.channel_time = 3.0;
3249    rain_arrows.channel_ticks = 6;
3250    rain_arrows.resource_type = ResourceType::Mana;
3251    rain_arrows.resource_cost = 50.0;
3252    rain_arrows.targeting_mode = TargetingMode::GroundTarget { indicator: AreaIndicator::Circle };
3253    let mut arrow_formula = DamageFormula::new(DamageElement::Physical, 12.0, 20.0);
3254    arrow_formula.scaling.push(ScalingCoeff::linear("dexterity", 0.6));
3255    rain_arrows.damage_formulas.push(arrow_formula);
3256    db.add_ability(rain_arrows);
3257
3258    // Summon Wolf
3259    let summon_wolf_id = db.alloc_id();
3260    let mut summon_wolf = AbilityDefinition::new(summon_wolf_id, "Summon Wolf", AbilityType::Summon);
3261    summon_wolf.description = "Summons a wolf companion to fight for you.".to_string();
3262    summon_wolf.range = 5.0;
3263    summon_wolf.base_cooldown = 30.0;
3264    summon_wolf.resource_cost = 60.0;
3265    summon_wolf.duration = 60.0;
3266    summon_wolf.resource_type = ResourceType::Mana;
3267    db.add_ability(summon_wolf);
3268
3269    // Poison Arrow
3270    let poison_arrow_id = db.alloc_id();
3271    let mut poison_arrow = AbilityDefinition::new(poison_arrow_id, "Poison Arrow", AbilityType::RangedAttack);
3272    poison_arrow.description = "Fire a poisoned arrow that applies stacking poison.".to_string();
3273    poison_arrow.range = 25.0;
3274    poison_arrow.base_cooldown = 4.0;
3275    poison_arrow.resource_type = ResourceType::Mana;
3276    poison_arrow.resource_cost = 15.0;
3277    poison_arrow.projectile_params = Some(ProjectileParams::default_arrow());
3278    let pa_formula = DamageFormula::new(DamageElement::Poison, 8.0, 14.0);
3279    poison_arrow.damage_formulas.push(pa_formula);
3280    poison_arrow.applied_effects.push(AppliedEffect {
3281        effect_id: poison_id,
3282        effect_type: StatusEffectType::Poison,
3283        apply_chance: 100.0,
3284        to_target: true,
3285        condition: None,
3286        stacks_applied: 2,
3287        duration_override: None,
3288    });
3289    db.add_ability(poison_arrow);
3290
3291    // Lightning Strike (Chain)
3292    let lightning_id = db.alloc_id();
3293    let mut lightning = AbilityDefinition::new(lightning_id, "Chain Lightning", AbilityType::ChainLightning);
3294    lightning.description = "Lightning that bounces between enemies.".to_string();
3295    lightning.range = 20.0;
3296    lightning.base_cooldown = 5.0;
3297    lightning.resource_cost = 35.0;
3298    lightning.targeting_mode = TargetingMode::Chain { max_bounces: 4, bounce_radius: 8.0, falloff_per_bounce: 0.3 };
3299    let mut chain_formula = DamageFormula::new(DamageElement::Lightning, 30.0, 55.0);
3300    chain_formula.scaling.push(ScalingCoeff::linear("spell_power", 1.0));
3301    chain_formula.crit_chance_base = 10.0;
3302    chain_formula.versus_status_bonus.push((StatusEffectType::Wet, 0.5));
3303    lightning.damage_formulas.push(chain_formula);
3304    db.add_ability(lightning);
3305
3306    // Heal
3307    let heal_id = db.alloc_id();
3308    let mut heal_ability = AbilityDefinition::new(heal_id, "Healing Touch", AbilityType::Heal);
3309    heal_ability.description = "Restores health to the target.".to_string();
3310    heal_ability.range = 30.0;
3311    heal_ability.base_cooldown = 0.0;
3312    heal_ability.cast_time = 1.5;
3313    heal_ability.resource_type = ResourceType::Mana;
3314    heal_ability.resource_cost = 40.0;
3315    heal_ability.targeting_mode = TargetingMode::SingleTarget;
3316    let mut h_formula = HealFormula {
3317        base_min: 50.0,
3318        base_max: 80.0,
3319        scaling: vec![ScalingCoeff::linear("spell_power", 1.5)],
3320        overheal_shield: 0.2,
3321        critical_heal_multiplier: 1.5,
3322        critical_heal_chance: 10.0,
3323    };
3324    heal_ability.heal_formula = h_formula;
3325    db.add_ability(heal_ability);
3326
3327    // ---- Talent Tree ----
3328
3329    let warrior_tree_id = db.alloc_id();
3330    let mut warrior_tree = TalentTree::new(warrior_tree_id, "Warrior Mastery");
3331    warrior_tree.description = "Enhances melee combat and physical abilities.".to_string();
3332
3333    let node1_id = db.alloc_id();
3334    warrior_tree.add_node(TalentNode {
3335        id: node1_id,
3336        name: "Iron Skin".to_string(),
3337        description: "+5 armor per rank".to_string(),
3338        ability_id: None,
3339        modifier_id: None,
3340        passive_stat_mods: vec![("physical_armor".to_string(), 5.0, 0.0)],
3341        point_cost: 1,
3342        max_ranks: 5,
3343        current_ranks: 0,
3344        position: Vec2::new(0.0, 0.0),
3345        icon_path: "node_ironskin".to_string(),
3346        is_keystone: false,
3347        is_notable: false,
3348        unlock_bonus_at: Some(5),
3349    });
3350
3351    let node2_id = db.alloc_id();
3352    warrior_tree.add_node(TalentNode {
3353        id: node2_id,
3354        name: "Berserker Rage".to_string(),
3355        description: "+3% attack speed per rank".to_string(),
3356        ability_id: None,
3357        modifier_id: None,
3358        passive_stat_mods: vec![("attack_speed".to_string(), 0.0, 3.0)],
3359        point_cost: 1,
3360        max_ranks: 5,
3361        current_ranks: 0,
3362        position: Vec2::new(100.0, 0.0),
3363        icon_path: "node_berserker".to_string(),
3364        is_keystone: false,
3365        is_notable: true,
3366        unlock_bonus_at: None,
3367    });
3368
3369    let node3_id = db.alloc_id();
3370    warrior_tree.add_node(TalentNode {
3371        id: node3_id,
3372        name: "Mighty Blow".to_string(),
3373        description: "Whirlwind now stuns enemies for 0.5s (keystone)".to_string(),
3374        ability_id: Some(whirlwind_id),
3375        modifier_id: None,
3376        passive_stat_mods: vec![],
3377        point_cost: 2,
3378        max_ranks: 1,
3379        current_ranks: 0,
3380        position: Vec2::new(50.0, 100.0),
3381        icon_path: "node_mightyblow".to_string(),
3382        is_keystone: true,
3383        is_notable: true,
3384        unlock_bonus_at: None,
3385    });
3386
3387    warrior_tree.connect(node1_id, node3_id, true);
3388    warrior_tree.connect(node2_id, node3_id, true);
3389
3390    warrior_tree.mastery_bonus_thresholds.push((
3391        10,
3392        vec![("physical_damage".to_string(), 10.0, 0.0)],
3393    ));
3394    warrior_tree.mastery_bonus_thresholds.push((
3395        20,
3396        vec![("critical_chance".to_string(), 5.0, 0.0), ("critical_multiplier".to_string(), 0.0, 25.0)],
3397    ));
3398
3399    warrior_tree.tiers.push(TalentTreeTier {
3400        tier_index: 0,
3401        node_ids: vec![node1_id, node2_id],
3402        points_required_to_unlock: 0,
3403        tier_mastery_ability: None,
3404        tier_mastery_bonus: vec![("stamina".to_string(), 10.0, 0.0)],
3405    });
3406    warrior_tree.tiers.push(TalentTreeTier {
3407        tier_index: 1,
3408        node_ids: vec![node3_id],
3409        points_required_to_unlock: 5,
3410        tier_mastery_ability: Some(whirlwind_id),
3411        tier_mastery_bonus: vec![("attack_power".to_string(), 20.0, 0.0)],
3412    });
3413
3414    db.add_talent_tree(warrior_tree);
3415
3416    // ---- Ability Modifiers ----
3417
3418    let burning_fireball_mod = AbilityModifier {
3419        id: db.alloc_id(),
3420        name: "Burning Barrage".to_string(),
3421        description: "Fireball deals 20% more damage and always applies Burn.".to_string(),
3422        changes: vec![
3423            AbilityChange::MultiplyDamage(1.2),
3424        ],
3425        unlock_cost: 2,
3426    };
3427    db.ability_modifiers.insert(burning_fireball_mod.id, burning_fireball_mod);
3428
3429    let extra_pierce_mod = AbilityModifier {
3430        id: db.alloc_id(),
3431        name: "Piercing Shot".to_string(),
3432        description: "Arrow pierces through 2 additional enemies.".to_string(),
3433        changes: vec![AbilityChange::AddPierce(2)],
3434        unlock_cost: 1,
3435    };
3436    db.ability_modifiers.insert(extra_pierce_mod.id, extra_pierce_mod);
3437
3438    let homing_mod = AbilityModifier {
3439        id: db.alloc_id(),
3440        name: "Seeking Flame".to_string(),
3441        description: "Fireball now homes toward targets.".to_string(),
3442        changes: vec![AbilityChange::EnableHoming],
3443        unlock_cost: 3,
3444    };
3445    db.ability_modifiers.insert(homing_mod.id, homing_mod);
3446
3447    // Aura
3448    let aura = AuraEffect {
3449        id: db.alloc_id(),
3450        name: "Warlord's Presence".to_string(),
3451        radius: 12.0,
3452        affects_allies: true,
3453        affects_enemies: false,
3454        stat_mods: vec![
3455            ConditionalModifier {
3456                id: 0,
3457                name: "Warlord Bonus".to_string(),
3458                condition: AbilityCondition::AlwaysTrue,
3459                stat_modifier: "attack_power".to_string(),
3460                flat_bonus: 15.0,
3461                percent_bonus: 0.0,
3462                duration_limit: None,
3463            }
3464        ],
3465        pulses: false,
3466        pulse_interval: 0.0,
3467        pulse_damage: None,
3468        visual_effect: "warlord_aura".to_string(),
3469    };
3470    db.aura_effects.insert(aura.id, aura);
3471
3472    db
3473}
3474
3475// ============================================================
3476// ABILITY COMPARISON
3477// ============================================================
3478
3479#[derive(Debug, Clone)]
3480pub struct AbilityComparison {
3481    pub a: AbilityDefinition,
3482    pub b: AbilityDefinition,
3483    pub damage_delta: f32,  // positive = B deals more
3484    pub heal_delta: f32,
3485    pub cooldown_delta: f32,
3486    pub range_delta: f32,
3487    pub cost_delta: f32,
3488    pub effective_dps_a: f32,
3489    pub effective_dps_b: f32,
3490    pub notes: Vec<String>,
3491}
3492
3493pub fn compare_abilities(a: &AbilityDefinition, b: &AbilityDefinition, config: &FormulaTesterConfig) -> AbilityComparison {
3494    let test_a = run_formula_test(a, config);
3495    let test_b = run_formula_test(b, config);
3496
3497    let mut notes = Vec::new();
3498    if a.resource_type != b.resource_type {
3499        notes.push(format!("Resource types differ: {} vs {}", a.resource_type.display_name(), b.resource_type.display_name()));
3500    }
3501    if a.targeting_mode != b.targeting_mode {
3502        notes.push(format!("Targeting modes differ: {} vs {}", a.targeting_mode.display_name(), b.targeting_mode.display_name()));
3503    }
3504    if !a.applied_effects.is_empty() || !b.applied_effects.is_empty() {
3505        notes.push(format!("A applies {} effects, B applies {} effects", a.applied_effects.len(), b.applied_effects.len()));
3506    }
3507
3508    AbilityComparison {
3509        a: a.clone(),
3510        b: b.clone(),
3511        damage_delta: test_b.total_avg_damage - test_a.total_avg_damage,
3512        heal_delta: test_b.heal_preview - test_a.heal_preview,
3513        cooldown_delta: b.base_cooldown - a.base_cooldown,
3514        range_delta: b.range - a.range,
3515        cost_delta: b.resource_cost - a.resource_cost,
3516        effective_dps_a: test_a.effective_dps,
3517        effective_dps_b: test_b.effective_dps,
3518        notes,
3519    }
3520}
3521
3522// ============================================================
3523// CAST QUEUE SIMULATION
3524// ============================================================
3525
3526#[derive(Debug, Clone)]
3527pub struct CastQueueEntry {
3528    pub ability_id: u64,
3529    pub target_id: Option<u64>,
3530    pub target_pos: Option<Vec3>,
3531    pub queued_at: f32,
3532    pub execute_at: f32,
3533}
3534
3535#[derive(Debug, Clone)]
3536pub struct CastResult {
3537    pub ability_id: u64,
3538    pub success: bool,
3539    pub damage_results: Vec<DamageResult>,
3540    pub heal_amount: f32,
3541    pub effects_applied: Vec<(u64, StatusEffectType)>, // (target_id, effect_type)
3542    pub resource_consumed: f32,
3543    pub cast_time: f32,
3544}
3545
3546pub struct CastSimulator {
3547    pub ability_db: AbilityDatabase,
3548    pub status_db: HashMap<u64, StatusEffectDefinition>,
3549    pub cooldown_mgr: CooldownManager,
3550    pub resources: HashMap<ResourceType, ResourceState>,
3551    pub current_time: f32,
3552    pub cast_queue: VecDeque<CastQueueEntry>,
3553    pub cast_log: VecDeque<CastResult>,
3554}
3555
3556impl CastSimulator {
3557    pub fn new(ability_db: AbilityDatabase) -> Self {
3558        let mut resources = HashMap::new();
3559        for rt in [ResourceType::Mana, ResourceType::Stamina, ResourceType::Energy] {
3560            resources.insert(rt, ResourceState::new(rt));
3561        }
3562        let mut mgr = CooldownManager::new();
3563        for (id, a) in &ability_db.abilities {
3564            mgr.register_ability(*id, a.base_cooldown, a.gcd_category, a.max_charges);
3565        }
3566        CastSimulator {
3567            ability_db,
3568            status_db: HashMap::new(),
3569            cooldown_mgr: mgr,
3570            resources,
3571            current_time: 0.0,
3572            cast_queue: VecDeque::new(),
3573            cast_log: VecDeque::new(),
3574        }
3575    }
3576
3577    pub fn queue_ability(&mut self, ability_id: u64, target_id: Option<u64>, target_pos: Option<Vec3>) {
3578        if let Some(ability) = self.ability_db.abilities.get(&ability_id) {
3579            let execute_at = self.current_time + ability.cast_time;
3580            self.cast_queue.push_back(CastQueueEntry {
3581                ability_id,
3582                target_id,
3583                target_pos,
3584                queued_at: self.current_time,
3585                execute_at,
3586            });
3587        }
3588    }
3589
3590    pub fn tick(&mut self, dt: f32) {
3591        self.current_time += dt;
3592        self.cooldown_mgr.tick(dt);
3593        for r in self.resources.values_mut() { r.tick(dt); }
3594
3595        let mut to_execute = Vec::new();
3596        while let Some(entry) = self.cast_queue.front() {
3597            if entry.execute_at <= self.current_time {
3598                to_execute.push(self.cast_queue.pop_front().unwrap());
3599            } else {
3600                break;
3601            }
3602        }
3603
3604        let default_stats: HashMap<String, f32> = {
3605            let mut m = HashMap::new();
3606            m.insert("attack_power".to_string(), 100.0);
3607            m.insert("spell_power".to_string(), 80.0);
3608            m
3609        };
3610
3611        for entry in to_execute {
3612            let result = self.execute_ability(entry.ability_id, &default_stats, 0.0, 0.5);
3613            if let Some(r) = result {
3614                if self.cast_log.len() >= 200 { self.cast_log.pop_front(); }
3615                self.cast_log.push_back(r);
3616            }
3617        }
3618    }
3619
3620    fn execute_ability(&mut self, ability_id: u64, stats: &HashMap<String, f32>, target_resist: f32, roll: f32) -> Option<CastResult> {
3621        let ability = self.ability_db.abilities.get(&ability_id)?.clone();
3622
3623        // Check resources
3624        let cost = ability.compute_resource_cost(stats);
3625        let res = self.resources.get_mut(&ability.resource_type)?;
3626        if !res.spend(cost) { return None; }
3627
3628        self.cooldown_mgr.trigger(ability_id, ability.triggers_gcd);
3629
3630        let mut damage_results = Vec::new();
3631        for formula in &ability.damage_formulas {
3632            let resist = target_resist;
3633            let result = formula.compute_final_damage(stats, resist, &[], roll, roll * 0.7);
3634            damage_results.push(result);
3635        }
3636
3637        let heal = ability.heal_formula.compute(stats, roll);
3638
3639        let mut effects_applied = Vec::new();
3640        for eff in &ability.applied_effects {
3641            // roll would normally be random; use roll for determinism in sim
3642            if roll * 100.0 < eff.apply_chance {
3643                effects_applied.push((0u64, eff.effect_type));
3644            }
3645        }
3646
3647        Some(CastResult {
3648            ability_id,
3649            success: true,
3650            damage_results,
3651            heal_amount: heal,
3652            effects_applied,
3653            resource_consumed: cost,
3654            cast_time: ability.cast_time,
3655        })
3656    }
3657
3658    pub fn total_simulated_damage(&self) -> f32 {
3659        self.cast_log.iter().map(|r| {
3660            r.damage_results.iter().map(|d| d.final_damage).sum::<f32>()
3661        }).sum()
3662    }
3663
3664    pub fn total_simulated_healing(&self) -> f32 {
3665        self.cast_log.iter().map(|r| r.heal_amount).sum()
3666    }
3667}
3668
3669// ============================================================
3670// UI LAYOUT HELPERS (targeting zone visualization)
3671// ============================================================
3672
3673#[derive(Debug, Clone)]
3674pub struct TargetingVisualizer {
3675    pub origin: Vec3,
3676    pub forward: Vec3,
3677    pub mode: TargetingMode,
3678    pub resolution: u32, // arc/circle segment count
3679}
3680
3681impl TargetingVisualizer {
3682    pub fn new(origin: Vec3, forward: Vec3, mode: TargetingMode) -> Self {
3683        TargetingVisualizer { origin, forward, mode, resolution: 32 }
3684    }
3685
3686    pub fn generate_outline_points_2d(&self) -> Vec<Vec2> {
3687        let origin_2d = Vec2::new(self.origin.x, self.origin.z);
3688        let forward_2d = Vec2::new(self.forward.x, self.forward.z).normalize_or_zero();
3689        let angle_fwd = forward_2d.y.atan2(forward_2d.x);
3690
3691        match &self.mode {
3692            TargetingMode::AoECircle { radius } | TargetingMode::AoESphere { radius } => {
3693                (0..=self.resolution).map(|i| {
3694                    let angle = i as f32 / self.resolution as f32 * std::f32::consts::TAU;
3695                    origin_2d + Vec2::new(angle.cos(), angle.sin()) * *radius
3696                }).collect()
3697            }
3698            TargetingMode::Cone { half_angle_deg, distance } => {
3699                let half_rad = half_angle_deg.to_radians();
3700                let mut pts = vec![origin_2d];
3701                let steps = self.resolution;
3702                for i in 0..=steps {
3703                    let frac = i as f32 / steps as f32;
3704                    let angle = angle_fwd - half_rad + frac * 2.0 * half_rad;
3705                    pts.push(origin_2d + Vec2::new(angle.cos(), angle.sin()) * *distance);
3706                }
3707                pts.push(origin_2d);
3708                pts
3709            }
3710            TargetingMode::Rectangle { width, length } => {
3711                let fwd = forward_2d;
3712                let right = Vec2::new(-fwd.y, fwd.x);
3713                let half_w = *width * 0.5;
3714                vec![
3715                    origin_2d + right * half_w,
3716                    origin_2d + right * half_w + fwd * *length,
3717                    origin_2d - right * half_w + fwd * *length,
3718                    origin_2d - right * half_w,
3719                    origin_2d + right * half_w,
3720                ]
3721            }
3722            _ => vec![origin_2d],
3723        }
3724    }
3725
3726    pub fn aabb_2d(&self) -> (Vec2, Vec2) {
3727        let pts = self.generate_outline_points_2d();
3728        if pts.is_empty() {
3729            let p = Vec2::new(self.origin.x, self.origin.z);
3730            return (p, p);
3731        }
3732        let min_x = pts.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
3733        let min_y = pts.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
3734        let max_x = pts.iter().map(|p| p.x).fold(f32::NEG_INFINITY, f32::max);
3735        let max_y = pts.iter().map(|p| p.y).fold(f32::NEG_INFINITY, f32::max);
3736        (Vec2::new(min_x, min_y), Vec2::new(max_x, max_y))
3737    }
3738}
3739
3740// ============================================================
3741// STATUS EFFECT INTERACTION MATRIX
3742// ============================================================
3743
3744#[derive(Debug, Clone)]
3745pub struct InteractionMatrix {
3746    pub reactions: HashMap<(StatusEffectType, StatusEffectType), Vec<StatusReaction>>,
3747}
3748
3749impl InteractionMatrix {
3750    pub fn new() -> Self {
3751        let mut m = InteractionMatrix { reactions: HashMap::new() };
3752        // Fire + Wet = extinguish fire
3753        m.add_reaction(StatusEffectType::Burn, StatusEffectType::Wet, StatusReaction::Remove);
3754        // Cold + Wet = super-freeze
3755        m.add_reaction(StatusEffectType::Freeze, StatusEffectType::Wet, StatusReaction::Amplify { factor: 1.5 });
3756        // Freeze + Burn = shatter explosion
3757        m.add_reaction(StatusEffectType::Freeze, StatusEffectType::Burn, StatusReaction::Explode {
3758            damage_formula: DamageFormula::new(DamageElement::Physical, 30.0, 60.0),
3759        });
3760        // Oiled + Fire = extra burn stacks
3761        m.add_reaction(StatusEffectType::Burn, StatusEffectType::Oiled, StatusReaction::Amplify { factor: 2.0 });
3762        // Shock + Wet = doubled shock damage
3763        m.add_reaction(StatusEffectType::Shock, StatusEffectType::Wet, StatusReaction::Amplify { factor: 2.0 });
3764        m
3765    }
3766
3767    pub fn add_reaction(&mut self, existing: StatusEffectType, incoming: StatusEffectType, reaction: StatusReaction) {
3768        self.reactions.entry((existing, incoming)).or_insert_with(Vec::new).push(reaction);
3769    }
3770
3771    pub fn get_reactions(&self, existing: StatusEffectType, incoming: StatusEffectType) -> Option<&Vec<StatusReaction>> {
3772        self.reactions.get(&(existing, incoming))
3773    }
3774
3775    pub fn process_interactions(
3776        &self,
3777        target_effects: &mut Vec<ActiveStatusEffect>,
3778        incoming_type: StatusEffectType,
3779    ) -> Vec<StatusReaction> {
3780        let mut triggered = Vec::new();
3781        for active in target_effects.iter() {
3782            if let Some(reactions) = self.get_reactions(active.effect_type, incoming_type) {
3783                triggered.extend(reactions.iter().cloned());
3784            }
3785        }
3786        triggered
3787    }
3788}
3789
3790// ============================================================
3791// ABILITY SEARCH INDEX (for large databases)
3792// ============================================================
3793
3794#[derive(Debug, Clone)]
3795pub struct AbilitySearchIndex {
3796    pub by_type: HashMap<AbilityType, Vec<u64>>,
3797    pub by_status_applied: HashMap<StatusEffectType, Vec<u64>>,
3798    pub by_resource: HashMap<ResourceType, Vec<u64>>,
3799    pub by_cooldown_range: BTreeMap<u64, Vec<u64>>, // (cd * 10) -> ids
3800    pub name_tokens: HashMap<String, Vec<u64>>,
3801}
3802
3803impl AbilitySearchIndex {
3804    pub fn build(db: &AbilityDatabase) -> Self {
3805        let mut idx = AbilitySearchIndex {
3806            by_type: HashMap::new(),
3807            by_status_applied: HashMap::new(),
3808            by_resource: HashMap::new(),
3809            by_cooldown_range: BTreeMap::new(),
3810            name_tokens: HashMap::new(),
3811        };
3812
3813        for (id, a) in &db.abilities {
3814            idx.by_type.entry(a.ability_type).or_insert_with(Vec::new).push(*id);
3815            idx.by_resource.entry(a.resource_type).or_insert_with(Vec::new).push(*id);
3816            let cd_key = (a.base_cooldown * 10.0) as u64;
3817            idx.by_cooldown_range.entry(cd_key).or_insert_with(Vec::new).push(*id);
3818            for eff in &a.applied_effects {
3819                idx.by_status_applied.entry(eff.effect_type).or_insert_with(Vec::new).push(*id);
3820            }
3821            for token in a.name.split_whitespace() {
3822                idx.name_tokens.entry(token.to_lowercase()).or_insert_with(Vec::new).push(*id);
3823            }
3824        }
3825
3826        idx
3827    }
3828
3829    pub fn search_by_tokens(&self, query: &str) -> HashSet<u64> {
3830        let mut results: Option<HashSet<u64>> = None;
3831        for token in query.split_whitespace() {
3832            let tok = token.to_lowercase();
3833            let ids: HashSet<u64> = self.name_tokens.get(&tok).map(|v| v.iter().copied().collect()).unwrap_or_default();
3834            results = Some(match results {
3835                None => ids,
3836                Some(prev) => prev.intersection(&ids).copied().collect(),
3837            });
3838        }
3839        results.unwrap_or_default()
3840    }
3841
3842    pub fn abilities_by_type(&self, t: AbilityType) -> &[u64] {
3843        self.by_type.get(&t).map(|v| v.as_slice()).unwrap_or(&[])
3844    }
3845
3846    pub fn abilities_in_cd_range(&self, min_cd: f32, max_cd: f32) -> Vec<u64> {
3847        let min_key = (min_cd * 10.0) as u64;
3848        let max_key = (max_cd * 10.0) as u64;
3849        self.by_cooldown_range.range(min_key..=max_key).flat_map(|(_, v)| v.iter().copied()).collect()
3850    }
3851}
3852
3853// ============================================================
3854// ABILITY DPS RANKING
3855// ============================================================
3856
3857#[derive(Debug, Clone)]
3858pub struct AbilityRank {
3859    pub ability_id: u64,
3860    pub ability_name: String,
3861    pub effective_dps: f32,
3862    pub burst_damage: f32,    // max single-cast damage
3863    pub total_damage_60s: f32, // total if spammed for 60 seconds
3864    pub rank: u32,
3865}
3866
3867pub fn rank_abilities_by_dps(db: &AbilityDatabase, config: &FormulaTesterConfig) -> Vec<AbilityRank> {
3868    let mut ranks: Vec<AbilityRank> = db.abilities.values()
3869        .filter(|a| a.ability_type.is_damage_dealing())
3870        .map(|a| {
3871            let result = run_formula_test(a, config);
3872            let burst = result.total_max_damage;
3873            let casts_per_60 = if a.base_cooldown > 0.0 {
3874                60.0 / (a.base_cooldown + a.cast_time)
3875            } else {
3876                60.0 / (1.5 + a.cast_time) // assume 1.5s GCD
3877            };
3878            let total_60 = result.total_avg_damage * casts_per_60;
3879            AbilityRank {
3880                ability_id: a.id,
3881                ability_name: a.name.clone(),
3882                effective_dps: result.effective_dps,
3883                burst_damage: burst,
3884                total_damage_60s: total_60,
3885                rank: 0,
3886            }
3887        }).collect();
3888
3889    ranks.sort_by(|a, b| b.effective_dps.partial_cmp(&a.effective_dps).unwrap_or(std::cmp::Ordering::Equal));
3890    for (i, rank) in ranks.iter_mut().enumerate() {
3891        rank.rank = (i + 1) as u32;
3892    }
3893    ranks
3894}
3895
3896// ============================================================
3897// BUILD EDITOR (combining talent trees + ability loadout)
3898// ============================================================
3899
3900#[derive(Debug, Clone)]
3901pub struct AbilityLoadout {
3902    pub id: u64,
3903    pub name: String,
3904    pub class_name: String,
3905    pub level: u32,
3906    pub slotted_ability_ids: Vec<Option<u64>>, // 10 ability slots
3907    pub active_talent_trees: Vec<u64>,
3908    pub available_points: u32,
3909    pub invested_points: HashMap<u64, u32>, // tree_id -> points_spent
3910}
3911
3912impl AbilityLoadout {
3913    pub fn new(id: u64, name: impl Into<String>, class: impl Into<String>, level: u32) -> Self {
3914        AbilityLoadout {
3915            id,
3916            name: name.into(),
3917            class_name: class.into(),
3918            level,
3919            slotted_ability_ids: vec![None; 10],
3920            active_talent_trees: Vec::new(),
3921            available_points: level,
3922            invested_points: HashMap::new(),
3923        }
3924    }
3925
3926    pub fn slot_ability(&mut self, slot_idx: usize, ability_id: u64) -> bool {
3927        if slot_idx >= self.slotted_ability_ids.len() { return false; }
3928        self.slotted_ability_ids[slot_idx] = Some(ability_id);
3929        true
3930    }
3931
3932    pub fn unslot_ability(&mut self, slot_idx: usize) {
3933        if slot_idx < self.slotted_ability_ids.len() {
3934            self.slotted_ability_ids[slot_idx] = None;
3935        }
3936    }
3937
3938    pub fn total_effective_dps(&self, db: &AbilityDatabase, config: &FormulaTesterConfig) -> f32 {
3939        self.slotted_ability_ids.iter().filter_map(|id| *id).map(|id| {
3940            db.abilities.get(&id).map(|a| {
3941                run_formula_test(a, config).effective_dps
3942            }).unwrap_or(0.0)
3943        }).sum()
3944    }
3945
3946    pub fn all_talent_stat_mods(&self, db: &AbilityDatabase) -> Vec<(String, f32, f32)> {
3947        let mut totals: HashMap<String, (f32, f32)> = HashMap::new();
3948        for &tree_id in &self.active_talent_trees {
3949            if let Some(tree) = db.talent_trees.get(&tree_id) {
3950                for (name, flat, pct) in tree.compute_all_stat_mods() {
3951                    let entry = totals.entry(name).or_insert((0.0, 0.0));
3952                    entry.0 += flat;
3953                    entry.1 += pct;
3954                }
3955            }
3956        }
3957        totals.into_iter().map(|(k, (f, p))| (k, f, p)).collect()
3958    }
3959}
3960
3961// ============================================================
3962// ABILITY EDITOR ENTRY POINTS
3963// ============================================================
3964
3965pub fn build_ability_editor() -> AbilityEditor {
3966    let mut editor = AbilityEditor::new();
3967    editor.database = create_starter_ability_database();
3968    editor.browser.refresh(&editor.database);
3969    // Register all abilities in cooldown manager
3970    for (id, a) in &editor.database.abilities {
3971        editor.cooldown_manager.register_ability(*id, a.base_cooldown, a.gcd_category, a.max_charges);
3972    }
3973    editor
3974}
3975
3976pub fn run_ability_editor_frame(editor: &mut AbilityEditor, dt: f32, input: &AbilityEditorInput) {
3977    editor.tick(dt);
3978
3979    match input.action {
3980        Some(AbilityEditorAction::Save) => { editor.save_current_ability(); }
3981        Some(AbilityEditorAction::Undo) => { editor.ability_editor.undo(); }
3982        Some(AbilityEditorAction::Redo) => { editor.ability_editor.redo(); }
3983        Some(AbilityEditorAction::Copy) => { editor.copy_ability(); }
3984        Some(AbilityEditorAction::Paste) => { editor.paste_ability(); }
3985        Some(AbilityEditorAction::RunFormulaTest) => { editor.run_formula_test_now(); }
3986        Some(AbilityEditorAction::NewAbility(t)) => { editor.create_new_ability(t); }
3987        Some(AbilityEditorAction::Delete) => { editor.delete_selected_abilities(); }
3988        None => {}
3989    }
3990
3991    if let Some(ref q) = input.search_query {
3992        editor.search_with_history(q.clone());
3993    }
3994
3995    if let Some(id) = input.open_ability_id {
3996        editor.open_ability_by_id(id);
3997    }
3998}
3999
4000#[derive(Debug, Clone)]
4001pub struct AbilityEditorInput {
4002    pub action: Option<AbilityEditorAction>,
4003    pub search_query: Option<String>,
4004    pub open_ability_id: Option<u64>,
4005    pub mouse_pos: Vec2,
4006    pub delta_time: f32,
4007}
4008
4009#[derive(Debug, Clone)]
4010pub enum AbilityEditorAction {
4011    Save,
4012    Undo,
4013    Redo,
4014    Copy,
4015    Paste,
4016    Delete,
4017    RunFormulaTest,
4018    NewAbility(AbilityType),
4019}
4020
4021impl AbilityEditorInput {
4022    pub fn idle() -> Self {
4023        AbilityEditorInput {
4024            action: None,
4025            search_query: None,
4026            open_ability_id: None,
4027            mouse_pos: Vec2::ZERO,
4028            delta_time: 0.016,
4029        }
4030    }
4031}
4032
4033// ============================================================
4034// MATH UTILITY FUNCTIONS
4035// ============================================================
4036
4037pub fn angle_between_2d(a: Vec2, b: Vec2) -> f32 {
4038    let dot = a.normalize_or_zero().dot(b.normalize_or_zero());
4039    dot.clamp(-1.0, 1.0).acos()
4040}
4041
4042pub fn rotate_vec2(v: Vec2, angle_rad: f32) -> Vec2 {
4043    let cos = angle_rad.cos();
4044    let sin = angle_rad.sin();
4045    Vec2::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos)
4046}
4047
4048pub fn reflect_vec2(v: Vec2, normal: Vec2) -> Vec2 {
4049    v - 2.0 * v.dot(normal) * normal
4050}
4051
4052pub fn closest_point_on_segment_2d(p: Vec2, a: Vec2, b: Vec2) -> Vec2 {
4053    let ab = b - a;
4054    let ap = p - a;
4055    let t = (ap.dot(ab) / ab.dot(ab)).clamp(0.0, 1.0);
4056    a + ab * t
4057}
4058
4059pub fn circle_intersects_rect(circle_center: Vec2, radius: f32, rect_min: Vec2, rect_max: Vec2) -> bool {
4060    let closest = Vec2::new(
4061        circle_center.x.clamp(rect_min.x, rect_max.x),
4062        circle_center.y.clamp(rect_min.y, rect_max.y),
4063    );
4064    (circle_center - closest).length_squared() <= radius * radius
4065}
4066
4067pub fn cone_intersects_circle(apex: Vec2, direction: Vec2, half_angle: f32, length: f32, circle_center: Vec2, radius: f32) -> bool {
4068    let to_circle = circle_center - apex;
4069    let dist = to_circle.length();
4070    if dist > length + radius { return false; }
4071    if dist < radius { return true; }
4072    let angle = angle_between_2d(direction, to_circle);
4073    angle <= half_angle + (radius / dist).asin()
4074}
4075
4076pub fn ring_contains_point(center: Vec2, inner_r: f32, outer_r: f32, point: Vec2) -> bool {
4077    let d = (point - center).length();
4078    d >= inner_r && d <= outer_r
4079}
4080
4081pub fn parabola_peak_position(origin: Vec3, velocity: Vec3, gravity: f32) -> Vec3 {
4082    // Time to reach peak: t = vy / g
4083    if gravity.abs() < 1e-6 { return origin + velocity * 100.0; }
4084    let t_peak = velocity.y / gravity;
4085    origin + velocity * t_peak - Vec3::new(0.0, 0.5 * gravity * t_peak * t_peak, 0.0)
4086}
4087
4088pub fn knockback_velocity(direction: Vec2, force: f32, target_mass: f32) -> Vec2 {
4089    let actual_force = force / target_mass.max(0.1);
4090    direction.normalize_or_zero() * actual_force
4091}
4092
4093pub fn ability_power_score(a: &AbilityDefinition) -> f32 {
4094    let dmg_score: f32 = a.damage_formulas.iter().map(|f| (f.base_min + f.base_max) / 2.0).sum::<f32>();
4095    let heal_score = (a.heal_formula.base_min + a.heal_formula.base_max) / 2.0;
4096    let cd_penalty = (a.base_cooldown * 0.5).max(1.0);
4097    let cost_penalty = a.resource_cost * 0.1;
4098    let range_bonus = (a.range * 0.5).min(15.0);
4099    let aoe_bonus = a.aoe_radius * 3.0;
4100    let effects_bonus = a.applied_effects.len() as f32 * 10.0;
4101    (dmg_score + heal_score + range_bonus + aoe_bonus + effects_bonus - cost_penalty) / cd_penalty
4102}
4103
4104// ============================================================
4105// EXTENDED: ABILITY EFFECT CHAIN SYSTEM
4106// ============================================================
4107
4108/// A chain of effects that triggers on ability hit.
4109/// Each link can spawn sub-abilities, apply more effects, or modify damage.
4110#[derive(Debug, Clone)]
4111pub struct AbilityEffectChain {
4112    pub id: u64,
4113    pub name: String,
4114    pub links: Vec<ChainLink>,
4115    pub trigger_condition: ChainTrigger,
4116}
4117
4118#[derive(Debug, Clone)]
4119pub struct ChainLink {
4120    pub sequence: u32,   // execution order
4121    pub delay: f32,      // seconds after previous link
4122    pub action: ChainAction,
4123}
4124
4125#[derive(Debug, Clone)]
4126pub enum ChainAction {
4127    DealDamage { formula: DamageFormula },
4128    ApplyStatus { effect_id: u64, chance: f32 },
4129    Heal { formula: HealFormula },
4130    SpawnProjectile { ability_id: u64 },
4131    PullTarget { force: f32 },
4132    PushTarget { force: f32 },
4133    GrantResource { resource: ResourceType, amount: f32 },
4134    DrainResource { resource: ResourceType, amount: f32 },
4135    TriggerChain { chain_id: u64 },
4136}
4137
4138#[derive(Debug, Clone)]
4139pub enum ChainTrigger {
4140    OnHit,
4141    OnKill,
4142    OnCrit,
4143    OnStatusApplied(StatusEffectType),
4144    OnResourceThreshold { resource: ResourceType, threshold: f32 },
4145    Always,
4146}
4147
4148impl ChainTrigger {
4149    pub fn should_trigger(&self, ctx: &ChainContext) -> bool {
4150        match self {
4151            ChainTrigger::OnHit => ctx.hit_occurred,
4152            ChainTrigger::OnKill => ctx.kill_occurred,
4153            ChainTrigger::OnCrit => ctx.crit_occurred,
4154            ChainTrigger::OnStatusApplied(s) => ctx.status_applied == Some(*s),
4155            ChainTrigger::OnResourceThreshold { resource, threshold } => {
4156                ctx.resource_fractions.get(resource).copied().unwrap_or(0.0) >= *threshold
4157            }
4158            ChainTrigger::Always => true,
4159        }
4160    }
4161}
4162
4163#[derive(Debug, Clone)]
4164pub struct ChainContext {
4165    pub hit_occurred: bool,
4166    pub kill_occurred: bool,
4167    pub crit_occurred: bool,
4168    pub status_applied: Option<StatusEffectType>,
4169    pub resource_fractions: HashMap<ResourceType, f32>,
4170    pub damage_dealt: f32,
4171}
4172
4173impl ChainContext {
4174    pub fn from_result(result: &CastResult) -> Self {
4175        let hit = !result.damage_results.is_empty();
4176        let crit = result.damage_results.iter().any(|d| d.is_crit);
4177        let dmg: f32 = result.damage_results.iter().map(|d| d.final_damage).sum();
4178        ChainContext {
4179            hit_occurred: hit,
4180            kill_occurred: false,
4181            crit_occurred: crit,
4182            status_applied: result.effects_applied.first().map(|(_, s)| *s),
4183            resource_fractions: HashMap::new(),
4184            damage_dealt: dmg,
4185        }
4186    }
4187}
4188
4189// ============================================================
4190// EXTENDED: STANCE SYSTEM
4191// ============================================================
4192
4193#[derive(Debug, Clone)]
4194pub struct StanceDefinition {
4195    pub id: u64,
4196    pub name: String,
4197    pub description: String,
4198    pub enter_cost: f32,
4199    pub resource_type: ResourceType,
4200    pub maintenance_cost_per_second: f32,
4201    pub stat_modifiers: Vec<(String, f32, f32)>, // (stat, flat, pct)
4202    pub ability_overrides: HashMap<u64, u64>,    // old_ability_id -> override_ability_id
4203    pub forbidden_abilities: Vec<u64>,
4204    pub enabled_abilities: Vec<u64>,
4205    pub visual_effect: String,
4206    pub transition_time: f32,
4207}
4208
4209impl StanceDefinition {
4210    pub fn can_enter(&self, resource: &ResourceState) -> bool {
4211        resource.current >= self.enter_cost
4212    }
4213
4214    pub fn tick_cost(&self, dt: f32) -> f32 {
4215        self.maintenance_cost_per_second * dt
4216    }
4217
4218    pub fn get_ability_override(&self, ability_id: u64) -> Option<u64> {
4219        self.ability_overrides.get(&ability_id).copied()
4220    }
4221
4222    pub fn can_use_ability(&self, ability_id: u64) -> bool {
4223        !self.forbidden_abilities.contains(&ability_id)
4224    }
4225}
4226
4227#[derive(Debug, Clone)]
4228pub struct StanceManager {
4229    pub active_stance: Option<u64>,
4230    pub previous_stance: Option<u64>,
4231    pub stances: HashMap<u64, StanceDefinition>,
4232    pub transition_timer: f32,
4233}
4234
4235impl StanceManager {
4236    pub fn new() -> Self {
4237        StanceManager {
4238            active_stance: None,
4239            previous_stance: None,
4240            stances: HashMap::new(),
4241            transition_timer: 0.0,
4242        }
4243    }
4244
4245    pub fn register_stance(&mut self, stance: StanceDefinition) {
4246        self.stances.insert(stance.id, stance);
4247    }
4248
4249    pub fn enter_stance(&mut self, stance_id: u64, resources: &mut HashMap<ResourceType, ResourceState>) -> bool {
4250        if let Some(stance) = self.stances.get(&stance_id) {
4251            if let Some(res) = resources.get_mut(&stance.resource_type) {
4252                if !stance.can_enter(res) { return false; }
4253                res.spend(stance.enter_cost);
4254                self.previous_stance = self.active_stance;
4255                self.active_stance = Some(stance_id);
4256                self.transition_timer = stance.transition_time;
4257                return true;
4258            }
4259        }
4260        false
4261    }
4262
4263    pub fn exit_stance(&mut self) {
4264        self.previous_stance = self.active_stance;
4265        self.active_stance = None;
4266    }
4267
4268    pub fn tick(&mut self, dt: f32, resources: &mut HashMap<ResourceType, ResourceState>) {
4269        if self.transition_timer > 0.0 {
4270            self.transition_timer = (self.transition_timer - dt).max(0.0);
4271        }
4272        if let Some(stance_id) = self.active_stance {
4273            if let Some(stance) = self.stances.get(&stance_id) {
4274                let cost = stance.tick_cost(dt);
4275                if let Some(res) = resources.get_mut(&stance.resource_type) {
4276                    if !res.spend(cost) {
4277                        self.exit_stance(); // can't maintain — auto-exit
4278                    }
4279                }
4280            }
4281        }
4282    }
4283
4284    pub fn active_stat_mods(&self) -> Vec<(String, f32, f32)> {
4285        if let Some(id) = self.active_stance {
4286            self.stances.get(&id).map(|s| s.stat_modifiers.clone()).unwrap_or_default()
4287        } else {
4288            Vec::new()
4289        }
4290    }
4291
4292    pub fn is_transitioning(&self) -> bool {
4293        self.transition_timer > 0.0
4294    }
4295}
4296
4297// ============================================================
4298// EXTENDED: PROC SYSTEM (on-hit effects)
4299// ============================================================
4300
4301#[derive(Debug, Clone)]
4302pub struct ProcEffect {
4303    pub id: u64,
4304    pub name: String,
4305    pub trigger: ProcTrigger,
4306    pub chance: f32,         // 0-100%
4307    pub internal_cooldown: f32,
4308    pub remaining_icd: f32,
4309    pub effect: ProcOutcome,
4310}
4311
4312#[derive(Debug, Clone)]
4313pub enum ProcTrigger {
4314    OnBasicAttack,
4315    OnAbilityCast,
4316    OnAbilityHit,
4317    OnCrit,
4318    OnKill,
4319    OnDamageTaken,
4320    OnHealCast,
4321    OnStatusApplication(StatusEffectType),
4322    OnLowHealth(f32), // below % threshold
4323}
4324
4325#[derive(Debug, Clone)]
4326pub enum ProcOutcome {
4327    DealBonusDamage { formula: DamageFormula },
4328    ApplyStatus { effect_id: u64 },
4329    HealCaster { formula: HealFormula },
4330    ResetCooldown { ability_id: u64 },
4331    GrantCharges { ability_id: u64, charges: u32 },
4332    GenerateResource { resource: ResourceType, amount: f32 },
4333    GrantBuff { ability_id: u64, duration: f32 },
4334}
4335
4336impl ProcEffect {
4337    pub fn can_proc(&self) -> bool {
4338        self.remaining_icd <= 0.0
4339    }
4340
4341    pub fn should_proc(&self, roll: f32) -> bool {
4342        self.can_proc() && roll * 100.0 < self.chance
4343    }
4344
4345    pub fn trigger_proc(&mut self) {
4346        self.remaining_icd = self.internal_cooldown;
4347    }
4348
4349    pub fn tick_icd(&mut self, dt: f32) {
4350        self.remaining_icd = (self.remaining_icd - dt).max(0.0);
4351    }
4352}
4353
4354#[derive(Debug, Clone)]
4355pub struct ProcManager {
4356    pub procs: Vec<ProcEffect>,
4357}
4358
4359impl ProcManager {
4360    pub fn new() -> Self {
4361        ProcManager { procs: Vec::new() }
4362    }
4363
4364    pub fn add_proc(&mut self, proc_effect: ProcEffect) {
4365        self.procs.push(proc_effect);
4366    }
4367
4368    pub fn tick(&mut self, dt: f32) {
4369        for p in &mut self.procs {
4370            p.tick_icd(dt);
4371        }
4372    }
4373
4374    pub fn check_procs(&mut self, trigger: &ProcTrigger, random_values: &[f32]) -> Vec<&ProcOutcome> {
4375        let mut triggered = Vec::new();
4376        let mut rv_idx = 0;
4377        for proc_effect in &mut self.procs {
4378            let trigger_match = match (&proc_effect.trigger, trigger) {
4379                (ProcTrigger::OnBasicAttack, ProcTrigger::OnBasicAttack) => true,
4380                (ProcTrigger::OnCrit, ProcTrigger::OnCrit) => true,
4381                (ProcTrigger::OnKill, ProcTrigger::OnKill) => true,
4382                (ProcTrigger::OnDamageTaken, ProcTrigger::OnDamageTaken) => true,
4383                (ProcTrigger::OnAbilityCast, ProcTrigger::OnAbilityCast) => true,
4384                (ProcTrigger::OnAbilityHit, ProcTrigger::OnAbilityHit) => true,
4385                _ => false,
4386            };
4387            if trigger_match {
4388                let roll = random_values.get(rv_idx).copied().unwrap_or(0.5);
4389                rv_idx += 1;
4390                if proc_effect.should_proc(roll) {
4391                    proc_effect.trigger_proc();
4392                    triggered.push(&proc_effect.effect);
4393                }
4394            }
4395        }
4396        triggered
4397    }
4398}
4399
4400// ============================================================
4401// EXTENDED: ABILITY COOLDOWN PREVIEW
4402// ============================================================
4403
4404#[derive(Debug, Clone)]
4405pub struct CooldownPreview {
4406    pub ability_id: u64,
4407    pub ability_name: String,
4408    pub base_cd: f32,
4409    pub with_cdr_10: f32,
4410    pub with_cdr_20: f32,
4411    pub with_cdr_30: f32,
4412    pub with_cdr_40: f32,
4413    pub with_cdr_50: f32,
4414    pub casts_per_minute_base: f32,
4415    pub casts_per_minute_max_cdr: f32,
4416}
4417
4418impl CooldownPreview {
4419    pub fn compute(ability: &AbilityDefinition) -> Self {
4420        let cd = ability.base_cooldown;
4421        let cast_t = ability.cast_time;
4422        let min_cycle = cd + cast_t;
4423
4424        let reduced = |pct: f32| -> f32 {
4425            let eff = diminishing_returns_cdr_ability(pct);
4426            (cd * (1.0 - eff / 100.0)).max(0.5)
4427        };
4428
4429        let casts_per_min = |cd_val: f32| -> f32 {
4430            if cd_val + cast_t <= 0.0 { return 0.0; }
4431            60.0 / (cd_val + cast_t)
4432        };
4433
4434        CooldownPreview {
4435            ability_id: ability.id,
4436            ability_name: ability.name.clone(),
4437            base_cd: cd,
4438            with_cdr_10: reduced(10.0),
4439            with_cdr_20: reduced(20.0),
4440            with_cdr_30: reduced(30.0),
4441            with_cdr_40: reduced(40.0),
4442            with_cdr_50: reduced(50.0),
4443            casts_per_minute_base: casts_per_min(cd),
4444            casts_per_minute_max_cdr: casts_per_min(reduced(50.0)),
4445        }
4446    }
4447}
4448
4449pub fn generate_cooldown_preview_table(db: &AbilityDatabase) -> Vec<CooldownPreview> {
4450    let mut previews: Vec<CooldownPreview> = db.abilities.values()
4451        .filter(|a| a.base_cooldown > 0.0)
4452        .map(CooldownPreview::compute)
4453        .collect();
4454    previews.sort_by(|a, b| a.base_cd.partial_cmp(&b.base_cd).unwrap_or(std::cmp::Ordering::Equal));
4455    previews
4456}
4457
4458// ============================================================
4459// EXTENDED: RESOURCE EFFICIENCY CALCULATOR
4460// ============================================================
4461
4462#[derive(Debug, Clone)]
4463pub struct ResourceEfficiency {
4464    pub ability_id: u64,
4465    pub ability_name: String,
4466    pub resource_type: ResourceType,
4467    pub cost: f32,
4468    pub avg_damage: f32,
4469    pub damage_per_resource: f32,
4470    pub heal_per_resource: f32,
4471    pub effective_casts_before_oom: f32,
4472    pub time_before_oom: f32,
4473    pub breakeven_regen_rate: f32, // resource regen needed to spam indefinitely
4474}
4475
4476pub fn compute_resource_efficiency(
4477    ability: &AbilityDefinition,
4478    config: &FormulaTesterConfig,
4479    resource_state: &ResourceState,
4480) -> ResourceEfficiency {
4481    let cost = ability.compute_resource_cost(&config.caster_stats);
4482    let test = run_formula_test(ability, config);
4483    let dpr = if cost > 0.0 { test.total_avg_damage / cost } else { f32::INFINITY };
4484    let hpr = if cost > 0.0 { test.heal_preview / cost } else { 0.0 };
4485    let casts_oom = if cost > 0.0 { resource_state.current / cost } else { f32::INFINITY };
4486    let time_oom = casts_oom * (ability.cast_time + ability.base_cooldown.max(1.5));
4487    let breakeven = if ability.base_cooldown > 0.0 { cost / ability.base_cooldown } else { f32::INFINITY };
4488
4489    ResourceEfficiency {
4490        ability_id: ability.id,
4491        ability_name: ability.name.clone(),
4492        resource_type: ability.resource_type,
4493        cost,
4494        avg_damage: test.total_avg_damage,
4495        damage_per_resource: dpr,
4496        heal_per_resource: hpr,
4497        effective_casts_before_oom: casts_oom,
4498        time_before_oom: time_oom,
4499        breakeven_regen_rate: breakeven,
4500    }
4501}
4502
4503pub fn rank_abilities_by_efficiency(
4504    db: &AbilityDatabase,
4505    config: &FormulaTesterConfig,
4506    resource: ResourceType,
4507) -> Vec<ResourceEfficiency> {
4508    let res = ResourceState::new(resource);
4509    let mut rankings: Vec<ResourceEfficiency> = db.abilities.values()
4510        .filter(|a| a.resource_type == resource && a.ability_type.is_damage_dealing())
4511        .map(|a| compute_resource_efficiency(a, config, &res))
4512        .collect();
4513    rankings.sort_by(|a, b| b.damage_per_resource.partial_cmp(&a.damage_per_resource).unwrap_or(std::cmp::Ordering::Equal));
4514    rankings
4515}
4516
4517// ============================================================
4518// EXTENDED: ABILITY SYNERGY DETECTOR
4519// ============================================================
4520
4521#[derive(Debug, Clone)]
4522pub struct AbilitySynergy {
4523    pub ability_a_id: u64,
4524    pub ability_b_id: u64,
4525    pub synergy_type: SynergyType,
4526    pub synergy_score: f32,
4527    pub description: String,
4528}
4529
4530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4531pub enum SynergyType {
4532    ApplyThenExplode,    // A applies status, B does bonus vs that status
4533    ComboBuilder,        // A builds combo points, B spends them
4534    BuffThenDPS,         // A buffs, B deals damage during buff window
4535    SetupCC,             // A applies CC, B does bonus vs CC'd
4536    ElementalReaction,   // A wets target, B does lightning for double damage
4537    ResourceLoop,        // A costs, B refunds
4538}
4539
4540pub fn detect_synergies(db: &AbilityDatabase) -> Vec<AbilitySynergy> {
4541    let mut synergies = Vec::new();
4542
4543    let abilities: Vec<&AbilityDefinition> = db.abilities.values().collect();
4544
4545    for i in 0..abilities.len() {
4546        for j in 0..abilities.len() {
4547            if i == j { continue; }
4548            let a = abilities[i];
4549            let b = abilities[j];
4550
4551            // Apply status A → B does bonus damage against that status
4552            for eff in &a.applied_effects {
4553                for formula in &b.damage_formulas {
4554                    for &(status, _bonus) in &formula.versus_status_bonus {
4555                        if status == eff.effect_type {
4556                            synergies.push(AbilitySynergy {
4557                                ability_a_id: a.id,
4558                                ability_b_id: b.id,
4559                                synergy_type: SynergyType::ApplyThenExplode,
4560                                synergy_score: 8.0,
4561                                description: format!(
4562                                    "{} applies {}, {} deals bonus vs {}",
4563                                    a.name, eff.effect_type.display_name(),
4564                                    b.name, status.display_name()
4565                                ),
4566                            });
4567                        }
4568                    }
4569                }
4570            }
4571
4572            // Elemental wet + lightning
4573            let a_applies_wet = a.applied_effects.iter().any(|e| e.effect_type == StatusEffectType::Wet);
4574            let b_has_lightning = b.damage_formulas.iter().any(|f| f.element == DamageElement::Lightning);
4575            if a_applies_wet && b_has_lightning {
4576                synergies.push(AbilitySynergy {
4577                    ability_a_id: a.id,
4578                    ability_b_id: b.id,
4579                    synergy_type: SynergyType::ElementalReaction,
4580                    synergy_score: 10.0,
4581                    description: format!("{} wets target, {} triggers electrocute", a.name, b.name),
4582                });
4583            }
4584
4585            // Buff then DPS
4586            if matches!(a.ability_type, AbilityType::Buff | AbilityType::Stance | AbilityType::Warcry) && b.ability_type.is_damage_dealing() {
4587                synergies.push(AbilitySynergy {
4588                    ability_a_id: a.id,
4589                    ability_b_id: b.id,
4590                    synergy_type: SynergyType::BuffThenDPS,
4591                    synergy_score: 5.0,
4592                    description: format!("{} buffs, then use {} for boosted damage", a.name, b.name),
4593                });
4594            }
4595        }
4596    }
4597
4598    synergies.sort_by(|a, b| b.synergy_score.partial_cmp(&a.synergy_score).unwrap_or(std::cmp::Ordering::Equal));
4599    synergies
4600}
4601
4602// ============================================================
4603// EXTENDED: MULTI-TARGET DAMAGE CALCULATOR
4604// ============================================================
4605
4606#[derive(Debug, Clone)]
4607pub struct MultiTargetCalculation {
4608    pub ability_id: u64,
4609    pub target_count: u32,
4610    pub total_damage: f32,
4611    pub per_target_damage: f32,
4612    pub falloff_applied: bool,
4613    pub chain_falloff_per_bounce: f32,
4614    pub aoe_falloff_at_edge: f32,
4615}
4616
4617pub fn calculate_multi_target_damage(
4618    ability: &AbilityDefinition,
4619    target_count: u32,
4620    config: &FormulaTesterConfig,
4621) -> MultiTargetCalculation {
4622    let single_test = run_formula_test(ability, config);
4623    let base_dmg = single_test.total_avg_damage;
4624
4625    let (total, falloff_applied, chain_falloff) = match &ability.targeting_mode {
4626        TargetingMode::Chain { max_bounces, falloff_per_bounce, .. } => {
4627            let actual_targets = target_count.min(*max_bounces + 1);
4628            let mut total = 0.0f32;
4629            let mut dmg = base_dmg;
4630            for _ in 0..actual_targets {
4631                total += dmg;
4632                dmg *= 1.0 - falloff_per_bounce;
4633            }
4634            (total, true, *falloff_per_bounce)
4635        }
4636        TargetingMode::AoECircle { .. } | TargetingMode::AoESphere { .. } => {
4637            // AoE typically hits all targets for full damage (some games reduce at edge)
4638            (base_dmg * target_count as f32, false, 0.0)
4639        }
4640        TargetingMode::MultiTarget { max_targets, .. } => {
4641            let actual = target_count.min(*max_targets);
4642            (base_dmg * actual as f32, false, 0.0)
4643        }
4644        _ => (base_dmg, false, 0.0),
4645    };
4646
4647    MultiTargetCalculation {
4648        ability_id: ability.id,
4649        target_count,
4650        total_damage: total,
4651        per_target_damage: if target_count > 0 { total / target_count as f32 } else { 0.0 },
4652        falloff_applied,
4653        chain_falloff_per_bounce: chain_falloff,
4654        aoe_falloff_at_edge: 0.0,
4655    }
4656}
4657
4658// ============================================================
4659// EXTENDED: TALENT BUILD OPTIMIZER
4660// ============================================================
4661
4662#[derive(Debug, Clone)]
4663pub struct TalentOptimizationGoal {
4664    pub maximize_stat: String,
4665    pub secondary_stats: Vec<(String, f32)>, // (stat, weight)
4666    pub required_abilities: Vec<u64>,
4667    pub budget_points: u32,
4668}
4669
4670#[derive(Debug, Clone)]
4671pub struct TalentOptimizationResult {
4672    pub recommended_nodes: Vec<u64>,
4673    pub total_points_used: u32,
4674    pub projected_stat_values: Vec<(String, f32, f32)>,
4675    pub score: f32,
4676}
4677
4678pub fn greedy_talent_optimizer(
4679    tree: &TalentTree,
4680    goal: &TalentOptimizationGoal,
4681) -> TalentOptimizationResult {
4682    let mut available_nodes: Vec<&TalentNode> = tree.nodes.values().collect();
4683    available_nodes.sort_by_key(|n| n.id);
4684
4685    let mut selected: Vec<u64> = Vec::new();
4686    let mut points_used = 0u32;
4687
4688    // Greedily select nodes by expected score contribution
4689    let score_node = |node: &TalentNode| -> f32 {
4690        let mut score = 0.0f32;
4691        for (stat, flat, pct) in node.current_stat_mods() {
4692            let base_weight = if stat == goal.maximize_stat { 1.0 } else {
4693                goal.secondary_stats.iter()
4694                    .find(|(s, _)| *s == stat)
4695                    .map(|(_, w)| *w)
4696                    .unwrap_or(0.0)
4697            };
4698            score += (flat.abs() + pct.abs()) * base_weight;
4699        }
4700        score / node.point_cost.max(1) as f32 // efficiency
4701    };
4702
4703    let mut remaining = goal.budget_points;
4704    let mut iterations = 0u32;
4705
4706    while remaining > 0 && iterations < 1000 {
4707        iterations += 1;
4708
4709        // Find best eligible node not yet selected
4710        let best = available_nodes.iter()
4711            .filter(|n| !selected.contains(&n.id))
4712            .filter(|n| n.point_cost <= remaining)
4713            .filter(|n| {
4714                // Check prerequisites in selected
4715                tree.edges.iter()
4716                    .filter(|e| e.is_prerequisite && e.to_node_id == n.id)
4717                    .all(|e| selected.contains(&e.from_node_id))
4718            })
4719            .max_by(|a, b| score_node(a).partial_cmp(&score_node(b)).unwrap_or(std::cmp::Ordering::Equal));
4720
4721        if let Some(node) = best {
4722            selected.push(node.id);
4723            points_used += node.point_cost;
4724            remaining -= node.point_cost;
4725        } else {
4726            break;
4727        }
4728    }
4729
4730    // Required abilities — ensure their tree nodes are included
4731    for &req_ab in &goal.required_abilities {
4732        for node in tree.nodes.values() {
4733            if node.ability_id == Some(req_ab) && !selected.contains(&node.id) {
4734                selected.push(node.id);
4735                points_used += node.point_cost;
4736            }
4737        }
4738    }
4739
4740    // Compute projected stats
4741    let mut stat_map: HashMap<String, (f32, f32)> = HashMap::new();
4742    for &node_id in &selected {
4743        if let Some(node) = tree.nodes.get(&node_id) {
4744            for (stat, flat, pct) in node.current_stat_mods() {
4745                let e = stat_map.entry(stat).or_insert((0.0, 0.0));
4746                e.0 += flat;
4747                e.1 += pct;
4748            }
4749        }
4750    }
4751    let projected: Vec<(String, f32, f32)> = stat_map.into_iter().map(|(k, (f, p))| (k, f, p)).collect();
4752
4753    let total_score: f32 = selected.iter()
4754        .filter_map(|id| tree.nodes.get(id))
4755        .map(|n| score_node(n))
4756        .sum();
4757
4758    TalentOptimizationResult {
4759        recommended_nodes: selected,
4760        total_points_used: points_used,
4761        projected_stat_values: projected,
4762        score: total_score,
4763    }
4764}
4765
4766// ============================================================
4767// EXTENDED: DAMAGE MITIGATION CALCULATOR
4768// ============================================================
4769
4770#[derive(Debug, Clone)]
4771pub struct MitigationProfile {
4772    pub name: String,
4773    pub physical_armor: f32,
4774    pub fire_resist: f32,
4775    pub cold_resist: f32,
4776    pub lightning_resist: f32,
4777    pub poison_resist: f32,
4778    pub arcane_resist: f32,
4779    pub dodge_chance: f32,
4780    pub block_chance: f32,
4781    pub block_reduction: f32,
4782    pub absorb_shields: f32,
4783}
4784
4785impl MitigationProfile {
4786    pub fn tank_profile() -> Self {
4787        MitigationProfile {
4788            name: "Heavy Tank".to_string(),
4789            physical_armor: 800.0,
4790            fire_resist: 40.0,
4791            cold_resist: 40.0,
4792            lightning_resist: 40.0,
4793            poison_resist: 40.0,
4794            arcane_resist: 20.0,
4795            dodge_chance: 5.0,
4796            block_chance: 30.0,
4797            block_reduction: 0.4,
4798            absorb_shields: 500.0,
4799        }
4800    }
4801
4802    pub fn glass_cannon_profile() -> Self {
4803        MitigationProfile {
4804            name: "Glass Cannon".to_string(),
4805            physical_armor: 80.0,
4806            fire_resist: 10.0,
4807            cold_resist: 10.0,
4808            lightning_resist: 10.0,
4809            poison_resist: 5.0,
4810            arcane_resist: 5.0,
4811            dodge_chance: 15.0,
4812            block_chance: 0.0,
4813            block_reduction: 0.0,
4814            absorb_shields: 0.0,
4815        }
4816    }
4817
4818    pub fn effective_resist_for_element(&self, element: DamageElement) -> f32 {
4819        match element {
4820            DamageElement::Physical => {
4821                let armor_factor = self.physical_armor / (self.physical_armor + 300.0);
4822                (armor_factor * 100.0).min(75.0)
4823            }
4824            DamageElement::Fire => self.fire_resist.min(75.0),
4825            DamageElement::Cold => self.cold_resist.min(75.0),
4826            DamageElement::Lightning => self.lightning_resist.min(75.0),
4827            DamageElement::Poison => self.poison_resist.min(75.0),
4828            DamageElement::Arcane => self.arcane_resist.min(60.0),
4829            DamageElement::True_ => 0.0,
4830            _ => 0.0,
4831        }
4832    }
4833
4834    pub fn final_damage_received(&self, raw: f32, element: DamageElement, roll: f32) -> f32 {
4835        // Check dodge
4836        if roll < self.dodge_chance / 100.0 { return 0.0; }
4837        let resist = self.effective_resist_for_element(element);
4838        let after_resist = raw * (1.0 - resist / 100.0);
4839        // Check block
4840        let after_block = if roll < (self.dodge_chance + self.block_chance) / 100.0 {
4841            after_resist * (1.0 - self.block_reduction)
4842        } else {
4843            after_resist
4844        };
4845        // Absorb shields
4846        (after_block - self.absorb_shields).max(0.0)
4847    }
4848}
4849
4850pub fn simulate_ability_vs_profile(
4851    ability: &AbilityDefinition,
4852    profile: &MitigationProfile,
4853    config: &FormulaTesterConfig,
4854) -> Vec<f32> {
4855    let mut seed: u64 = 54321987;
4856    let lcg_local = |s: &mut u64| -> f32 {
4857        *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
4858        ((*s >> 32) as f32) / (u32::MAX as f32)
4859    };
4860
4861    (0..1000).map(|_| {
4862        let mut total = 0.0f32;
4863        for formula in &ability.damage_formulas {
4864            let t = lcg_local(&mut seed);
4865            let crit_roll = lcg_local(&mut seed);
4866            let dodge_roll = lcg_local(&mut seed);
4867            let resist = profile.effective_resist_for_element(formula.element);
4868            let hit = formula.compute_final_damage(&config.caster_stats, resist, &[], t, crit_roll);
4869            total += profile.final_damage_received(hit.raw_damage, formula.element, dodge_roll);
4870        }
4871        total
4872    }).collect()
4873}
4874
4875// ============================================================
4876// EXTENDED: ABILITY UPGRADE TREE BUILDER
4877// ============================================================
4878
4879pub struct AbilityUpgradeBuilder {
4880    pub ability_id: u64,
4881    pub upgrades: Vec<AbilityModifier>,
4882    pub upgrade_graph: HashMap<u64, Vec<u64>>, // parent_upgrade -> child_upgrades
4883    pub active_upgrades: HashSet<u64>,
4884    pub total_points_spent: u32,
4885}
4886
4887impl AbilityUpgradeBuilder {
4888    pub fn new(ability_id: u64) -> Self {
4889        AbilityUpgradeBuilder {
4890            ability_id,
4891            upgrades: Vec::new(),
4892            upgrade_graph: HashMap::new(),
4893            active_upgrades: HashSet::new(),
4894            total_points_spent: 0,
4895        }
4896    }
4897
4898    pub fn add_upgrade(&mut self, modifier: AbilityModifier, parent_id: Option<u64>) {
4899        if let Some(pid) = parent_id {
4900            self.upgrade_graph.entry(pid).or_insert_with(Vec::new).push(modifier.id);
4901        }
4902        self.upgrades.push(modifier);
4903    }
4904
4905    pub fn can_unlock(&self, modifier_id: u64) -> bool {
4906        // Check if parent is active (if any)
4907        let has_parent = self.upgrade_graph.iter().any(|(_, children)| children.contains(&modifier_id));
4908        if has_parent {
4909            // Must have at least one parent unlocked
4910            self.upgrade_graph.iter().any(|(parent, children)| {
4911                children.contains(&modifier_id) && self.active_upgrades.contains(parent)
4912            })
4913        } else {
4914            true // root node, always available
4915        }
4916    }
4917
4918    pub fn unlock(&mut self, modifier_id: u64, db: &AbilityDatabase, def: &mut AbilityDefinition) -> bool {
4919        if !self.can_unlock(modifier_id) { return false; }
4920        if self.active_upgrades.contains(&modifier_id) { return false; }
4921
4922        if let Some(modifier) = db.ability_modifiers.get(&modifier_id).cloned() {
4923            modifier.apply_to_definition(def);
4924            self.active_upgrades.insert(modifier_id);
4925            self.total_points_spent += modifier.unlock_cost;
4926            true
4927        } else {
4928            false
4929        }
4930    }
4931
4932    pub fn respec(&mut self, def: &mut AbilityDefinition, original: AbilityDefinition, db: &AbilityDatabase) {
4933        *def = original;
4934        // Re-apply all active upgrades in topological order
4935        let ordered = self.topological_order();
4936        for mid in ordered {
4937            if self.active_upgrades.contains(&mid) {
4938                if let Some(modifier) = db.ability_modifiers.get(&mid).cloned() {
4939                    modifier.apply_to_definition(def);
4940                }
4941            }
4942        }
4943    }
4944
4945    fn topological_order(&self) -> Vec<u64> {
4946        // BFS from roots
4947        let all_ids: HashSet<u64> = self.upgrades.iter().map(|u| u.id).collect();
4948        let child_ids: HashSet<u64> = self.upgrade_graph.values().flat_map(|v| v.iter().copied()).collect();
4949        let roots: Vec<u64> = all_ids.difference(&child_ids).copied().collect();
4950        let mut result = Vec::new();
4951        let mut queue: VecDeque<u64> = roots.into_iter().collect();
4952        while let Some(id) = queue.pop_front() {
4953            result.push(id);
4954            if let Some(children) = self.upgrade_graph.get(&id) {
4955                for &child in children {
4956                    queue.push_back(child);
4957                }
4958            }
4959        }
4960        result
4961    }
4962}
4963
4964// ============================================================
4965// EXTENDED: CHANNEL ABILITY TICK PLANNER
4966// ============================================================
4967
4968#[derive(Debug, Clone)]
4969pub struct ChannelTickPlan {
4970    pub total_duration: f32,
4971    pub tick_count: u32,
4972    pub tick_interval: f32,
4973    pub tick_times: Vec<f32>,
4974    pub tick_damage_values: Vec<f32>,
4975    pub cumulative_damage: Vec<f32>,
4976    pub can_be_interrupted: bool,
4977    pub partial_damage_on_interrupt: bool,
4978}
4979
4980impl ChannelTickPlan {
4981    pub fn build(ability: &AbilityDefinition, config: &FormulaTesterConfig) -> Self {
4982        let duration = ability.channel_time;
4983        let ticks = ability.channel_ticks;
4984        let interval = if ticks > 0 { duration / ticks as f32 } else { duration };
4985
4986        let mut tick_times = Vec::new();
4987        let mut tick_damages = Vec::new();
4988        let mut cumulative = Vec::new();
4989
4990        let base_dmg_per_tick: f32 = if ticks > 0 {
4991            ability.damage_formulas.iter().map(|f| {
4992                let r = f.compute_final_damage(&config.caster_stats, config.resist_for_element(f.element), &[], 0.5, 0.9);
4993                r.final_damage / ticks as f32
4994            }).sum()
4995        } else { 0.0 };
4996
4997        let mut cumul = 0.0f32;
4998        for i in 0..ticks {
4999            let t = interval * (i + 1) as f32;
5000            tick_times.push(t);
5001            tick_damages.push(base_dmg_per_tick);
5002            cumul += base_dmg_per_tick;
5003            cumulative.push(cumul);
5004        }
5005
5006        ChannelTickPlan {
5007            total_duration: duration,
5008            tick_count: ticks,
5009            tick_interval: interval,
5010            tick_times,
5011            tick_damage_values: tick_damages,
5012            cumulative_damage: cumulative,
5013            can_be_interrupted: ability.interrupt_on_move,
5014            partial_damage_on_interrupt: true,
5015        }
5016    }
5017
5018    pub fn damage_at_time(&self, elapsed: f32) -> f32 {
5019        if self.tick_times.is_empty() { return 0.0; }
5020        let ticks_elapsed = self.tick_times.iter().filter(|&&t| t <= elapsed).count();
5021        self.cumulative_damage.get(ticks_elapsed.saturating_sub(1)).copied().unwrap_or(0.0)
5022    }
5023
5024    pub fn dps(&self) -> f32 {
5025        if self.total_duration <= 0.0 { return 0.0; }
5026        self.cumulative_damage.last().copied().unwrap_or(0.0) / self.total_duration
5027    }
5028}
5029
5030// ============================================================
5031// EXTENDED: ABILITY VISUAL EFFECTS PREVIEW
5032// ============================================================
5033
5034#[derive(Debug, Clone)]
5035pub struct VfxPreviewData {
5036    pub cast_particles: Vec<ParticleEmitter>,
5037    pub impact_particles: Vec<ParticleEmitter>,
5038    pub projectile_trail: Option<TrailEffect>,
5039    pub screen_shake: Option<ScreenShake>,
5040    pub status_effect_glow: Vec<(StatusEffectType, Vec4)>,
5041}
5042
5043#[derive(Debug, Clone)]
5044pub struct ParticleEmitter {
5045    pub name: String,
5046    pub position_offset: Vec3,
5047    pub direction: Vec3,
5048    pub spread_angle: f32,
5049    pub emission_rate: f32,
5050    pub lifetime: f32,
5051    pub speed: f32,
5052    pub color_start: Vec4,
5053    pub color_end: Vec4,
5054    pub size_start: f32,
5055    pub size_end: f32,
5056    pub gravity: f32,
5057    pub count: u32,
5058}
5059
5060impl ParticleEmitter {
5061    pub fn simple(name: impl Into<String>, color: Vec4) -> Self {
5062        ParticleEmitter {
5063            name: name.into(),
5064            position_offset: Vec3::ZERO,
5065            direction: Vec3::Y,
5066            spread_angle: 30.0,
5067            emission_rate: 50.0,
5068            lifetime: 0.5,
5069            speed: 3.0,
5070            color_start: color,
5071            color_end: Vec4::new(color.x, color.y, color.z, 0.0),
5072            size_start: 0.2,
5073            size_end: 0.0,
5074            gravity: -2.0,
5075            count: 20,
5076        }
5077    }
5078
5079    pub fn simulate_position(&self, t: f32, seed: u32) -> Vec3 {
5080        let angle_rad = (seed as f32 * 2.3 + t) % (std::f32::consts::TAU);
5081        let spread = self.spread_angle.to_radians();
5082        let dir = Vec3::new(
5083            angle_rad.sin() * spread.sin(),
5084            self.direction.y,
5085            angle_rad.cos() * spread.sin(),
5086        ).normalize_or_zero();
5087        let gravity_offset = Vec3::new(0.0, -0.5 * self.gravity * t * t, 0.0);
5088        self.position_offset + dir * self.speed * t + gravity_offset
5089    }
5090}
5091
5092#[derive(Debug, Clone)]
5093pub struct TrailEffect {
5094    pub width: f32,
5095    pub length: f32,
5096    pub color: Vec4,
5097    pub fade_time: f32,
5098    pub subdivisions: u32,
5099}
5100
5101#[derive(Debug, Clone)]
5102pub struct ScreenShake {
5103    pub intensity: f32,
5104    pub duration: f32,
5105    pub frequency: f32,
5106    pub falloff: f32,
5107}
5108
5109impl ScreenShake {
5110    pub fn displacement_at_time(&self, t: f32) -> Vec2 {
5111        if t >= self.duration { return Vec2::ZERO; }
5112        let decay = (1.0 - t / self.duration).powf(self.falloff);
5113        let wave = (t * self.frequency * std::f32::consts::TAU).sin();
5114        Vec2::new(wave * self.intensity * decay, wave * 0.7 * self.intensity * decay)
5115    }
5116}
5117
5118pub fn build_vfx_preview(ability: &AbilityDefinition) -> VfxPreviewData {
5119    let mut cast_particles = Vec::new();
5120    let mut impact_particles = Vec::new();
5121    let mut status_glows = Vec::new();
5122
5123    // Base cast effect
5124    for formula in &ability.damage_formulas {
5125        cast_particles.push(ParticleEmitter::simple(
5126            format!("{} cast", formula.element.display_name()),
5127            formula.element.color(),
5128        ));
5129        let mut impact = ParticleEmitter::simple(
5130            format!("{} impact", formula.element.display_name()),
5131            formula.element.color(),
5132        );
5133        impact.count = 40;
5134        impact.speed = 5.0;
5135        impact.size_start = 0.4;
5136        impact_particles.push(impact);
5137    }
5138
5139    for applied in &ability.applied_effects {
5140        status_glows.push((applied.effect_type, applied.effect_type.color()));
5141    }
5142
5143    let trail = ability.projectile_params.as_ref().map(|p| TrailEffect {
5144        width: p.size * 2.0,
5145        length: p.speed * 0.1,
5146        color: Vec4::new(1.0, 0.8, 0.3, 0.8),
5147        fade_time: 0.2,
5148        subdivisions: 8,
5149    });
5150
5151    let shake = if ability.aoe_radius > 0.0 {
5152        Some(ScreenShake {
5153            intensity: (ability.aoe_radius * 0.05).min(0.3),
5154            duration: 0.4,
5155            frequency: 12.0,
5156            falloff: 2.0,
5157        })
5158    } else {
5159        None
5160    };
5161
5162    VfxPreviewData {
5163        cast_particles,
5164        impact_particles,
5165        projectile_trail: trail,
5166        screen_shake: shake,
5167        status_effect_glow: status_glows,
5168    }
5169}
5170
5171// ============================================================
5172// EXTENDED: ABILITY BALANCE CHECKER
5173// ============================================================
5174
5175#[derive(Debug, Clone)]
5176pub struct AbilityBalanceReport {
5177    pub ability_name: String,
5178    pub effective_dps: f32,
5179    pub expected_dps_for_cooldown: f32,
5180    pub is_overpowered: bool,
5181    pub is_underpowered: bool,
5182    pub resource_efficiency: f32,
5183    pub utility_score: f32,
5184    pub total_score: f32,
5185    pub warnings: Vec<String>,
5186    pub suggestions: Vec<String>,
5187}
5188
5189pub fn check_ability_balance(ability: &AbilityDefinition, config: &FormulaTesterConfig) -> AbilityBalanceReport {
5190    let test = run_formula_test(ability, config);
5191    let eff_dps = test.effective_dps;
5192
5193    // Expected DPS based on cooldown and ability type
5194    let expected_dps = expected_dps_for_cd(ability.base_cooldown, ability.ability_type);
5195    let tolerance = expected_dps * 0.3;
5196
5197    let resource_eff = if ability.resource_cost > 0.0 { test.total_avg_damage / ability.resource_cost } else { 100.0 };
5198
5199    // Utility score: CC, healing, mobility add value
5200    let utility = ability.applied_effects.iter().map(|e| {
5201        if e.effect_type.is_crowd_control() { 20.0 }
5202        else if e.effect_type.is_beneficial() { 10.0 }
5203        else { 5.0 }
5204    }).sum::<f32>()
5205        + if ability.ability_type.is_movement() { 15.0 } else { 0.0 }
5206        + ability.heal_formula.base_max * 0.1;
5207
5208    let mut warnings = Vec::new();
5209    let mut suggestions = Vec::new();
5210
5211    if eff_dps > expected_dps + tolerance {
5212        warnings.push(format!("DPS {:.1} exceeds expected {:.1} by {:.1}%",
5213            eff_dps, expected_dps, (eff_dps / expected_dps - 1.0) * 100.0));
5214        suggestions.push("Consider increasing cooldown or reducing base damage".to_string());
5215    }
5216    if eff_dps < expected_dps - tolerance && ability.ability_type.is_damage_dealing() {
5217        warnings.push(format!("DPS {:.1} below expected {:.1}", eff_dps, expected_dps));
5218        suggestions.push("Consider reducing cooldown or increasing base damage".to_string());
5219    }
5220    if ability.resource_cost > 100.0 {
5221        warnings.push("Very high resource cost — may be unusable in sustained combat".to_string());
5222    }
5223    if ability.base_cooldown > 120.0 {
5224        warnings.push("Very long cooldown — should have correspondingly high impact".to_string());
5225    }
5226    if ability.applied_effects.iter().any(|e| e.apply_chance >= 100.0 && e.effect_type.is_crowd_control()) {
5227        warnings.push("100% CC application chance with no counter-play may be overpowered".to_string());
5228        suggestions.push("Reduce CC application chance or add a resistance check".to_string());
5229    }
5230
5231    let total_score = eff_dps / expected_dps.max(1.0) + utility * 0.01 + resource_eff * 0.05;
5232
5233    AbilityBalanceReport {
5234        ability_name: ability.name.clone(),
5235        effective_dps: eff_dps,
5236        expected_dps_for_cooldown: expected_dps,
5237        is_overpowered: eff_dps > expected_dps + tolerance,
5238        is_underpowered: eff_dps < expected_dps - tolerance && ability.ability_type.is_damage_dealing(),
5239        resource_efficiency: resource_eff,
5240        utility_score: utility,
5241        total_score,
5242        warnings,
5243        suggestions,
5244    }
5245}
5246
5247fn expected_dps_for_cd(cooldown: f32, ability_type: AbilityType) -> f32 {
5248    let type_multiplier = match ability_type {
5249        AbilityType::MeleeAttack => 0.8,
5250        AbilityType::RangedAttack => 0.9,
5251        AbilityType::AreaOfEffect => 1.5,
5252        AbilityType::Nova => 1.4,
5253        AbilityType::Beam => 1.1,
5254        AbilityType::ChainLightning => 1.6,
5255        AbilityType::Channel => 1.3,
5256        _ => 1.0,
5257    };
5258    // Base 100 DPS at 0s CD, scales down for longer CDs
5259    let base = 100.0 * type_multiplier;
5260    base * (1.0 / (1.0 + cooldown * 0.05))
5261}
5262
5263// ============================================================
5264// EXTENDED: EFFECT PRIORITY QUEUE (for game simulation)
5265// ============================================================
5266
5267#[derive(Debug, Clone)]
5268pub struct ScheduledEffect {
5269    pub execute_at: f32,
5270    pub effect_type: ScheduledEffectType,
5271    pub source_ability_id: u64,
5272    pub target_id: u64,
5273}
5274
5275#[derive(Debug, Clone)]
5276pub enum ScheduledEffectType {
5277    ApplyStatus(u64),   // status effect definition id
5278    DealDamage(DamageFormula, HashMap<String, f32>),
5279    Heal(f32),
5280    TriggerAbility(u64),
5281    SpawnProjectile { origin: Vec3, direction: Vec3, ability_id: u64 },
5282}
5283
5284pub struct EffectScheduler {
5285    pub queue: Vec<ScheduledEffect>, // maintained sorted by execute_at
5286    pub current_time: f32,
5287}
5288
5289impl EffectScheduler {
5290    pub fn new() -> Self {
5291        EffectScheduler { queue: Vec::new(), current_time: 0.0 }
5292    }
5293
5294    pub fn schedule(&mut self, at: f32, effect: ScheduledEffectType, source: u64, target: u64) {
5295        let entry = ScheduledEffect { execute_at: at, effect_type: effect, source_ability_id: source, target_id: target };
5296        let pos = self.queue.partition_point(|e| e.execute_at <= at);
5297        self.queue.insert(pos, entry);
5298    }
5299
5300    pub fn pop_ready(&mut self, now: f32) -> Vec<ScheduledEffect> {
5301        self.current_time = now;
5302        let split = self.queue.partition_point(|e| e.execute_at <= now);
5303        self.queue.drain(..split).collect()
5304    }
5305
5306    pub fn cancel_for_ability(&mut self, ability_id: u64) {
5307        self.queue.retain(|e| e.source_ability_id != ability_id);
5308    }
5309
5310    pub fn next_scheduled_time(&self) -> Option<f32> {
5311        self.queue.first().map(|e| e.execute_at)
5312    }
5313}
5314
5315// ============================================================
5316// FINAL ENTRY POINT
5317// ============================================================
5318
5319pub fn ability_editor_main() {
5320    let mut editor = build_ability_editor();
5321
5322    // Test formula tester
5323    editor.formula_tester.selected_ability_id = editor.database.abilities.keys().next().copied();
5324    editor.run_formula_test_now();
5325
5326    // Detect synergies
5327    let _synergies = detect_synergies(&editor.database);
5328
5329    // Rank abilities
5330    let config = FormulaTesterConfig::default_lvl_20();
5331    let _dps_ranking = rank_abilities_by_dps(&editor.database, &config);
5332    let _cd_table = generate_cooldown_preview_table(&editor.database);
5333
5334    // Balance check all abilities
5335    let ability_ids: Vec<u64> = editor.database.abilities.keys().copied().collect();
5336    for id in &ability_ids {
5337        if let Some(a) = editor.database.abilities.get(id) {
5338            let _report = check_ability_balance(a, &config);
5339        }
5340    }
5341
5342    // Resource efficiency
5343    let _mana_ranking = rank_abilities_by_efficiency(&editor.database, &config, ResourceType::Mana);
5344    let _stamina_ranking = rank_abilities_by_efficiency(&editor.database, &config, ResourceType::Stamina);
5345
5346    // Stance system
5347    let mut stance_mgr = StanceManager::new();
5348    let mut resources: HashMap<ResourceType, ResourceState> = HashMap::new();
5349    resources.insert(ResourceType::Rage, ResourceState::new(ResourceType::Rage));
5350
5351    // Channel tick plan
5352    if let Some(a) = editor.database.abilities.values().find(|a| a.channel_ticks > 0) {
5353        let plan = ChannelTickPlan::build(a, &config);
5354        let _dmg_at_half = plan.damage_at_time(a.channel_time * 0.5);
5355        let _dps = plan.dps();
5356    }
5357
5358    // VFX previews
5359    for (_, ability) in &editor.database.abilities {
5360        let _vfx = build_vfx_preview(ability);
5361    }
5362
5363    // Effect scheduler test
5364    let mut scheduler = EffectScheduler::new();
5365    scheduler.schedule(0.5, ScheduledEffectType::Heal(50.0), 1, 100);
5366    scheduler.schedule(1.0, ScheduledEffectType::ApplyStatus(1), 2, 100);
5367    let _ready = scheduler.pop_ready(0.8);
5368
5369    // Talent optimizer
5370    for (tree_id, tree) in &editor.database.talent_trees {
5371        let goal = TalentOptimizationGoal {
5372            maximize_stat: "attack_power".to_string(),
5373            secondary_stats: vec![("stamina".to_string(), 0.5)],
5374            required_abilities: Vec::new(),
5375            budget_points: 10,
5376        };
5377        let _result = greedy_talent_optimizer(tree, &goal);
5378    }
5379
5380    // Status timeline
5381    let status_defs: Vec<&StatusEffectDefinition> = editor.database.status_effects.values().collect();
5382    if !status_defs.is_empty() {
5383        let pairs: Vec<(&StatusEffectDefinition, u32)> = status_defs.iter().map(|d| (*d, 2)).collect();
5384        let stats = HashMap::new();
5385        let frames = simulate_status_timeline(&pairs, &stats, 10.0, 0.1);
5386        let _summary = summarize_timeline(&frames);
5387    }
5388
5389    // Interaction matrix
5390    let matrix = InteractionMatrix::new();
5391    let mut mock_effects = Vec::new();
5392    let _reactions = matrix.process_interactions(&mut mock_effects, StatusEffectType::Burn);
5393
5394    // Proc system
5395    let mut proc_mgr = ProcManager::new();
5396    proc_mgr.add_proc(ProcEffect {
5397        id: 1,
5398        name: "Flames of Fury".to_string(),
5399        trigger: ProcTrigger::OnCrit,
5400        chance: 15.0,
5401        internal_cooldown: 5.0,
5402        remaining_icd: 0.0,
5403        effect: ProcOutcome::DealBonusDamage { formula: DamageFormula::new(DamageElement::Fire, 20.0, 35.0) },
5404    });
5405    proc_mgr.tick(1.0);
5406    let rolls = vec![0.1, 0.05, 0.5];
5407    let _triggered = proc_mgr.check_procs(&ProcTrigger::OnCrit, &rolls);
5408
5409    // Search index
5410    let idx = AbilitySearchIndex::build(&editor.database);
5411    let _fireball_ids = idx.search_by_tokens("fireball");
5412    let _low_cd = idx.abilities_in_cd_range(0.0, 5.0);
5413
5414    println!("AbilityEditor initialized with {} abilities, {} status effects, {} talent trees",
5415        editor.database.abilities.len(),
5416        editor.database.status_effects.len(),
5417        editor.database.talent_trees.len()
5418    );
5419}
5420
5421// ============================================================
5422// SECTION: ABILITY COMBO SYSTEM
5423// ============================================================
5424
5425#[derive(Debug, Clone, PartialEq)]
5426pub enum ComboTrigger {
5427    AbilityUsed(u64),
5428    StatusApplied(StatusEffectType),
5429    HitLanded,
5430    KillSecured,
5431    ResourceThreshold { resource: ResourceType, threshold_pct: f32, above: bool },
5432    TimeElapsed(f32),
5433}
5434
5435#[derive(Debug, Clone)]
5436pub struct ComboStep {
5437    pub trigger: ComboTrigger,
5438    pub time_window: f32,
5439    pub required_index: usize,
5440}
5441
5442#[derive(Debug, Clone)]
5443pub struct ComboDefinition {
5444    pub id: u64,
5445    pub name: String,
5446    pub steps: Vec<ComboStep>,
5447    pub reward_ability_id: Option<u64>,
5448    pub reward_modifiers: Vec<(String, f32)>,
5449    pub reward_duration: f32,
5450}
5451
5452#[derive(Debug, Clone)]
5453pub struct ComboTracker {
5454    pub definition: ComboDefinition,
5455    pub current_step: usize,
5456    pub step_timestamp: f32,
5457    pub active_reward_timer: f32,
5458}
5459
5460impl ComboTracker {
5461    pub fn new(definition: ComboDefinition) -> Self {
5462        Self { definition, current_step: 0, step_timestamp: 0.0, active_reward_timer: 0.0 }
5463    }
5464
5465    pub fn advance(&mut self, trigger: &ComboTrigger, current_time: f32) -> bool {
5466        if self.current_step >= self.definition.steps.len() { return false; }
5467        let step = &self.definition.steps[self.current_step];
5468        let elapsed = current_time - self.step_timestamp;
5469        if elapsed > step.time_window && self.current_step > 0 {
5470            self.current_step = 0;
5471            self.step_timestamp = current_time;
5472        }
5473        if &step.trigger == trigger {
5474            self.current_step += 1;
5475            self.step_timestamp = current_time;
5476            if self.current_step >= self.definition.steps.len() {
5477                self.current_step = 0;
5478                self.active_reward_timer = self.definition.reward_duration;
5479                return true; // Combo completed
5480            }
5481        } else {
5482            self.current_step = 0;
5483            self.step_timestamp = current_time;
5484        }
5485        false
5486    }
5487
5488    pub fn update_reward_timer(&mut self, dt: f32) {
5489        if self.active_reward_timer > 0.0 {
5490            self.active_reward_timer = (self.active_reward_timer - dt).max(0.0);
5491        }
5492    }
5493
5494    pub fn reward_active(&self) -> bool { self.active_reward_timer > 0.0 }
5495
5496    pub fn progress_fraction(&self) -> f32 {
5497        if self.definition.steps.is_empty() { return 0.0; }
5498        self.current_step as f32 / self.definition.steps.len() as f32
5499    }
5500}
5501
5502#[derive(Debug)]
5503pub struct ComboManager {
5504    pub trackers: Vec<ComboTracker>,
5505}
5506
5507impl ComboManager {
5508    pub fn new() -> Self { Self { trackers: Vec::new() } }
5509
5510    pub fn add_combo(&mut self, definition: ComboDefinition) {
5511        self.trackers.push(ComboTracker::new(definition));
5512    }
5513
5514    pub fn process_trigger(&mut self, trigger: &ComboTrigger, current_time: f32) -> Vec<u64> {
5515        let mut completed = Vec::new();
5516        for tracker in &mut self.trackers {
5517            if tracker.advance(trigger, current_time) {
5518                completed.push(tracker.definition.id);
5519            }
5520        }
5521        completed
5522    }
5523
5524    pub fn update(&mut self, dt: f32) {
5525        for tracker in &mut self.trackers {
5526            tracker.update_reward_timer(dt);
5527        }
5528    }
5529
5530    pub fn active_rewards(&self) -> Vec<&ComboDefinition> {
5531        self.trackers.iter().filter(|t| t.reward_active()).map(|t| &t.definition).collect()
5532    }
5533}
5534
5535// ============================================================
5536// SECTION: ABILITY CAST QUEUE
5537// ============================================================
5538
5539#[derive(Debug, Clone)]
5540pub struct QueuedCast {
5541    pub ability_id: u64,
5542    pub target_position: Vec3,
5543    pub target_entity: Option<u64>,
5544    pub queued_at: f32,
5545    pub expire_at: f32,
5546}
5547
5548#[derive(Debug)]
5549pub struct AbilityCastQueue {
5550    pub queue: VecDeque<QueuedCast>,
5551    pub max_queue_size: usize,
5552    pub queue_window: f32,
5553}
5554
5555impl AbilityCastQueue {
5556    pub fn new(max_queue_size: usize, queue_window: f32) -> Self {
5557        Self { queue: VecDeque::new(), max_queue_size, queue_window }
5558    }
5559
5560    pub fn enqueue(&mut self, cast: QueuedCast) -> bool {
5561        if self.queue.len() >= self.max_queue_size { return false; }
5562        self.queue.push_back(cast);
5563        true
5564    }
5565
5566    pub fn pop_valid(&mut self, current_time: f32) -> Option<QueuedCast> {
5567        while let Some(front) = self.queue.front() {
5568            if front.expire_at < current_time {
5569                self.queue.pop_front();
5570            } else {
5571                return self.queue.pop_front();
5572            }
5573        }
5574        None
5575    }
5576
5577    pub fn expire_old(&mut self, current_time: f32) {
5578        self.queue.retain(|c| c.expire_at >= current_time);
5579    }
5580
5581    pub fn clear(&mut self) { self.queue.clear(); }
5582}
5583
5584// ============================================================
5585// SECTION: STATUS IMMUNITY TRACKER
5586// ============================================================
5587
5588#[derive(Debug, Clone)]
5589pub struct ImmunityWindow {
5590    pub effect_type: StatusEffectType,
5591    pub expires_at: f32,
5592    pub source: String,
5593}
5594
5595#[derive(Debug, Clone)]
5596pub struct StatusImmunityTracker {
5597    pub entity_id: u64,
5598    pub immunities: Vec<ImmunityWindow>,
5599    pub tenacity: f32,
5600}
5601
5602impl StatusImmunityTracker {
5603    pub fn new(entity_id: u64, tenacity: f32) -> Self {
5604        Self { entity_id, immunities: Vec::new(), tenacity }
5605    }
5606
5607    pub fn add_immunity(&mut self, effect_type: StatusEffectType, duration: f32, current_time: f32, source: String) {
5608        self.immunities.push(ImmunityWindow { effect_type, expires_at: current_time + duration, source });
5609    }
5610
5611    pub fn add_post_effect_immunity(&mut self, effect_type: StatusEffectType, base_duration: f32, current_time: f32) {
5612        let duration = base_duration * 0.5;
5613        self.add_immunity(effect_type, duration, current_time, "post_effect_immunity".to_string());
5614    }
5615
5616    pub fn is_immune(&self, effect_type: &StatusEffectType, current_time: f32) -> bool {
5617        self.immunities.iter().any(|imm| &imm.effect_type == effect_type && imm.expires_at > current_time)
5618    }
5619
5620    pub fn effective_duration(&self, base_duration: f32) -> f32 {
5621        base_duration * (1.0 - self.tenacity / 100.0).max(0.0)
5622    }
5623
5624    pub fn update(&mut self, current_time: f32) {
5625        self.immunities.retain(|imm| imm.expires_at > current_time);
5626    }
5627}
5628
5629// ============================================================
5630// SECTION: PARABOLIC ARC TARGETING
5631// ============================================================
5632
5633#[derive(Debug, Clone)]
5634pub struct ParabolicArcParams {
5635    pub launch_angle_deg: f32,
5636    pub gravity: f32,
5637    pub initial_speed: f32,
5638}
5639
5640impl ParabolicArcParams {
5641    pub fn launch_velocity(&self, origin: Vec3, target: Vec3) -> Vec3 {
5642        let diff = target - origin;
5643        let horizontal_dist = (diff.x * diff.x + diff.z * diff.z).sqrt();
5644        let angle_rad = self.launch_angle_deg.to_radians();
5645        let speed = self.initial_speed;
5646        let vy = speed * angle_rad.sin();
5647        let horizontal_speed = speed * angle_rad.cos();
5648        let horiz_dir = if horizontal_dist > 1e-6 {
5649            Vec3::new(diff.x / horizontal_dist, 0.0, diff.z / horizontal_dist)
5650        } else {
5651            Vec3::new(1.0, 0.0, 0.0)
5652        };
5653        horiz_dir * horizontal_speed + Vec3::new(0.0, vy, 0.0)
5654    }
5655
5656    pub fn time_of_flight(&self, launch_velocity: Vec3, height_diff: f32) -> f32 {
5657        let vy = launch_velocity.y;
5658        let g = self.gravity;
5659        // Solve: height_diff = vy*t - 0.5*g*t^2
5660        let discriminant = vy * vy + 2.0 * g * height_diff;
5661        if discriminant < 0.0 { return 0.0; }
5662        let t1 = (vy + discriminant.sqrt()) / g;
5663        let t2 = (vy - discriminant.sqrt()) / g;
5664        t1.max(t2).max(0.0)
5665    }
5666
5667    pub fn position_at_time(&self, origin: Vec3, launch_velocity: Vec3, t: f32) -> Vec3 {
5668        origin + launch_velocity * t + Vec3::new(0.0, -0.5 * self.gravity * t * t, 0.0)
5669    }
5670
5671    pub fn trajectory_points(&self, origin: Vec3, launch_velocity: Vec3, tof: f32, steps: usize) -> Vec<Vec3> {
5672        (0..=steps).map(|i| {
5673            let t = tof * i as f32 / steps as f32;
5674            self.position_at_time(origin, launch_velocity, t)
5675        }).collect()
5676    }
5677
5678    pub fn apex(&self, origin: Vec3, launch_velocity: Vec3) -> Vec3 {
5679        let t_apex = launch_velocity.y / self.gravity;
5680        self.position_at_time(origin, launch_velocity, t_apex.max(0.0))
5681    }
5682}
5683
5684// ============================================================
5685// SECTION: LINE-OF-SIGHT CHECKER
5686// ============================================================
5687
5688#[derive(Debug, Clone)]
5689pub struct OccluderSegment {
5690    pub a: Vec2,
5691    pub b: Vec2,
5692    pub height: f32,
5693}
5694
5695pub fn segments_intersect_2d(p1: Vec2, p2: Vec2, p3: Vec2, p4: Vec2) -> bool {
5696    let d1 = p2 - p1;
5697    let d2 = p4 - p3;
5698    let cross = d1.x * d2.y - d1.y * d2.x;
5699    if cross.abs() < 1e-9 { return false; }
5700    let diff = p3 - p1;
5701    let t = (diff.x * d2.y - diff.y * d2.x) / cross;
5702    let u = (diff.x * d1.y - diff.y * d1.x) / cross;
5703    t >= 0.0 && t <= 1.0 && u >= 0.0 && u <= 1.0
5704}
5705
5706pub fn has_line_of_sight(origin: Vec2, target: Vec2, occluders: &[OccluderSegment], caster_height: f32, target_height: f32) -> bool {
5707    for occ in occluders {
5708        if caster_height > occ.height && target_height > occ.height { continue; }
5709        if segments_intersect_2d(origin, target, occ.a, occ.b) { return false; }
5710    }
5711    true
5712}
5713
5714pub fn find_first_occluder(origin: Vec2, direction: Vec2, max_range: f32, occluders: &[OccluderSegment]) -> Option<(f32, usize)> {
5715    let target = origin + direction * max_range;
5716    let mut closest: Option<(f32, usize)> = None;
5717    for (i, occ) in occluders.iter().enumerate() {
5718        let d1 = direction * max_range;
5719        let d2 = occ.b - occ.a;
5720        let cross = d1.x * d2.y - d1.y * d2.x;
5721        if cross.abs() < 1e-9 { continue; }
5722        let diff = occ.a - origin;
5723        let t = (diff.x * d2.y - diff.y * d2.x) / cross;
5724        let u = (diff.x * d1.y - diff.y * d1.x) / cross;
5725        if t >= 0.0 && t <= 1.0 && u >= 0.0 && u <= 1.0 {
5726            let dist = t * max_range;
5727            if closest.map(|(d, _)| dist < d).unwrap_or(true) {
5728                closest = Some((dist, i));
5729            }
5730        }
5731    }
5732    let _ = target;
5733    closest
5734}
5735
5736// ============================================================
5737// SECTION: ABILITY HOTBAR MANAGEMENT
5738// ============================================================
5739
5740#[derive(Debug, Clone)]
5741pub struct HotbarSlot {
5742    pub slot_index: usize,
5743    pub ability_id: Option<u64>,
5744    pub keybind: Option<String>,
5745    pub cooldown_remaining: f32,
5746    pub active: bool,
5747}
5748
5749#[derive(Debug, Clone)]
5750pub struct AbilityHotbar {
5751    pub slots: Vec<HotbarSlot>,
5752    pub global_cooldown: f32,
5753    pub global_cooldown_remaining: f32,
5754}
5755
5756impl AbilityHotbar {
5757    pub fn new(slot_count: usize) -> Self {
5758        let slots = (0..slot_count).map(|i| HotbarSlot {
5759            slot_index: i,
5760            ability_id: None,
5761            keybind: None,
5762            cooldown_remaining: 0.0,
5763            active: false,
5764        }).collect();
5765        Self { slots, global_cooldown: 1.5, global_cooldown_remaining: 0.0 }
5766    }
5767
5768    pub fn assign(&mut self, slot_index: usize, ability_id: u64, keybind: Option<String>) {
5769        if let Some(slot) = self.slots.get_mut(slot_index) {
5770            slot.ability_id = Some(ability_id);
5771            slot.keybind = keybind;
5772        }
5773    }
5774
5775    pub fn unassign(&mut self, slot_index: usize) {
5776        if let Some(slot) = self.slots.get_mut(slot_index) {
5777            slot.ability_id = None;
5778            slot.keybind = None;
5779        }
5780    }
5781
5782    pub fn can_use(&self, slot_index: usize) -> bool {
5783        if self.global_cooldown_remaining > 0.0 { return false; }
5784        self.slots.get(slot_index).map(|s| s.cooldown_remaining <= 0.0 && s.ability_id.is_some()).unwrap_or(false)
5785    }
5786
5787    pub fn trigger(&mut self, slot_index: usize, ability_cooldown: f32) -> Option<u64> {
5788        if !self.can_use(slot_index) { return None; }
5789        let ability_id = self.slots[slot_index].ability_id;
5790        if let Some(slot) = self.slots.get_mut(slot_index) {
5791            slot.cooldown_remaining = ability_cooldown;
5792        }
5793        self.global_cooldown_remaining = self.global_cooldown;
5794        ability_id
5795    }
5796
5797    pub fn update(&mut self, dt: f32) {
5798        self.global_cooldown_remaining = (self.global_cooldown_remaining - dt).max(0.0);
5799        for slot in &mut self.slots {
5800            slot.cooldown_remaining = (slot.cooldown_remaining - dt).max(0.0);
5801        }
5802    }
5803
5804    pub fn find_slot_by_ability(&self, ability_id: u64) -> Option<usize> {
5805        self.slots.iter().find(|s| s.ability_id == Some(ability_id)).map(|s| s.slot_index)
5806    }
5807
5808    pub fn swap_slots(&mut self, a: usize, b: usize) {
5809        if a < self.slots.len() && b < self.slots.len() {
5810            let (ability_a, keybind_a) = (self.slots[a].ability_id, self.slots[a].keybind.clone());
5811            let (ability_b, keybind_b) = (self.slots[b].ability_id, self.slots[b].keybind.clone());
5812            self.slots[a].ability_id = ability_b;
5813            self.slots[a].keybind = keybind_b;
5814            self.slots[b].ability_id = ability_a;
5815            self.slots[b].keybind = keybind_a;
5816        }
5817    }
5818}
5819
5820// ============================================================
5821// SECTION: ABILITY SERIALIZATION (TEXT FORMAT)
5822// ============================================================
5823
5824pub fn serialize_ability_brief(ability: &AbilityDefinition) -> String {
5825    let mut parts = Vec::new();
5826    parts.push(format!("id={}", ability.id));
5827    parts.push(format!("name=\"{}\"", ability.name));
5828    parts.push(format!("type={:?}", ability.ability_type));
5829    parts.push(format!("cooldown={:.2}", ability.base_cooldown));
5830    parts.push(format!("cast_time={:.2}", ability.cast_time));
5831    parts.push(format!("range={:.1}", ability.range));
5832    parts.join(";")
5833}
5834
5835pub fn serialize_damage_formula(formula: &DamageFormula) -> String {
5836    format!(
5837        "element={:?};base={:.0}-{:.0};crit_chance={:.2};crit_mult={:.2};pen={:.0};pen_pct={:.2}",
5838        formula.element, formula.base_min, formula.base_max,
5839        formula.crit_chance_base, formula.crit_multiplier_base,
5840        formula.penetration, formula.penetration_percent
5841    )
5842}
5843
5844pub fn parse_scaling_coefficients(input: &str) -> Vec<ScalingCoeff> {
5845    let mut results = Vec::new();
5846    for part in input.split(',') {
5847        let trimmed = part.trim();
5848        let tokens: Vec<&str> = trimmed.split(':').collect();
5849        if tokens.len() == 2 {
5850            if let Ok(coeff) = tokens[1].trim().parse::<f32>() {
5851                let stat_name = tokens[0].trim().to_string();
5852                results.push(ScalingCoeff { stat_name, coefficient: coeff, exponent: 1.0 });
5853            }
5854        }
5855    }
5856    results
5857}
5858
5859pub fn ability_to_description_text(ability: &AbilityDefinition) -> String {
5860    let mut lines = Vec::new();
5861    lines.push(format!("=== {} ===", ability.name));
5862    lines.push(format!("Type: {:?}", ability.ability_type));
5863    if !ability.description.is_empty() {
5864        lines.push(format!("Description: {}", ability.description));
5865    }
5866    lines.push(format!("Cast Time: {:.2}s | Cooldown: {:.2}s | Range: {:.1}", ability.cast_time, ability.base_cooldown, ability.range));
5867    if ability.resource_cost > 0.0 {
5868        lines.push(format!("Cost: {:.0} {:?}", ability.resource_cost, ability.resource_type));
5869    }
5870    if !ability.damage_formulas.is_empty() {
5871        lines.push(format!("Damage ({} formula(s)):", ability.damage_formulas.len()));
5872        for f in &ability.damage_formulas {
5873            lines.push(format!("  {:?}: {:.0}-{:.0} base, {:.0}% crit chance", f.element, f.base_min, f.base_max, f.crit_chance_base * 100.0));
5874        }
5875    }
5876    if !ability.applied_effects.is_empty() {
5877        lines.push(format!("Applied Effects:"));
5878        for eff in &ability.applied_effects {
5879            lines.push(format!("  {:?} for {:.1}s ({}%)", eff.effect_type, eff.duration_override.unwrap_or(0.0), (eff.apply_chance * 100.0) as u32));
5880        }
5881    }
5882    lines.join("\n")
5883}
5884
5885// ============================================================
5886// SECTION: AREA DENIAL ABILITY SYSTEM
5887// ============================================================
5888
5889#[derive(Debug, Clone)]
5890pub struct AreaDenialZone {
5891    pub id: u64,
5892    pub ability_id: u64,
5893    pub caster_id: u64,
5894    pub position: Vec3,
5895    pub radius: f32,
5896    pub duration_remaining: f32,
5897    pub tick_interval: f32,
5898    pub tick_accumulator: f32,
5899    pub damage_per_tick: f32,
5900    pub effect_per_tick: Option<StatusEffectType>,
5901    pub effect_duration: f32,
5902    pub tags: Vec<String>,
5903}
5904
5905impl AreaDenialZone {
5906    pub fn new(id: u64, ability_id: u64, caster_id: u64, position: Vec3, radius: f32, duration: f32, damage_per_tick: f32, tick_interval: f32) -> Self {
5907        Self {
5908            id, ability_id, caster_id, position, radius, duration_remaining: duration,
5909            tick_interval, tick_accumulator: 0.0, damage_per_tick, effect_per_tick: None,
5910            effect_duration: 2.0, tags: Vec::new(),
5911        }
5912    }
5913
5914    pub fn update(&mut self, dt: f32) -> bool {
5915        self.duration_remaining -= dt;
5916        self.tick_accumulator += dt;
5917        self.duration_remaining > 0.0
5918    }
5919
5920    pub fn should_tick(&mut self) -> bool {
5921        if self.tick_accumulator >= self.tick_interval {
5922            self.tick_accumulator -= self.tick_interval;
5923            true
5924        } else { false }
5925    }
5926
5927    pub fn contains_point(&self, point: Vec3) -> bool {
5928        let dx = point.x - self.position.x;
5929        let dz = point.z - self.position.z;
5930        dx * dx + dz * dz <= self.radius * self.radius
5931    }
5932
5933    pub fn lifetime_fraction(&self, total_duration: f32) -> f32 {
5934        1.0 - (self.duration_remaining / total_duration).clamp(0.0, 1.0)
5935    }
5936}
5937
5938#[derive(Debug)]
5939pub struct AreaDenialManager {
5940    pub zones: Vec<AreaDenialZone>,
5941    pub next_zone_id: u64,
5942}
5943
5944impl AreaDenialManager {
5945    pub fn new() -> Self { Self { zones: Vec::new(), next_zone_id: 1 } }
5946
5947    pub fn spawn_zone(&mut self, ability_id: u64, caster_id: u64, position: Vec3, radius: f32, duration: f32, damage_per_tick: f32, tick_interval: f32) -> u64 {
5948        let id = self.next_zone_id;
5949        self.next_zone_id += 1;
5950        self.zones.push(AreaDenialZone::new(id, ability_id, caster_id, position, radius, duration, damage_per_tick, tick_interval));
5951        id
5952    }
5953
5954    pub fn update(&mut self, dt: f32) -> Vec<(u64, Vec<u64>)> {
5955        // Returns: zone_id -> list of entity_ids to tick (caller supplies entity positions)
5956        self.zones.retain_mut(|z| z.update(dt));
5957        Vec::new() // In real use, caller queries entity positions against zones
5958    }
5959
5960    pub fn query_zones_at(&self, point: Vec3) -> Vec<&AreaDenialZone> {
5961        self.zones.iter().filter(|z| z.contains_point(point)).collect()
5962    }
5963
5964    pub fn remove_zone(&mut self, zone_id: u64) {
5965        self.zones.retain(|z| z.id != zone_id);
5966    }
5967
5968    pub fn caster_zones(&self, caster_id: u64) -> Vec<&AreaDenialZone> {
5969        self.zones.iter().filter(|z| z.caster_id == caster_id).collect()
5970    }
5971}
5972
5973// ============================================================
5974// SECTION: ABILITY INTERRUPT SYSTEM
5975// ============================================================
5976
5977#[derive(Debug, Clone, PartialEq)]
5978pub enum InterruptType { Stun, Silence, Knockback, Knockup, Pushback, AbilityCancel }
5979
5980#[derive(Debug, Clone)]
5981pub struct InterruptEvent {
5982    pub interrupt_type: InterruptType,
5983    pub source_entity: u64,
5984    pub magnitude: f32,
5985    pub interrupt_time: f32,
5986}
5987
5988#[derive(Debug, Clone)]
5989pub struct CastInterruptData {
5990    pub was_interrupted: bool,
5991    pub interrupt_type: Option<InterruptType>,
5992    pub progress_at_interrupt: f32,
5993    pub refund_pct: f32,
5994}
5995
5996pub fn compute_cast_interrupt(ability: &AbilityDefinition, interrupt: &InterruptEvent, cast_progress: f32) -> CastInterruptData {
5997    let interruptible = match interrupt.interrupt_type {
5998        InterruptType::Stun | InterruptType::Knockback | InterruptType::Knockup => true,
5999        InterruptType::Silence => matches!(ability.ability_type, AbilityType::Buff | AbilityType::Heal | AbilityType::Summon),
6000        InterruptType::Pushback => ability.cast_time > 0.5,
6001        InterruptType::AbilityCancel => true,
6002    };
6003    if !interruptible {
6004        return CastInterruptData { was_interrupted: false, interrupt_type: None, progress_at_interrupt: cast_progress, refund_pct: 0.0 };
6005    }
6006    let refund_pct = if cast_progress < 0.3 { 1.0 } else if cast_progress < 0.7 { 0.5 } else { 0.0 };
6007    CastInterruptData {
6008        was_interrupted: true,
6009        interrupt_type: Some(interrupt.interrupt_type.clone()),
6010        progress_at_interrupt: cast_progress,
6011        refund_pct,
6012    }
6013}
6014
6015// ============================================================
6016// SECTION: ABILITY AURA SYSTEM
6017// ============================================================
6018
6019#[derive(Debug, Clone)]
6020pub struct AuraDefinition {
6021    pub id: u64,
6022    pub name: String,
6023    pub ability_id: u64,
6024    pub radius: f32,
6025    pub affects_allies: bool,
6026    pub affects_enemies: bool,
6027    pub affects_self: bool,
6028    pub stat_modifiers: Vec<(String, f32)>,
6029    pub tick_effects: Vec<StatusEffectType>,
6030    pub tick_interval: f32,
6031    pub reserved_resource: Option<(ResourceType, f32)>,
6032}
6033
6034#[derive(Debug, Clone)]
6035pub struct ActiveAura {
6036    pub definition_id: u64,
6037    pub caster_id: u64,
6038    pub position: Vec3,
6039    pub tick_accumulator: f32,
6040}
6041
6042impl ActiveAura {
6043    pub fn new(definition_id: u64, caster_id: u64, position: Vec3) -> Self {
6044        Self { definition_id, caster_id, position, tick_accumulator: 0.0 }
6045    }
6046
6047    pub fn update_position(&mut self, new_pos: Vec3) { self.position = new_pos; }
6048
6049    pub fn tick(&mut self, dt: f32, tick_interval: f32) -> bool {
6050        self.tick_accumulator += dt;
6051        if self.tick_accumulator >= tick_interval {
6052            self.tick_accumulator -= tick_interval;
6053            true
6054        } else { false }
6055    }
6056
6057    pub fn affects_point(&self, point: Vec3, radius: f32) -> bool {
6058        let dx = point.x - self.position.x;
6059        let dz = point.z - self.position.z;
6060        dx * dx + dz * dz <= radius * radius
6061    }
6062}
6063
6064#[derive(Debug)]
6065pub struct AuraManager {
6066    pub aura_defs: HashMap<u64, AuraDefinition>,
6067    pub active_auras: Vec<ActiveAura>,
6068}
6069
6070impl AuraManager {
6071    pub fn new() -> Self { Self { aura_defs: HashMap::new(), active_auras: Vec::new() } }
6072
6073    pub fn register_aura(&mut self, def: AuraDefinition) {
6074        self.aura_defs.insert(def.id, def);
6075    }
6076
6077    pub fn activate_aura(&mut self, def_id: u64, caster_id: u64, position: Vec3) -> bool {
6078        if !self.aura_defs.contains_key(&def_id) { return false; }
6079        // Remove existing aura of same type from same caster
6080        self.active_auras.retain(|a| !(a.definition_id == def_id && a.caster_id == caster_id));
6081        self.active_auras.push(ActiveAura::new(def_id, caster_id, position));
6082        true
6083    }
6084
6085    pub fn deactivate_aura(&mut self, def_id: u64, caster_id: u64) {
6086        self.active_auras.retain(|a| !(a.definition_id == def_id && a.caster_id == caster_id));
6087    }
6088
6089    pub fn auras_affecting_point(&self, point: Vec3) -> Vec<(&ActiveAura, &AuraDefinition)> {
6090        self.active_auras.iter()
6091            .filter_map(|a| {
6092                self.aura_defs.get(&a.definition_id).and_then(|def| {
6093                    if a.affects_point(point, def.radius) { Some((a, def)) } else { None }
6094                })
6095            })
6096            .collect()
6097    }
6098
6099    pub fn update_positions(&mut self, positions: &HashMap<u64, Vec3>) {
6100        for aura in &mut self.active_auras {
6101            if let Some(&pos) = positions.get(&aura.caster_id) {
6102                aura.update_position(pos);
6103            }
6104        }
6105    }
6106
6107    pub fn stat_bonus_at_point(&self, point: Vec3, stat_name: &str, entity_is_ally: bool) -> f32 {
6108        self.auras_affecting_point(point).iter()
6109            .filter(|(_, def)| (entity_is_ally && def.affects_allies) || (!entity_is_ally && def.affects_enemies))
6110            .flat_map(|(_, def)| def.stat_modifiers.iter())
6111            .filter(|(name, _)| name == stat_name)
6112            .map(|(_, val)| val)
6113            .sum()
6114    }
6115}
6116
6117// ============================================================
6118// SECTION: ABILITY TAG SYSTEM
6119// ============================================================
6120
6121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6122pub enum AbilityTag {
6123    Spell, Attack, Projectile, AoE, Channeled, Instant, Movement, Summon,
6124    Fire, Cold, Lightning, Chaos, Physical, Arcane,
6125    Buff, Debuff, Heal, Support, Ultimate, Basic,
6126    Melee, Ranged, Ground, Self_,
6127}
6128
6129#[derive(Debug, Clone)]
6130pub struct AbilityTagFilter {
6131    pub required_all: Vec<AbilityTag>,
6132    pub required_any: Vec<AbilityTag>,
6133    pub excluded: Vec<AbilityTag>,
6134}
6135
6136impl AbilityTagFilter {
6137    pub fn matches(&self, tags: &HashSet<AbilityTag>) -> bool {
6138        for req in &self.required_all {
6139            if !tags.contains(req) { return false; }
6140        }
6141        if !self.required_any.is_empty() {
6142            if !self.required_any.iter().any(|t| tags.contains(t)) { return false; }
6143        }
6144        for excl in &self.excluded {
6145            if tags.contains(excl) { return false; }
6146        }
6147        true
6148    }
6149}
6150
6151pub fn infer_tags_from_ability(ability: &AbilityDefinition) -> HashSet<AbilityTag> {
6152    let mut tags = HashSet::new();
6153    match ability.ability_type {
6154        AbilityType::MeleeAttack | AbilityType::Charge => { tags.insert(AbilityTag::Attack); tags.insert(AbilityTag::Melee); }
6155        AbilityType::RangedAttack | AbilityType::Projectile => { tags.insert(AbilityTag::Attack); tags.insert(AbilityTag::Ranged); tags.insert(AbilityTag::Projectile); }
6156        AbilityType::AreaOfEffect | AbilityType::Nova => { tags.insert(AbilityTag::Spell); tags.insert(AbilityTag::AoE); }
6157        AbilityType::Buff => { tags.insert(AbilityTag::Spell); tags.insert(AbilityTag::Buff); }
6158        AbilityType::Debuff | AbilityType::Curse => { tags.insert(AbilityTag::Spell); tags.insert(AbilityTag::Debuff); }
6159        AbilityType::Heal => { tags.insert(AbilityTag::Spell); tags.insert(AbilityTag::Heal); }
6160        AbilityType::Summon | AbilityType::Totem | AbilityType::Pet => { tags.insert(AbilityTag::Summon); }
6161        AbilityType::Dash | AbilityType::Teleport => { tags.insert(AbilityTag::Movement); }
6162        AbilityType::Channel => { tags.insert(AbilityTag::Channeled); }
6163        _ => {}
6164    }
6165    if ability.cast_time == 0.0 { tags.insert(AbilityTag::Instant); }
6166    if ability.aoe_radius > 0.0 { tags.insert(AbilityTag::AoE); }
6167    for formula in &ability.damage_formulas {
6168        match formula.element {
6169            DamageElement::Fire => { tags.insert(AbilityTag::Fire); tags.insert(AbilityTag::Spell); }
6170            DamageElement::Cold => { tags.insert(AbilityTag::Cold); tags.insert(AbilityTag::Spell); }
6171            DamageElement::Lightning => { tags.insert(AbilityTag::Lightning); tags.insert(AbilityTag::Spell); }
6172            DamageElement::Physical => { tags.insert(AbilityTag::Physical); }
6173            DamageElement::Arcane => { tags.insert(AbilityTag::Arcane); tags.insert(AbilityTag::Spell); }
6174            _ => {}
6175        }
6176    }
6177    tags
6178}
6179
6180// ============================================================
6181// SECTION: ABILITY UNLOCK SYSTEM
6182// ============================================================
6183
6184#[derive(Debug, Clone)]
6185pub enum UnlockCondition {
6186    CharacterLevel(u32),
6187    AbilityKnown(u64),
6188    ClassIs(String),
6189    QuestCompleted(u64),
6190    AttributeMinimum { stat_name: String, min_value: f32 },
6191    PointsSpentInTree { tree_id: u64, min_points: u32 },
6192}
6193
6194#[derive(Debug, Clone)]
6195pub struct AbilityUnlockEntry {
6196    pub ability_id: u64,
6197    pub conditions: Vec<UnlockCondition>,
6198    pub cost_points: u32,
6199    pub cost_gold: u64,
6200    pub one_time_unlock: bool,
6201    pub display_category: String,
6202}
6203
6204#[derive(Debug)]
6205pub struct AbilityUnlockBook {
6206    pub entries: HashMap<u64, AbilityUnlockEntry>,
6207    pub unlocked: HashSet<u64>,
6208}
6209
6210impl AbilityUnlockBook {
6211    pub fn new() -> Self { Self { entries: HashMap::new(), unlocked: HashSet::new() } }
6212
6213    pub fn register(&mut self, entry: AbilityUnlockEntry) {
6214        self.entries.insert(entry.ability_id, entry);
6215    }
6216
6217    pub fn can_unlock(&self, ability_id: u64, char_level: u32, known_abilities: &HashSet<u64>, class: &str, available_points: u32) -> bool {
6218        let entry = match self.entries.get(&ability_id) { Some(e) => e, None => return false };
6219        if self.unlocked.contains(&ability_id) { return false; }
6220        if available_points < entry.cost_points { return false; }
6221        for cond in &entry.conditions {
6222            match cond {
6223                UnlockCondition::CharacterLevel(lvl) => { if char_level < *lvl { return false; } }
6224                UnlockCondition::AbilityKnown(id) => { if !known_abilities.contains(id) { return false; } }
6225                UnlockCondition::ClassIs(c) => { if c != class { return false; } }
6226                UnlockCondition::AttributeMinimum { .. } => {} // caller handles stat lookup
6227                UnlockCondition::PointsSpentInTree { .. } => {} // caller handles
6228                UnlockCondition::QuestCompleted(_) => {} // caller handles
6229            }
6230        }
6231        true
6232    }
6233
6234    pub fn unlock(&mut self, ability_id: u64) -> bool {
6235        if self.entries.contains_key(&ability_id) {
6236            self.unlocked.insert(ability_id);
6237            true
6238        } else { false }
6239    }
6240
6241    pub fn available_to_unlock(&self, char_level: u32, known_abilities: &HashSet<u64>, class: &str, available_points: u32) -> Vec<u64> {
6242        self.entries.keys()
6243            .filter(|&&id| self.can_unlock(id, char_level, known_abilities, class, available_points))
6244            .cloned()
6245            .collect()
6246    }
6247}
6248
6249// ============================================================
6250// SECTION: DAMAGE OVER TIME (DOT) TRACKER
6251// ============================================================
6252
6253#[derive(Debug, Clone)]
6254pub struct DotInstance {
6255    pub id: u64,
6256    pub source_ability_id: u64,
6257    pub caster_id: u64,
6258    pub target_id: u64,
6259    pub element: DamageElement,
6260    pub damage_per_tick: f32,
6261    pub tick_interval: f32,
6262    pub duration_remaining: f32,
6263    pub tick_accumulator: f32,
6264    pub stack_count: u32,
6265    pub max_stacks: u32,
6266    pub can_crit: bool,
6267    pub crit_chance: f32,
6268    pub crit_multiplier: f32,
6269}
6270
6271impl DotInstance {
6272    pub fn new(id: u64, source_ability_id: u64, caster_id: u64, target_id: u64, element: DamageElement, damage_per_tick: f32, tick_interval: f32, duration: f32) -> Self {
6273        Self {
6274            id, source_ability_id, caster_id, target_id, element,
6275            damage_per_tick, tick_interval, duration_remaining: duration,
6276            tick_accumulator: 0.0, stack_count: 1, max_stacks: 1,
6277            can_crit: false, crit_chance: 0.05, crit_multiplier: 1.5,
6278        }
6279    }
6280
6281    pub fn update(&mut self, dt: f32) -> (bool, bool) {
6282        self.duration_remaining -= dt;
6283        self.tick_accumulator += dt;
6284        let ticked = self.tick_accumulator >= self.tick_interval;
6285        if ticked { self.tick_accumulator -= self.tick_interval; }
6286        let alive = self.duration_remaining > 0.0;
6287        (alive, ticked)
6288    }
6289
6290    pub fn effective_damage_per_tick(&self) -> f32 {
6291        self.damage_per_tick * self.stack_count as f32
6292    }
6293
6294    pub fn add_stack(&mut self, new_instance: &DotInstance) {
6295        if self.stack_count < self.max_stacks {
6296            self.stack_count += 1;
6297        }
6298        // Refresh duration to whichever is longer
6299        self.duration_remaining = self.duration_remaining.max(new_instance.duration_remaining);
6300    }
6301
6302    pub fn expected_total_damage(&self) -> f32 {
6303        let remaining_ticks = (self.duration_remaining / self.tick_interval).ceil();
6304        self.effective_damage_per_tick() * remaining_ticks
6305    }
6306}
6307
6308#[derive(Debug)]
6309pub struct DotTracker {
6310    pub dots: Vec<DotInstance>,
6311    pub next_dot_id: u64,
6312}
6313
6314impl DotTracker {
6315    pub fn new() -> Self { Self { dots: Vec::new(), next_dot_id: 1 } }
6316
6317    pub fn apply(&mut self, mut instance: DotInstance) -> u64 {
6318        instance.id = self.next_dot_id;
6319        self.next_dot_id += 1;
6320        // Check for stacking with existing dot from same source on same target
6321        let existing = self.dots.iter_mut().find(|d| {
6322            d.source_ability_id == instance.source_ability_id && d.target_id == instance.target_id
6323        });
6324        if let Some(existing_dot) = existing {
6325            let clone = instance.clone();
6326            existing_dot.add_stack(&clone);
6327            return existing_dot.id;
6328        }
6329        let id = instance.id;
6330        self.dots.push(instance);
6331        id
6332    }
6333
6334    pub fn update_all(&mut self, dt: f32) -> Vec<(u64, f32, DamageElement, bool)> {
6335        // Returns: (target_id, damage, element, is_crit)
6336        let mut damage_events = Vec::new();
6337        self.dots.retain_mut(|dot| {
6338            let (alive, ticked) = dot.update(dt);
6339            if ticked {
6340                let dmg = dot.effective_damage_per_tick();
6341                damage_events.push((dot.target_id, dmg, dot.element.clone(), false));
6342            }
6343            alive
6344        });
6345        damage_events
6346    }
6347
6348    pub fn dispel_by_element(&mut self, target_id: u64, element: &DamageElement) {
6349        self.dots.retain(|d| !(d.target_id == target_id && &d.element == element));
6350    }
6351
6352    pub fn total_dot_dps_on_target(&self, target_id: u64) -> f32 {
6353        self.dots.iter()
6354            .filter(|d| d.target_id == target_id)
6355            .map(|d| d.effective_damage_per_tick() / d.tick_interval)
6356            .sum()
6357    }
6358}
6359
6360// ============================================================
6361// SECTION: ABILITY MODIFIER STACK (RUNTIME BUFFS/DEBUFFS)
6362// ============================================================
6363
6364#[derive(Debug, Clone)]
6365pub struct RuntimeAbilityModifier {
6366    pub id: u64,
6367    pub target_ability_id: Option<u64>,
6368    pub target_tag_filter: Option<AbilityTagFilter>,
6369    pub stat_changes: Vec<(String, f32)>,
6370    pub mult_changes: Vec<(String, f32)>,
6371    pub duration: Option<f32>,
6372    pub source: String,
6373}
6374
6375#[derive(Debug)]
6376pub struct RuntimeAbilityModStack {
6377    pub modifiers: Vec<(RuntimeAbilityModifier, f32)>,
6378    pub next_id: u64,
6379}
6380
6381impl RuntimeAbilityModStack {
6382    pub fn new() -> Self { Self { modifiers: Vec::new(), next_id: 1 } }
6383
6384    pub fn push(&mut self, mut modifier: RuntimeAbilityModifier, current_time: f32) -> u64 {
6385        modifier.id = self.next_id;
6386        self.next_id += 1;
6387        self.modifiers.push((modifier, current_time));
6388        self.next_id - 1
6389    }
6390
6391    pub fn update(&mut self, current_time: f32) {
6392        self.modifiers.retain(|(m, start_time)| {
6393            match m.duration {
6394                None => true,
6395                Some(dur) => current_time - start_time < dur,
6396            }
6397        });
6398    }
6399
6400    pub fn remove_by_id(&mut self, id: u64) {
6401        self.modifiers.retain(|(m, _)| m.id != id);
6402    }
6403
6404    pub fn flat_bonus_for_ability(&self, ability_id: u64, ability_tags: &HashSet<AbilityTag>, stat_name: &str) -> f32 {
6405        self.modifiers.iter().filter(|(m, _)| {
6406            let applies_by_id = m.target_ability_id.map(|id| id == ability_id).unwrap_or(true);
6407            let applies_by_tag = m.target_tag_filter.as_ref().map(|f| f.matches(ability_tags)).unwrap_or(true);
6408            applies_by_id && applies_by_tag
6409        }).flat_map(|(m, _)| m.stat_changes.iter())
6410          .filter(|(name, _)| name == stat_name)
6411          .map(|(_, v)| v)
6412          .sum()
6413    }
6414
6415    pub fn mult_bonus_for_ability(&self, ability_id: u64, ability_tags: &HashSet<AbilityTag>, stat_name: &str) -> f32 {
6416        self.modifiers.iter().filter(|(m, _)| {
6417            let applies_by_id = m.target_ability_id.map(|id| id == ability_id).unwrap_or(true);
6418            let applies_by_tag = m.target_tag_filter.as_ref().map(|f| f.matches(ability_tags)).unwrap_or(true);
6419            applies_by_id && applies_by_tag
6420        }).flat_map(|(m, _)| m.mult_changes.iter())
6421          .filter(|(name, _)| name == stat_name)
6422          .map(|(_, v)| v)
6423          .product()
6424    }
6425}
6426
6427// ============================================================
6428// SECTION: ABILITY EDITOR STATE (EXTENDED)
6429// ============================================================
6430
6431#[derive(Debug)]
6432pub struct AbilityEditorExtendedState {
6433    pub hotbar: AbilityHotbar,
6434    pub combo_manager: ComboManager,
6435    pub area_denial_manager: AreaDenialManager,
6436    pub aura_manager: AuraManager,
6437    pub dot_tracker: DotTracker,
6438    pub unlock_book: AbilityUnlockBook,
6439    pub cast_queue: AbilityCastQueue,
6440    pub runtime_mods: RuntimeAbilityModStack,
6441    pub immunity_trackers: HashMap<u64, StatusImmunityTracker>,
6442    pub selected_ability_tags: HashSet<AbilityTag>,
6443    pub tag_filter: Option<AbilityTagFilter>,
6444    pub parabolic_preview: Option<Vec<Vec3>>,
6445    pub los_occluders: Vec<OccluderSegment>,
6446}
6447
6448impl AbilityEditorExtendedState {
6449    pub fn new() -> Self {
6450        Self {
6451            hotbar: AbilityHotbar::new(10),
6452            combo_manager: ComboManager::new(),
6453            area_denial_manager: AreaDenialManager::new(),
6454            aura_manager: AuraManager::new(),
6455            dot_tracker: DotTracker::new(),
6456            unlock_book: AbilityUnlockBook::new(),
6457            cast_queue: AbilityCastQueue::new(3, 0.5),
6458            runtime_mods: RuntimeAbilityModStack::new(),
6459            immunity_trackers: HashMap::new(),
6460            selected_ability_tags: HashSet::new(),
6461            tag_filter: None,
6462            parabolic_preview: None,
6463            los_occluders: Vec::new(),
6464        }
6465    }
6466
6467    pub fn update(&mut self, dt: f32, current_time: f32) {
6468        self.hotbar.update(dt);
6469        self.combo_manager.update(dt);
6470        self.area_denial_manager.update(dt);
6471        self.dot_tracker.update_all(dt);
6472        self.runtime_mods.update(current_time);
6473        self.cast_queue.expire_old(current_time);
6474        for tracker in self.immunity_trackers.values_mut() {
6475            tracker.update(current_time);
6476        }
6477    }
6478
6479    pub fn abilities_matching_filter(&self, database: &AbilityDatabase) -> Vec<u64> {
6480        match &self.tag_filter {
6481            None => database.abilities.keys().cloned().collect(),
6482            Some(filter) => database.abilities.iter()
6483                .filter(|(_, ab)| {
6484                    let tags = infer_tags_from_ability(ab);
6485                    filter.matches(&tags)
6486                })
6487                .map(|(id, _)| *id)
6488                .collect(),
6489        }
6490    }
6491
6492    pub fn preview_parabolic_arc(&mut self, origin: Vec3, target: Vec3, params: &ParabolicArcParams) {
6493        let launch_vel = params.launch_velocity(origin, target);
6494        let tof = params.time_of_flight(launch_vel, target.y - origin.y);
6495        self.parabolic_preview = Some(params.trajectory_points(origin, launch_vel, tof, 32));
6496    }
6497
6498    pub fn check_los(&self, origin: Vec2, target: Vec2, caster_height: f32, target_height: f32) -> bool {
6499        has_line_of_sight(origin, target, &self.los_occluders, caster_height, target_height)
6500    }
6501}
6502
6503// ============================================================
6504// SECTION: ABILITY DATABASE SEARCH
6505// ============================================================
6506
6507pub fn ability_fuzzy_search(database: &AbilityDatabase, query: &str, max_results: usize) -> Vec<u64> {
6508    let q = query.to_lowercase();
6509    let mut scored: Vec<(u64, usize)> = database.abilities.iter()
6510        .map(|(&id, ab)| {
6511            let name = ab.name.to_lowercase();
6512            let score = if name.contains(&q) { 0 }
6513                else { ability_edit_distance(&q, &name) };
6514            (id, score)
6515        })
6516        .collect();
6517    scored.sort_by_key(|(_, s)| *s);
6518    scored.truncate(max_results);
6519    scored.into_iter().map(|(id, _)| id).collect()
6520}
6521
6522fn ability_edit_distance(a: &str, b: &str) -> usize {
6523    let a: Vec<char> = a.chars().collect();
6524    let b: Vec<char> = b.chars().collect();
6525    let m = a.len();
6526    let n = b.len();
6527    let mut dp = vec![vec![0usize; n + 1]; m + 1];
6528    for i in 0..=m { dp[i][0] = i; }
6529    for j in 0..=n { dp[0][j] = j; }
6530    for i in 1..=m {
6531        for j in 1..=n {
6532            dp[i][j] = if a[i-1] == b[j-1] {
6533                dp[i-1][j-1]
6534            } else {
6535                1 + dp[i-1][j].min(dp[i][j-1]).min(dp[i-1][j-1])
6536            };
6537        }
6538    }
6539    dp[m][n]
6540}
6541
6542pub fn filter_abilities_by_type(database: &AbilityDatabase, ability_type: &AbilityType) -> Vec<u64> {
6543    database.abilities.iter()
6544        .filter(|(_, ab)| std::mem::discriminant(&ab.ability_type) == std::mem::discriminant(ability_type))
6545        .map(|(id, _)| *id)
6546        .collect()
6547}
6548
6549pub fn abilities_sorted_by_cooldown(database: &AbilityDatabase) -> Vec<(u64, f32)> {
6550    let mut list: Vec<(u64, f32)> = database.abilities.iter()
6551        .map(|(&id, ab)| (id, ab.base_cooldown))
6552        .collect();
6553    list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6554    list
6555}
6556
6557pub fn abilities_by_resource_type(database: &AbilityDatabase, resource: &ResourceType) -> Vec<u64> {
6558    database.abilities.iter()
6559        .filter(|(_, ab)| std::mem::discriminant(&ab.resource_type) == std::mem::discriminant(resource))
6560        .map(|(id, _)| *id)
6561        .collect()
6562}
6563
6564// ============================================================
6565// SECTION: DAMAGE SIMULATION (EXTENDED MONTE CARLO)
6566// ============================================================
6567
6568#[derive(Debug, Clone)]
6569pub struct ExtendedMcSimResult {
6570    pub samples: Vec<f32>,
6571    pub mean: f32,
6572    pub variance: f32,
6573    pub std_dev: f32,
6574    pub min: f32,
6575    pub max: f32,
6576    pub p10: f32,
6577    pub p25: f32,
6578    pub p50: f32,
6579    pub p75: f32,
6580    pub p90: f32,
6581    pub p95: f32,
6582    pub p99: f32,
6583    pub crit_rate_observed: f32,
6584    pub dps_at_mean: f32,
6585}
6586
6587pub fn run_extended_monte_carlo(ability: &AbilityDefinition, stats: &HashMap<String, f32>, target_resist: f32, iterations: usize) -> ExtendedMcSimResult {
6588    let mut samples = Vec::with_capacity(iterations);
6589    let mut crit_count = 0usize;
6590    let mut seed = 12345u64;
6591
6592    let lcg = |s: &mut u64| -> f32 {
6593        *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
6594        ((*s >> 33) as f32) / (u32::MAX as f32)
6595    };
6596
6597    for _ in 0..iterations {
6598        let mut total_damage = 0.0f32;
6599        let mut had_crit = false;
6600        for formula in &ability.damage_formulas {
6601            let roll_t = lcg(&mut seed);
6602            let crit_roll = lcg(&mut seed);
6603            let result = formula.compute_final_damage(stats, target_resist, &[], roll_t, crit_roll);
6604            total_damage += result.final_damage;
6605            if result.is_crit { had_crit = true; }
6606        }
6607        if had_crit { crit_count += 1; }
6608        samples.push(total_damage);
6609    }
6610
6611    let n = samples.len() as f32;
6612    let mean = samples.iter().sum::<f32>() / n;
6613    let variance = samples.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / n;
6614    let std_dev = variance.sqrt();
6615    let min = samples.iter().cloned().fold(f32::INFINITY, f32::min);
6616    let max = samples.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
6617
6618    let mut sorted = samples.clone();
6619    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
6620    let pct = |p: f32| sorted[((p / 100.0) * (sorted.len() - 1) as f32) as usize];
6621
6622    let dps_at_mean = if ability.base_cooldown > 0.0 { mean / ability.base_cooldown } else { mean };
6623    let crit_rate_observed = crit_count as f32 / n;
6624
6625    ExtendedMcSimResult {
6626        samples, mean, variance, std_dev, min, max,
6627        p10: pct(10.0), p25: pct(25.0), p50: pct(50.0),
6628        p75: pct(75.0), p90: pct(90.0), p95: pct(95.0), p99: pct(99.0),
6629        crit_rate_observed, dps_at_mean,
6630    }
6631}
6632
6633// ============================================================
6634// SECTION: ABILITY EDITOR TOP-LEVEL DEMO EXTENSION
6635// ============================================================
6636
6637pub fn ability_editor_extended_demo() {
6638    let mut db = AbilityDatabase::new();
6639
6640    // Create a test ability
6641    let mut fireball = AbilityDefinition::new(1001, "Fireball", AbilityType::Projectile);
6642    fireball.description = "Hurls a blazing ball of fire that explodes on impact.".to_string();
6643    fireball.targeting_mode = TargetingMode::GroundTarget { indicator: AreaIndicator::Circle };
6644    fireball.range = 25.0;
6645    fireball.aoe_radius = 4.0;
6646    fireball.cast_time = 1.5;
6647    fireball.base_cooldown = 6.0;
6648    fireball.resource_cost = 80.0;
6649    fireball.resource_type = ResourceType::Mana;
6650    fireball.school = "Fire".to_string();
6651    fireball.unlock_level = 5;
6652    fireball.damage_formulas = vec![
6653        DamageFormula {
6654            element: DamageElement::Fire,
6655            base_min: 120.0,
6656            base_max: 180.0,
6657            scaling: vec![ScalingCoeff { stat_name: "SpellPower".to_string(), coefficient: 0.85, exponent: 1.0 }],
6658            crit_chance_base: 0.15,
6659            crit_multiplier_base: 1.75,
6660            crit_chance_scaling: Vec::new(),
6661            crit_multiplier_scaling: Vec::new(),
6662            penetration: 20.0,
6663            penetration_percent: 0.1,
6664            versus_status_bonus: Vec::new(),
6665            variance: 0.1,
6666        }
6667    ];
6668    fireball.applied_effects = vec![
6669        AppliedEffect {
6670            effect_id: 0,
6671            effect_type: StatusEffectType::Burn,
6672            apply_chance: 40.0,
6673            to_target: true,
6674            condition: None,
6675            stacks_applied: 1,
6676            duration_override: Some(4.0),
6677        }
6678    ];
6679    fireball.projectile_params = Some(ProjectileParams {
6680        speed: 20.0,
6681        acceleration: 0.0,
6682        gravity: 0.0,
6683        max_range: 30.0,
6684        pierce_count: 0,
6685        split_count: 0,
6686        fork_count: 0,
6687        chain_count: 0,
6688        chain_radius: 0.0,
6689        homing: false,
6690        homing_strength: 0.0,
6691        aoe_on_impact: true,
6692        aoe_radius: 4.0,
6693        visual_trail: String::new(),
6694        impact_effect: String::new(),
6695        size: 0.5,
6696    });
6697
6698    db.abilities.insert(fireball.id, fireball);
6699
6700    let stats: HashMap<String, f32> = [
6701        ("SpellPower".to_string(), 250.0_f32),
6702        ("CritChance".to_string(), 0.2),
6703        ("CritMultiplier".to_string(), 2.0),
6704    ].iter().cloned().collect();
6705
6706    // Run extended MC
6707    if let Some(ability) = db.abilities.get(&1001) {
6708        let result = run_extended_monte_carlo(ability, &stats, 0.15, 1000);
6709        let _mean = result.mean;
6710        let _p95 = result.p95;
6711        let _dps = result.dps_at_mean;
6712    }
6713
6714    // Hotbar management
6715    let mut ext_state = AbilityEditorExtendedState::new();
6716    ext_state.hotbar.assign(0, 1001, Some("Q".to_string()));
6717    let can_use = ext_state.hotbar.can_use(0);
6718    let _ = can_use;
6719
6720    // Tag inference
6721    if let Some(ability) = db.abilities.get(&1001) {
6722        let tags = infer_tags_from_ability(ability);
6723        let _has_fire = tags.contains(&AbilityTag::Fire);
6724        let description = ability_to_description_text(ability);
6725        let _ = description;
6726    }
6727
6728    // DOT tracker
6729    let dot = DotInstance::new(0, 1001, 100, 200, DamageElement::Fire, 25.0, 1.0, 4.0);
6730    ext_state.dot_tracker.apply(dot);
6731    let _total_dps = ext_state.dot_tracker.total_dot_dps_on_target(200);
6732
6733    // Ability search
6734    let results = ability_fuzzy_search(&db, "fire", 5);
6735    let _ = results;
6736
6737    // Parabolic arc preview
6738    let arc_params = ParabolicArcParams { launch_angle_deg: 45.0, gravity: 9.81, initial_speed: 20.0 };
6739    ext_state.preview_parabolic_arc(Vec3::new(0.0, 0.0, 0.0), Vec3::new(15.0, 0.0, 0.0), &arc_params);
6740
6741    // LOS check
6742    let _los = ext_state.check_los(Vec2::new(0.0, 0.0), Vec2::new(15.0, 0.0), 1.8, 1.8);
6743
6744    // Combo setup
6745    let combo = ComboDefinition {
6746        id: 1,
6747        name: "Flame Burst Combo".to_string(),
6748        steps: vec![
6749            ComboStep { trigger: ComboTrigger::AbilityUsed(1001), time_window: 3.0, required_index: 0 },
6750            ComboStep { trigger: ComboTrigger::StatusApplied(StatusEffectType::Burn), time_window: 2.0, required_index: 1 },
6751        ],
6752        reward_ability_id: Some(1002),
6753        reward_modifiers: vec![("CritChance".to_string(), 0.25)],
6754        reward_duration: 5.0,
6755    };
6756    ext_state.combo_manager.add_combo(combo);
6757    let completed = ext_state.combo_manager.process_trigger(&ComboTrigger::AbilityUsed(1001), 0.0);
6758    let _ = completed;
6759
6760    // Area denial
6761    let _zone_id = ext_state.area_denial_manager.spawn_zone(1001, 100, Vec3::new(10.0, 0.0, 10.0), 5.0, 10.0, 30.0, 1.0);
6762    let zones_at = ext_state.area_denial_manager.query_zones_at(Vec3::new(10.0, 0.0, 10.0));
6763    let _ = zones_at;
6764
6765    // Runtime mod
6766    let mod_entry = RuntimeAbilityModifier {
6767        id: 0,
6768        target_ability_id: Some(1001),
6769        target_tag_filter: None,
6770        stat_changes: vec![("base_damage".to_string(), 50.0)],
6771        mult_changes: vec![("damage_mult".to_string(), 1.2)],
6772        duration: Some(10.0),
6773        source: "talent_bonus".to_string(),
6774    };
6775    ext_state.runtime_mods.push(mod_entry, 0.0);
6776
6777    // Sorted by cooldown
6778    let sorted_cds = abilities_sorted_by_cooldown(&db);
6779    let _ = sorted_cds;
6780
6781    // Filter by type
6782    let projectiles = filter_abilities_by_type(&db, &AbilityType::Projectile);
6783    let _ = projectiles;
6784}
6785
6786// ============================================================
6787// SECTION: ABILITY SCALING PREVIEW TABLE
6788// ============================================================
6789
6790#[derive(Debug, Clone)]
6791pub struct ScalingPreviewRow {
6792    pub stat_value: f32,
6793    pub min_damage: f32,
6794    pub max_damage: f32,
6795    pub average_damage: f32,
6796    pub crit_average: f32,
6797    pub dps: f32,
6798}
6799
6800pub fn build_scaling_preview_table(ability: &AbilityDefinition, stat_name: &str, min_stat: f32, max_stat: f32, steps: usize) -> Vec<ScalingPreviewRow> {
6801    let mut rows = Vec::new();
6802    if steps == 0 { return rows; }
6803    for i in 0..=steps {
6804        let t = i as f32 / steps as f32;
6805        let stat_val = min_stat + (max_stat - min_stat) * t;
6806        let mut stats: HashMap<String, f32> = HashMap::new();
6807        stats.insert(stat_name.to_string(), stat_val);
6808
6809        let mut total_min = 0.0f32;
6810        let mut total_max = 0.0f32;
6811        let mut total_crit_avg = 0.0f32;
6812
6813        for formula in &ability.damage_formulas {
6814            let scaling_bonus: f32 = formula.scaling.iter().map(|sc| {
6815                let base = stats.get(&sc.stat_name).cloned().unwrap_or(0.0);
6816                base * sc.coefficient
6817            }).sum();
6818            let base_min = formula.base_min + scaling_bonus;
6819            let base_max = formula.base_max + scaling_bonus;
6820            let avg = (base_min + base_max) * 0.5;
6821            let crit_avg = avg * (1.0 + formula.crit_chance_base * (formula.crit_multiplier_base - 1.0));
6822            total_min += base_min;
6823            total_max += base_max;
6824            total_crit_avg += crit_avg;
6825        }
6826
6827        let avg = (total_min + total_max) * 0.5;
6828        let dps = if ability.base_cooldown > 0.0 { avg / ability.base_cooldown } else { avg };
6829        rows.push(ScalingPreviewRow { stat_value: stat_val, min_damage: total_min, max_damage: total_max, average_damage: avg, crit_average: total_crit_avg, dps });
6830    }
6831    rows
6832}
6833
6834pub fn find_breakeven_stat_value(ability: &AbilityDefinition, stat_name: &str, target_dps: f32, max_search: f32) -> Option<f32> {
6835    // Binary search for stat value that gives target DPS
6836    let mut lo = 0.0f32;
6837    let mut hi = max_search;
6838    for _ in 0..50 {
6839        let mid = (lo + hi) * 0.5;
6840        let mut stats: HashMap<String, f32> = HashMap::new();
6841        stats.insert(stat_name.to_string(), mid);
6842        let avg: f32 = ability.damage_formulas.iter().map(|f| {
6843            let bonus: f32 = f.scaling.iter().map(|s| stats.get(&s.stat_name).cloned().unwrap_or(0.0) * s.coefficient).sum();
6844            (f.base_min + f.base_max) * 0.5 + bonus
6845        }).sum();
6846        let dps = if ability.base_cooldown > 0.0 { avg / ability.base_cooldown } else { avg };
6847        if (dps - target_dps).abs() < 0.1 { return Some(mid); }
6848        if dps < target_dps { lo = mid; } else { hi = mid; }
6849    }
6850    None
6851}
6852
6853// ============================================================
6854// SECTION: ABILITY RESONANCE SYSTEM (ELEMENTAL COMBOS)
6855// ============================================================
6856
6857#[derive(Debug, Clone, PartialEq)]
6858pub enum ResonanceType { Ignition, Vaporize, Melt, Overload, Superconduct, Crystallize, Swirl, Shatter, Electrified }
6859
6860#[derive(Debug, Clone)]
6861pub struct ResonanceResult {
6862    pub resonance_type: ResonanceType,
6863    pub damage_multiplier: f32,
6864    pub bonus_effect: Option<StatusEffectType>,
6865    pub clears_aura_a: bool,
6866    pub clears_aura_b: bool,
6867}
6868
6869pub fn compute_resonance(element_a: &DamageElement, element_b: &DamageElement) -> Option<ResonanceResult> {
6870    match (element_a, element_b) {
6871        (DamageElement::Fire, DamageElement::Poison) | (DamageElement::Poison, DamageElement::Fire) => Some(ResonanceResult {
6872            resonance_type: ResonanceType::Vaporize,
6873            damage_multiplier: 1.5,
6874            bonus_effect: None,
6875            clears_aura_a: true,
6876            clears_aura_b: true,
6877        }),
6878        (DamageElement::Fire, DamageElement::Cold) | (DamageElement::Cold, DamageElement::Fire) => Some(ResonanceResult {
6879            resonance_type: ResonanceType::Melt,
6880            damage_multiplier: 2.0,
6881            bonus_effect: None,
6882            clears_aura_a: true,
6883            clears_aura_b: true,
6884        }),
6885        (DamageElement::Lightning, DamageElement::Poison) | (DamageElement::Poison, DamageElement::Lightning) => Some(ResonanceResult {
6886            resonance_type: ResonanceType::Electrified,
6887            damage_multiplier: 1.0,
6888            bonus_effect: Some(StatusEffectType::Stun),
6889            clears_aura_a: false,
6890            clears_aura_b: false,
6891        }),
6892        (DamageElement::Fire, DamageElement::Lightning) | (DamageElement::Lightning, DamageElement::Fire) => Some(ResonanceResult {
6893            resonance_type: ResonanceType::Overload,
6894            damage_multiplier: 1.6,
6895            bonus_effect: Some(StatusEffectType::Knockback),
6896            clears_aura_a: true,
6897            clears_aura_b: true,
6898        }),
6899        (DamageElement::Cold, DamageElement::Poison) | (DamageElement::Poison, DamageElement::Cold) => Some(ResonanceResult {
6900            resonance_type: ResonanceType::Shatter,
6901            damage_multiplier: 1.8,
6902            bonus_effect: Some(StatusEffectType::Freeze),
6903            clears_aura_a: true,
6904            clears_aura_b: true,
6905        }),
6906        _ => None,
6907    }
6908}
6909
6910#[derive(Debug, Clone)]
6911pub struct ElementalAura {
6912    pub entity_id: u64,
6913    pub element: DamageElement,
6914    pub strength: f32,
6915    pub applied_at: f32,
6916}
6917
6918#[derive(Debug)]
6919pub struct ResonanceTracker {
6920    pub auras: HashMap<u64, ElementalAura>,
6921}
6922
6923impl ResonanceTracker {
6924    pub fn new() -> Self { Self { auras: HashMap::new() } }
6925
6926    pub fn apply_element(&mut self, entity_id: u64, element: DamageElement, strength: f32, current_time: f32) -> Option<ResonanceResult> {
6927        if let Some(existing) = self.auras.get(&entity_id) {
6928            if let Some(mut resonance) = compute_resonance(&existing.element, &element) {
6929                if resonance.clears_aura_a { self.auras.remove(&entity_id); }
6930                if !resonance.clears_aura_b {
6931                    self.auras.insert(entity_id, ElementalAura { entity_id, element, strength, applied_at: current_time });
6932                }
6933                resonance.damage_multiplier *= strength;
6934                return Some(resonance);
6935            }
6936        }
6937        self.auras.insert(entity_id, ElementalAura { entity_id, element, strength, applied_at: current_time });
6938        None
6939    }
6940
6941    pub fn decay_auras(&mut self, current_time: f32, decay_time: f32) {
6942        self.auras.retain(|_, a| current_time - a.applied_at < decay_time);
6943    }
6944
6945    pub fn aura_on_entity(&self, entity_id: u64) -> Option<&ElementalAura> {
6946        self.auras.get(&entity_id)
6947    }
6948}
6949
6950// ============================================================
6951// SECTION: ABILITY COST EFFICIENCY ANALYZER (EXTENDED)
6952// ============================================================
6953
6954#[derive(Debug, Clone)]
6955pub struct AbilityEfficiencyProfile {
6956    pub ability_id: u64,
6957    pub name: String,
6958    pub raw_dps: f32,
6959    pub resource_cost_per_second: f32,
6960    pub damage_per_resource: f32,
6961    pub effective_cast_time_with_cd: f32,
6962    pub uptime_fraction: f32,
6963    pub weighted_score: f32,
6964}
6965
6966pub fn build_efficiency_profiles(database: &AbilityDatabase, stats: &HashMap<String, f32>, fight_duration: f32) -> Vec<AbilityEfficiencyProfile> {
6967    let mut profiles = Vec::new();
6968    for (&id, ability) in &database.abilities {
6969        let avg_damage: f32 = ability.damage_formulas.iter().map(|f| {
6970            let bonus: f32 = f.scaling.iter().map(|s| stats.get(&s.stat_name).cloned().unwrap_or(0.0) * s.coefficient).sum();
6971            let avg_base = (f.base_min + f.base_max) * 0.5 + bonus;
6972            avg_base * (1.0 + f.crit_chance_base * (f.crit_multiplier_base - 1.0))
6973        }).sum();
6974
6975        let total_cycle_time = ability.cast_time + ability.base_cooldown;
6976        let raw_dps = if total_cycle_time > 0.0 { avg_damage / total_cycle_time } else { avg_damage };
6977        let uses_in_fight = if total_cycle_time > 0.0 { (fight_duration / total_cycle_time).floor() } else { 1.0 };
6978        let uptime = if fight_duration > 0.0 { (uses_in_fight * ability.cast_time) / fight_duration } else { 0.0 };
6979
6980        let resource_per_use = ability.resource_cost;
6981        let resource_per_second = if total_cycle_time > 0.0 { resource_per_use / total_cycle_time } else { 0.0 };
6982        let damage_per_resource = if resource_per_use > 0.0 { avg_damage / resource_per_use } else { f32::INFINITY };
6983
6984        let weighted_score = raw_dps * 0.5 + damage_per_resource.min(10.0) * 0.3 + uptime * 100.0 * 0.2;
6985
6986        profiles.push(AbilityEfficiencyProfile {
6987            ability_id: id,
6988            name: ability.name.clone(),
6989            raw_dps,
6990            resource_cost_per_second: resource_per_second,
6991            damage_per_resource,
6992            effective_cast_time_with_cd: total_cycle_time,
6993            uptime_fraction: uptime,
6994            weighted_score,
6995        });
6996    }
6997    profiles.sort_by(|a, b| b.weighted_score.partial_cmp(&a.weighted_score).unwrap_or(std::cmp::Ordering::Equal));
6998    profiles
6999}
7000
7001// ============================================================
7002// SECTION: ABILITY PHYSICS (BOUNCE AND RICCOCHET)
7003// ============================================================
7004
7005#[derive(Debug, Clone)]
7006pub struct BounceParams {
7007    pub max_bounces: u32,
7008    pub restitution: f32,
7009    pub damage_falloff_per_bounce: f32,
7010    pub angle_deviation_deg: f32,
7011}
7012
7013#[derive(Debug, Clone)]
7014pub struct BounceState {
7015    pub position: Vec3,
7016    pub velocity: Vec3,
7017    pub bounces_remaining: u32,
7018    pub current_damage_mult: f32,
7019}
7020
7021pub fn simulate_bounce(params: &BounceParams, initial_pos: Vec3, initial_vel: Vec3, gravity: f32, dt: f32, max_steps: usize, floor_y: f32) -> Vec<Vec3> {
7022    let mut positions = Vec::new();
7023    let mut state = BounceState {
7024        position: initial_pos,
7025        velocity: initial_vel,
7026        bounces_remaining: params.max_bounces,
7027        current_damage_mult: 1.0,
7028    };
7029    positions.push(state.position);
7030
7031    for _ in 0..max_steps {
7032        state.velocity.y -= gravity * dt;
7033        state.position += state.velocity * dt;
7034        positions.push(state.position);
7035
7036        if state.position.y <= floor_y && state.velocity.y < 0.0 {
7037            if state.bounces_remaining == 0 { break; }
7038            state.velocity.y = -state.velocity.y * params.restitution;
7039            state.velocity.x *= params.restitution;
7040            state.velocity.z *= params.restitution;
7041            state.position.y = floor_y;
7042            state.bounces_remaining -= 1;
7043            state.current_damage_mult *= 1.0 - params.damage_falloff_per_bounce;
7044        }
7045
7046        if state.velocity.length() < 0.01 { break; }
7047    }
7048    positions
7049}
7050
7051pub fn reflect_velocity_off_normal(velocity: Vec3, surface_normal: Vec3, restitution: f32) -> Vec3 {
7052    let dot = velocity.dot(surface_normal);
7053    let reflected = velocity - surface_normal * (2.0 * dot);
7054    reflected * restitution
7055}
7056
7057// ============================================================
7058// SECTION: ABILITY CHAIN LIGHTNING SIMULATION
7059// ============================================================
7060
7061#[derive(Debug, Clone)]
7062pub struct ChainLightningParams {
7063    pub max_jumps: u32,
7064    pub jump_range: f32,
7065    pub damage_falloff: f32,
7066    pub fork_probability: f32,
7067    pub max_forks: u32,
7068    pub cannot_rehit_duration: f32,
7069}
7070
7071#[derive(Debug, Clone)]
7072pub struct LightningJump {
7073    pub from_entity: u64,
7074    pub to_entity: u64,
7075    pub jump_index: u32,
7076    pub damage_multiplier: f32,
7077    pub is_fork: bool,
7078}
7079
7080pub fn simulate_chain_lightning(
7081    params: &ChainLightningParams,
7082    initial_target: u64,
7083    entity_positions: &HashMap<u64, Vec3>,
7084    seed: &mut u64,
7085) -> Vec<LightningJump> {
7086    let lcg = |s: &mut u64| -> f32 {
7087        *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
7088        ((*s >> 33) as f32) / (u32::MAX as f32)
7089    };
7090
7091    let mut jumps = Vec::new();
7092    let mut hit_set: HashSet<u64> = HashSet::new();
7093    hit_set.insert(initial_target);
7094
7095    let mut queue: VecDeque<(u64, u32, f32)> = VecDeque::new();
7096    queue.push_back((initial_target, 0, 1.0));
7097
7098    while let Some((from_id, jump_index, damage_mult)) = queue.pop_front() {
7099        if jump_index >= params.max_jumps { continue; }
7100
7101        let from_pos = match entity_positions.get(&from_id) { Some(p) => *p, None => continue };
7102
7103        let mut candidates: Vec<(u64, f32)> = entity_positions.iter()
7104            .filter(|(&id, _)| !hit_set.contains(&id))
7105            .map(|(&id, &pos)| {
7106                let dist = (pos - from_pos).length();
7107                (id, dist)
7108            })
7109            .filter(|(_, dist)| *dist <= params.jump_range)
7110            .collect();
7111
7112        if candidates.is_empty() { continue; }
7113        candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
7114
7115        let next_id = candidates[0].0;
7116        let new_mult = damage_mult * params.damage_falloff;
7117        hit_set.insert(next_id);
7118
7119        jumps.push(LightningJump {
7120            from_entity: from_id,
7121            to_entity: next_id,
7122            jump_index,
7123            damage_multiplier: new_mult,
7124            is_fork: false,
7125        });
7126
7127        queue.push_back((next_id, jump_index + 1, new_mult));
7128
7129        // Forks
7130        let fork_count = (params.max_forks as usize).min(candidates.len() - 1);
7131        let mut forks_created = 0u32;
7132        for (fork_id, _) in candidates.iter().skip(1) {
7133            if forks_created >= params.max_forks { break; }
7134            if lcg(seed) < params.fork_probability {
7135                hit_set.insert(*fork_id);
7136                jumps.push(LightningJump {
7137                    from_entity: from_id,
7138                    to_entity: *fork_id,
7139                    jump_index,
7140                    damage_multiplier: new_mult * 0.5,
7141                    is_fork: true,
7142                });
7143                forks_created += 1;
7144            }
7145        }
7146        let _ = fork_count;
7147    }
7148    jumps
7149}
7150
7151// ============================================================
7152// SECTION: SPELL BOOK UI STATE
7153// ============================================================
7154
7155#[derive(Debug, Clone, PartialEq)]
7156pub enum SpellBookTab { AllAbilities, Favorites, BySchool, ByType, RecentlyUsed }
7157
7158#[derive(Debug)]
7159pub struct SpellBookState {
7160    pub active_tab: SpellBookTab,
7161    pub search_query: String,
7162    pub selected_ability_id: Option<u64>,
7163    pub school_filter: Option<String>,
7164    pub type_filter: Option<AbilityType>,
7165    pub favorites: HashSet<u64>,
7166    pub recently_used: VecDeque<u64>,
7167    pub max_recent: usize,
7168    pub scroll_offset: usize,
7169    pub page_size: usize,
7170}
7171
7172impl SpellBookState {
7173    pub fn new() -> Self {
7174        Self {
7175            active_tab: SpellBookTab::AllAbilities,
7176            search_query: String::new(),
7177            selected_ability_id: None,
7178            school_filter: None,
7179            type_filter: None,
7180            favorites: HashSet::new(),
7181            recently_used: VecDeque::new(),
7182            max_recent: 20,
7183            scroll_offset: 0,
7184            page_size: 12,
7185        }
7186    }
7187
7188    pub fn toggle_favorite(&mut self, ability_id: u64) {
7189        if self.favorites.contains(&ability_id) {
7190            self.favorites.remove(&ability_id);
7191        } else {
7192            self.favorites.insert(ability_id);
7193        }
7194    }
7195
7196    pub fn record_use(&mut self, ability_id: u64) {
7197        self.recently_used.retain(|&id| id != ability_id);
7198        self.recently_used.push_front(ability_id);
7199        if self.recently_used.len() > self.max_recent {
7200            self.recently_used.pop_back();
7201        }
7202    }
7203
7204    pub fn filtered_abilities<'a>(&self, database: &'a AbilityDatabase) -> Vec<(u64, &'a AbilityDefinition)> {
7205        let query = self.search_query.to_lowercase();
7206        let mut result: Vec<(u64, &'a AbilityDefinition)> = database.abilities.iter()
7207            .filter(|(&_id, ab)| {
7208                if !query.is_empty() && !ab.name.to_lowercase().contains(&query) { return false; }
7209                if let Some(school) = &self.school_filter {
7210                    if &ab.school != school { return false; }
7211                }
7212                if let Some(ab_type) = &self.type_filter {
7213                    if std::mem::discriminant(&ab.ability_type) != std::mem::discriminant(ab_type) { return false; }
7214                }
7215                match self.active_tab {
7216                    SpellBookTab::Favorites => self.favorites.contains(&_id),
7217                    SpellBookTab::RecentlyUsed => self.recently_used.contains(&_id),
7218                    _ => true,
7219                }
7220            })
7221            .map(|(&id, ab)| (id, ab))
7222            .collect();
7223        result.sort_by(|a, b| a.1.name.cmp(&b.1.name));
7224        result
7225    }
7226
7227    pub fn current_page<'a, 'b>(&'b self, filtered: &'a [( u64, &'a AbilityDefinition)]) -> &'a [(u64, &'a AbilityDefinition)] {
7228        let start = self.scroll_offset;
7229        let end = (start + self.page_size).min(filtered.len());
7230        if start >= filtered.len() { return &[]; }
7231        &filtered[start..end]
7232    }
7233
7234    pub fn page_count(&self, total: usize) -> usize {
7235        if self.page_size == 0 { return 1; }
7236        (total + self.page_size - 1) / self.page_size
7237    }
7238
7239    pub fn all_schools(&self, database: &AbilityDatabase) -> Vec<String> {
7240        let mut schools: HashSet<String> = database.abilities.values().map(|ab| ab.school.clone()).collect();
7241        let mut sorted: Vec<String> = schools.drain().collect();
7242        sorted.sort();
7243        sorted
7244    }
7245}
7246
7247// ============================================================
7248// SECTION: ABILITY IMPORT/EXPORT (JSON-LIKE SERIALIZATION)
7249// ============================================================
7250
7251pub fn export_ability_database_to_string(database: &AbilityDatabase) -> String {
7252    let mut lines = Vec::new();
7253    lines.push("# AbilityDatabase Export".to_string());
7254    lines.push(format!("ability_count={}", database.abilities.len()));
7255    lines.push(String::new());
7256    for (id, ability) in &database.abilities {
7257        lines.push(format!("[ability:{}]", id));
7258        lines.push(format!("name={}", ability.name));
7259        lines.push(format!("type={:?}", ability.ability_type));
7260        lines.push(format!("school={}", ability.school));
7261        lines.push(format!("level_req={}", ability.unlock_level));
7262        lines.push(format!("cast_time={:.3}", ability.cast_time));
7263        lines.push(format!("cooldown={:.3}", ability.base_cooldown));
7264        lines.push(format!("range={:.1}", ability.range));
7265        lines.push(format!("area_radius={:.1}", ability.aoe_radius));
7266        if ability.resource_cost > 0.0 {
7267            lines.push(format!("resource_type={:?}", ability.resource_type));
7268            lines.push(format!("resource_cost={:.1}", ability.resource_cost));
7269        }
7270        lines.push(format!("damage_formula_count={}", ability.damage_formulas.len()));
7271        for (i, f) in ability.damage_formulas.iter().enumerate() {
7272            lines.push(format!("formula[{}].element={:?}", i, f.element));
7273            lines.push(format!("formula[{}].base={:.0}-{:.0}", i, f.base_min, f.base_max));
7274            lines.push(format!("formula[{}].crit={:.3}", i, f.crit_chance_base));
7275        }
7276        lines.push(format!("effect_count={}", ability.applied_effects.len()));
7277        for (i, eff) in ability.applied_effects.iter().enumerate() {
7278            lines.push(format!("effect[{}].type={:?}", i, eff.effect_type));
7279            lines.push(format!("effect[{}].chance={:.3}", i, eff.apply_chance));
7280        }
7281        lines.push(String::new());
7282    }
7283    lines.join("\n")
7284}
7285
7286pub fn validate_ability_export_string(data: &str) -> (bool, Vec<String>) {
7287    let mut errors = Vec::new();
7288    let mut has_header = false;
7289    let mut ability_count_declared = 0usize;
7290    let mut ability_blocks_found = 0usize;
7291
7292    for line in data.lines() {
7293        if line.starts_with("# AbilityDatabase") { has_header = true; }
7294        if line.starts_with("ability_count=") {
7295            if let Ok(n) = line["ability_count=".len()..].parse::<usize>() {
7296                ability_count_declared = n;
7297            }
7298        }
7299        if line.starts_with("[ability:") { ability_blocks_found += 1; }
7300    }
7301    if !has_header { errors.push("Missing header line".to_string()); }
7302    if ability_count_declared != ability_blocks_found {
7303        errors.push(format!("Declared {} abilities but found {}", ability_count_declared, ability_blocks_found));
7304    }
7305    (errors.is_empty(), errors)
7306}
7307
7308// ============================================================
7309// SECTION: STATUS EFFECT DURATION SCALING
7310// ============================================================
7311
7312pub fn scale_status_duration(base_duration: f32, caster_stat_bonus: f32, target_tenacity: f32, diminishing_returns_stacks: u32) -> f32 {
7313    let bonus_mult = 1.0 + (caster_stat_bonus / 100.0).min(1.0);
7314    let tenacity_reduction = (target_tenacity / 100.0).clamp(0.0, 0.75);
7315    let dr_factor = match diminishing_returns_stacks {
7316        0 => 1.0,
7317        1 => 0.65,
7318        2 => 0.42,
7319        3 => 0.27,
7320        _ => 0.15,
7321    };
7322    base_duration * bonus_mult * (1.0 - tenacity_reduction) * dr_factor
7323}
7324
7325pub fn compute_status_magnitude(base_magnitude: f32, spellpower: f32, level_scaling: f32, item_level: u32) -> f32 {
7326    let sp_bonus = spellpower * level_scaling;
7327    let level_bonus = (item_level as f32 * 0.5).min(50.0);
7328    base_magnitude + sp_bonus + level_bonus
7329}
7330
7331pub fn status_tick_damage(status: &StatusEffectDefinition, caster_spellpower: f32, tick_index: u32) -> f32 {
7332    let base = (status.tick_damage.base_min + status.tick_damage.base_max) * 0.5;
7333    let sp_mult = 1.0 + caster_spellpower * status.tick_damage.scaling.first().map(|s| s.coefficient).unwrap_or(0.0);
7334    // Some effects ramp up over time (e.g., Bleed)
7335    let ramp = match status.effect_type {
7336        StatusEffectType::Bleed => 1.0 + (tick_index as f32 * 0.05).min(0.5),
7337        StatusEffectType::Poison => 1.0,
7338        StatusEffectType::Burn => 1.0 - (tick_index as f32 * 0.02).min(0.3), // decays
7339        _ => 1.0,
7340    };
7341    base * sp_mult * ramp
7342}
7343
7344// ============================================================
7345// SECTION: ABILITY RANGE DISPLAY HELPERS
7346// ============================================================
7347
7348#[derive(Debug, Clone)]
7349pub struct RangeIndicator {
7350    pub center: Vec3,
7351    pub indicator_type: RangeIndicatorType,
7352    pub color: Vec4,
7353    pub opacity: f32,
7354}
7355
7356#[derive(Debug, Clone)]
7357pub enum RangeIndicatorType {
7358    Circle { radius: f32 },
7359    Cone { angle_deg: f32, length: f32 },
7360    Rectangle { width: f32, length: f32 },
7361    Ring { inner_radius: f32, outer_radius: f32 },
7362    Line { direction: Vec3, length: f32, width: f32 },
7363}
7364
7365impl RangeIndicatorType {
7366    pub fn bounding_radius(&self) -> f32 {
7367        match self {
7368            Self::Circle { radius } => *radius,
7369            Self::Cone { length, .. } => *length,
7370            Self::Rectangle { width, length } => (width * width + length * length).sqrt() * 0.5,
7371            Self::Ring { outer_radius, .. } => *outer_radius,
7372            Self::Line { length, width, .. } => (length * length + width * width).sqrt() * 0.5,
7373        }
7374    }
7375
7376    pub fn area(&self) -> f32 {
7377        match self {
7378            Self::Circle { radius } => std::f32::consts::PI * radius * radius,
7379            Self::Cone { angle_deg, length } => {
7380                let angle_rad = angle_deg.to_radians();
7381                0.5 * angle_rad * length * length
7382            }
7383            Self::Rectangle { width, length } => width * length,
7384            Self::Ring { inner_radius, outer_radius } => {
7385                std::f32::consts::PI * (outer_radius * outer_radius - inner_radius * inner_radius)
7386            }
7387            Self::Line { width, length, .. } => width * length,
7388        }
7389    }
7390}
7391
7392pub fn build_range_indicators_for_ability(ability: &AbilityDefinition, caster_pos: Vec3, caster_facing: Vec3) -> Vec<RangeIndicator> {
7393    let mut indicators = Vec::new();
7394    let cast_color = Vec4::new(0.2, 0.6, 1.0, 0.4);
7395    let area_color = Vec4::new(1.0, 0.3, 0.1, 0.35);
7396
7397    // Cast range indicator
7398    indicators.push(RangeIndicator {
7399        center: caster_pos,
7400        indicator_type: RangeIndicatorType::Circle { radius: ability.range },
7401        color: cast_color,
7402        opacity: 0.4,
7403    });
7404
7405    // Area indicator based on targeting mode
7406    match &ability.targeting_mode {
7407        TargetingMode::AoECircle { .. } => {
7408            indicators.push(RangeIndicator {
7409                center: caster_pos + caster_facing * (ability.range * 0.5),
7410                indicator_type: RangeIndicatorType::Circle { radius: ability.aoe_radius },
7411                color: area_color,
7412                opacity: 0.5,
7413            });
7414        }
7415        TargetingMode::Cone { .. } => {
7416            indicators.push(RangeIndicator {
7417                center: caster_pos,
7418                indicator_type: RangeIndicatorType::Cone { angle_deg: 60.0, length: ability.range },
7419                color: area_color,
7420                opacity: 0.5,
7421            });
7422        }
7423        TargetingMode::Rectangle { .. } => {
7424            indicators.push(RangeIndicator {
7425                center: caster_pos + caster_facing * (ability.range * 0.5),
7426                indicator_type: RangeIndicatorType::Rectangle { width: ability.aoe_radius * 2.0, length: ability.range },
7427                color: area_color,
7428                opacity: 0.5,
7429            });
7430        }
7431        _ => {}
7432    }
7433    indicators
7434}
7435
7436// ============================================================
7437// SECTION: TALENT NODE UPGRADE PATHS
7438// ============================================================
7439
7440#[derive(Debug, Clone)]
7441pub struct TalentUpgradePath {
7442    pub from_node: u64,
7443    pub to_node: u64,
7444    pub upgrade_label: String,
7445    pub replaced_modifier: Option<(String, f32)>,
7446    pub new_modifier: (String, f32),
7447    pub cost_additional_points: u32,
7448}
7449
7450pub fn find_upgrade_paths_for_node(tree: &TalentTree, node_id: u64) -> Vec<TalentUpgradePath> {
7451    tree.edges.iter()
7452        .filter(|e| e.from_node_id == node_id)
7453        .map(|e| TalentUpgradePath {
7454            from_node: e.from_node_id,
7455            to_node: e.to_node_id,
7456            upgrade_label: format!("Upgrade to node {}", e.to_node_id),
7457            replaced_modifier: None,
7458            new_modifier: ("damage_percent".to_string(), 5.0),
7459            cost_additional_points: 1,
7460        })
7461        .collect()
7462}
7463
7464pub fn compute_talent_path_dps_gain(path: &TalentUpgradePath, current_dps: f32) -> f32 {
7465    let gain_pct = path.new_modifier.1 / 100.0;
7466    let replaced_pct = path.replaced_modifier.as_ref().map(|(_, v)| v / 100.0).unwrap_or(0.0);
7467    current_dps * (1.0 + gain_pct - replaced_pct) - current_dps
7468}
7469
7470// ============================================================
7471// SECTION: ABILITY ANIMATION BINDINGS
7472// ============================================================
7473
7474#[derive(Debug, Clone)]
7475pub struct AnimationBinding {
7476    pub ability_id: u64,
7477    pub cast_anim: String,
7478    pub cast_anim_speed: f32,
7479    pub channel_anim: String,
7480    pub channel_loop: bool,
7481    pub hit_anim: String,
7482    pub miss_anim: Option<String>,
7483    pub cast_point: f32,
7484    pub backswing_point: f32,
7485}
7486
7487impl AnimationBinding {
7488    pub fn new(ability_id: u64, cast_anim: &str) -> Self {
7489        Self {
7490            ability_id,
7491            cast_anim: cast_anim.to_string(),
7492            cast_anim_speed: 1.0,
7493            channel_anim: format!("{}_channel", cast_anim),
7494            channel_loop: true,
7495            hit_anim: format!("{}_hit", cast_anim),
7496            miss_anim: None,
7497            cast_point: 0.5,
7498            backswing_point: 0.8,
7499        }
7500    }
7501
7502    pub fn normalized_cast_point(&self, total_cast_time: f32) -> f32 {
7503        self.cast_point * total_cast_time
7504    }
7505}
7506
7507#[derive(Debug)]
7508pub struct AnimationBindingRegistry {
7509    pub bindings: HashMap<u64, AnimationBinding>,
7510}
7511
7512impl AnimationBindingRegistry {
7513    pub fn new() -> Self { Self { bindings: HashMap::new() } }
7514    pub fn register(&mut self, binding: AnimationBinding) { self.bindings.insert(binding.ability_id, binding); }
7515    pub fn get(&self, ability_id: u64) -> Option<&AnimationBinding> { self.bindings.get(&ability_id) }
7516    pub fn get_cast_anim(&self, ability_id: u64) -> &str {
7517        self.bindings.get(&ability_id).map(|b| b.cast_anim.as_str()).unwrap_or("generic_cast")
7518    }
7519}
7520
7521// ============================================================
7522// SECTION: ABILITY SOUND BINDINGS
7523// ============================================================
7524
7525#[derive(Debug, Clone)]
7526pub struct AbilitySoundSet {
7527    pub ability_id: u64,
7528    pub cast_start_sound: Option<String>,
7529    pub cast_end_sound: Option<String>,
7530    pub projectile_loop_sound: Option<String>,
7531    pub impact_sounds: Vec<String>,
7532    pub channel_loop_sound: Option<String>,
7533    pub volume_cast: f32,
7534    pub volume_impact: f32,
7535    pub pitch_variance: f32,
7536}
7537
7538impl AbilitySoundSet {
7539    pub fn new(ability_id: u64) -> Self {
7540        Self {
7541            ability_id,
7542            cast_start_sound: None,
7543            cast_end_sound: None,
7544            projectile_loop_sound: None,
7545            impact_sounds: Vec::new(),
7546            channel_loop_sound: None,
7547            volume_cast: 1.0,
7548            volume_impact: 1.0,
7549            pitch_variance: 0.05,
7550        }
7551    }
7552
7553    pub fn pick_impact_sound(&self, seed: u64) -> Option<&str> {
7554        if self.impact_sounds.is_empty() { return None; }
7555        let idx = (seed as usize) % self.impact_sounds.len();
7556        Some(&self.impact_sounds[idx])
7557    }
7558
7559    pub fn randomized_pitch(&self, base_pitch: f32, seed: u64) -> f32 {
7560        let t = ((seed % 10000) as f32) / 10000.0;
7561        base_pitch + (t - 0.5) * 2.0 * self.pitch_variance
7562    }
7563}
7564
7565// ============================================================
7566// SECTION: ABILITY STATISTICS TRACKER (PER-SESSION)
7567// ============================================================
7568
7569#[derive(Debug, Clone)]
7570pub struct AbilityUseRecord {
7571    pub ability_id: u64,
7572    pub timestamp: f32,
7573    pub targets_hit: u32,
7574    pub total_damage: f32,
7575    pub was_crit: bool,
7576    pub effects_applied: Vec<StatusEffectType>,
7577    pub interrupted: bool,
7578}
7579
7580#[derive(Debug)]
7581pub struct AbilitySessionStats {
7582    pub ability_id: u64,
7583    pub uses: u32,
7584    pub total_damage: f32,
7585    pub crits: u32,
7586    pub interrupts: u32,
7587    pub total_targets_hit: u32,
7588    pub effects_applied_counts: HashMap<String, u32>,
7589    pub last_use_timestamp: f32,
7590    pub first_use_timestamp: Option<f32>,
7591}
7592
7593impl AbilitySessionStats {
7594    pub fn new(ability_id: u64) -> Self {
7595        Self { ability_id, uses: 0, total_damage: 0.0, crits: 0, interrupts: 0, total_targets_hit: 0, effects_applied_counts: HashMap::new(), last_use_timestamp: 0.0, first_use_timestamp: None }
7596    }
7597
7598    pub fn record(&mut self, record: &AbilityUseRecord) {
7599        self.uses += 1;
7600        self.total_damage += record.total_damage;
7601        if record.was_crit { self.crits += 1; }
7602        if record.interrupted { self.interrupts += 1; }
7603        self.total_targets_hit += record.targets_hit;
7604        for eff in &record.effects_applied {
7605            *self.effects_applied_counts.entry(format!("{:?}", eff)).or_insert(0) += 1;
7606        }
7607        self.last_use_timestamp = record.timestamp;
7608        if self.first_use_timestamp.is_none() { self.first_use_timestamp = Some(record.timestamp); }
7609    }
7610
7611    pub fn average_damage_per_use(&self) -> f32 {
7612        if self.uses == 0 { return 0.0; }
7613        self.total_damage / self.uses as f32
7614    }
7615
7616    pub fn crit_rate(&self) -> f32 {
7617        if self.uses == 0 { return 0.0; }
7618        self.crits as f32 / self.uses as f32
7619    }
7620
7621    pub fn average_targets_hit(&self) -> f32 {
7622        if self.uses == 0 { return 0.0; }
7623        self.total_targets_hit as f32 / self.uses as f32
7624    }
7625
7626    pub fn dps_over_session(&self) -> f32 {
7627        if let Some(first) = self.first_use_timestamp {
7628            let duration = self.last_use_timestamp - first;
7629            if duration > 0.0 { return self.total_damage / duration; }
7630        }
7631        0.0
7632    }
7633}
7634
7635#[derive(Debug)]
7636pub struct SessionStatsTracker {
7637    pub stats: HashMap<u64, AbilitySessionStats>,
7638}
7639
7640impl SessionStatsTracker {
7641    pub fn new() -> Self { Self { stats: HashMap::new() } }
7642
7643    pub fn record_use(&mut self, record: AbilityUseRecord) {
7644        self.stats.entry(record.ability_id).or_insert_with(|| AbilitySessionStats::new(record.ability_id)).record(&record);
7645    }
7646
7647    pub fn top_n_by_damage(&self, n: usize) -> Vec<(u64, f32)> {
7648        let mut list: Vec<(u64, f32)> = self.stats.iter().map(|(&id, s)| (id, s.total_damage)).collect();
7649        list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7650        list.truncate(n);
7651        list
7652    }
7653
7654    pub fn most_interrupted(&self) -> Option<u64> {
7655        self.stats.iter().max_by_key(|(_, s)| s.interrupts).map(|(&id, _)| id)
7656    }
7657
7658    pub fn global_crit_rate(&self) -> f32 {
7659        let total_uses: u32 = self.stats.values().map(|s| s.uses).sum();
7660        let total_crits: u32 = self.stats.values().map(|s| s.crits).sum();
7661        if total_uses == 0 { return 0.0; }
7662        total_crits as f32 / total_uses as f32
7663    }
7664
7665    pub fn reset(&mut self) { self.stats.clear(); }
7666}
7667
7668// ============================================================
7669// SECTION: ABILITY EDITOR FINAL WIRING
7670// ============================================================
7671
7672pub fn ability_editor_full_init() -> AbilityEditor {
7673    AbilityEditor::new()
7674}
7675
7676pub fn ability_editor_session_demo() {
7677    let mut tracker = SessionStatsTracker::new();
7678    tracker.record_use(AbilityUseRecord {
7679        ability_id: 1001,
7680        timestamp: 1.5,
7681        targets_hit: 3,
7682        total_damage: 450.0,
7683        was_crit: true,
7684        effects_applied: vec![StatusEffectType::Burn],
7685        interrupted: false,
7686    });
7687    tracker.record_use(AbilityUseRecord {
7688        ability_id: 1001,
7689        timestamp: 8.5,
7690        targets_hit: 2,
7691        total_damage: 310.0,
7692        was_crit: false,
7693        effects_applied: Vec::new(),
7694        interrupted: false,
7695    });
7696    let top = tracker.top_n_by_damage(5);
7697    let global_crit = tracker.global_crit_rate();
7698    let _ = (top, global_crit);
7699
7700    let resonance_tracker = ResonanceTracker::new();
7701    let _ = resonance_tracker;
7702
7703    let mut anim_reg = AnimationBindingRegistry::new();
7704    anim_reg.register(AnimationBinding::new(1001, "fireball_cast"));
7705    let cast_anim = anim_reg.get_cast_anim(1001);
7706    let _ = cast_anim;
7707
7708    let mut sound_set = AbilitySoundSet::new(1001);
7709    sound_set.cast_start_sound = Some("fire_cast_start.ogg".to_string());
7710    sound_set.impact_sounds = vec!["fire_impact_1.ogg".to_string(), "fire_impact_2.ogg".to_string()];
7711    let impact = sound_set.pick_impact_sound(42);
7712    let _ = impact;
7713}
7714
7715// ============================================================
7716// SECTION: ABILITY POWER BUDGET VALIDATOR
7717// ============================================================
7718
7719#[derive(Debug, Clone)]
7720pub struct AbilityPowerBudget {
7721    pub target_dps: f32,
7722    pub target_utility_score: f32,
7723    pub target_mobility_score: f32,
7724    pub tolerance_pct: f32,
7725}
7726
7727impl AbilityPowerBudget {
7728    pub fn for_level(level: u32, role: &str) -> Self {
7729        let base_dps = level as f32 * 12.5;
7730        let (utility, mobility) = match role {
7731            "tank" => (0.8, 0.3),
7732            "healer" => (0.9, 0.4),
7733            "support" => (0.7, 0.5),
7734            "assassin" => (0.3, 0.9),
7735            _ => (0.5, 0.5), // dps
7736        };
7737        Self { target_dps: base_dps, target_utility_score: utility, target_mobility_score: mobility, tolerance_pct: 25.0 }
7738    }
7739}
7740
7741#[derive(Debug, Clone)]
7742pub struct AbilityPowerBudgetReport {
7743    pub ability_id: u64,
7744    pub computed_dps: f32,
7745    pub computed_utility: f32,
7746    pub computed_mobility: f32,
7747    pub dps_within_budget: bool,
7748    pub overall_passed: bool,
7749    pub suggestions: Vec<String>,
7750}
7751
7752pub fn validate_ability_power_budget(ability: &AbilityDefinition, stats: &HashMap<String, f32>, budget: &AbilityPowerBudget) -> AbilityPowerBudgetReport {
7753    let avg_damage: f32 = ability.damage_formulas.iter().map(|f| {
7754        let bonus: f32 = f.scaling.iter().map(|s| stats.get(&s.stat_name).cloned().unwrap_or(0.0) * s.coefficient).sum();
7755        (f.base_min + f.base_max) * 0.5 + bonus
7756    }).sum();
7757
7758    let cycle = ability.cast_time + ability.base_cooldown;
7759    let computed_dps = if cycle > 0.0 { avg_damage / cycle } else { avg_damage };
7760
7761    let utility = ability.applied_effects.len() as f32 * 0.15
7762        + if ability.aoe_radius > 5.0 { 0.2 } else { 0.0 }
7763        + if ability.applied_effects.iter().any(|e| matches!(e.effect_type, StatusEffectType::Stun | StatusEffectType::Root | StatusEffectType::Silence)) { 0.3 } else { 0.0 };
7764
7765    let mobility = match ability.ability_type {
7766        AbilityType::Dash | AbilityType::Teleport | AbilityType::Charge => 1.0,
7767        _ => 0.0,
7768    };
7769
7770    let tol = budget.tolerance_pct / 100.0;
7771    let dps_ok = computed_dps <= budget.target_dps * (1.0 + tol);
7772    let overall = dps_ok;
7773
7774    let mut suggestions = Vec::new();
7775    if computed_dps > budget.target_dps * (1.0 + tol) {
7776        suggestions.push(format!("DPS {:.1} exceeds budget {:.1} by {:.0}% — increase cooldown or reduce base damage",
7777            computed_dps, budget.target_dps, (computed_dps / budget.target_dps - 1.0) * 100.0));
7778    }
7779    if utility > budget.target_utility_score + 0.3 {
7780        suggestions.push("Utility score high — consider reducing effect duration or application chance".to_string());
7781    }
7782
7783    AbilityPowerBudgetReport {
7784        ability_id: ability.id,
7785        computed_dps,
7786        computed_utility: utility.min(1.0),
7787        computed_mobility: mobility,
7788        dps_within_budget: dps_ok,
7789        overall_passed: overall,
7790        suggestions,
7791    }
7792}
7793
7794// ============================================================
7795// SECTION: RADIUS FALLOFF DAMAGE
7796// ============================================================
7797
7798#[derive(Debug, Clone)]
7799pub enum FalloffCurve { Linear, Quadratic, Cosine, Exponential(f32), None }
7800
7801impl FalloffCurve {
7802    pub fn multiplier(&self, distance: f32, max_radius: f32) -> f32 {
7803        if max_radius <= 0.0 { return 1.0; }
7804        let t = (distance / max_radius).clamp(0.0, 1.0);
7805        match self {
7806            FalloffCurve::None => 1.0,
7807            FalloffCurve::Linear => 1.0 - t,
7808            FalloffCurve::Quadratic => 1.0 - t * t,
7809            FalloffCurve::Cosine => ((1.0 - t) * std::f32::consts::PI * 0.5).cos(),
7810            FalloffCurve::Exponential(k) => (-k * t).exp(),
7811        }
7812    }
7813
7814    pub fn integrate_over_radius(&self, max_radius: f32, steps: usize) -> f32 {
7815        if steps == 0 || max_radius <= 0.0 { return 0.0; }
7816        let dr = max_radius / steps as f32;
7817        let mut integral = 0.0f32;
7818        for i in 0..steps {
7819            let r = (i as f32 + 0.5) * dr;
7820            let mult = self.multiplier(r, max_radius);
7821            // Weight by ring area: 2*pi*r*dr
7822            integral += mult * 2.0 * std::f32::consts::PI * r * dr;
7823        }
7824        integral
7825    }
7826}
7827
7828pub fn compute_aoe_total_damage(base_damage: f32, targets: &[(u64, f32)], max_radius: f32, falloff: &FalloffCurve, min_damage_pct: f32) -> Vec<(u64, f32)> {
7829    targets.iter().map(|(id, dist)| {
7830        let mult = falloff.multiplier(*dist, max_radius).max(min_damage_pct / 100.0);
7831        (*id, base_damage * mult)
7832    }).collect()
7833}
7834
7835// ============================================================
7836// SECTION: ABILITY EDITOR EXTENDED STATS SUMMARY
7837// ============================================================
7838
7839pub fn summarize_ability_database(database: &AbilityDatabase) -> String {
7840    let total = database.abilities.len();
7841    let mut type_counts: HashMap<String, usize> = HashMap::new();
7842    let mut school_counts: HashMap<String, usize> = HashMap::new();
7843    let mut total_cooldown: f32 = 0.0;
7844    let mut total_cast_time: f32 = 0.0;
7845    let mut has_damage = 0usize;
7846    let mut has_effects = 0usize;
7847
7848    for ab in database.abilities.values() {
7849        *type_counts.entry(format!("{:?}", ab.ability_type)).or_insert(0) += 1;
7850        *school_counts.entry(ab.school.clone()).or_insert(0) += 1;
7851        total_cooldown += ab.base_cooldown;
7852        total_cast_time += ab.cast_time;
7853        if !ab.damage_formulas.is_empty() { has_damage += 1; }
7854        if !ab.applied_effects.is_empty() { has_effects += 1; }
7855    }
7856
7857    let avg_cd = if total > 0 { total_cooldown / total as f32 } else { 0.0 };
7858    let avg_ct = if total > 0 { total_cast_time / total as f32 } else { 0.0 };
7859
7860    let mut lines = vec![
7861        format!("=== Ability Database Summary ==="),
7862        format!("Total Abilities: {}", total),
7863        format!("With Damage Formulas: {}", has_damage),
7864        format!("With Status Effects: {}", has_effects),
7865        format!("Average Cooldown: {:.2}s", avg_cd),
7866        format!("Average Cast Time: {:.2}s", avg_ct),
7867        format!("Schools: {}", school_counts.len()),
7868        format!("Types:"),
7869    ];
7870    let mut type_list: Vec<(String, usize)> = type_counts.into_iter().collect();
7871    type_list.sort_by(|a, b| b.1.cmp(&a.1));
7872    for (ty, count) in type_list {
7873        lines.push(format!("  {}: {}", ty, count));
7874    }
7875    lines.join("\n")
7876}
7877
7878pub fn ability_database_integrity_check(database: &AbilityDatabase) -> Vec<String> {
7879    let mut issues = Vec::new();
7880    for (id, ab) in &database.abilities {
7881        if ab.name.is_empty() { issues.push(format!("Ability {} has empty name", id)); }
7882        if ab.base_cooldown < 0.0 { issues.push(format!("Ability {} '{}' has negative cooldown", id, ab.name)); }
7883        if ab.cast_time < 0.0 { issues.push(format!("Ability {} '{}' has negative cast time", id, ab.name)); }
7884        if ab.range < 0.0 { issues.push(format!("Ability {} '{}' has negative range", id, ab.name)); }
7885        for (i, formula) in ab.damage_formulas.iter().enumerate() {
7886            if formula.base_min > formula.base_max {
7887                issues.push(format!("Ability {} formula[{}]: base_min > base_max", id, i));
7888            }
7889            if formula.crit_chance_base < 0.0 || formula.crit_chance_base > 1.0 {
7890                issues.push(format!("Ability {} formula[{}]: crit_chance out of [0,1]", id, i));
7891            }
7892        }
7893        for (i, eff) in ab.applied_effects.iter().enumerate() {
7894            if eff.apply_chance < 0.0 || eff.apply_chance > 100.0 {
7895                issues.push(format!("Ability {} effect[{}]: apply_chance out of [0,100]", id, i));
7896            }
7897        }
7898    }
7899    issues
7900}
7901
7902// ============================================================
7903// SECTION: ABILITY MULTIPLIER STACK RESOLUTION
7904// ============================================================
7905
7906/// Resolves stacked damage multipliers using the standard additive-within-category, multiplicative-between-category model.
7907/// Categories: base, increased (additive within), more (multiplicative between).
7908pub struct MultiplierStack {
7909    pub base: f32,
7910    pub increased: Vec<f32>,
7911    pub more: Vec<f32>,
7912}
7913
7914impl MultiplierStack {
7915    pub fn new(base: f32) -> Self { Self { base, increased: Vec::new(), more: Vec::new() } }
7916
7917    pub fn add_increased(&mut self, pct: f32) { self.increased.push(pct); }
7918    pub fn add_more(&mut self, pct: f32) { self.more.push(pct); }
7919
7920    pub fn resolve(&self) -> f32 {
7921        let increased_total = 1.0 + self.increased.iter().sum::<f32>() / 100.0;
7922        let more_total: f32 = self.more.iter().map(|m| 1.0 + m / 100.0).product();
7923        self.base * increased_total * more_total
7924    }
7925
7926    pub fn resolve_with_resistance(&self, resistance_pct: f32) -> f32 {
7927        let raw = self.resolve();
7928        let resist = (resistance_pct / 100.0).clamp(0.0, 0.75);
7929        raw * (1.0 - resist)
7930    }
7931
7932    pub fn marginal_value_of_increased(&self, added_pct: f32) -> f32 {
7933        let current = self.resolve();
7934        let mut copy = self.clone_with_increased(added_pct);
7935        copy.resolve() - current
7936    }
7937
7938    fn clone_with_increased(&self, extra: f32) -> Self {
7939        let mut c = MultiplierStack { base: self.base, increased: self.increased.clone(), more: self.more.clone() };
7940        c.increased.push(extra);
7941        c
7942    }
7943}
7944
7945pub fn compare_increased_vs_more(stack: &MultiplierStack, pct: f32) -> (f32, f32) {
7946    let with_increased = {
7947        let mut s = MultiplierStack { base: stack.base, increased: stack.increased.clone(), more: stack.more.clone() };
7948        s.add_increased(pct);
7949        s.resolve()
7950    };
7951    let with_more = {
7952        let mut s = MultiplierStack { base: stack.base, increased: stack.increased.clone(), more: stack.more.clone() };
7953        s.add_more(pct);
7954        s.resolve()
7955    };
7956    let current = stack.resolve();
7957    (with_increased - current, with_more - current)
7958}
7959
7960/// Compute the effective damage after full pipeline: multiplier stack + resistance + armor reduction
7961pub fn full_damage_pipeline(stack: &MultiplierStack, armor: f32, armor_penetration: f32, resistance_pct: f32, resistance_penetration_pct: f32) -> f32 {
7962    let effective_armor = (armor - armor_penetration).max(0.0);
7963    let armor_reduction = effective_armor / (effective_armor + 100.0);
7964    let effective_resist_pct = (resistance_pct - resistance_penetration_pct * resistance_pct).max(0.0);
7965    stack.resolve() * (1.0 - armor_reduction) * (1.0 - effective_resist_pct / 100.0)
7966}
7967
7968// ============================================================
7969// SECTION: UTILITY MATH HELPERS
7970// ============================================================
7971
7972/// Lerp between two f32 values
7973pub fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t.clamp(0.0, 1.0) }
7974
7975/// Smoothstep interpolation (3t^2 - 2t^3)
7976pub fn smoothstep(t: f32) -> f32 { let c = t.clamp(0.0, 1.0); c * c * (3.0 - 2.0 * c) }
7977
7978/// Inverse lerp: find t such that lerp(a, b, t) == v
7979pub fn inv_lerp(a: f32, b: f32, v: f32) -> f32 { if (b - a).abs() < 1e-9 { 0.0 } else { ((v - a) / (b - a)).clamp(0.0, 1.0) } }
7980
7981/// Remap value from [a,b] range to [c,d] range
7982pub fn remap(v: f32, a: f32, b: f32, c: f32, d: f32) -> f32 { lerp_f32(c, d, inv_lerp(a, b, v)) }
7983
7984/// Exponential decay: value decays toward target with rate per second
7985pub fn exp_decay(current: f32, target: f32, rate: f32, dt: f32) -> f32 {
7986    target + (current - target) * (-rate * dt).exp()
7987}
7988
7989/// Critically-damped spring toward target (avoids overshoot)
7990pub fn spring_towards(current: f32, velocity: &mut f32, target: f32, stiffness: f32, dt: f32) -> f32 {
7991    let damping = 2.0 * stiffness.sqrt();
7992    let force = -stiffness * (current - target) - damping * (*velocity);
7993    *velocity += force * dt;
7994    current + *velocity * dt
7995}
7996
7997/// Wrap angle to [-PI, PI]
7998pub fn wrap_angle(mut a: f32) -> f32 {
7999    while a > std::f32::consts::PI { a -= 2.0 * std::f32::consts::PI; }
8000    while a < -std::f32::consts::PI { a += 2.0 * std::f32::consts::PI; }
8001    a
8002}
8003
8004/// Angular distance between two angles (radians)
8005pub fn angle_distance(a: f32, b: f32) -> f32 { wrap_angle(b - a).abs() }
8006
8007// ============================================================
8008// END OF FILE
8009// ============================================================