pub mod inventory;
pub mod abilities;
pub mod combo;
pub use inventory::{Item, ItemCategory, Inventory, Equipment, Rarity, LootTable, LootDrop};
pub use abilities::{Ability, AbilityBar, ResourcePool, ResourceType, AbilityEffect};
pub use combo::{ComboState, ComboDatabase, ComboTracker, ComboInput, InputBuffer};
use glam::Vec3;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Element {
Physical,
Fire,
Ice,
Lightning,
Void,
Entropy,
Gravity,
Radiant,
Shadow,
Temporal,
}
impl Element {
pub fn color(self) -> glam::Vec4 {
match self {
Element::Physical => glam::Vec4::new(0.85, 0.80, 0.75, 1.0),
Element::Fire => glam::Vec4::new(1.00, 0.40, 0.10, 1.0),
Element::Ice => glam::Vec4::new(0.50, 0.85, 1.00, 1.0),
Element::Lightning => glam::Vec4::new(1.00, 0.95, 0.20, 1.0),
Element::Void => glam::Vec4::new(0.20, 0.00, 0.40, 1.0),
Element::Entropy => glam::Vec4::new(0.60, 0.10, 0.80, 1.0),
Element::Gravity => glam::Vec4::new(0.30, 0.30, 0.60, 1.0),
Element::Radiant => glam::Vec4::new(1.00, 1.00, 0.70, 1.0),
Element::Shadow => glam::Vec4::new(0.10, 0.05, 0.20, 1.0),
Element::Temporal => glam::Vec4::new(0.40, 0.90, 0.70, 1.0),
}
}
pub fn glyph(self) -> char {
match self {
Element::Physical => '✦',
Element::Fire => '♨',
Element::Ice => '❄',
Element::Lightning => '⚡',
Element::Void => '◈',
Element::Entropy => '∞',
Element::Gravity => '⊕',
Element::Radiant => '☀',
Element::Shadow => '◆',
Element::Temporal => '⧗',
}
}
}
#[derive(Debug, Clone)]
pub struct ResistanceProfile {
pub resistances: HashMap<Element, f32>,
}
impl ResistanceProfile {
pub fn neutral() -> Self {
let mut r = HashMap::new();
for &el in &[
Element::Physical, Element::Fire, Element::Ice, Element::Lightning,
Element::Void, Element::Entropy, Element::Gravity, Element::Radiant,
Element::Shadow, Element::Temporal,
] {
r.insert(el, 1.0);
}
Self { resistances: r }
}
pub fn get(&self, el: Element) -> f32 {
*self.resistances.get(&el).unwrap_or(&1.0)
}
pub fn set(&mut self, el: Element, value: f32) {
self.resistances.insert(el, value);
}
pub fn fire_elemental() -> Self {
let mut p = Self::neutral();
p.set(Element::Fire, 0.0);
p.set(Element::Ice, 2.0);
p.set(Element::Shadow, 1.3);
p
}
pub fn void_entity() -> Self {
let mut p = Self::neutral();
p.set(Element::Void, 0.0);
p.set(Element::Radiant, 2.5);
p.set(Element::Shadow, 0.3);
p.set(Element::Physical, 0.5);
p
}
pub fn chaos_rift() -> Self {
let mut p = Self::neutral();
p.set(Element::Entropy, 0.0);
p.set(Element::Temporal, 0.0);
p.set(Element::Physical, 0.3);
p.set(Element::Gravity, 2.0);
p
}
pub fn boss_resist() -> Self {
let mut p = Self::neutral();
for (_, v) in p.resistances.iter_mut() {
*v *= 0.5;
}
p.set(Element::Entropy, 1.5);
p
}
}
#[derive(Debug, Clone)]
pub struct CombatStats {
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,
pub hp: f32,
pub level: u32,
pub entropy: f32, }
impl Default for CombatStats {
fn default() -> Self {
Self {
attack: 10.0, crit_chance: 0.05, crit_mult: 2.0, penetration: 0.0,
entropy_amp: 1.0, armor: 5.0, dodge_chance: 0.05, block_chance: 0.0,
block_amount: 0.0, max_hp: 100.0, hp: 100.0, level: 1, entropy: 0.0,
}
}
}
impl CombatStats {
pub fn hp_fraction(&self) -> f32 {
(self.hp / self.max_hp.max(1.0)).clamp(0.0, 1.0)
}
pub fn is_alive(&self) -> bool { self.hp > 0.0 }
pub fn take_damage(&mut self, amount: f32) {
self.hp = (self.hp - amount).max(0.0);
}
pub fn heal(&mut self, amount: f32) {
self.hp = (self.hp + amount).min(self.max_hp);
}
pub fn effective_armor(&self, penetration: f32) -> f32 {
self.armor * (1.0 - penetration.clamp(0.0, 1.0))
}
}
#[derive(Debug, Clone)]
pub struct DamageEvent {
pub base_damage: f32,
pub element: Element,
pub attacker_pos: Vec3,
pub defender_pos: Vec3,
pub roll: f32, }
#[derive(Debug, Clone)]
pub struct HitResult {
pub final_damage: f32,
pub is_crit: bool,
pub is_dodge: bool,
pub is_block: bool,
pub is_kill: bool,
pub element: Element,
pub pre_resist: f32, pub post_resist: f32, pub post_armor: f32, pub overkill: f32, }
impl HitResult {
pub fn miss(element: Element) -> Self {
Self {
final_damage: 0.0, is_crit: false, is_dodge: true, is_block: false,
is_kill: false, element, pre_resist: 0.0, post_resist: 0.0,
post_armor: 0.0, overkill: 0.0,
}
}
}
pub struct CombatFormulas;
impl CombatFormulas {
pub fn resolve(
event: &DamageEvent,
attacker: &CombatStats,
defender: &CombatStats,
resistances: &ResistanceProfile,
) -> HitResult {
if event.roll < defender.dodge_chance {
return HitResult::miss(event.element);
}
let crit_roll = (event.roll * 1.61803) % 1.0; let is_crit = crit_roll < attacker.crit_chance;
let crit_factor = if is_crit { attacker.crit_mult } else { 1.0 };
let base = event.base_damage * attacker.attack * crit_factor * attacker.entropy_amp;
let level_armor = (defender.level as f32 - attacker.level as f32).max(0.0) * 2.0;
let effective_armor = defender.effective_armor(attacker.penetration) + level_armor;
let resist = resistances.get(event.element);
let post_resist = base * resist;
let block_roll = (event.roll * 2.71828) % 1.0;
let is_block = block_roll < defender.block_chance;
let post_block = if is_block {
(post_resist - defender.block_amount).max(post_resist * 0.1)
} else {
post_resist
};
let armor_factor = 100.0 / (100.0 + effective_armor.max(0.0));
let post_armor = (post_block * armor_factor).max(1.0);
let final_damage = post_armor;
let is_kill = final_damage >= defender.hp;
let overkill = if is_kill { final_damage - defender.hp } else { 0.0 };
HitResult {
final_damage,
is_crit,
is_dodge: false,
is_block,
is_kill,
element: event.element,
pre_resist: base,
post_resist,
post_armor,
overkill,
}
}
pub fn splash_damage(base_result: &HitResult, splash_radius: f32, distance: f32) -> f32 {
let falloff = (1.0 - (distance / splash_radius.max(0.001))).max(0.0);
base_result.final_damage * falloff * falloff
}
pub fn entropy_damage(base_damage: f32, defender_entropy: f32, attacker_entropy_amp: f32) -> f32 {
base_damage * defender_entropy * attacker_entropy_amp * 0.5
}
pub fn gravity_damage(base_damage: f32, attacker_pos: Vec3, defender_pos: Vec3) -> f32 {
let height_diff = (attacker_pos.y - defender_pos.y).max(0.0);
base_damage * (1.0 + height_diff * 0.1)
}
pub fn temporal_slow_factor(damage: f32, defender_max_hp: f32) -> f32 {
let ratio = (damage / defender_max_hp.max(1.0)).min(1.0);
(1.0 - ratio * 0.8).max(0.1)
}
pub fn dps(damage_per_hit: f32, hits_per_second: f32, crit_chance: f32, crit_mult: f32) -> f32 {
let avg_mult = 1.0 + crit_chance * (crit_mult - 1.0);
damage_per_hit * hits_per_second * avg_mult
}
pub fn effective_hp(hp: f32, armor: f32) -> f32 {
hp * (1.0 + armor / 100.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StatusKind {
Burning,
Frozen,
Poisoned,
Stunned,
Cursed,
Corroded,
Vulnerable,
Thorned,
Regenerating,
Enraged,
Silenced,
Entropied,
TemporalSnare,
GravityWell,
}
impl StatusKind {
pub fn is_debuff(self) -> bool {
!matches!(self, StatusKind::Regenerating | StatusKind::Enraged | StatusKind::Thorned)
}
pub fn element(self) -> Element {
match self {
StatusKind::Burning => Element::Fire,
StatusKind::Frozen => Element::Ice,
StatusKind::Poisoned => Element::Physical,
StatusKind::Stunned => Element::Physical,
StatusKind::Cursed => Element::Shadow,
StatusKind::Corroded => Element::Physical,
StatusKind::Vulnerable => Element::Physical,
StatusKind::Thorned => Element::Physical,
StatusKind::Regenerating => Element::Radiant,
StatusKind::Enraged => Element::Fire,
StatusKind::Silenced => Element::Void,
StatusKind::Entropied => Element::Entropy,
StatusKind::TemporalSnare => Element::Temporal,
StatusKind::GravityWell => Element::Gravity,
}
}
pub fn indicator_glyph(self) -> char {
match self {
StatusKind::Burning => '🔥',
StatusKind::Frozen => '❄',
StatusKind::Poisoned => '☠',
StatusKind::Stunned => '★',
StatusKind::Cursed => '⊗',
StatusKind::Corroded => '⊙',
StatusKind::Vulnerable => '↓',
StatusKind::Thorned => '✦',
StatusKind::Regenerating => '✚',
StatusKind::Enraged => '↑',
StatusKind::Silenced => '∅',
StatusKind::Entropied => '∞',
StatusKind::TemporalSnare => '⧗',
StatusKind::GravityWell => '⊕',
}
}
}
#[derive(Debug, Clone)]
pub struct StatusEffect {
pub kind: StatusKind,
pub duration: f32,
pub age: f32,
pub strength: f32,
pub stacks: u32,
pub max_stacks: u32,
pub source_id: Option<u32>,
}
impl StatusEffect {
pub fn new(kind: StatusKind, duration: f32, strength: f32) -> Self {
Self { kind, duration, age: 0.0, strength, stacks: 1, max_stacks: 5, source_id: None }
}
pub fn burning(dps: f32) -> Self { Self::new(StatusKind::Burning, 4.0, dps) }
pub fn frozen() -> Self { Self::new(StatusKind::Frozen, 2.0, 0.3) }
pub fn poisoned(dps: f32) -> Self {
let mut s = Self::new(StatusKind::Poisoned, 6.0, dps);
s.max_stacks = 8;
s
}
pub fn stunned(duration: f32) -> Self { Self::new(StatusKind::Stunned, duration, 1.0) }
pub fn regen(hp_per_sec: f32, duration: f32) -> Self {
Self::new(StatusKind::Regenerating, duration, hp_per_sec)
}
pub fn enraged() -> Self { Self::new(StatusKind::Enraged, 8.0, 1.5) }
pub fn entropied(entropy: f32, duration: f32) -> Self {
Self::new(StatusKind::Entropied, duration, entropy)
}
pub fn is_expired(&self) -> bool { self.age >= self.duration }
pub fn remaining(&self) -> f32 { (self.duration - self.age).max(0.0) }
pub fn progress(&self) -> f32 { (self.age / self.duration).clamp(0.0, 1.0) }
pub fn effective_strength(&self) -> f32 {
self.strength * self.stacks as f32
}
pub fn tick(&mut self, dt: f32) -> f32 {
self.age += dt;
match self.kind {
StatusKind::Burning | StatusKind::Poisoned => {
self.effective_strength() * dt
}
StatusKind::Regenerating => {
-self.effective_strength() * dt
}
_ => 0.0,
}
}
pub fn add_stack(&mut self) -> bool {
if self.stacks < self.max_stacks {
self.stacks += 1;
self.age = 0.0; true
} else {
false
}
}
pub fn movement_slow(&self) -> f32 {
match self.kind {
StatusKind::Frozen => 1.0 - self.strength.clamp(0.0, 0.9),
StatusKind::Stunned => 0.0,
StatusKind::TemporalSnare => 1.0 - self.strength.clamp(0.0, 0.8),
StatusKind::Poisoned => 1.0 - self.stacks as f32 * 0.03,
_ => 1.0,
}
}
pub fn attack_speed_mult(&self) -> f32 {
match self.kind {
StatusKind::Frozen => 0.3,
StatusKind::Stunned => 0.0,
StatusKind::Enraged => self.strength,
StatusKind::Silenced => 0.0,
_ => 1.0,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StatusTracker {
pub effects: Vec<StatusEffect>,
}
impl StatusTracker {
pub fn new() -> Self { Self { effects: Vec::new() } }
pub fn apply(&mut self, mut effect: StatusEffect) {
for existing in &mut self.effects {
if existing.kind == effect.kind {
if !existing.add_stack() {
existing.age = 0.0;
}
return;
}
}
effect.stacks = 1;
self.effects.push(effect);
}
pub fn tick(&mut self, dt: f32) -> f32 {
let mut total_damage = 0.0;
for effect in &mut self.effects {
total_damage += effect.tick(dt);
}
self.effects.retain(|e| !e.is_expired());
total_damage
}
pub fn remove(&mut self, kind: StatusKind) {
self.effects.retain(|e| e.kind != kind);
}
pub fn clear(&mut self) {
self.effects.clear();
}
pub fn has(&self, kind: StatusKind) -> bool {
self.effects.iter().any(|e| e.kind == kind)
}
pub fn is_stunned(&self) -> bool { self.has(StatusKind::Stunned) }
pub fn is_frozen(&self) -> bool { self.has(StatusKind::Frozen) }
pub fn is_silenced(&self) -> bool { self.has(StatusKind::Silenced) }
pub fn movement_factor(&self) -> f32 {
self.effects.iter().map(|e| e.movement_slow())
.fold(1.0_f32, f32::min)
}
pub fn attack_speed_factor(&self) -> f32 {
self.effects.iter().map(|e| e.attack_speed_mult())
.fold(1.0_f32, f32::min)
}
pub fn entropy_amp(&self) -> f32 {
self.effects.iter()
.filter(|e| e.kind == StatusKind::Entropied)
.map(|e| e.effective_strength())
.sum::<f32>()
.clamp(0.0, 3.0)
}
pub fn vulnerable_mult(&self) -> f32 {
if self.has(StatusKind::Vulnerable) { 1.25 } else { 1.0 }
}
pub fn thorns_reflection(&self) -> f32 {
self.effects.iter()
.filter(|e| e.kind == StatusKind::Thorned)
.map(|e| e.effective_strength() * 0.1)
.sum::<f32>()
.min(0.5)
}
}
#[derive(Debug, Clone)]
pub struct DpsTracker {
pub window: f32,
samples: std::collections::VecDeque<(f32, f32)>, pub time: f32,
}
impl DpsTracker {
pub fn new(window_seconds: f32) -> Self {
Self { window: window_seconds, samples: std::collections::VecDeque::new(), time: 0.0 }
}
pub fn record(&mut self, damage: f32) {
self.samples.push_back((self.time, damage));
}
pub fn tick(&mut self, dt: f32) {
self.time += dt;
let cutoff = self.time - self.window;
while self.samples.front().map_or(false, |&(t, _)| t < cutoff) {
self.samples.pop_front();
}
}
pub fn dps(&self) -> f32 {
let total: f32 = self.samples.iter().map(|(_, d)| d).sum();
total / self.window.max(0.001)
}
pub fn total_damage(&self) -> f32 {
self.samples.iter().map(|(_, d)| d).sum()
}
pub fn hit_count(&self) -> usize { self.samples.len() }
pub fn reset(&mut self) {
self.samples.clear();
self.time = 0.0;
}
}
pub struct HitDetection;
impl HitDetection {
pub fn point_in_sphere(point: Vec3, center: Vec3, radius: f32) -> bool {
(point - center).length_squared() <= radius * radius
}
pub fn point_in_aabb(point: Vec3, min: Vec3, max: Vec3) -> bool {
point.x >= min.x && point.x <= max.x
&& point.y >= min.y && point.y <= max.y
&& point.z >= min.z && point.z <= max.z
}
pub fn point_in_cylinder(point: Vec3, center: Vec3, radius: f32, half_height: f32) -> bool {
let dx = point.x - center.x;
let dz = point.z - center.z;
let dy = (point.y - center.y).abs();
dx * dx + dz * dz <= radius * radius && dy <= half_height
}
pub fn sphere_overlap(ca: Vec3, ra: f32, cb: Vec3, rb: f32) -> f32 {
let dist = (ca - cb).length();
ra + rb - dist
}
pub fn point_in_cone(
target: Vec3, origin: Vec3, direction: Vec3, half_angle_rad: f32, range: f32,
) -> bool {
let to_target = target - origin;
let dist = to_target.length();
if dist > range || dist < 1e-6 { return false; }
let cos_angle = to_target.dot(direction.normalize_or_zero()) / dist;
cos_angle >= half_angle_rad.cos()
}
pub fn ray_vs_sphere(
ray_origin: Vec3, ray_dir: Vec3, sphere_center: Vec3, sphere_radius: f32,
) -> Option<f32> {
let oc = ray_origin - sphere_center;
let b = oc.dot(ray_dir);
let c = oc.dot(oc) - sphere_radius * sphere_radius;
let discriminant = b * b - c;
if discriminant < 0.0 { return None; }
let sqrt_d = discriminant.sqrt();
let t0 = -b - sqrt_d;
let t1 = -b + sqrt_d;
if t0 >= 0.0 { Some(t0) } else if t1 >= 0.0 { Some(t1) } else { None }
}
pub fn targets_in_range<'a>(
origin: Vec3,
targets: &'a [Vec3],
range: f32,
) -> Vec<(usize, f32)> {
let mut hits: Vec<(usize, f32)> = targets.iter().enumerate()
.filter_map(|(i, &pos)| {
let dist = (pos - origin).length();
if dist <= range { Some((i, dist)) } else { None }
})
.collect();
hits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
hits
}
pub fn knockback(attacker_pos: Vec3, defender_pos: Vec3, strength: f32) -> Vec3 {
let dir = (defender_pos - attacker_pos).normalize_or_zero();
dir * strength
}
}
#[derive(Debug, Clone)]
pub struct CombatLogEntry {
pub timestamp: f32,
pub attacker_id: u32,
pub defender_id: u32,
pub result: HitResult,
pub status_applied: Option<StatusKind>,
}
#[derive(Debug, Clone)]
pub struct CombatLog {
pub entries: Vec<CombatLogEntry>,
pub max_entries: usize,
}
impl CombatLog {
pub fn new(max_entries: usize) -> Self {
Self { entries: Vec::new(), max_entries }
}
pub fn push(&mut self, entry: CombatLogEntry) {
if self.entries.len() >= self.max_entries {
self.entries.remove(0);
}
self.entries.push(entry);
}
pub fn kills(&self) -> usize {
self.entries.iter().filter(|e| e.result.is_kill).count()
}
pub fn crits(&self) -> usize {
self.entries.iter().filter(|e| e.result.is_crit).count()
}
pub fn total_damage(&self) -> f32 {
self.entries.iter().map(|e| e.result.final_damage).sum()
}
pub fn crit_rate(&self) -> f32 {
if self.entries.is_empty() { return 0.0; }
self.crits() as f32 / self.entries.len() as f32
}
pub fn avg_damage(&self) -> f32 {
if self.entries.is_empty() { return 0.0; }
self.total_damage() / self.entries.len() as f32
}
pub fn clear(&mut self) { self.entries.clear(); }
}
#[derive(Debug, Clone, Default)]
pub struct ThreatTable {
pub threat: HashMap<u32, f32>,
}
impl ThreatTable {
pub fn new() -> Self { Self { threat: HashMap::new() } }
pub fn add_threat(&mut self, id: u32, amount: f32) {
*self.threat.entry(id).or_insert(0.0) += amount;
}
pub fn reduce_threat(&mut self, id: u32, amount: f32) {
if let Some(t) = self.threat.get_mut(&id) {
*t = (*t - amount).max(0.0);
}
}
pub fn decay(&mut self, dt: f32, factor: f32) {
for t in self.threat.values_mut() {
*t *= (1.0 - factor * dt).max(0.0);
}
self.threat.retain(|_, &mut t| t > 0.001);
}
pub fn top_target(&self) -> Option<u32> {
self.threat.iter()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(&id, _)| id)
}
pub fn sorted_targets(&self) -> Vec<(u32, f32)> {
let mut v: Vec<(u32, f32)> = self.threat.iter().map(|(&id, &t)| (id, t)).collect();
v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
v
}
pub fn remove(&mut self, id: u32) { self.threat.remove(&id); }
pub fn clear(&mut self) { self.threat.clear(); }
pub fn get(&self, id: u32) -> f32 { *self.threat.get(&id).unwrap_or(&0.0) }
pub fn target_count(&self) -> usize { self.threat.len() }
}
#[cfg(test)]
mod tests {
use super::*;
fn make_attacker() -> CombatStats {
CombatStats { attack: 20.0, crit_chance: 0.0, crit_mult: 2.0, ..Default::default() }
}
fn make_defender() -> CombatStats {
CombatStats { hp: 100.0, max_hp: 100.0, armor: 0.0, dodge_chance: 0.0, ..Default::default() }
}
#[test]
fn dodge_on_low_roll() {
let att = make_attacker();
let mut def = make_defender();
def.dodge_chance = 1.0; let event = DamageEvent {
base_damage: 1.0, element: Element::Physical,
attacker_pos: Vec3::ZERO, defender_pos: Vec3::ONE,
roll: 0.5,
};
let result = CombatFormulas::resolve(&event, &att, &def, &ResistanceProfile::neutral());
assert!(result.is_dodge, "should dodge with dodge_chance=1.0");
assert_eq!(result.final_damage, 0.0);
}
#[test]
fn crit_doubles_damage() {
let att = CombatStats { attack: 10.0, crit_chance: 1.0, crit_mult: 2.0, ..Default::default() };
let def = make_defender();
let event = DamageEvent {
base_damage: 1.0, element: Element::Physical,
attacker_pos: Vec3::ZERO, defender_pos: Vec3::ONE,
roll: 0.5,
};
let result = CombatFormulas::resolve(&event, &att, &def, &ResistanceProfile::neutral());
assert!(result.is_crit, "should be crit with crit_chance=1.0");
assert!(result.pre_resist > 10.0, "crit should amplify damage");
}
#[test]
fn fire_resistance_halves_fire_damage() {
let att = make_attacker();
let def = make_defender();
let mut resist = ResistanceProfile::neutral();
resist.set(Element::Fire, 0.5);
let event = DamageEvent {
base_damage: 1.0, element: Element::Fire,
attacker_pos: Vec3::ZERO, defender_pos: Vec3::ONE,
roll: 0.5,
};
let result = CombatFormulas::resolve(&event, &att, &def, &resist);
assert!((result.post_resist - result.pre_resist * 0.5).abs() < 0.01,
"fire resist 0.5 should halve damage");
}
#[test]
fn status_tracker_stacks() {
let mut tracker = StatusTracker::new();
tracker.apply(StatusEffect::poisoned(5.0));
tracker.apply(StatusEffect::poisoned(5.0));
let poison = tracker.effects.iter().find(|e| e.kind == StatusKind::Poisoned).unwrap();
assert_eq!(poison.stacks, 2);
}
#[test]
fn status_tracker_dots_damage() {
let mut tracker = StatusTracker::new();
tracker.apply(StatusEffect::burning(10.0));
let dmg = tracker.tick(1.0); assert!((dmg - 10.0).abs() < 0.01, "burning 10 dps for 1 sec = 10 damage, got {}", dmg);
}
#[test]
fn dps_tracker_rolling() {
let mut tracker = DpsTracker::new(3.0);
tracker.record(30.0);
tracker.tick(1.0);
assert!((tracker.dps() - 10.0).abs() < 0.01, "30 damage over 3s window = 10 dps");
}
#[test]
fn hit_detection_sphere() {
assert!(HitDetection::point_in_sphere(Vec3::new(0.5, 0.0, 0.0), Vec3::ZERO, 1.0));
assert!(!HitDetection::point_in_sphere(Vec3::new(2.0, 0.0, 0.0), Vec3::ZERO, 1.0));
}
#[test]
fn hit_detection_cone() {
let in_cone = HitDetection::point_in_cone(
Vec3::new(0.0, 0.0, 1.0), Vec3::ZERO, Vec3::Z, 0.5, 5.0
);
assert!(in_cone, "point directly in front should be in cone");
let behind = HitDetection::point_in_cone(
Vec3::new(0.0, 0.0, -1.0), Vec3::ZERO, Vec3::Z, 0.5, 5.0
);
assert!(!behind, "point behind should not be in cone");
}
#[test]
fn threat_table_top_target() {
let mut tt = ThreatTable::new();
tt.add_threat(1, 50.0);
tt.add_threat(2, 200.0);
tt.add_threat(3, 10.0);
assert_eq!(tt.top_target(), Some(2));
}
#[test]
fn combat_log_stats() {
let mut log = CombatLog::new(100);
log.push(CombatLogEntry {
timestamp: 0.0, attacker_id: 1, defender_id: 2,
result: HitResult {
final_damage: 50.0, is_crit: true, is_dodge: false,
is_block: false, is_kill: false, element: Element::Fire,
pre_resist: 60.0, post_resist: 50.0, post_armor: 50.0, overkill: 0.0,
},
status_applied: None,
});
assert!((log.total_damage() - 50.0).abs() < 0.01);
assert_eq!(log.crits(), 1);
}
}