Skip to main content

forge/
talent_target_plan_summary.rs

1//! Filtered cross-spec unsupported target-plan reporting.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use clap::ValueEnum;
6#[cfg(test)]
7use googletest::{Result as GtestResult, prelude::*};
8use wowlab_common::output;
9use wowlab_engine_domain::targeting::{TargetPlanAxis, TargetPlanGap};
10use wowlab_types::game::SpecId;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
13pub(crate) enum TargetPlanAxisFilter {
14    Object,
15    Reference,
16    Algorithm,
17    Check,
18    Direction,
19    EffectFallback,
20}
21
22impl TargetPlanAxisFilter {
23    const fn matches(self, axis: TargetPlanAxis) -> bool {
24        matches!(
25            (self, axis),
26            (Self::Object, TargetPlanAxis::Object)
27                | (Self::Reference, TargetPlanAxis::Reference)
28                | (Self::Algorithm, TargetPlanAxis::Algorithm)
29                | (Self::Check, TargetPlanAxis::Check)
30                | (Self::Direction, TargetPlanAxis::Direction)
31                | (Self::EffectFallback, TargetPlanAxis::EffectFallback)
32        )
33    }
34}
35
36#[derive(Default)]
37pub(super) struct TargetPlanSummaries(BTreeMap<(i32, i32), TargetPlanSummary>);
38
39#[derive(Debug, Default)]
40struct TargetPlanSummary {
41    spell_name: String,
42    target_a: i32,
43    target_b: i32,
44    selector: Option<i32>,
45    axis: Option<TargetPlanAxis>,
46    value: &'static str,
47    specs: BTreeSet<String>,
48}
49
50#[derive(Clone, Copy)]
51pub(super) struct TargetPlanObservation<'a> {
52    pub spec: SpecId,
53    pub spell_id: i32,
54    pub spell_name: &'a str,
55    pub effect_index: i32,
56    pub target_a: i32,
57    pub target_b: i32,
58}
59
60#[derive(tabled::Tabled)]
61struct TargetPlanSummaryRow {
62    #[tabled(rename = "Spell")]
63    spell: String,
64    #[tabled(rename = "Effect")]
65    effect: i32,
66    #[tabled(rename = "TargetA / TargetB")]
67    selectors: String,
68    #[tabled(rename = "Gap selector")]
69    gap_selector: String,
70    #[tabled(rename = "Axis")]
71    axis: String,
72    #[tabled(rename = "Value")]
73    value: &'static str,
74    #[tabled(rename = "Specs")]
75    specs: usize,
76    #[tabled(rename = "Spec keys")]
77    spec_keys: String,
78}
79
80impl TargetPlanSummaries {
81    pub(super) fn observe(
82        &mut self,
83        observation: TargetPlanObservation<'_>,
84        gap: TargetPlanGap,
85        filter: &[TargetPlanAxisFilter],
86    ) {
87        if !filter.iter().any(|candidate| candidate.matches(gap.axis)) {
88            return;
89        }
90
91        let summary = self
92            .0
93            .entry((observation.spell_id, observation.effect_index))
94            .or_default();
95
96        summary.spell_name = observation.spell_name.to_string();
97        summary.target_a = observation.target_a;
98        summary.target_b = observation.target_b;
99        summary.selector = gap.selector;
100        summary.axis = Some(gap.axis);
101        summary.value = gap.value;
102        summary.specs.insert(observation.spec.slug().to_string());
103    }
104
105    pub(super) fn print(&self) {
106        output::blank();
107        output::header("Cross-spec unsupported target-plan summary");
108        output::table(self.0.iter().map(|(&(spell_id, effect), summary)| {
109            TargetPlanSummaryRow {
110                spell: format!("{} ({spell_id})", summary.spell_name),
111                effect,
112                selectors: format!("{} / {}", summary.target_a, summary.target_b),
113                gap_selector: summary
114                    .selector
115                    .map_or_else(|| "effect fallback".to_string(), |value| value.to_string()),
116                axis: format!("{:?}", summary.axis.expect("observed gap has an axis")),
117                value: summary.value,
118                specs: summary.specs.len(),
119                spec_keys: summary.specs.iter().cloned().collect::<Vec<_>>().join(","),
120            }
121        }));
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[gtest]
130    fn filtered_observation_retains_gap_coordinate_and_spec_provenance() -> GtestResult<()> {
131        let mut summaries = TargetPlanSummaries::default();
132
133        summaries.observe(
134            TargetPlanObservation {
135                spec: SpecId::Devastation,
136                spell_id: 100,
137                spell_name: "Directional spell",
138                effect_index: 2,
139                target_a: 78,
140                target_b: 16,
141            },
142            TargetPlanGap {
143                selector: Some(78),
144                axis: TargetPlanAxis::Direction,
145                value: "front",
146            },
147            &[TargetPlanAxisFilter::Direction],
148        );
149
150        let summary = &summaries.0[&(100, 2)];
151
152        verify_that!(
153            summary,
154            matches_pattern!(TargetPlanSummary {
155                target_a: eq(&78),
156                selector: eq(&Some(78)),
157                axis: eq(&Some(TargetPlanAxis::Direction)),
158                ..
159            })
160        )?;
161        verify_true!(summary.specs.contains("devastation_evoker"))?;
162
163        Ok(())
164    }
165}