1pub mod inventory;
17pub mod abilities;
18pub mod combo;
19
20pub use inventory::{Item, ItemCategory, Inventory, Equipment, Rarity, LootTable, LootDrop};
21pub use abilities::{Ability, AbilityBar, ResourcePool, ResourceType, AbilityEffect};
22pub use combo::{ComboState, ComboDatabase, ComboTracker, ComboInput, InputBuffer};
23
24use glam::Vec3;
25use std::collections::HashMap;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum Element {
32 Physical,
33 Fire,
34 Ice,
35 Lightning,
36 Void,
37 Entropy,
38 Gravity,
39 Radiant,
40 Shadow,
41 Temporal,
42}
43
44impl Element {
45 pub fn color(self) -> glam::Vec4 {
47 match self {
48 Element::Physical => glam::Vec4::new(0.85, 0.80, 0.75, 1.0),
49 Element::Fire => glam::Vec4::new(1.00, 0.40, 0.10, 1.0),
50 Element::Ice => glam::Vec4::new(0.50, 0.85, 1.00, 1.0),
51 Element::Lightning => glam::Vec4::new(1.00, 0.95, 0.20, 1.0),
52 Element::Void => glam::Vec4::new(0.20, 0.00, 0.40, 1.0),
53 Element::Entropy => glam::Vec4::new(0.60, 0.10, 0.80, 1.0),
54 Element::Gravity => glam::Vec4::new(0.30, 0.30, 0.60, 1.0),
55 Element::Radiant => glam::Vec4::new(1.00, 1.00, 0.70, 1.0),
56 Element::Shadow => glam::Vec4::new(0.10, 0.05, 0.20, 1.0),
57 Element::Temporal => glam::Vec4::new(0.40, 0.90, 0.70, 1.0),
58 }
59 }
60
61 pub fn glyph(self) -> char {
63 match self {
64 Element::Physical => '✦',
65 Element::Fire => '♨',
66 Element::Ice => '❄',
67 Element::Lightning => '⚡',
68 Element::Void => '◈',
69 Element::Entropy => '∞',
70 Element::Gravity => '⊕',
71 Element::Radiant => '☀',
72 Element::Shadow => '◆',
73 Element::Temporal => '⧗',
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
85pub struct ResistanceProfile {
86 pub resistances: HashMap<Element, f32>,
87}
88
89impl ResistanceProfile {
90 pub fn neutral() -> Self {
91 let mut r = HashMap::new();
92 for &el in &[
93 Element::Physical, Element::Fire, Element::Ice, Element::Lightning,
94 Element::Void, Element::Entropy, Element::Gravity, Element::Radiant,
95 Element::Shadow, Element::Temporal,
96 ] {
97 r.insert(el, 1.0);
98 }
99 Self { resistances: r }
100 }
101
102 pub fn get(&self, el: Element) -> f32 {
103 *self.resistances.get(&el).unwrap_or(&1.0)
104 }
105
106 pub fn set(&mut self, el: Element, value: f32) {
107 self.resistances.insert(el, value);
108 }
109
110 pub fn fire_elemental() -> Self {
112 let mut p = Self::neutral();
113 p.set(Element::Fire, 0.0);
114 p.set(Element::Ice, 2.0);
115 p.set(Element::Shadow, 1.3);
116 p
117 }
118
119 pub fn void_entity() -> Self {
121 let mut p = Self::neutral();
122 p.set(Element::Void, 0.0);
123 p.set(Element::Radiant, 2.5);
124 p.set(Element::Shadow, 0.3);
125 p.set(Element::Physical, 0.5);
126 p
127 }
128
129 pub fn chaos_rift() -> Self {
131 let mut p = Self::neutral();
132 p.set(Element::Entropy, 0.0);
133 p.set(Element::Temporal, 0.0);
134 p.set(Element::Physical, 0.3);
135 p.set(Element::Gravity, 2.0);
136 p
137 }
138
139 pub fn boss_resist() -> Self {
141 let mut p = Self::neutral();
142 for (_, v) in p.resistances.iter_mut() {
143 *v *= 0.5;
144 }
145 p.set(Element::Entropy, 1.5);
146 p
147 }
148}
149
150#[derive(Debug, Clone)]
154pub struct CombatStats {
155 pub attack: f32, pub crit_chance: f32, pub crit_mult: f32, pub penetration: f32, pub entropy_amp: f32, pub armor: f32, pub dodge_chance: f32, pub block_chance: f32, pub block_amount: f32, pub max_hp: f32,
168 pub hp: f32,
169
170 pub level: u32,
172 pub entropy: f32, }
174
175impl Default for CombatStats {
176 fn default() -> Self {
177 Self {
178 attack: 10.0, crit_chance: 0.05, crit_mult: 2.0, penetration: 0.0,
179 entropy_amp: 1.0, armor: 5.0, dodge_chance: 0.05, block_chance: 0.0,
180 block_amount: 0.0, max_hp: 100.0, hp: 100.0, level: 1, entropy: 0.0,
181 }
182 }
183}
184
185impl CombatStats {
186 pub fn hp_fraction(&self) -> f32 {
187 (self.hp / self.max_hp.max(1.0)).clamp(0.0, 1.0)
188 }
189
190 pub fn is_alive(&self) -> bool { self.hp > 0.0 }
191
192 pub fn take_damage(&mut self, amount: f32) {
193 self.hp = (self.hp - amount).max(0.0);
194 }
195
196 pub fn heal(&mut self, amount: f32) {
197 self.hp = (self.hp + amount).min(self.max_hp);
198 }
199
200 pub fn effective_armor(&self, penetration: f32) -> f32 {
202 self.armor * (1.0 - penetration.clamp(0.0, 1.0))
203 }
204}
205
206#[derive(Debug, Clone)]
210pub struct DamageEvent {
211 pub base_damage: f32,
212 pub element: Element,
213 pub attacker_pos: Vec3,
214 pub defender_pos: Vec3,
215 pub roll: f32, }
218
219#[derive(Debug, Clone)]
223pub struct HitResult {
224 pub final_damage: f32,
225 pub is_crit: bool,
226 pub is_dodge: bool,
227 pub is_block: bool,
228 pub is_kill: bool,
229 pub element: Element,
230 pub pre_resist: f32, pub post_resist: f32, pub post_armor: f32, pub overkill: f32, }
235
236impl HitResult {
237 pub fn miss(element: Element) -> Self {
238 Self {
239 final_damage: 0.0, is_crit: false, is_dodge: true, is_block: false,
240 is_kill: false, element, pre_resist: 0.0, post_resist: 0.0,
241 post_armor: 0.0, overkill: 0.0,
242 }
243 }
244}
245
246pub struct CombatFormulas;
250
251impl CombatFormulas {
252 pub fn resolve(
257 event: &DamageEvent,
258 attacker: &CombatStats,
259 defender: &CombatStats,
260 resistances: &ResistanceProfile,
261 ) -> HitResult {
262 if event.roll < defender.dodge_chance {
264 return HitResult::miss(event.element);
265 }
266
267 let crit_roll = (event.roll * 1.61803) % 1.0; let is_crit = crit_roll < attacker.crit_chance;
270 let crit_factor = if is_crit { attacker.crit_mult } else { 1.0 };
271
272 let base = event.base_damage * attacker.attack * crit_factor * attacker.entropy_amp;
274
275 let level_armor = (defender.level as f32 - attacker.level as f32).max(0.0) * 2.0;
277 let effective_armor = defender.effective_armor(attacker.penetration) + level_armor;
278
279 let resist = resistances.get(event.element);
281 let post_resist = base * resist;
282
283 let block_roll = (event.roll * 2.71828) % 1.0;
285 let is_block = block_roll < defender.block_chance;
286 let post_block = if is_block {
287 (post_resist - defender.block_amount).max(post_resist * 0.1)
288 } else {
289 post_resist
290 };
291
292 let armor_factor = 100.0 / (100.0 + effective_armor.max(0.0));
296 let post_armor = (post_block * armor_factor).max(1.0); let final_damage = post_armor;
300 let is_kill = final_damage >= defender.hp;
301 let overkill = if is_kill { final_damage - defender.hp } else { 0.0 };
302
303 HitResult {
304 final_damage,
305 is_crit,
306 is_dodge: false,
307 is_block,
308 is_kill,
309 element: event.element,
310 pre_resist: base,
311 post_resist,
312 post_armor,
313 overkill,
314 }
315 }
316
317 pub fn splash_damage(base_result: &HitResult, splash_radius: f32, distance: f32) -> f32 {
319 let falloff = (1.0 - (distance / splash_radius.max(0.001))).max(0.0);
320 base_result.final_damage * falloff * falloff
321 }
322
323 pub fn entropy_damage(base_damage: f32, defender_entropy: f32, attacker_entropy_amp: f32) -> f32 {
327 base_damage * defender_entropy * attacker_entropy_amp * 0.5
328 }
329
330 pub fn gravity_damage(base_damage: f32, attacker_pos: Vec3, defender_pos: Vec3) -> f32 {
332 let height_diff = (attacker_pos.y - defender_pos.y).max(0.0);
333 base_damage * (1.0 + height_diff * 0.1)
334 }
335
336 pub fn temporal_slow_factor(damage: f32, defender_max_hp: f32) -> f32 {
339 let ratio = (damage / defender_max_hp.max(1.0)).min(1.0);
340 (1.0 - ratio * 0.8).max(0.1)
341 }
342
343 pub fn dps(damage_per_hit: f32, hits_per_second: f32, crit_chance: f32, crit_mult: f32) -> f32 {
345 let avg_mult = 1.0 + crit_chance * (crit_mult - 1.0);
346 damage_per_hit * hits_per_second * avg_mult
347 }
348
349 pub fn effective_hp(hp: f32, armor: f32) -> f32 {
351 hp * (1.0 + armor / 100.0)
352 }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
359pub enum StatusKind {
360 Burning,
362 Frozen,
364 Poisoned,
366 Stunned,
368 Cursed,
370 Corroded,
372 Vulnerable,
374 Thorned,
376 Regenerating,
378 Enraged,
380 Silenced,
382 Entropied,
384 TemporalSnare,
386 GravityWell,
388}
389
390impl StatusKind {
391 pub fn is_debuff(self) -> bool {
393 !matches!(self, StatusKind::Regenerating | StatusKind::Enraged | StatusKind::Thorned)
394 }
395
396 pub fn element(self) -> Element {
398 match self {
399 StatusKind::Burning => Element::Fire,
400 StatusKind::Frozen => Element::Ice,
401 StatusKind::Poisoned => Element::Physical,
402 StatusKind::Stunned => Element::Physical,
403 StatusKind::Cursed => Element::Shadow,
404 StatusKind::Corroded => Element::Physical,
405 StatusKind::Vulnerable => Element::Physical,
406 StatusKind::Thorned => Element::Physical,
407 StatusKind::Regenerating => Element::Radiant,
408 StatusKind::Enraged => Element::Fire,
409 StatusKind::Silenced => Element::Void,
410 StatusKind::Entropied => Element::Entropy,
411 StatusKind::TemporalSnare => Element::Temporal,
412 StatusKind::GravityWell => Element::Gravity,
413 }
414 }
415
416 pub fn indicator_glyph(self) -> char {
418 match self {
419 StatusKind::Burning => '🔥',
420 StatusKind::Frozen => '❄',
421 StatusKind::Poisoned => '☠',
422 StatusKind::Stunned => '★',
423 StatusKind::Cursed => '⊗',
424 StatusKind::Corroded => '⊙',
425 StatusKind::Vulnerable => '↓',
426 StatusKind::Thorned => '✦',
427 StatusKind::Regenerating => '✚',
428 StatusKind::Enraged => '↑',
429 StatusKind::Silenced => '∅',
430 StatusKind::Entropied => '∞',
431 StatusKind::TemporalSnare => '⧗',
432 StatusKind::GravityWell => '⊕',
433 }
434 }
435}
436
437#[derive(Debug, Clone)]
439pub struct StatusEffect {
440 pub kind: StatusKind,
441 pub duration: f32,
443 pub age: f32,
445 pub strength: f32,
447 pub stacks: u32,
449 pub max_stacks: u32,
451 pub source_id: Option<u32>,
453}
454
455impl StatusEffect {
456 pub fn new(kind: StatusKind, duration: f32, strength: f32) -> Self {
457 Self { kind, duration, age: 0.0, strength, stacks: 1, max_stacks: 5, source_id: None }
458 }
459
460 pub fn burning(dps: f32) -> Self { Self::new(StatusKind::Burning, 4.0, dps) }
462
463 pub fn frozen() -> Self { Self::new(StatusKind::Frozen, 2.0, 0.3) }
465
466 pub fn poisoned(dps: f32) -> Self {
468 let mut s = Self::new(StatusKind::Poisoned, 6.0, dps);
469 s.max_stacks = 8;
470 s
471 }
472
473 pub fn stunned(duration: f32) -> Self { Self::new(StatusKind::Stunned, duration, 1.0) }
475
476 pub fn regen(hp_per_sec: f32, duration: f32) -> Self {
478 Self::new(StatusKind::Regenerating, duration, hp_per_sec)
479 }
480
481 pub fn enraged() -> Self { Self::new(StatusKind::Enraged, 8.0, 1.5) }
483
484 pub fn entropied(entropy: f32, duration: f32) -> Self {
486 Self::new(StatusKind::Entropied, duration, entropy)
487 }
488
489 pub fn is_expired(&self) -> bool { self.age >= self.duration }
490 pub fn remaining(&self) -> f32 { (self.duration - self.age).max(0.0) }
491 pub fn progress(&self) -> f32 { (self.age / self.duration).clamp(0.0, 1.0) }
492
493 pub fn effective_strength(&self) -> f32 {
495 self.strength * self.stacks as f32
496 }
497
498 pub fn tick(&mut self, dt: f32) -> f32 {
501 self.age += dt;
502 match self.kind {
503 StatusKind::Burning | StatusKind::Poisoned => {
504 self.effective_strength() * dt
505 }
506 StatusKind::Regenerating => {
507 -self.effective_strength() * dt
509 }
510 _ => 0.0,
511 }
512 }
513
514 pub fn add_stack(&mut self) -> bool {
516 if self.stacks < self.max_stacks {
517 self.stacks += 1;
518 self.age = 0.0; true
520 } else {
521 false
522 }
523 }
524
525 pub fn movement_slow(&self) -> f32 {
527 match self.kind {
528 StatusKind::Frozen => 1.0 - self.strength.clamp(0.0, 0.9),
529 StatusKind::Stunned => 0.0,
530 StatusKind::TemporalSnare => 1.0 - self.strength.clamp(0.0, 0.8),
531 StatusKind::Poisoned => 1.0 - self.stacks as f32 * 0.03,
532 _ => 1.0,
533 }
534 }
535
536 pub fn attack_speed_mult(&self) -> f32 {
538 match self.kind {
539 StatusKind::Frozen => 0.3,
540 StatusKind::Stunned => 0.0,
541 StatusKind::Enraged => self.strength,
542 StatusKind::Silenced => 0.0,
543 _ => 1.0,
544 }
545 }
546}
547
548#[derive(Debug, Clone, Default)]
554pub struct StatusTracker {
555 pub effects: Vec<StatusEffect>,
556}
557
558impl StatusTracker {
559 pub fn new() -> Self { Self { effects: Vec::new() } }
560
561 pub fn apply(&mut self, mut effect: StatusEffect) {
563 for existing in &mut self.effects {
564 if existing.kind == effect.kind {
565 if !existing.add_stack() {
566 existing.age = 0.0;
568 }
569 return;
570 }
571 }
572 effect.stacks = 1;
573 self.effects.push(effect);
574 }
575
576 pub fn tick(&mut self, dt: f32) -> f32 {
578 let mut total_damage = 0.0;
579 for effect in &mut self.effects {
580 total_damage += effect.tick(dt);
581 }
582 self.effects.retain(|e| !e.is_expired());
583 total_damage
584 }
585
586 pub fn remove(&mut self, kind: StatusKind) {
588 self.effects.retain(|e| e.kind != kind);
589 }
590
591 pub fn clear(&mut self) {
593 self.effects.clear();
594 }
595
596 pub fn has(&self, kind: StatusKind) -> bool {
597 self.effects.iter().any(|e| e.kind == kind)
598 }
599
600 pub fn is_stunned(&self) -> bool { self.has(StatusKind::Stunned) }
601 pub fn is_frozen(&self) -> bool { self.has(StatusKind::Frozen) }
602 pub fn is_silenced(&self) -> bool { self.has(StatusKind::Silenced) }
603
604 pub fn movement_factor(&self) -> f32 {
606 self.effects.iter().map(|e| e.movement_slow())
607 .fold(1.0_f32, f32::min)
608 }
609
610 pub fn attack_speed_factor(&self) -> f32 {
612 self.effects.iter().map(|e| e.attack_speed_mult())
613 .fold(1.0_f32, f32::min)
614 }
615
616 pub fn entropy_amp(&self) -> f32 {
618 self.effects.iter()
619 .filter(|e| e.kind == StatusKind::Entropied)
620 .map(|e| e.effective_strength())
621 .sum::<f32>()
622 .clamp(0.0, 3.0)
623 }
624
625 pub fn vulnerable_mult(&self) -> f32 {
627 if self.has(StatusKind::Vulnerable) { 1.25 } else { 1.0 }
628 }
629
630 pub fn thorns_reflection(&self) -> f32 {
632 self.effects.iter()
633 .filter(|e| e.kind == StatusKind::Thorned)
634 .map(|e| e.effective_strength() * 0.1)
635 .sum::<f32>()
636 .min(0.5)
637 }
638}
639
640#[derive(Debug, Clone)]
644pub struct DpsTracker {
645 pub window: f32,
647 samples: std::collections::VecDeque<(f32, f32)>, pub time: f32,
649}
650
651impl DpsTracker {
652 pub fn new(window_seconds: f32) -> Self {
653 Self { window: window_seconds, samples: std::collections::VecDeque::new(), time: 0.0 }
654 }
655
656 pub fn record(&mut self, damage: f32) {
657 self.samples.push_back((self.time, damage));
658 }
659
660 pub fn tick(&mut self, dt: f32) {
661 self.time += dt;
662 let cutoff = self.time - self.window;
663 while self.samples.front().map_or(false, |&(t, _)| t < cutoff) {
664 self.samples.pop_front();
665 }
666 }
667
668 pub fn dps(&self) -> f32 {
670 let total: f32 = self.samples.iter().map(|(_, d)| d).sum();
671 total / self.window.max(0.001)
672 }
673
674 pub fn total_damage(&self) -> f32 {
676 self.samples.iter().map(|(_, d)| d).sum()
677 }
678
679 pub fn hit_count(&self) -> usize { self.samples.len() }
680
681 pub fn reset(&mut self) {
682 self.samples.clear();
683 self.time = 0.0;
684 }
685}
686
687pub struct HitDetection;
693
694impl HitDetection {
695 pub fn point_in_sphere(point: Vec3, center: Vec3, radius: f32) -> bool {
697 (point - center).length_squared() <= radius * radius
698 }
699
700 pub fn point_in_aabb(point: Vec3, min: Vec3, max: Vec3) -> bool {
702 point.x >= min.x && point.x <= max.x
703 && point.y >= min.y && point.y <= max.y
704 && point.z >= min.z && point.z <= max.z
705 }
706
707 pub fn point_in_cylinder(point: Vec3, center: Vec3, radius: f32, half_height: f32) -> bool {
709 let dx = point.x - center.x;
710 let dz = point.z - center.z;
711 let dy = (point.y - center.y).abs();
712 dx * dx + dz * dz <= radius * radius && dy <= half_height
713 }
714
715 pub fn sphere_overlap(ca: Vec3, ra: f32, cb: Vec3, rb: f32) -> f32 {
717 let dist = (ca - cb).length();
718 ra + rb - dist
719 }
720
721 pub fn point_in_cone(
726 target: Vec3, origin: Vec3, direction: Vec3, half_angle_rad: f32, range: f32,
727 ) -> bool {
728 let to_target = target - origin;
729 let dist = to_target.length();
730 if dist > range || dist < 1e-6 { return false; }
731 let cos_angle = to_target.dot(direction.normalize_or_zero()) / dist;
732 cos_angle >= half_angle_rad.cos()
733 }
734
735 pub fn ray_vs_sphere(
737 ray_origin: Vec3, ray_dir: Vec3, sphere_center: Vec3, sphere_radius: f32,
738 ) -> Option<f32> {
739 let oc = ray_origin - sphere_center;
740 let b = oc.dot(ray_dir);
741 let c = oc.dot(oc) - sphere_radius * sphere_radius;
742 let discriminant = b * b - c;
743 if discriminant < 0.0 { return None; }
744 let sqrt_d = discriminant.sqrt();
745 let t0 = -b - sqrt_d;
746 let t1 = -b + sqrt_d;
747 if t0 >= 0.0 { Some(t0) } else if t1 >= 0.0 { Some(t1) } else { None }
748 }
749
750 pub fn targets_in_range<'a>(
752 origin: Vec3,
753 targets: &'a [Vec3],
754 range: f32,
755 ) -> Vec<(usize, f32)> {
756 let mut hits: Vec<(usize, f32)> = targets.iter().enumerate()
757 .filter_map(|(i, &pos)| {
758 let dist = (pos - origin).length();
759 if dist <= range { Some((i, dist)) } else { None }
760 })
761 .collect();
762 hits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
763 hits
764 }
765
766 pub fn knockback(attacker_pos: Vec3, defender_pos: Vec3, strength: f32) -> Vec3 {
768 let dir = (defender_pos - attacker_pos).normalize_or_zero();
769 dir * strength
770 }
771}
772
773#[derive(Debug, Clone)]
777pub struct CombatLogEntry {
778 pub timestamp: f32,
779 pub attacker_id: u32,
780 pub defender_id: u32,
781 pub result: HitResult,
782 pub status_applied: Option<StatusKind>,
783}
784
785#[derive(Debug, Clone)]
787pub struct CombatLog {
788 pub entries: Vec<CombatLogEntry>,
789 pub max_entries: usize,
790}
791
792impl CombatLog {
793 pub fn new(max_entries: usize) -> Self {
794 Self { entries: Vec::new(), max_entries }
795 }
796
797 pub fn push(&mut self, entry: CombatLogEntry) {
798 if self.entries.len() >= self.max_entries {
799 self.entries.remove(0);
800 }
801 self.entries.push(entry);
802 }
803
804 pub fn kills(&self) -> usize {
805 self.entries.iter().filter(|e| e.result.is_kill).count()
806 }
807
808 pub fn crits(&self) -> usize {
809 self.entries.iter().filter(|e| e.result.is_crit).count()
810 }
811
812 pub fn total_damage(&self) -> f32 {
813 self.entries.iter().map(|e| e.result.final_damage).sum()
814 }
815
816 pub fn crit_rate(&self) -> f32 {
817 if self.entries.is_empty() { return 0.0; }
818 self.crits() as f32 / self.entries.len() as f32
819 }
820
821 pub fn avg_damage(&self) -> f32 {
822 if self.entries.is_empty() { return 0.0; }
823 self.total_damage() / self.entries.len() as f32
824 }
825
826 pub fn clear(&mut self) { self.entries.clear(); }
827}
828
829#[derive(Debug, Clone, Default)]
835pub struct ThreatTable {
836 pub threat: HashMap<u32, f32>,
837}
838
839impl ThreatTable {
840 pub fn new() -> Self { Self { threat: HashMap::new() } }
841
842 pub fn add_threat(&mut self, id: u32, amount: f32) {
843 *self.threat.entry(id).or_insert(0.0) += amount;
844 }
845
846 pub fn reduce_threat(&mut self, id: u32, amount: f32) {
847 if let Some(t) = self.threat.get_mut(&id) {
848 *t = (*t - amount).max(0.0);
849 }
850 }
851
852 pub fn decay(&mut self, dt: f32, factor: f32) {
854 for t in self.threat.values_mut() {
855 *t *= (1.0 - factor * dt).max(0.0);
856 }
857 self.threat.retain(|_, &mut t| t > 0.001);
858 }
859
860 pub fn top_target(&self) -> Option<u32> {
862 self.threat.iter()
863 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
864 .map(|(&id, _)| id)
865 }
866
867 pub fn sorted_targets(&self) -> Vec<(u32, f32)> {
869 let mut v: Vec<(u32, f32)> = self.threat.iter().map(|(&id, &t)| (id, t)).collect();
870 v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
871 v
872 }
873
874 pub fn remove(&mut self, id: u32) { self.threat.remove(&id); }
875 pub fn clear(&mut self) { self.threat.clear(); }
876 pub fn get(&self, id: u32) -> f32 { *self.threat.get(&id).unwrap_or(&0.0) }
877 pub fn target_count(&self) -> usize { self.threat.len() }
878}
879
880#[cfg(test)]
883mod tests {
884 use super::*;
885
886 fn make_attacker() -> CombatStats {
887 CombatStats { attack: 20.0, crit_chance: 0.0, crit_mult: 2.0, ..Default::default() }
888 }
889
890 fn make_defender() -> CombatStats {
891 CombatStats { hp: 100.0, max_hp: 100.0, armor: 0.0, dodge_chance: 0.0, ..Default::default() }
892 }
893
894 #[test]
895 fn dodge_on_low_roll() {
896 let att = make_attacker();
897 let mut def = make_defender();
898 def.dodge_chance = 1.0; let event = DamageEvent {
900 base_damage: 1.0, element: Element::Physical,
901 attacker_pos: Vec3::ZERO, defender_pos: Vec3::ONE,
902 roll: 0.5,
903 };
904 let result = CombatFormulas::resolve(&event, &att, &def, &ResistanceProfile::neutral());
905 assert!(result.is_dodge, "should dodge with dodge_chance=1.0");
906 assert_eq!(result.final_damage, 0.0);
907 }
908
909 #[test]
910 fn crit_doubles_damage() {
911 let att = CombatStats { attack: 10.0, crit_chance: 1.0, crit_mult: 2.0, ..Default::default() };
912 let def = make_defender();
913 let event = DamageEvent {
914 base_damage: 1.0, element: Element::Physical,
915 attacker_pos: Vec3::ZERO, defender_pos: Vec3::ONE,
916 roll: 0.5,
917 };
918 let result = CombatFormulas::resolve(&event, &att, &def, &ResistanceProfile::neutral());
919 assert!(result.is_crit, "should be crit with crit_chance=1.0");
920 assert!(result.pre_resist > 10.0, "crit should amplify damage");
921 }
922
923 #[test]
924 fn fire_resistance_halves_fire_damage() {
925 let att = make_attacker();
926 let def = make_defender();
927 let mut resist = ResistanceProfile::neutral();
928 resist.set(Element::Fire, 0.5);
929 let event = DamageEvent {
930 base_damage: 1.0, element: Element::Fire,
931 attacker_pos: Vec3::ZERO, defender_pos: Vec3::ONE,
932 roll: 0.5,
933 };
934 let result = CombatFormulas::resolve(&event, &att, &def, &resist);
935 assert!((result.post_resist - result.pre_resist * 0.5).abs() < 0.01,
937 "fire resist 0.5 should halve damage");
938 }
939
940 #[test]
941 fn status_tracker_stacks() {
942 let mut tracker = StatusTracker::new();
943 tracker.apply(StatusEffect::poisoned(5.0));
944 tracker.apply(StatusEffect::poisoned(5.0));
945 let poison = tracker.effects.iter().find(|e| e.kind == StatusKind::Poisoned).unwrap();
946 assert_eq!(poison.stacks, 2);
947 }
948
949 #[test]
950 fn status_tracker_dots_damage() {
951 let mut tracker = StatusTracker::new();
952 tracker.apply(StatusEffect::burning(10.0));
953 let dmg = tracker.tick(1.0); assert!((dmg - 10.0).abs() < 0.01, "burning 10 dps for 1 sec = 10 damage, got {}", dmg);
955 }
956
957 #[test]
958 fn dps_tracker_rolling() {
959 let mut tracker = DpsTracker::new(3.0);
960 tracker.record(30.0);
961 tracker.tick(1.0);
962 assert!((tracker.dps() - 10.0).abs() < 0.01, "30 damage over 3s window = 10 dps");
963 }
964
965 #[test]
966 fn hit_detection_sphere() {
967 assert!(HitDetection::point_in_sphere(Vec3::new(0.5, 0.0, 0.0), Vec3::ZERO, 1.0));
968 assert!(!HitDetection::point_in_sphere(Vec3::new(2.0, 0.0, 0.0), Vec3::ZERO, 1.0));
969 }
970
971 #[test]
972 fn hit_detection_cone() {
973 let in_cone = HitDetection::point_in_cone(
974 Vec3::new(0.0, 0.0, 1.0), Vec3::ZERO, Vec3::Z, 0.5, 5.0
975 );
976 assert!(in_cone, "point directly in front should be in cone");
977 let behind = HitDetection::point_in_cone(
978 Vec3::new(0.0, 0.0, -1.0), Vec3::ZERO, Vec3::Z, 0.5, 5.0
979 );
980 assert!(!behind, "point behind should not be in cone");
981 }
982
983 #[test]
984 fn threat_table_top_target() {
985 let mut tt = ThreatTable::new();
986 tt.add_threat(1, 50.0);
987 tt.add_threat(2, 200.0);
988 tt.add_threat(3, 10.0);
989 assert_eq!(tt.top_target(), Some(2));
990 }
991
992 #[test]
993 fn combat_log_stats() {
994 let mut log = CombatLog::new(100);
995 log.push(CombatLogEntry {
996 timestamp: 0.0, attacker_id: 1, defender_id: 2,
997 result: HitResult {
998 final_damage: 50.0, is_crit: true, is_dodge: false,
999 is_block: false, is_kill: false, element: Element::Fire,
1000 pre_resist: 60.0, post_resist: 50.0, post_armor: 50.0, overkill: 0.0,
1001 },
1002 status_applied: None,
1003 });
1004 assert!((log.total_damage() - 50.0).abs() < 0.01);
1005 assert_eq!(log.crits(), 1);
1006 }
1007}