Skip to main content

forge/
talent_aura_summary.rs

1// #t(file: rust_alloc_in_loop) the bounded CLI aggregation owns diagnostic strings.
2// #t(file: rust_clone_in_loop) repeated rows intentionally retain cross-spec provenance.
3
4//! Filtered cross-spec aura-subtype DBC reporting.
5
6use 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}