wowlab_engine_domain/rotation/
check.rs1use 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#[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#[derive(Debug, Default)]
34pub struct ReferencedNames {
35 pub spells: BTreeSet<String>,
36 pub auras: BTreeSet<String>,
37 pub talents: BTreeSet<String>,
38}
39
40#[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 #[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 #[must_use]
60 pub fn is_clean(&self) -> bool {
61 self.total_missing() == 0
62 }
63}
64
65pub 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#[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#[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 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}