1use std::collections::{BTreeMap, BTreeSet};
7
8#[cfg(test)]
9use googletest::{Result as GtestResult, prelude::*};
10use wowlab_common::output;
11use wowlab_types::{data::SpellDataFlat, game::SpecId};
12
13const EFFECT_CLASS_MASK_WORDS: usize = 4;
14
15#[derive(Default)]
16pub(super) struct AuraSummaries(BTreeMap<(i32, i32), AuraSummary>);
17
18#[derive(Debug, Default)]
19struct AuraSummary {
20 aura_subtype: i32,
21 spell_name: String,
22 is_passive: bool,
23 effect_base: f64,
24 chance_pct: i32,
25 proc_mask: i64,
26 icd_ms: i32,
27 rppm_base_rate: f32,
28 rppm_flags: i32,
29 rppm_mods: String,
30 trigger_spell: i32,
31 class_mask: [i32; EFFECT_CLASS_MASK_WORDS],
32 labels: Vec<i32>,
33 specs: BTreeSet<String>,
34}
35
36#[derive(tabled::Tabled)]
37struct AuraSummaryRow {
38 #[tabled(rename = "Driver")]
39 driver: String,
40 #[tabled(rename = "Effect")]
41 effect: i32,
42 #[tabled(rename = "Aura")]
43 aura_subtype: i32,
44 #[tabled(rename = "Chance %")]
45 chance_pct: i32,
46 #[tabled(rename = "Passive")]
47 is_passive: bool,
48 #[tabled(rename = "Effect base")]
49 effect_base: f64,
50 #[tabled(rename = "Proc mask")]
51 proc_mask: String,
52 #[tabled(rename = "ICD ms")]
53 icd_ms: i32,
54 #[tabled(rename = "RPPM")]
55 rppm_base_rate: f32,
56 #[tabled(rename = "RPPM flags")]
57 rppm_flags: i32,
58 #[tabled(rename = "RPPM mods")]
59 rppm_mods: String,
60 #[tabled(rename = "Trigger")]
61 trigger_spell: i32,
62 #[tabled(rename = "Class mask")]
63 class_mask: String,
64 #[tabled(rename = "Labels")]
65 labels: String,
66 #[tabled(rename = "Actor side")]
67 actor_side: &'static str,
68 #[tabled(rename = "Specs")]
69 specs: usize,
70}
71
72#[derive(tabled::Tabled)]
73struct AuraSpecSummaryRow {
74 #[tabled(rename = "Spec")]
75 spec: String,
76 #[tabled(rename = "Unique rows")]
77 rows: usize,
78}
79
80#[derive(tabled::Tabled)]
81struct AuraCoverageRow {
82 #[tabled(rename = "Unique rows")]
83 unique_rows: usize,
84 #[tabled(rename = "Observations")]
85 observations: usize,
86 #[tabled(rename = "Specs")]
87 specs: usize,
88}
89
90impl AuraSummaries {
91 pub(super) fn observe(&mut self, spec: SpecId, spell: &SpellDataFlat, filter: &[i32]) {
92 for effect in &spell.effects {
93 if !filter.contains(&effect.aura) {
94 continue;
95 }
96
97 let effect_index = effect.index + 1;
98 let summary = self.0.entry((spell.id, effect_index)).or_default();
99
100 summary.aura_subtype = effect.aura;
101 summary.spell_name.clone_from(&spell.name.to_string());
102 summary.is_passive = spell.is_passive;
103 summary.effect_base = effect.base_points;
104 summary.chance_pct = spell.proc_chance;
105 summary.proc_mask = spell.proc_type_mask;
106 summary.icd_ms = spell.proc_category_recovery_ms;
107 summary.rppm_base_rate = spell.rppm_base_rate;
108 summary.rppm_flags = spell.rppm_flags;
109 summary.rppm_mods = spell
110 .rppm_mods
111 .iter()
112 .map(|modifier| {
113 format!(
114 "{}:{}:{}",
115 modifier.mod_type, modifier.param, modifier.coeff
116 )
117 })
118 .collect::<Vec<_>>()
119 .join(",");
120 summary.trigger_spell = effect.trigger_spell;
121 summary.class_mask = [
122 effect.effect_class_mask_1,
123 effect.effect_class_mask_2,
124 effect.effect_class_mask_3,
125 effect.effect_class_mask_4,
126 ];
127 summary.labels = spell.labels.iter().map(|label| label.0).collect();
128 summary.specs.insert(spec.slug().to_string());
129 }
130 }
131
132 pub(super) fn print(&self, filter: &[i32]) {
133 output::blank();
134 output::header(&format!(
135 "Cross-spec aura-subtype summary ({})",
136 filter
137 .iter()
138 .map(i32::to_string)
139 .collect::<Vec<_>>()
140 .join(",")
141 ));
142 let observations = self.0.values().map(|summary| summary.specs.len()).sum();
143 let specs = self
144 .0
145 .values()
146 .flat_map(|summary| summary.specs.iter())
147 .collect::<BTreeSet<_>>()
148 .len();
149
150 output::table([AuraCoverageRow {
151 unique_rows: self.0.len(),
152 observations,
153 specs,
154 }]);
155 output::blank();
156 output::table(self.0.iter().map(|(&(spell_id, effect), summary)| {
157 AuraSummaryRow {
158 driver: format!("{} ({spell_id})", summary.spell_name),
159 effect,
160 aura_subtype: summary.aura_subtype,
161 chance_pct: summary.chance_pct,
162 is_passive: summary.is_passive,
163 effect_base: summary.effect_base,
164 proc_mask: format!("0x{:010x}", summary.proc_mask),
165 icd_ms: summary.icd_ms,
166 rppm_base_rate: summary.rppm_base_rate,
167 rppm_flags: summary.rppm_flags,
168 rppm_mods: summary.rppm_mods.clone(),
169 trigger_spell: summary.trigger_spell,
170 class_mask: summary
171 .class_mask
172 .iter()
173 .map(i32::to_string)
174 .collect::<Vec<_>>()
175 .join("/"),
176 labels: summary
177 .labels
178 .iter()
179 .map(i32::to_string)
180 .collect::<Vec<_>>()
181 .join(","),
182 actor_side: actor_side(summary.proc_mask),
183 specs: summary.specs.len(),
184 }
185 }));
186
187 let mut spec_rows = BTreeMap::new();
188
189 for summary in self.0.values() {
190 for spec in &summary.specs {
191 *spec_rows.entry(spec.clone()).or_default() += 1;
192 }
193 }
194
195 let mut spec_rows: Vec<_> = spec_rows
196 .into_iter()
197 .map(|(spec, rows)| AuraSpecSummaryRow { spec, rows })
198 .collect();
199
200 spec_rows.sort_by(|left, right| {
201 right
202 .rows
203 .cmp(&left.rows)
204 .then_with(|| left.spec.cmp(&right.spec))
205 });
206 output::blank();
207 output::header("Filtered aura incidence by spec");
208 output::table(spec_rows);
209 }
210}
211
212fn actor_side(mask: i64) -> &'static str {
213 let mask =
214 wowlab_engine_domain::dbc::ProcTypeMask::from_dbc(u64::from_ne_bytes(mask.to_ne_bytes()));
215
216 match (mask.has_caster_events(), mask.has_target_events()) {
217 (true, true) => "caster + target",
218 (false, true) => "target",
219 _ => "caster",
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use wowlab_types::data::{RppmMod, SpellEffect};
226
227 use super::*;
228
229 #[gtest]
230 fn filtered_observation_retains_proc_rate_and_spec_provenance() -> GtestResult<()> {
231 let spell = SpellDataFlat {
232 id: 16864,
233 name: "Omen of Clarity".into(),
234 proc_chance: 100,
235 proc_type_mask: 1 << 2,
236 rppm_base_rate: 2.5,
237 rppm_flags: 1,
238 rppm_mods: vec![RppmMod {
239 mod_type: 1,
240 param: 32,
241 coeff: 1.3,
242 }],
243 effects: vec![SpellEffect {
244 index: 0,
245 aura: 42,
246 trigger_spell: 135_700,
247 ..SpellEffect::default()
248 }],
249 ..SpellDataFlat::default()
250 };
251 let mut summaries = AuraSummaries::default();
252
253 summaries.observe(SpecId::Feral, &spell, &[42]);
254
255 let summary = summaries.0.get(&(16864, 1)).or_fail()?;
256
257 verify_that!(
258 summary,
259 matches_pattern!(AuraSummary {
260 rppm_base_rate: near(2.5, f32::EPSILON),
261 rppm_flags: eq(&1),
262 rppm_mods: eq("1:32:1.3"),
263 ..
264 })
265 )?;
266 verify_true!(summary.specs.contains("feral_druid"))?;
267
268 Ok(())
269 }
270
271 #[gtest]
272 fn actor_side_separates_caster_target_and_mixed_masks() -> GtestResult<()> {
273 verify_that!(actor_side(1 << 2), eq("caster"))?;
274 verify_that!(actor_side(1 << 3), eq("target"))?;
275 verify_that!(actor_side((1 << 2) | (1 << 3)), eq("caster + target"))?;
276
277 Ok(())
278 }
279}