Skip to main content

forge/
talent_modifier_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 spell-modifier DBC reporting.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8#[cfg(test)]
9use googletest::{Result as GtestResult, prelude::*};
10use wowlab_common::output;
11use wowlab_engine_domain::dbc::aura_subtype_semantic;
12use wowlab_types::{data::SpellDataFlat, game::SpecId};
13
14const EFFECT_CLASS_MASK_WORDS: usize = 4;
15
16#[derive(Default)]
17pub(super) struct ModifierSummaries(BTreeMap<(i32, i32), ModifierSummary>);
18
19#[derive(Debug, Default)]
20struct ModifierSummary {
21    spell_name: String,
22    aura_subtype: i32,
23    property: i32,
24    base_points: f64,
25    label: i32,
26    class_mask: [i32; EFFECT_CLASS_MASK_WORDS],
27    is_passive: bool,
28    specs: BTreeSet<String>,
29}
30
31#[derive(tabled::Tabled)]
32struct ModifierSummaryRow {
33    #[tabled(rename = "Owner")]
34    owner: String,
35    #[tabled(rename = "Effect")]
36    effect: i32,
37    #[tabled(rename = "Aura")]
38    aura_subtype: i32,
39    #[tabled(rename = "Property")]
40    property: i32,
41    #[tabled(rename = "Base")]
42    base_points: f64,
43    #[tabled(rename = "Label")]
44    label: i32,
45    #[tabled(rename = "Class mask")]
46    class_mask: String,
47    #[tabled(rename = "Passive")]
48    is_passive: bool,
49    #[tabled(rename = "Specs")]
50    specs: usize,
51    #[tabled(rename = "Spec keys")]
52    spec_keys: String,
53}
54
55#[derive(tabled::Tabled)]
56// #t(rust_similar_structs) each summary command owns its table schema and output section independently
57struct ModifierCoverageRow {
58    #[tabled(rename = "Unique rows")]
59    unique_rows: usize,
60    #[tabled(rename = "Observations")]
61    observations: usize,
62    #[tabled(rename = "Specs")]
63    specs: usize,
64}
65
66#[derive(tabled::Tabled)]
67// #t(rust_similar_structs) each summary command owns its per-spec table row and rendering independently
68struct ModifierSpecSummaryRow {
69    #[tabled(rename = "Spec")]
70    spec: String,
71    #[tabled(rename = "Unique rows")]
72    rows: usize,
73}
74
75impl ModifierSummaries {
76    pub(super) fn observe(
77        &mut self,
78        spec: SpecId,
79        spell: &SpellDataFlat,
80        property_filter: &[i32],
81        aura_filter: &[i32],
82    ) {
83        for effect in &spell.effects {
84            if !property_filter.contains(&effect.misc_value_0)
85                || (!aura_filter.is_empty() && !aura_filter.contains(&effect.aura))
86                || aura_subtype_semantic(effect.aura)
87                    .and_then(|semantic| semantic.modifier)
88                    .is_none()
89            {
90                continue;
91            }
92
93            let effect_index = effect.index + 1;
94            let summary = self.0.entry((spell.id, effect_index)).or_default();
95
96            summary.spell_name.clone_from(&spell.name.to_string());
97            summary.aura_subtype = effect.aura;
98            summary.property = effect.misc_value_0;
99            summary.base_points = effect.base_points;
100            summary.label = effect.misc_value_1;
101            summary.class_mask = [
102                effect.effect_class_mask_1,
103                effect.effect_class_mask_2,
104                effect.effect_class_mask_3,
105                effect.effect_class_mask_4,
106            ];
107            summary.is_passive = spell.is_passive;
108            summary.specs.insert(spec.slug().to_string());
109        }
110    }
111
112    pub(super) fn print(&self, filter: &[i32]) {
113        output::blank();
114        output::header(&format!(
115            "Cross-spec modifier-property summary ({})",
116            filter
117                .iter()
118                .map(i32::to_string)
119                .collect::<Vec<_>>()
120                .join(",")
121        ));
122        let observations = self.0.values().map(|summary| summary.specs.len()).sum();
123        let specs = self
124            .0
125            .values()
126            .flat_map(|summary| summary.specs.iter())
127            .collect::<BTreeSet<_>>()
128            .len();
129
130        output::table([ModifierCoverageRow {
131            unique_rows: self.0.len(),
132            observations,
133            specs,
134        }]);
135        output::blank();
136        output::table(self.0.iter().map(|(&(spell_id, effect), summary)| {
137            ModifierSummaryRow {
138                owner: format!("{} ({spell_id})", summary.spell_name),
139                effect,
140                aura_subtype: summary.aura_subtype,
141                property: summary.property,
142                base_points: summary.base_points,
143                label: summary.label,
144                class_mask: summary
145                    .class_mask
146                    .iter()
147                    .map(i32::to_string)
148                    .collect::<Vec<_>>()
149                    .join("/"),
150                is_passive: summary.is_passive,
151                specs: summary.specs.len(),
152                spec_keys: summary.specs.iter().cloned().collect::<Vec<_>>().join(","),
153            }
154        }));
155
156        let mut spec_rows = BTreeMap::new();
157
158        for summary in self.0.values() {
159            for spec in &summary.specs {
160                *spec_rows.entry(spec.clone()).or_default() += 1;
161            }
162        }
163
164        let mut spec_rows: Vec<_> = spec_rows
165            .into_iter()
166            .map(|(spec, rows)| ModifierSpecSummaryRow { spec, rows })
167            .collect();
168
169        spec_rows.sort_by(|left, right| {
170            right
171                .rows
172                .cmp(&left.rows)
173                .then_with(|| left.spec.cmp(&right.spec))
174        });
175        output::blank();
176        output::header("Filtered modifier-property incidence by spec");
177        output::table(spec_rows);
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use wowlab_types::data::SpellEffect;
184
185    use super::*;
186
187    #[gtest]
188    fn filtered_observation_retains_operation_filter_and_spec_provenance() -> GtestResult<()> {
189        let spell = SpellDataFlat {
190            id: 100,
191            name: "Flat label damage".into(),
192            is_passive: true,
193            effects: vec![SpellEffect {
194                index: 2,
195                aura: 219,
196                base_points: 15.0,
197                misc_value_0: 0,
198                misc_value_1: 4137,
199                ..SpellEffect::default()
200            }],
201            ..SpellDataFlat::default()
202        };
203        let mut summaries = ModifierSummaries::default();
204
205        summaries.observe(SpecId::Arcane, &spell, &[0], &[219]);
206
207        let summary = summaries.0.get(&(100, 3)).or_fail()?;
208
209        verify_that!(
210            summary,
211            matches_pattern!(ModifierSummary {
212                aura_subtype: eq(&219),
213                base_points: near(15.0, f64::EPSILON),
214                label: eq(&4137),
215                is_passive: eq(&true),
216                ..
217            })
218        )?;
219        verify_true!(summary.specs.contains("arcane_mage"))?;
220
221        Ok(())
222    }
223}