Skip to main content

forge/
talent_effect_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-effect 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
13#[derive(Default)]
14pub(super) struct EffectSummaries {
15    rows: BTreeMap<(i32, i32), EffectSummary>,
16    spells: BTreeMap<i32, SpellShape>,
17}
18
19#[derive(Default)]
20struct EffectSummary {
21    spell_name: String,
22    effect_type: i32,
23    aura_subtype: i32,
24    base_points: f64,
25    misc_value_0: i32,
26    trigger_spell: i32,
27    specs: BTreeSet<String>,
28}
29
30#[derive(Default)]
31struct SpellShape {
32    name: String,
33    operations: String,
34}
35
36#[derive(tabled::Tabled)]
37struct EffectSummaryRow {
38    #[tabled(rename = "Parent")]
39    parent: String,
40    #[tabled(rename = "Index")]
41    effect_index: i32,
42    #[tabled(rename = "SE")]
43    effect_type: i32,
44    #[tabled(rename = "AU")]
45    aura_subtype: i32,
46    #[tabled(rename = "Base")]
47    base_points: f64,
48    #[tabled(rename = "Misc 0")]
49    misc_value_0: i32,
50    #[tabled(rename = "Child")]
51    child: String,
52    #[tabled(rename = "Child operations")]
53    child_operations: String,
54    #[tabled(rename = "Specs")]
55    specs: usize,
56}
57
58#[derive(tabled::Tabled)]
59// #t(rust_similar_structs) each summary command owns its table schema and output section independently
60struct EffectCoverageRow {
61    #[tabled(rename = "Unique rows")]
62    unique_rows: usize,
63    #[tabled(rename = "Observations")]
64    observations: usize,
65    #[tabled(rename = "Specs")]
66    specs: usize,
67}
68
69#[derive(tabled::Tabled)]
70// #t(rust_similar_structs) each summary command owns its per-spec table row and rendering independently
71struct EffectSpecSummaryRow {
72    #[tabled(rename = "Spec")]
73    spec: String,
74    #[tabled(rename = "Unique rows")]
75    rows: usize,
76}
77
78impl EffectSummaries {
79    pub(super) fn observe(&mut self, spec: SpecId, spell: &SpellDataFlat, filter: &[i32]) {
80        self.spells.insert(
81            spell.id,
82            SpellShape {
83                name: spell.name.to_string(),
84                operations: spell
85                    .effects
86                    .iter()
87                    .map(|effect| {
88                        format!(
89                            "{}:SE{}:AU{}:base{}:misc{}",
90                            effect.index + 1,
91                            effect.effect,
92                            effect.aura,
93                            effect.base_points,
94                            effect.misc_value_0
95                        )
96                    })
97                    .collect::<Vec<_>>()
98                    .join(","),
99            },
100        );
101
102        for effect in &spell.effects {
103            if !filter.contains(&effect.effect) {
104                continue;
105            }
106
107            let effect_index = effect.index + 1;
108            let summary = self.rows.entry((spell.id, effect_index)).or_default();
109
110            summary.spell_name.clone_from(&spell.name.to_string());
111            summary.effect_type = effect.effect;
112            summary.aura_subtype = effect.aura;
113            summary.base_points = effect.base_points;
114            summary.misc_value_0 = effect.misc_value_0;
115            summary.trigger_spell = effect.trigger_spell;
116            summary.specs.insert(spec.slug().to_string());
117        }
118    }
119
120    pub(super) fn print(&self, filter: &[i32]) {
121        output::blank();
122        output::header(&format!(
123            "Cross-spec spell-effect summary ({})",
124            filter
125                .iter()
126                .map(i32::to_string)
127                .collect::<Vec<_>>()
128                .join(",")
129        ));
130        let observations = self.rows.values().map(|summary| summary.specs.len()).sum();
131        let specs = self
132            .rows
133            .values()
134            .flat_map(|summary| summary.specs.iter())
135            .collect::<BTreeSet<_>>()
136            .len();
137
138        output::table([EffectCoverageRow {
139            unique_rows: self.rows.len(),
140            observations,
141            specs,
142        }]);
143        output::blank();
144        output::table(
145            self.rows
146                .iter()
147                .map(|(&(spell_id, effect_index), summary)| {
148                    let child = self.spells.get(&summary.trigger_spell);
149
150                    EffectSummaryRow {
151                        parent: format!("{} ({spell_id})", summary.spell_name),
152                        effect_index,
153                        effect_type: summary.effect_type,
154                        aura_subtype: summary.aura_subtype,
155                        base_points: summary.base_points,
156                        misc_value_0: summary.misc_value_0,
157                        child: child.map_or_else(
158                            || summary.trigger_spell.to_string(),
159                            |shape| format!("{} ({})", shape.name, summary.trigger_spell),
160                        ),
161                        child_operations: child
162                            .map_or_else(String::new, |shape| shape.operations.clone()),
163                        specs: summary.specs.len(),
164                    }
165                }),
166        );
167
168        let mut spec_rows = BTreeMap::new();
169
170        for summary in self.rows.values() {
171            for spec in &summary.specs {
172                *spec_rows.entry(spec.clone()).or_default() += 1;
173            }
174        }
175
176        let mut spec_rows: Vec<_> = spec_rows
177            .into_iter()
178            .map(|(spec, rows)| EffectSpecSummaryRow { spec, rows })
179            .collect();
180
181        spec_rows.sort_by(|left, right| {
182            right
183                .rows
184                .cmp(&left.rows)
185                .then_with(|| left.spec.cmp(&right.spec))
186        });
187        output::blank();
188        output::header("Filtered spell-effect incidence by spec");
189        output::table(spec_rows);
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use wowlab_types::data::SpellEffect;
196
197    use super::*;
198
199    #[gtest]
200    fn filtered_observation_joins_child_operation_shape_and_provenance() -> GtestResult<()> {
201        let parent = SpellDataFlat {
202            id: 100,
203            name: "Parent".into(),
204            effects: vec![SpellEffect {
205                index: 0,
206                effect: 64,
207                trigger_spell: 200,
208                ..SpellEffect::default()
209            }],
210            ..SpellDataFlat::default()
211        };
212        let child = SpellDataFlat {
213            id: 200,
214            name: "Child".into(),
215            effects: vec![SpellEffect {
216                index: 0,
217                effect: 30,
218                base_points: 8.0,
219                misc_value_0: 3,
220                ..SpellEffect::default()
221            }],
222            ..SpellDataFlat::default()
223        };
224        let mut summaries = EffectSummaries::default();
225
226        summaries.observe(SpecId::Arms, &parent, &[64]);
227        summaries.observe(SpecId::Arms, &child, &[64]);
228
229        let summary = summaries.rows.get(&(100, 1)).or_fail()?;
230
231        verify_that!(summary.trigger_spell, eq(200))?;
232        verify_true!(summary.specs.contains("arms_warrior"))?;
233        verify_that!(
234            summaries.spells[&200].operations,
235            eq("1:SE30:AU0:base8:misc3")
236        )?;
237
238        Ok(())
239    }
240}