use std::collections::{BTreeSet, HashMap};
use serde::{Deserialize, Serialize};
use crate::Stat;
use crate::constants::{
DAMAGE_CAPS_OUT_OF_COMBAT, DAMAGE_CAPS_PVE, DAMAGE_CAPS_PVP, INNATE_BLEED_RATE,
KHAN_REQ_REDUCTION, MAX_SINGLE_RESIST, PROFICIENCY_PER_POINT, REQUIREMENT_PENALTY, RING_FACTOR,
SCALING_DIVISOR, SCALING_FACTOR, SILENTHEART, SILENTHEART_REQ_REDUCTION, TRAIT_CAP,
};
use crate::model::aggregate::{BuildParams, DamageKind, StarMod, StatOrigin, StatSource};
use crate::model::data::DeepData;
use crate::model::req::{Atom, ClauseType, Requirement};
use crate::model::stat;
use crate::util::statmap::StatMap;
fn clamp_resist(percent: f64) -> f64 {
percent.clamp(0.0, MAX_SINGLE_RESIST) / 100.0
}
#[must_use]
pub fn damage_reduction(
percents: &HashMap<String, Vec<StatSource>>,
kind: impl Into<DamageKind>,
) -> f64 {
let (group, subtype) = kind.into().keys();
let mut equipment = 0.0;
let mut product = 1.0;
for key in [Some(group), subtype].into_iter().flatten() {
let Some(entries) = percents.get(key) else {
continue;
};
for entry in entries {
if entry.origin == StatOrigin::Equipment {
equipment += entry.value;
} else {
product *= 1.0 - clamp_resist(entry.value);
}
}
}
product *= 1.0 - clamp_resist(equipment);
1.0 - product
}
#[must_use]
pub fn effective_health(health: f64, reduction: f64) -> f64 {
health / (1.0 - reduction).max(0.01)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CombatState {
OutOfCombat,
Pve,
#[default]
Pvp,
}
impl CombatState {
#[must_use]
pub fn damage_caps(self) -> (f64, f64) {
match self {
CombatState::OutOfCombat => DAMAGE_CAPS_OUT_OF_COMBAT,
CombatState::Pve => DAMAGE_CAPS_PVE,
CombatState::Pvp => DAMAGE_CAPS_PVP,
}
}
}
#[must_use]
#[allow(clippy::cast_possible_truncation, reason = "at most 4 rings")]
pub fn scaled_damage(base: f64, scaling: &[(f64, f64)], rings: &[f64], proficiency: i64) -> f64 {
let stat_term: f64 = scaling.iter().map(|(value, coeff)| value * coeff).sum();
let stat_fraction = SCALING_FACTOR * stat_term / SCALING_DIVISOR;
let mut ranked = rings.to_vec();
ranked.sort_by(|a, b| b.total_cmp(a));
let ring_fraction: f64 = ranked
.iter()
.enumerate()
.map(|(i, investment)| RING_FACTOR * investment / (2f64.powi(i as i32) * SCALING_DIVISOR))
.sum();
let proficiency_mult = 1.0 + proficiency as f64 * PROFICIENCY_PER_POINT;
base * (1.0 + (stat_fraction + ring_fraction) * proficiency_mult)
}
#[must_use]
pub fn damage_modifier(raw: f64, state: CombatState) -> f64 {
let (soft, hard) = state.damage_caps();
let softened = if raw > soft { soft + (raw - soft) / 2.0 } else { raw };
softened.min(hard)
}
#[must_use]
pub fn bleed_damage(scaled: f64, bleed_rate: f64) -> f64 {
scaled * bleed_rate
}
#[must_use]
pub fn requirement_debuff(worst_ratio: f64) -> f64 {
if worst_ratio >= 1.0 {
1.0
} else {
1.0 - REQUIREMENT_PENALTY * (1.0 - worst_ratio.max(0.0))
}
}
#[must_use]
pub fn damage_after_resistance(damage: f64, reduction: f64, penetration: f64) -> f64 {
damage * (1.0 - reduction.clamp(0.0, 1.0) * (1.0 - penetration.clamp(0.0, 1.0)))
}
#[must_use]
pub fn attack_cycle(
attack_duration: Option<f64>,
swing_speed: Option<f64>,
endlag: f64,
) -> Option<f64> {
match (attack_duration, swing_speed) {
(Some(duration), _) if duration > 0.0 => Some(duration),
(_, Some(speed)) if speed > 0.0 => Some(1.0 / speed + endlag),
_ => None,
}
}
#[must_use]
pub fn dps(damage: f64, cycle: Option<f64>) -> Option<f64> {
cycle.filter(|c| *c > 0.0).map(|c| damage / c)
}
fn requirement_reduction(build: &BuildParams, talents: &[String], atom_stats: &BTreeSet<Stat>) -> i64 {
let mut reduction = 0;
if build.race == "Khan" && !atom_stats.contains(&Stat::Total) {
reduction += KHAN_REQ_REDUCTION;
}
let weapon_only = atom_stats.iter().all(|stat| stat::WEAPON.contains(stat));
if weapon_only && talents.iter().any(|t| t == SILENTHEART) {
reduction += SILENTHEART_REQ_REDUCTION;
}
reduction
}
#[must_use]
#[allow(clippy::cast_precision_loss, reason = "stat values are small")]
pub fn worst_requirement_ratio(build: &BuildParams, talents: &[String], reqs: &Requirement) -> f64 {
let ratio = |atom: &Atom| -> f64 {
let needed = atom.value - requirement_reduction(build, talents, &atom.stats);
if needed <= 0 {
return 1.0;
}
let have: i64 = atom
.stats
.iter()
.map(|stat| {
if *stat == Stat::Total {
build.stats.cost()
} else {
build.stats.get(stat)
}
})
.sum();
(have as f64 / needed as f64).min(1.0)
};
let mut worst = 1.0_f64;
for clause in reqs.iter() {
if clause.is_empty() {
continue;
}
let clause_ratio = match clause.clause_type {
ClauseType::Or => clause.atoms().iter().map(ratio).fold(0.0_f64, f64::max),
ClauseType::And => clause.atoms().iter().map(ratio).fold(1.0_f64, f64::min),
};
worst = worst.min(clause_ratio);
}
worst
}
#[allow(clippy::cast_precision_loss, reason = "stat values are small")]
fn scaling_value(name: &str, stats: &StatMap) -> Option<f64> {
if let Ok(stat) = name.parse::<Stat>() {
return Some(stats.get(&stat) as f64);
}
let members = stat::category(name)?;
members.iter().map(|s| stats.get(s)).max().map(|v| v as f64)
}
#[must_use]
#[allow(clippy::cast_precision_loss, reason = "stat values are small")]
pub fn weapon_damage(
data: &DeepData,
build: &BuildParams,
talents: &[String],
percent: &HashMap<String, f64>,
combat_state: CombatState,
) -> Option<(f64, Option<f64>)> {
let selection = build.weapon.as_ref()?;
let weapon = data.get_weapon(&selection.name)?;
let mut base = weapon.damage?;
if selection.star_mod() == Some(StarMod::Damage) {
base *= 1.0 + selection.star_bonus();
}
let scaling: Vec<(f64, f64)> = weapon
.scaling
.iter()
.filter_map(|(name, coeff)| Some((scaling_value(name, &build.stats)?, *coeff)))
.collect();
let proficiency = build.traits.get("Proficiency").copied().unwrap_or(0).min(TRAIT_CAP);
let scaled = scaled_damage(base, &scaling, &[], proficiency);
let raw_modifier = percent.get("Damage").copied().unwrap_or(0.0) / 100.0;
let modifier = damage_modifier(raw_modifier, combat_state);
let innate_bleed = weapon
.damage_types
.iter()
.any(|t| t == "Bleed")
.then_some(INNATE_BLEED_RATE)
.unwrap_or(0.0);
let bleed_rate = (percent.get("Bleed").copied().unwrap_or(0.0) / 100.0).max(innate_bleed);
let bleed = bleed_damage(scaled, bleed_rate);
let debuff = requirement_debuff(worst_requirement_ratio(build, talents, &weapon.reqs));
let m1 = (scaled * (1.0 + modifier) + bleed) * debuff;
let cycle = attack_cycle(
weapon.attack_duration,
weapon.swing_speed,
weapon.endlag.unwrap_or(0.0),
);
Some((m1, dps(m1, cycle)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::aggregate::{DamageGroup, DamageType};
fn percents(entries: &[(&str, f64, StatOrigin)]) -> HashMap<String, Vec<StatSource>> {
let mut map: HashMap<String, Vec<StatSource>> = HashMap::new();
for (key, value, origin) in entries {
map.entry((*key).to_string()).or_default().push(StatSource {
value: *value,
source: String::new(),
origin: *origin,
});
}
map
}
#[test]
fn matches_wiki_worked_example() {
let p = percents(&[
("Physical Resistance", 30.0, StatOrigin::Outfit),
("Blunt Resistance", 10.0, StatOrigin::Outfit),
("Physical Resistance", 10.0, StatOrigin::Equipment),
("Physical Resistance", 16.0, StatOrigin::Equipment),
("Physical Resistance", 3.0, StatOrigin::Equipment),
("Blunt Resistance", 3.0, StatOrigin::Talent),
("Blunt Resistance", 15.0, StatOrigin::Talent),
]);
let dr = damage_reduction(&p, DamageType::Blunt);
assert!((dr - 0.6312).abs() < 1e-4, "got {dr}");
}
#[test]
fn equipment_sums_before_multiplying() {
let equip = percents(&[
("Physical Resistance", 10.0, StatOrigin::Equipment),
("Physical Resistance", 10.0, StatOrigin::Equipment),
]);
assert!((damage_reduction(&equip, DamageType::Blunt) - 0.20).abs() < 1e-9);
let talents = percents(&[
("Physical Resistance", 10.0, StatOrigin::Talent),
("Physical Resistance", 10.0, StatOrigin::Talent),
]);
assert!((damage_reduction(&talents, DamageType::Blunt) - 0.19).abs() < 1e-9);
}
#[test]
fn group_is_not_the_mean_over_types() {
let p = percents(&[
("Elemental Resistance", 30.0, StatOrigin::Outfit),
("Wind Resistance", 5.0, StatOrigin::Outfit),
]);
assert!((damage_reduction(&p, DamageGroup::Elemental) - 0.30).abs() < 1e-9);
assert!((damage_reduction(&p, DamageType::Wind) - 0.335).abs() < 1e-9);
assert!((damage_reduction(&p, DamageType::Flame) - 0.30).abs() < 1e-9);
}
#[test]
fn effective_health_floors_the_denominator() {
assert!((effective_health(220.0, 0.0) - 220.0).abs() < 1e-9);
assert!((effective_health(220.0, 0.5) - 440.0).abs() < 1e-9);
assert!((effective_health(220.0, 1.0) - 22000.0).abs() < 1e-9);
}
#[test]
fn scaled_damage_matches_rimebreakers() {
let scaling = [(65.0, 5.0), (50.0, 3.5), (78.0, 3.5)];
let scaled = scaled_damage(16.5, &scaling, &[], 0);
assert!((scaled - 26.065_875).abs() < 1e-6, "got {scaled}");
}
#[test]
fn scaling_rings_rank_by_investment() {
let ordered = scaled_damage(100.0, &[], &[100.0, 50.0], 0);
let shuffled = scaled_damage(100.0, &[], &[50.0, 100.0], 0);
assert!((ordered - shuffled).abs() < 1e-9);
let expected = 100.0 * (1.0 + (1.2 * 100.0 / 1000.0 + 1.2 * 50.0 / 2000.0));
assert!((ordered - expected).abs() < 1e-9);
}
#[test]
fn proficiency_multiplies_only_the_scaling_term() {
assert!((scaled_damage(20.0, &[], &[], 6) - 20.0).abs() < 1e-9);
let with = scaled_damage(20.0, &[(100.0, 4.0)], &[], 6);
let expected = 20.0 * (1.0 + 0.75 * 400.0 / 1000.0 * (1.0 + 6.0 * 0.065));
assert!((with - expected).abs() < 1e-9);
}
#[test]
fn damage_modifier_halves_past_the_soft_cap() {
assert!((damage_modifier(0.20, CombatState::Pvp) - 0.20).abs() < 1e-9);
assert!((damage_modifier(0.44, CombatState::Pvp) - 0.345).abs() < 1e-9);
assert!((damage_modifier(2.00, CombatState::Pvp) - 0.50).abs() < 1e-9);
assert!((damage_modifier(0.44, CombatState::OutOfCombat) - 0.44).abs() < 1e-9);
assert!((damage_modifier(2.00, CombatState::Pve) - 0.75).abs() < 1e-9);
}
#[test]
fn requirement_debuff_bottoms_out_at_three_quarters() {
assert!((requirement_debuff(1.0) - 1.0).abs() < 1e-9);
assert!((requirement_debuff(0.0) - 0.75).abs() < 1e-9);
assert!((requirement_debuff(0.85) - 0.9625).abs() < 1e-9);
}
#[test]
fn or_clause_takes_the_best_branch() {
let reqs = Requirement::parse("50r INT OR 50r WLL OR 50r CHA").unwrap();
let mut met = BuildParams::default();
met.stats.insert(Stat::Intelligence, 50);
assert!((worst_requirement_ratio(&met, &[], &reqs) - 1.0).abs() < 1e-9);
let mut partial = BuildParams::default();
partial.stats.insert(Stat::Intelligence, 40);
assert!((worst_requirement_ratio(&partial, &[], &reqs) - 0.8).abs() < 1e-9);
}
#[test]
fn silentheart_lowers_weapon_stat_reqs_only() {
let reqs = Requirement::parse("55r HVY, 20r STR").unwrap();
let oath = [SILENTHEART.to_string()];
let mut met = BuildParams::default();
met.stats.insert(Stat::HeavyWeapon, 30);
met.stats.insert(Stat::Strength, 20);
assert!((worst_requirement_ratio(&met, &oath, &reqs) - 1.0).abs() < 1e-9);
let mut short_str = BuildParams::default();
short_str.stats.insert(Stat::HeavyWeapon, 30);
short_str.stats.insert(Stat::Strength, 10);
assert!((worst_requirement_ratio(&short_str, &oath, &reqs) - 0.5).abs() < 1e-9);
}
#[test]
fn total_requirement_reads_build_cost() {
use crate::model::req::Clause;
let mut reqs = Requirement::new();
reqs.add_clause(Clause::and().atom(Atom::strict().value(150).stat(Stat::Total)));
let mut build = BuildParams::default();
build.stats.insert(Stat::Strength, 150);
assert!((worst_requirement_ratio(&build, &[], &reqs) - 1.0).abs() < 1e-9);
}
#[test]
fn scaling_resolves_categories_as_max() {
let mut stats = StatMap::new();
stats.insert(Stat::Intelligence, 50);
stats.insert(Stat::Willpower, 30);
stats.insert(Stat::Charisma, 10);
stats.insert(Stat::HeavyWeapon, 40);
assert_eq!(scaling_value("Mind", &stats), Some(50.0));
assert_eq!(scaling_value("Heavy Weapon", &stats), Some(40.0));
assert_eq!(scaling_value("Body", &stats), Some(0.0));
assert_eq!(scaling_value("Nonsense", &stats), None);
}
#[test]
fn penetration_erodes_resistance() {
let taken = damage_after_resistance(100.0, 0.30, 0.0);
assert!((taken - 70.0).abs() < 1e-9);
let pierced = damage_after_resistance(100.0, 0.30, 0.35);
assert!((pierced - 80.5).abs() < 1e-9);
let full = damage_after_resistance(100.0, 0.30, 1.0);
assert!((full - 100.0).abs() < 1e-9);
}
#[test]
fn attack_duration_beats_swing_speed() {
assert_eq!(attack_cycle(Some(0.5), Some(1.1), 0.0), Some(0.5));
assert_eq!(attack_cycle(None, Some(1.25), 0.2), Some(1.0 / 1.25 + 0.2));
assert_eq!(attack_cycle(None, None, 0.0), None);
assert_eq!(dps(27.0, None), None);
assert!((dps(27.0, Some(0.5)).unwrap() - 54.0).abs() < 1e-9);
}
}