Skip to main content

wowlab_engine_domain/rotation/
check.rs

1//! Extracts referenced spell/aura/talent names and checks them against a spec resolver.
2
3use std::collections::BTreeSet;
4
5use wowlab_types::sim::{Condition, FieldRead, Rotation, RotationAction as Action};
6
7use super::{condition::KeyCategory, resolver::SpecResolver};
8
9wowlab_engine_macros::define_error! {
10/// Failure to extract referenced names from a rotation JSON document.
11#[derive(Debug)]
12#[non_exhaustive]
13pub struct NameExtractionError {
14    kind: NameExtractionErrorKind,
15}
16
17#[derive(Debug, thiserror::Error)]
18enum NameExtractionErrorKind {
19    #[error("Failed to parse rotation: {0}")]
20    Json(#[source] serde_json::Error),
21}
22}
23
24impl From<serde_json::Error> for NameExtractionError {
25    fn from(source: serde_json::Error) -> Self {
26        Self {
27            kind: NameExtractionErrorKind::Json(source),
28        }
29    }
30}
31
32/// All spell, aura, and talent names referenced by a parsed rotation.
33#[derive(Debug, Default)]
34pub struct ReferencedNames {
35    pub spells: BTreeSet<String>,
36    pub auras: BTreeSet<String>,
37    pub talents: BTreeSet<String>,
38}
39
40/// Outcome of checking referenced names against a spec resolver.
41#[derive(Debug)]
42pub struct CheckResult {
43    pub supported_spells: BTreeSet<String>,
44    pub missing_spells: BTreeSet<String>,
45    pub supported_auras: BTreeSet<String>,
46    pub missing_auras: BTreeSet<String>,
47    pub supported_talents: BTreeSet<String>,
48    pub missing_talents: BTreeSet<String>,
49}
50
51impl CheckResult {
52    /// Total missing names across all categories.
53    #[must_use]
54    pub fn total_missing(&self) -> usize {
55        self.missing_spells.len() + self.missing_auras.len() + self.missing_talents.len()
56    }
57
58    /// True when every referenced name resolved.
59    #[must_use]
60    pub fn is_clean(&self) -> bool {
61        self.total_missing() == 0
62    }
63}
64
65/// Parses `json` as a [`Rotation`] and collects every referenced name.
66///
67/// # Errors
68/// Returns the JSON parse failure as a diagnostic string.
69pub fn extract_names(json: &str) -> Result<ReferencedNames, NameExtractionError> {
70    let rotation: Rotation = serde_json::from_str(json)?;
71
72    Ok(extract_names_from_rotation(&rotation))
73}
74
75/// Walks a parsed [`Rotation`] and collects every referenced name.
76#[must_use]
77pub fn extract_names_from_rotation(rotation: &Rotation) -> ReferencedNames {
78    let mut names = ReferencedNames::default();
79
80    for cond in rotation.variables.values() {
81        walk_condition(cond, &mut names);
82    }
83
84    for action in &rotation.actions {
85        walk_action(action, &mut names);
86    }
87
88    for actions in rotation.lists.values() {
89        for action in actions {
90            walk_action(action, &mut names);
91        }
92    }
93
94    names
95}
96
97/// Partitions each referenced name into supported/missing against `resolver`.
98#[must_use]
99pub fn check(names: &ReferencedNames, resolver: &SpecResolver) -> CheckResult {
100    let (supported_spells, missing_spells) = partition(&names.spells, |n| resolver.has_spell(n));
101    let (supported_auras, missing_auras) = partition(&names.auras, |n| resolver.has_aura(n));
102    let (supported_talents, missing_talents) =
103        partition(&names.talents, |n| resolver.knows_talent(n));
104
105    CheckResult {
106        supported_spells,
107        missing_spells,
108        supported_auras,
109        missing_auras,
110        supported_talents,
111        missing_talents,
112    }
113}
114
115fn partition(
116    set: &BTreeSet<String>,
117    predicate: impl Fn(&str) -> bool,
118) -> (BTreeSet<String>, BTreeSet<String>) {
119    let mut supported = BTreeSet::new();
120    let mut missing = BTreeSet::new();
121
122    // #t(block: rust_clone_in_loop) partitioning borrowed set into two new sets requires cloning
123    for name in set {
124        if predicate(name) {
125            supported.insert(name.clone());
126        } else {
127            missing.insert(name.clone());
128        }
129    }
130
131    (supported, missing)
132}
133
134fn walk_action(action: &Action, names: &mut ReferencedNames) {
135    if let Action::Cast { spell, .. } = action {
136        names.spells.insert(spell.clone());
137    }
138
139    for cond in action.conditions() {
140        walk_condition(cond, names);
141    }
142}
143
144fn walk_condition(root: &Condition, names: &mut ReferencedNames) {
145    for field in root.field_reads() {
146        extract_field_names(field, names);
147    }
148}
149
150fn extract_field_names(field: &FieldRead, names: &mut ReferencedNames) {
151    let Some(key) = &field.key else { return };
152
153    match KeyCategory::for_domain(&field.domain) {
154        Some(KeyCategory::Spell) => {
155            names.spells.insert(key.clone());
156        }
157        Some(KeyCategory::Aura) => {
158            names.auras.insert(key.clone());
159        }
160        Some(KeyCategory::Named)
161            if field.domain == super::condition::domain::TALENT
162                || field.domain == super::condition::domain::HERO_TREE =>
163        {
164            names.talents.insert(key.clone());
165        }
166        _ => {}
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use googletest::prelude::*;
173
174    use super::*;
175
176    #[gtest]
177    fn extract_names_from_simple_rotation() -> Result<()> {
178        let json = r#"{
179            "version": 1,
180            "name": "Test",
181            "variables": {},
182            "actions": [{ "type": "call", "list": "main" }],
183            "lists": {
184                "main": [
185                    {
186                        "type": "cast",
187                        "spell": "kill_command",
188                        "condition": { "type": "read", "domain": "cooldown", "name": "is_ready", "key": "kill_command" }
189                    },
190                    {
191                        "type": "cast",
192                        "spell": "cobra_shot",
193                        "condition": { "type": "compare", "op": "gte", "left": { "type": "read", "domain": "resource", "name": "current", "key": "focus" }, "right": { "type": "int", "value": 50 } }
194                    },
195                    {
196                        "type": "cast",
197                        "spell": "barbed_shot",
198                        "condition": { "type": "read", "domain": "aura", "name": "is_active", "key": "frenzy" }
199                    }
200                ]
201            }
202        }"#;
203
204        let names = extract_names(json).unwrap();
205
206        verify_that!(names.spells, contains(eq("kill_command")))?;
207        verify_that!(names.spells, contains(eq("cobra_shot")))?;
208        verify_that!(names.spells, contains(eq("barbed_shot")))?;
209        verify_that!(names.auras, contains(eq("frenzy")))?;
210        verify_that!(names.talents, is_empty())?;
211
212        Ok(())
213    }
214
215    #[gtest]
216    fn extract_names_with_target_auras_and_talents() -> Result<()> {
217        let json = r#"{
218            "version": 1,
219            "name": "Test",
220            "variables": {
221                "use_dot": { "type": "read", "domain": "aura", "name": "is_refreshable", "key": "serpent_sting", "on": "target" }
222            },
223            "actions": [],
224            "lists": {
225                "main": [
226                    {
227                        "type": "cast",
228                        "spell": "aimed_shot",
229                        "condition": { "type": "read", "domain": "talent", "name": "is_enabled", "key": "careful_aim" }
230                    }
231                ]
232            }
233        }"#;
234
235        let names = extract_names(json).unwrap();
236
237        verify_that!(names.spells, contains(eq("aimed_shot")))?;
238        verify_that!(names.auras, contains(eq("serpent_sting")))?;
239        verify_that!(names.talents, contains(eq("careful_aim")))?;
240
241        Ok(())
242    }
243
244    #[gtest]
245    fn check_against_resolver() -> Result<()> {
246        let resolver = SpecResolver::new("test")
247            .spell("kill_command", 1)
248            .spell("cobra_shot", 2)
249            .aura("frenzy", 100)
250            .talent("killer_instinct", true);
251
252        let mut names = ReferencedNames::default();
253
254        names.spells.insert("kill_command".to_string());
255        names.spells.insert("cobra_shot".to_string());
256        names.spells.insert("unknown_spell".to_string());
257        names.auras.insert("frenzy".to_string());
258        names.auras.insert("unknown_buff".to_string());
259        names.talents.insert("killer_instinct".to_string());
260        names.talents.insert("unknown_talent".to_string());
261
262        let result = check(&names, &resolver);
263
264        verify_that!(result.supported_spells.len(), eq(2))?;
265        verify_that!(result.missing_spells.len(), eq(1))?;
266        verify_that!(result.missing_spells, contains(eq("unknown_spell")))?;
267        verify_that!(result.supported_auras.len(), eq(1))?;
268        verify_that!(result.missing_auras.len(), eq(1))?;
269        verify_that!(result.missing_auras, contains(eq("unknown_buff")))?;
270        verify_that!(result.supported_talents.len(), eq(1))?;
271        verify_that!(result.missing_talents.len(), eq(1))?;
272        verify_that!(result.total_missing(), eq(3))?;
273        verify_that!(result.is_clean(), eq(false))?;
274
275        Ok(())
276    }
277
278    #[gtest]
279    fn clean_result() -> Result<()> {
280        let resolver = SpecResolver::new("test")
281            .spell("kill_command", 1)
282            .aura("frenzy", 100);
283
284        let mut names = ReferencedNames::default();
285
286        names.spells.insert("kill_command".to_string());
287        names.auras.insert("frenzy".to_string());
288
289        let result = check(&names, &resolver);
290
291        verify_that!(result.is_clean(), eq(true))?;
292        verify_that!(result.total_missing(), eq(0))?;
293
294        Ok(())
295    }
296
297    #[gtest]
298    fn spell_referenced_multiple_ways_deduplicates() -> Result<()> {
299        let json = r#"{
300            "version": 1,
301            "name": "Test",
302            "variables": {},
303            "actions": [],
304            "lists": {
305                "main": [
306                    {
307                        "type": "cast",
308                        "spell": "bestial_wrath",
309                        "condition": {
310                            "type": "and",
311                            "operands": [
312                                { "type": "read", "domain": "spell", "name": "is_usable", "key": "bestial_wrath" },
313                                { "type": "read", "domain": "cooldown", "name": "is_ready", "key": "bestial_wrath" }
314                            ]
315                        }
316                    }
317                ]
318            }
319        }"#;
320
321        let names = extract_names(json).unwrap();
322
323        verify_that!(names.spells, contains(eq("bestial_wrath")))?;
324        verify_that!(names.spells.len(), eq(1))?;
325
326        Ok(())
327    }
328
329    #[gtest]
330    fn target_aura_resolves_via_dot_lookup() -> Result<()> {
331        let resolver = SpecResolver::new("test").dot("serpent_sting", 200);
332
333        let mut names = ReferencedNames::default();
334
335        names.auras.insert("serpent_sting".to_string());
336
337        let result = check(&names, &resolver);
338
339        verify_that!(result.supported_auras.len(), eq(1))?;
340        verify_that!(result.supported_auras, contains(eq("serpent_sting")))?;
341        verify_that!(result.is_clean(), eq(true))?;
342
343        Ok(())
344    }
345
346    #[gtest]
347    fn hero_tree_extracted_as_talent() -> Result<()> {
348        let json = r#"{
349            "version": 1,
350            "name": "Test",
351            "variables": {},
352            "actions": [],
353            "lists": {
354                "main": [
355                    {
356                        "type": "cast",
357                        "spell": "kill_command",
358                        "condition": { "type": "read", "domain": "hero_tree", "name": "is_active", "key": "pack_leader" }
359                    }
360                ]
361            }
362        }"#;
363
364        let names = extract_names(json).unwrap();
365
366        verify_that!(names.talents, contains(eq("pack_leader")))?;
367        verify_that!(names.spells, contains(eq("kill_command")))?;
368
369        Ok(())
370    }
371}