Skip to main content

proof_engine/combat/
mod.rs

1//! Combat system — damage, status effects, hit detection, and DPS calculation.
2//!
3//! The combat system is purely mathematical: no sprites, no hitboxes.
4//! Damage is a function of attacker stats, defender stats, and environmental
5//! entropy. Status effects are time-varying functions applied each tick.
6//!
7//! # Architecture
8//!
9//! - `DamageEvent`     — a single damage application with element and source
10//! - `StatusEffect`    — a timed, stackable debuff/buff with per-tick function
11//! - `CombatStats`     — attacker/defender stat block
12//! - `HitResult`       — full damage resolution output
13//! - `DpsTracker`      — rolling DPS measurement
14//! - `CombatFormulas`  — all damage formulas in one place
15
16pub 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// ── Element ────────────────────────────────────────────────────────────────────
28
29/// Elemental type. Determines resistances, weakness multipliers, and visual effects.
30#[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    /// Default color for this element.
46    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    /// Glyph character used to represent this element in damage numbers.
62    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// ── ResistanceProfile ──────────────────────────────────────────────────────────
79
80/// Per-element resistance multipliers for a combatant.
81///
82/// `1.0` = normal damage, `0.5` = 50% resistance, `2.0` = 200% (weakness),
83/// `0.0` = immune, `-0.5` = healed by that element.
84#[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    /// Common preset: fire elemental — immune to fire, weak to ice.
111    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    /// Common preset: void entity — immune to void, weak to radiant.
120    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    /// Common preset: chaos rift — amplified by entropy, normal otherwise.
130    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    /// Boss resist profile: everything at half, but entropy at 1.5x.
140    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// ── CombatStats ───────────────────────────────────────────────────────────────
151
152/// Stat block for an attacker or defender.
153#[derive(Debug, Clone)]
154pub struct CombatStats {
155    // Offensive
156    pub attack:       f32,   // Base attack power
157    pub crit_chance:  f32,   // [0, 1] probability of a critical hit
158    pub crit_mult:    f32,   // Multiplier when critting (e.g., 2.0 = double damage)
159    pub penetration:  f32,   // Armor penetration percentage [0, 1]
160    pub entropy_amp:  f32,   // Amplifier from entropy field (1.0 = normal)
161
162    // Defensive
163    pub armor:        f32,   // Flat damage reduction
164    pub dodge_chance: f32,   // [0, 1] probability of full miss
165    pub block_chance: f32,   // [0, 1] probability of reducing damage by block_amount
166    pub block_amount: f32,   // How much damage is absorbed on a successful block
167    pub max_hp:       f32,
168    pub hp:           f32,
169
170    // Meta
171    pub level:        u32,
172    pub entropy:      f32,   // Current chaos level [0, 1] — affects entropy damage
173}
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    /// Effective armor after penetration applied.
201    pub fn effective_armor(&self, penetration: f32) -> f32 {
202        self.armor * (1.0 - penetration.clamp(0.0, 1.0))
203    }
204}
205
206// ── DamageEvent ───────────────────────────────────────────────────────────────
207
208/// A single damage application.
209#[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    /// Applies a RNG seed for deterministic crit resolution.
216    pub roll:           f32,  // [0, 1] — pre-rolled random value
217}
218
219// ── HitResult ─────────────────────────────────────────────────────────────────
220
221/// Full result of damage resolution.
222#[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,   // damage before element resistance
231    pub post_resist:    f32,   // damage after element resistance
232    pub post_armor:     f32,   // damage after armor subtraction
233    pub overkill:       f32,   // damage beyond remaining HP (0 if no kill)
234}
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
246// ── CombatFormulas ────────────────────────────────────────────────────────────
247
248/// Stateless damage resolution functions.
249pub struct CombatFormulas;
250
251impl CombatFormulas {
252    /// Resolve a single damage event, returning a `HitResult`.
253    ///
254    /// Uses the provided `roll` value (from attacker's entropy or RNG) instead of
255    /// an actual RNG call, making the system fully deterministic and seedable.
256    pub fn resolve(
257        event: &DamageEvent,
258        attacker: &CombatStats,
259        defender: &CombatStats,
260        resistances: &ResistanceProfile,
261    ) -> HitResult {
262        // ── Dodge check ───────────────────────────────────────────────────────
263        if event.roll < defender.dodge_chance {
264            return HitResult::miss(event.element);
265        }
266
267        // ── Crit check ────────────────────────────────────────────────────────
268        let crit_roll = (event.roll * 1.61803) % 1.0; // different sample of roll
269        let is_crit = crit_roll < attacker.crit_chance;
270        let crit_factor = if is_crit { attacker.crit_mult } else { 1.0 };
271
272        // ── Base damage ───────────────────────────────────────────────────────
273        let base = event.base_damage * attacker.attack * crit_factor * attacker.entropy_amp;
274
275        // ── Level scaling — higher level enemies get a natural armor bonus ───
276        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        // ── Element resistance ────────────────────────────────────────────────
280        let resist = resistances.get(event.element);
281        let post_resist = base * resist;
282
283        // ── Block check ───────────────────────────────────────────────────────
284        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        // ── Armor subtraction (multiplicative formula to prevent negatives) ──
293        // Damage after armor = damage * armor_reduction_factor
294        // armor_reduction_factor = 100 / (100 + armor)
295        let armor_factor = 100.0 / (100.0 + effective_armor.max(0.0));
296        let post_armor = (post_block * armor_factor).max(1.0); // minimum 1 damage
297
298        // ── Kill check ────────────────────────────────────────────────────────
299        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    /// Splash damage — apply a full hit result at reduced strength to multiple targets.
318    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    /// Entropy damage — additional chaos damage based on defender's current entropy.
324    ///
325    /// High-entropy targets take bonus damage; low-entropy targets are unaffected.
326    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    /// Gravity damage — scales with relative height difference between attacker and defender.
331    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    /// Temporal damage — slows time for the defender based on damage dealt.
337    /// Returns a slow factor in [0, 1] where 0 = full stop, 1 = normal.
338    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    /// Damage-per-second — useful for comparing damage sources.
344    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    /// Effective HP — actual HP accounting for armor reduction.
350    pub fn effective_hp(hp: f32, armor: f32) -> f32 {
351        hp * (1.0 + armor / 100.0)
352    }
353}
354
355// ── StatusEffect ──────────────────────────────────────────────────────────────
356
357/// Type of status effect.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
359pub enum StatusKind {
360    /// Deals fire damage each second.
361    Burning,
362    /// Slows movement and attack speed.
363    Frozen,
364    /// Applies random small damages each tick.
365    Poisoned,
366    /// Stuns the target — no actions possible.
367    Stunned,
368    /// Drains HP and transfers to attacker (vampiric).
369    Cursed,
370    /// Reduces armor.
371    Corroded,
372    /// Increases damage taken.
373    Vulnerable,
374    /// Reflects a portion of damage back to attacker.
375    Thorned,
376    /// Regenerates HP each second.
377    Regenerating,
378    /// Increases all stats temporarily.
379    Enraged,
380    /// Disables skill use.
381    Silenced,
382    /// Amplifies entropy field on target.
383    Entropied,
384    /// Slows time around the target.
385    TemporalSnare,
386    /// Gravity well — pulls nearby particles toward the target.
387    GravityWell,
388}
389
390impl StatusKind {
391    /// Is this effect a debuff (negative)?
392    pub fn is_debuff(self) -> bool {
393        !matches!(self, StatusKind::Regenerating | StatusKind::Enraged | StatusKind::Thorned)
394    }
395
396    /// Element associated with this status.
397    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    /// Glyph shown on the target while this status is active.
417    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/// A timed status effect applied to a combatant.
438#[derive(Debug, Clone)]
439pub struct StatusEffect {
440    pub kind:       StatusKind,
441    /// Total duration in seconds.
442    pub duration:   f32,
443    /// Elapsed time since application.
444    pub age:        f32,
445    /// Strength of the effect (damage per second, slow factor, etc.).
446    pub strength:   f32,
447    /// How many times this effect has been applied (stacking).
448    pub stacks:     u32,
449    /// Maximum stacks allowed.
450    pub max_stacks: u32,
451    /// Source entity that applied this effect.
452    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    /// Burning: x damage/sec for 4 seconds.
461    pub fn burning(dps: f32) -> Self { Self::new(StatusKind::Burning, 4.0, dps) }
462
463    /// Frozen: slow factor 0.3, lasts 2 seconds.
464    pub fn frozen() -> Self { Self::new(StatusKind::Frozen, 2.0, 0.3) }
465
466    /// Poisoned: x damage/sec for 6 seconds, stacks up to 8.
467    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    /// Stunned: full stop for duration seconds.
474    pub fn stunned(duration: f32) -> Self { Self::new(StatusKind::Stunned, duration, 1.0) }
475
476    /// Regenerating: x hp/sec for duration seconds.
477    pub fn regen(hp_per_sec: f32, duration: f32) -> Self {
478        Self::new(StatusKind::Regenerating, duration, hp_per_sec)
479    }
480
481    /// Enraged: attack +50%, speed +30%, lasts 8 seconds.
482    pub fn enraged() -> Self { Self::new(StatusKind::Enraged, 8.0, 1.5) }
483
484    /// Entropied: increases entropy by strength, lasts duration.
485    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    /// Effective strength, accounting for stacking.
494    pub fn effective_strength(&self) -> f32 {
495        self.strength * self.stacks as f32
496    }
497
498    /// Advance the effect by `dt` seconds.
499    /// Returns the damage dealt this tick (for DoT effects).
500    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                // healing is positive but returned as negative damage
508                -self.effective_strength() * dt
509            }
510            _ => 0.0,
511        }
512    }
513
514    /// Try to add a stack. Returns true if stack was added.
515    pub fn add_stack(&mut self) -> bool {
516        if self.stacks < self.max_stacks {
517            self.stacks += 1;
518            self.age = 0.0; // refresh duration on stack
519            true
520        } else {
521            false
522        }
523    }
524
525    /// Slow factor this effect applies to movement (1.0 = normal, 0.0 = stopped).
526    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    /// Attack speed multiplier this effect applies (1.0 = normal).
537    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// ── StatusTracker ─────────────────────────────────────────────────────────────
549
550/// Manages all status effects on a single entity.
551///
552/// Handles stacking, expiry, and per-tick resolution.
553#[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    /// Apply a status effect. If the same kind already exists, attempts to stack.
562    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                    // Max stacks reached — refresh duration only
567                    existing.age = 0.0;
568                }
569                return;
570            }
571        }
572        effect.stacks = 1;
573        self.effects.push(effect);
574    }
575
576    /// Advance all effects by `dt`. Returns total damage dealt this tick (DoT).
577    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    /// Remove all effects of a given kind.
587    pub fn remove(&mut self, kind: StatusKind) {
588        self.effects.retain(|e| e.kind != kind);
589    }
590
591    /// Remove all effects.
592    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    /// Combined movement slow from all active effects.
605    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    /// Combined attack speed factor from all active effects.
611    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    /// Current entropy amplification from Entropied stacks.
617    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    /// Damage multiplier from Vulnerable status.
626    pub fn vulnerable_mult(&self) -> f32 {
627        if self.has(StatusKind::Vulnerable) { 1.25 } else { 1.0 }
628    }
629
630    /// Thorns damage: fraction of incoming damage reflected.
631    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// ── DpsTracker ────────────────────────────────────────────────────────────────
641
642/// Tracks damage-per-second in a rolling window.
643#[derive(Debug, Clone)]
644pub struct DpsTracker {
645    /// Rolling window in seconds.
646    pub window:  f32,
647    samples:     std::collections::VecDeque<(f32, f32)>, // (timestamp, damage)
648    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    /// Current DPS over the rolling window.
669    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    /// Total damage recorded.
675    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
687// ── HitDetection ──────────────────────────────────────────────────────────────
688
689/// Geometric hit detection utilities.
690///
691/// All hit detection is point-vs-shape — no physics engine is required.
692pub struct HitDetection;
693
694impl HitDetection {
695    /// Point vs sphere. Returns true if `point` is within `radius` of `center`.
696    pub fn point_in_sphere(point: Vec3, center: Vec3, radius: f32) -> bool {
697        (point - center).length_squared() <= radius * radius
698    }
699
700    /// Point vs AABB. Returns true if `point` is inside the box.
701    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    /// Point vs cylinder (along Y axis). Returns true if point is inside.
708    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    /// Sphere vs sphere intersection. Returns overlap depth (negative = no overlap).
716    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    /// Cone hit test — used for directional attacks (breath, slam, sweep).
722    ///
723    /// Returns true if `target` is inside a cone from `origin` pointing `direction`,
724    /// with half-angle `half_angle_rad` and max range `range`.
725    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    /// Raycast against a sphere. Returns distance to hit (None if miss).
736    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    /// Find all targets within range of `origin`, sorted by distance.
751    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    /// Compute knockback vector from attacker to defender, scaled by `strength`.
767    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// ── CombatEvent log ───────────────────────────────────────────────────────────
774
775/// An entry in the combat event log.
776#[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/// Rolling combat event log.
786#[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// ── Threat system ─────────────────────────────────────────────────────────────
830
831/// Tracks threat levels for AI targeting.
832///
833/// Entities with higher threat are prioritized for attacks.
834#[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    /// Decay all threat by `factor` per second.
853    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    /// Highest threat entity ID.
861    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    /// All threats sorted by amount (descending).
868    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// ── Tests ──────────────────────────────────────────────────────────────────────
881
882#[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; // always dodge
899        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        // post_resist should be ~half of pre_resist
936        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); // 1 second
954        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}