Skip to main content

wowlab_parsers/parsers/spell_desc/
game_data_resolver.rs

1use wowlab_types::{
2    constants::{MS_PER_SECOND, SECONDS_PER_MINUTE},
3    data::SpellEffect,
4    spell_render::{SpellRenderInput, SpellRenderSpell},
5};
6
7use super::resolver::{EffectValueResolver, PlayerStateResolver, SpellTextResolver};
8
9const VARIANCE_DELTA_DIVISOR: f64 = 2.0;
10
11#[derive(Clone, Copy)]
12enum Spread {
13    Base,
14    Min,
15    Max,
16}
17
18/// Resolves a spell description against a fully resolved character bundle.
19#[derive(Debug)]
20pub struct GameDataResolver<'a>(&'a SpellRenderInput);
21
22impl<'a> GameDataResolver<'a> {
23    #[must_use]
24    pub fn new(input: &'a SpellRenderInput) -> Self {
25        Self(input)
26    }
27
28    fn find_spell(&self, spell_id: u32) -> Option<&SpellRenderSpell> {
29        if self.0.self_spell.id == spell_id {
30            Some(&self.0.self_spell)
31        } else {
32            self.0.cross_spells.iter().find(|s| s.id == spell_id)
33        }
34    }
35
36    // #t(fn: rust_floating_point_eq) exact zero-variance check is intentional
37    fn scaled(&self, effect: &SpellEffect, spread: Spread) -> f64 {
38        let stats = &self.0.paperdoll.stats;
39        let base = effect.base_points
40            + effect.bonus_coefficient * stats.spell_power
41            + effect.bonus_coefficient_from_ap * stats.attack_power;
42        let variance = f64::from(effect.variance);
43        // variance is the raw DBC m_delta column; halve it for the spread.
44        let value = match spread {
45            Spread::Base => base,
46            Spread::Min => base * (1.0 - variance / VARIANCE_DELTA_DIVISOR),
47            Spread::Max => base * (1.0 + variance / VARIANCE_DELTA_DIVISOR),
48        };
49        // WHY: a negative zero-variance value renders as its magnitude.
50
51        if base < 0.0 && variance == 0.0 {
52            -value
53        } else {
54            value
55        }
56    }
57}
58
59impl EffectValueResolver for GameDataResolver<'_> {
60    fn get_effect_value(&self, spell_id: u32, effect_index: u8, var_type: &str) -> Option<f64> {
61        let spell = self.find_spell(spell_id)?;
62        let target_index = i32::from(effect_index) - 1;
63        let effect = spell.effects.iter().find(|e| e.index == target_index)?;
64
65        match var_type {
66            "a" => Some(f64::from(effect.radius_min)),
67            "A" => Some(f64::from(effect.radius_max)),
68            "bc" => Some(effect.bonus_coefficient),
69            "e" => Some(f64::from(effect.amplitude)),
70            "m" => Some(self.scaled(effect, Spread::Min)),
71            "M" => Some(self.scaled(effect, Spread::Max)),
72            "o" => (effect.period != 0 && spell.duration != 0).then(|| {
73                let ticks = spell.duration / effect.period;
74
75                f64::from(ticks) * effect.base_points
76            }),
77            "q" => Some(effect.base_points),
78            "s" | "S" => Some(self.scaled(effect, Spread::Base)),
79            "sw" | "w" | "W" => Some(f64::from(effect.coefficient)),
80            "t" => (effect.period != 0).then(|| f64::from(effect.period) / MS_PER_SECOND),
81            "x" => Some(f64::from(effect.chain_targets)),
82            _ => None,
83        }
84    }
85
86    // #t(fn: rust_floating_point_eq) non-zero truthiness checks are intentional
87    fn get_spell_value(&self, spell_id: u32, var_type: &str) -> Option<String> {
88        let spell = self.find_spell(spell_id)?;
89        let base_type = var_type.trim_end_matches(|c: char| c.is_ascii_digit());
90
91        match base_type {
92            "c" => (spell.cast_time != 0).then(|| format_duration_ms(spell.cast_time)),
93            "d" => (spell.duration != 0).then(|| format_duration_ms(spell.duration)),
94            "n" => (spell.max_charges != 0).then(|| spell.max_charges.to_string()),
95            "r" => (spell.range_max_0 != 0.0).then(|| format_yards(f64::from(spell.range_max_0))),
96            "u" => (spell.max_stacks != 0).then(|| spell.max_stacks.to_string()),
97            _ => None,
98        }
99    }
100
101    fn get_custom_var(&self, name: &str) -> Option<f64> {
102        // WHY: description_variables is frequently "" or non-JSON; guard the parse.
103        let raw = &self.0.self_spell.description_variables;
104
105        if raw.is_empty() {
106            return None;
107        }
108
109        let parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
110
111        parsed.get(name)?.as_f64()
112    }
113}
114
115impl PlayerStateResolver for GameDataResolver<'_> {
116    // #t(rust_cyclomatic_complexity) player-stat dispatch; every arm sources real character data.
117    fn get_player_stat(&self, stat: &str) -> Option<f64> {
118        let pd = &self.0.paperdoll;
119        let stats = &pd.stats;
120        let weapon = &pd.weapon;
121
122        match stat.to_lowercase().as_str() {
123            "ap" | "rap" => Some(stats.attack_power),
124            "sp" | "sps" => Some(stats.spell_power),
125            "int" => Some(stats.intellect),
126            "crit" => Some(stats.crit),
127            "haste" => Some(stats.haste),
128            "mas" | "mastery" => Some(stats.mastery),
129            "vers" | "versadmg" | "versaheal" | "versatility" => Some(stats.versatility),
130            "pl" | "lpoint" => Some(f64::from(pd.level)),
131            "mhp" => Some(pd.max_health),
132            "pri" => Some(stats.primary_stat),
133            "rolemult" => Some(pd.role_mult),
134            "mws" => Some(weapon.main_min),
135            "mwb" => Some(weapon.main_max),
136            "ows" => Some(weapon.off_min),
137            "owb" => Some(weapon.off_max),
138            "procrppm" => Some(f64::from(self.0.self_spell.proc_rppm)),
139            "proccooldown" => Some(f64::from(self.0.self_spell.internal_cooldown)),
140            _ => None,
141        }
142    }
143
144    fn knows_spell(&self, spell_id: u32) -> bool {
145        self.0.paperdoll.known_spell_ids.contains(&spell_id)
146    }
147
148    fn has_aura(&self, aura_id: u32) -> bool {
149        self.0.paperdoll.active_aura_ids.contains(&aura_id)
150    }
151
152    fn is_specialization(&self, spec_index: u8) -> bool {
153        // `$?cN` is 1-based over `ChrSpecialization.OrderIndex`, so `$?c0` names no specialization.
154        spec_index
155            .checked_sub(1)
156            .is_some_and(|order| order == self.0.paperdoll.spec_order_index)
157    }
158
159    fn is_male(&self) -> bool {
160        self.0.paperdoll.is_male
161    }
162}
163
164impl SpellTextResolver for GameDataResolver<'_> {
165    fn get_spell_description(&self, spell_id: u32) -> Option<String> {
166        self.find_spell(spell_id).map(|s| s.description.clone())
167    }
168
169    fn get_spell_name(&self, spell_id: u32) -> Option<String> {
170        self.find_spell(spell_id).map(|s| s.name.clone())
171    }
172
173    fn get_spell_icon(&self, spell_id: u32) -> Option<String> {
174        self.find_spell(spell_id).map(|s| s.file_name.clone())
175    }
176}
177
178fn format_duration_ms(ms: i32) -> String {
179    let sec = f64::from(ms) / MS_PER_SECOND;
180
181    if sec >= SECONDS_PER_MINUTE {
182        let min = wowlab_types::numeric::f64_to_i64_saturating_floor(sec / SECONDS_PER_MINUTE);
183        let remainder =
184            wowlab_types::numeric::f64_to_i64_saturating_round(sec % SECONDS_PER_MINUTE);
185
186        if remainder > 0 {
187            format!("{min} min {remainder} sec")
188        } else {
189            format!("{min} min")
190        }
191    } else {
192        format!("{} sec", trim_one_decimal(sec))
193    }
194}
195
196fn format_yards(yards: f64) -> String {
197    format!(
198        "{} yd",
199        wowlab_types::numeric::f64_to_i64_saturating_round(yards)
200    )
201}
202
203fn trim_one_decimal(value: f64) -> String {
204    let formatted = format!("{value:.1}");
205
206    formatted
207        .trim_end_matches('0')
208        .trim_end_matches('.')
209        .to_string()
210}
211
212#[cfg(test)]
213mod tests;