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#[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#[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 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 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 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 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#[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#[derive(Debug, Clone)]
315pub struct ScalingCoeff {
316 pub stat_name: String,
317 pub coefficient: f32,
318 pub exponent: f32, }
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, pub crit_multiplier_base: f32, pub crit_chance_scaling: Vec<ScalingCoeff>,
344 pub crit_multiplier_scaling: Vec<ScalingCoeff>,
345 pub penetration: f32, pub penetration_percent: f32, pub versus_status_bonus: Vec<(StatusEffectType, f32)>, pub variance: f32, }
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 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 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 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) };
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#[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
596trait 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, Refresh, AddStack, AddStackRefreshAll, Ignore, }
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, pub tick_interval: f32, 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, 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, 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, }
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#[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, 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#[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 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 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>, 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#[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, pub minimum: f32,
1016}
1017
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub enum OverflowBehavior {
1020 Clamp,
1021 Wraparound,
1022 Explode, }
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 }
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 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#[derive(Debug, Clone)]
1107pub struct ProjectileParams {
1108 pub speed: f32,
1109 pub acceleration: f32, pub gravity: f32, pub max_range: f32,
1112 pub pierce_count: u32, pub split_count: u32, pub fork_count: u32, pub chain_count: u32, pub chain_radius: f32,
1117 pub homing: bool,
1118 pub homing_strength: f32, 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 let angle = current_dir.dot(target_dir).acos().min(self.homing_strength * dt);
1199 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#[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#[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>, }
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.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#[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, 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 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 pub targeting_mode: TargetingMode,
1462 pub range: f32,
1463 pub aoe_radius: f32,
1464 pub cast_time: f32, pub channel_time: f32, pub channel_ticks: u32, pub base_cooldown: f32,
1469 pub max_charges: u32,
1470 pub gcd_category: CooldownCategory,
1471 pub triggers_gcd: bool,
1472 pub resource_type: ResourceType,
1474 pub resource_cost: f32,
1475 pub resource_cost_scaling: Vec<ScalingCoeff>,
1476 pub resource_generation: f32, pub damage_formulas: Vec<DamageFormula>,
1479 pub projectile_params: Option<ProjectileParams>,
1480 pub projectile_count: u32,
1481 pub spread_angle: f32, pub heal_formula: HealFormula,
1484 pub applied_effects: Vec<AppliedEffect>,
1486 pub required_target_status: Vec<StatusEffectType>, pub consumed_target_status: Vec<StatusEffectType>, pub duration: f32, 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 pub applied_modifiers: Vec<u64>, 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 pub school: String,
1505 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, pub to_target: bool, 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), }
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 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 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 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 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#[derive(Debug, Clone)]
1719pub struct TalentNode {
1720 pub id: u64,
1721 pub name: String,
1722 pub description: String,
1723 pub ability_id: Option<u64>, pub modifier_id: Option<u64>, pub passive_stat_mods: Vec<(String, f32, f32)>, pub point_cost: u32,
1727 pub max_ranks: u32,
1728 pub current_ranks: u32,
1729 pub position: Vec2, pub icon_path: String,
1731 pub is_keystone: bool, pub is_notable: bool, pub unlock_bonus_at: Option<u32>, }
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, pub is_mutual_exclusion: bool, }
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>, 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)>)>, }
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 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#[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#[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, pub samples: Vec<f32>, 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 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#[derive(Debug, Clone)]
2226pub struct TimelineFrame {
2227 pub time: f32,
2228 pub active_effects: Vec<(StatusEffectType, u32)>, 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)], 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 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#[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)>, }
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 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#[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>, }
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 }
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
2894pub fn create_starter_ability_database() -> AbilityDatabase {
2899 let mut db = AbilityDatabase::new();
2900
2901 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 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 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 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 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 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 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 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 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 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 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 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; 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
3480pub struct AbilityComparison {
3481 pub a: AbilityDefinition,
3482 pub b: AbilityDefinition,
3483 pub damage_delta: f32, 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#[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)>, 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 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 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#[derive(Debug, Clone)]
3674pub struct TargetingVisualizer {
3675 pub origin: Vec3,
3676 pub forward: Vec3,
3677 pub mode: TargetingMode,
3678 pub resolution: u32, }
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#[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 m.add_reaction(StatusEffectType::Burn, StatusEffectType::Wet, StatusReaction::Remove);
3754 m.add_reaction(StatusEffectType::Freeze, StatusEffectType::Wet, StatusReaction::Amplify { factor: 1.5 });
3756 m.add_reaction(StatusEffectType::Freeze, StatusEffectType::Burn, StatusReaction::Explode {
3758 damage_formula: DamageFormula::new(DamageElement::Physical, 30.0, 60.0),
3759 });
3760 m.add_reaction(StatusEffectType::Burn, StatusEffectType::Oiled, StatusReaction::Amplify { factor: 2.0 });
3762 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#[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>>, 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#[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, pub total_damage_60s: f32, 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) };
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#[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>>, pub active_talent_trees: Vec<u64>,
3908 pub available_points: u32,
3909 pub invested_points: HashMap<u64, u32>, }
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
3961pub 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 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
4033pub 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 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#[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, pub delay: f32, 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#[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)>, pub ability_overrides: HashMap<u64, u64>, 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(); }
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#[derive(Debug, Clone)]
4302pub struct ProcEffect {
4303 pub id: u64,
4304 pub name: String,
4305 pub trigger: ProcTrigger,
4306 pub chance: f32, 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), }
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#[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#[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, }
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#[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, ComboBuilder, BuffThenDPS, SetupCC, ElementalReaction, ResourceLoop, }
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 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 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 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#[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 (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#[derive(Debug, Clone)]
4663pub struct TalentOptimizationGoal {
4664 pub maximize_stat: String,
4665 pub secondary_stats: Vec<(String, f32)>, 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 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 };
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 let best = available_nodes.iter()
4711 .filter(|n| !selected.contains(&n.id))
4712 .filter(|n| n.point_cost <= remaining)
4713 .filter(|n| {
4714 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 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 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#[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 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 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 (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
4875pub struct AbilityUpgradeBuilder {
4880 pub ability_id: u64,
4881 pub upgrades: Vec<AbilityModifier>,
4882 pub upgrade_graph: HashMap<u64, Vec<u64>>, 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 let has_parent = self.upgrade_graph.iter().any(|(_, children)| children.contains(&modifier_id));
4908 if has_parent {
4909 self.upgrade_graph.iter().any(|(parent, children)| {
4911 children.contains(&modifier_id) && self.active_upgrades.contains(parent)
4912 })
4913 } else {
4914 true }
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 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 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#[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#[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 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#[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 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 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 let base = 100.0 * type_multiplier;
5260 base * (1.0 / (1.0 + cooldown * 0.05))
5261}
5262
5263#[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), 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>, 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
5315pub fn ability_editor_main() {
5320 let mut editor = build_ability_editor();
5321
5322 editor.formula_tester.selected_ability_id = editor.database.abilities.keys().next().copied();
5324 editor.run_formula_test_now();
5325
5326 let _synergies = detect_synergies(&editor.database);
5328
5329 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 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 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 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 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 for (_, ability) in &editor.database.abilities {
5360 let _vfx = build_vfx_preview(ability);
5361 }
5362
5363 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 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 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 let matrix = InteractionMatrix::new();
5391 let mut mock_effects = Vec::new();
5392 let _reactions = matrix.process_interactions(&mut mock_effects, StatusEffectType::Burn);
5393
5394 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 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#[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; }
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#[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#[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#[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 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#[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#[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
5820pub 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#[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 self.zones.retain_mut(|z| z.update(dt));
5957 Vec::new() }
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#[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#[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 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#[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#[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 { .. } => {} UnlockCondition::PointsSpentInTree { .. } => {} UnlockCondition::QuestCompleted(_) => {} }
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#[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 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 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 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#[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#[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
6503pub 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#[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
6633pub fn ability_editor_extended_demo() {
6638 let mut db = AbilityDatabase::new();
6639
6640 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 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 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 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 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 let results = ability_fuzzy_search(&db, "fire", 5);
6735 let _ = results;
6736
6737 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 let _los = ext_state.check_los(Vec2::new(0.0, 0.0), Vec2::new(15.0, 0.0), 1.8, 1.8);
6743
6744 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 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 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 let sorted_cds = abilities_sorted_by_cooldown(&db);
6779 let _ = sorted_cds;
6780
6781 let projectiles = filter_abilities_by_type(&db, &AbilityType::Projectile);
6783 let _ = projectiles;
6784}
6785
6786#[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 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#[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#[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#[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#[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 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#[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
7247pub 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
7308pub 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 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), _ => 1.0,
7340 };
7341 base * sp_mult * ramp
7342}
7343
7344#[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 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 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#[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#[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#[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#[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
7668pub 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#[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), };
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#[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 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
7835pub 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
7902pub 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
7960pub 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
7968pub fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t.clamp(0.0, 1.0) }
7974
7975pub fn smoothstep(t: f32) -> f32 { let c = t.clamp(0.0, 1.0); c * c * (3.0 - 2.0 * c) }
7977
7978pub 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
7981pub fn remap(v: f32, a: f32, b: f32, c: f32, d: f32) -> f32 { lerp_f32(c, d, inv_lerp(a, b, v)) }
7983
7984pub fn exp_decay(current: f32, target: f32, rate: f32, dt: f32) -> f32 {
7986 target + (current - target) * (-rate * dt).exp()
7987}
7988
7989pub 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
7997pub 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
8004pub fn angle_distance(a: f32, b: f32) -> f32 { wrap_angle(b - a).abs() }
8006
8007