wowlab_parsers/parsers/spell_desc/
game_data_resolver.rs1use 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#[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 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 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 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 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 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 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 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;