wowlab_engine_domain/rotation/validate/
mod.rs1use wowlab_types::sim::{Condition, FastSet, Rotation, RotationAction as Action, VarOp};
4
5use super::buffer::DescriptorTable;
6
7mod dependency_graph;
8mod errors;
9mod schema;
10#[cfg(test)]
11mod tests;
12
13use dependency_graph::{
14 action_list_dependency_graph, dependency_postorder, expanded_rotation_action_count,
15 variable_dependency_graph,
16};
17pub use errors::{ValidationError, ValidationResult, ValidationWarning};
18pub use schema::{VarPathCategory, VarPathInfo, get_var_path_schema};
19
20pub(super) const MAX_CONDITION_DEPTH: usize = 256;
21
22pub(super) const MAX_EXPANDED_ACTIONS: usize = 65_536;
24
25fn condition_depth(root: &Condition) -> usize {
26 let mut max_depth = 0;
27
28 root.walk_with_depth(&mut |_condition, depth| max_depth = max_depth.max(depth));
29
30 max_depth
31}
32
33struct ValidationCtx<'rotation, 'ctx> {
34 variable_names: &'ctx FastSet<String>,
35 list_names: &'ctx FastSet<String>,
36 table: &'ctx DescriptorTable,
37 used_variables: &'ctx mut FastSet<&'rotation str>,
38 used_lists: &'ctx mut FastSet<String>,
39 errors: &'ctx mut Vec<ValidationError>,
40}
41
42#[must_use]
45pub fn validate_rotation(rotation: &Rotation) -> ValidationResult {
46 fn collect_setvar_names(actions: &[Action], names: &mut FastSet<String>) {
47 for action in actions {
48 if let Action::SetVar { name, .. } = action {
49 names.insert(name.clone());
50 }
51 }
52 }
53
54 let expected_findings = rotation.variables.len() + rotation.lists.len();
55 let mut errors = Vec::with_capacity(expected_findings);
56 let mut warnings = Vec::with_capacity(expected_findings);
57
58 let table = DescriptorTable::build();
59
60 let mut variable_names: FastSet<_> = rotation.variables.keys().cloned().collect();
61 let list_names: FastSet<_> = rotation.lists.keys().cloned().collect();
62
63 collect_setvar_names(&rotation.actions, &mut variable_names);
64
65 for actions in rotation.lists.values() {
66 collect_setvar_names(actions, &mut variable_names);
67 }
68
69 let mut used_variables = FastSet::default();
70 let mut used_lists = FastSet::default();
71
72 if rotation.actions.is_empty() {
73 errors.push(ValidationError::EmptyActionList {
74 list_name: "actions".to_string(),
75 });
76 }
77
78 for (name, actions) in &rotation.lists {
79 if actions.is_empty() {
80 errors.push(ValidationError::EmptyActionList {
81 list_name: name.clone(),
82 });
83 }
84 }
85
86 let mut ctx = ValidationCtx {
87 variable_names: &variable_names,
88 list_names: &list_names,
89 table: &table,
90 used_variables: &mut used_variables,
91 used_lists: &mut used_lists,
92 errors: &mut errors,
93 };
94
95 for (idx, action) in rotation.actions.iter().enumerate() {
96 validate_action(&mut ctx, action, "actions", idx);
97 }
98
99 for (list_name, actions) in &rotation.lists {
100 for (idx, action) in actions.iter().enumerate() {
101 validate_action(&mut ctx, action, list_name, idx);
102 }
103 }
104
105 for cond in rotation.variables.values() {
106 validate_condition(&mut ctx, cond);
107 }
108
109 validate_dependency_graphs(rotation, ctx.errors);
110
111 for name in &variable_names {
112 if !used_variables.contains(name.as_str()) {
113 warnings.push(ValidationWarning::UnusedVariable { name: name.clone() });
114 }
115 }
116
117 for name in &list_names {
118 if !used_lists.contains(name) {
119 warnings.push(ValidationWarning::UnusedList { name: name.clone() });
120 }
121 }
122
123 ValidationResult {
124 valid: errors.is_empty(),
125 errors,
126 warnings,
127 }
128}
129
130fn validate_dependency_graphs(rotation: &Rotation, errors: &mut Vec<ValidationError>) {
131 let variable_graph = variable_dependency_graph(&rotation.variables);
132
133 if let Err(path) = dependency_postorder(&variable_graph) {
134 errors.push(ValidationError::CircularReference { path });
135 }
136
137 let list_graph = action_list_dependency_graph(&rotation.lists);
138
139 match dependency_postorder(&list_graph) {
140 Err(path) => errors.push(ValidationError::CircularReference { path }),
141 Ok(postorder) => {
142 let actions = expanded_rotation_action_count(rotation, &postorder);
143
144 if actions > MAX_EXPANDED_ACTIONS {
145 errors.push(ValidationError::ActionExpansionLimitExceeded {
146 actions,
147 max: MAX_EXPANDED_ACTIONS,
148 });
149 }
150 }
151 }
152}
153
154fn is_supported_action(action: &Action) -> bool {
155 matches!(
156 action,
157 Action::Cast { .. }
158 | Action::Call { .. }
159 | Action::Run { .. }
160 | Action::SetVar { .. }
161 | Action::ModifyVar { .. }
162 | Action::Wait { .. }
163 | Action::WaitUntil { .. }
164 | Action::Pool { .. }
165 | Action::UseTrinket { .. }
166 | Action::UseItem { .. }
167 )
168}
169
170fn is_supported_var_op(op: VarOp) -> bool {
171 matches!(
172 op,
173 VarOp::Add
174 | VarOp::Sub
175 | VarOp::Mul
176 | VarOp::Div
177 | VarOp::Mod
178 | VarOp::Max
179 | VarOp::Min
180 | VarOp::Floor
181 | VarOp::Ceil
182 | VarOp::Reset
183 )
184}
185
186fn validate_empower_rank(
187 errors: &mut Vec<ValidationError>,
188 action: &Action,
189 list_name: &str,
190 action_index: usize,
191) {
192 let invalid_slug = match action {
193 Action::Cast {
194 spell,
195 empower_rank: Some(0),
196 ..
197 } => Some(spell.clone()),
198 Action::UseItem {
199 name,
200 empower_rank: Some(0),
201 ..
202 } => Some(name.clone()),
203 Action::UseTrinket {
204 slot,
205 empower_rank: Some(0),
206 ..
207 } => Some(format!("trinket{slot}")),
208 _ => None,
209 };
210
211 if let Some(slug) = invalid_slug {
212 errors.push(ValidationError::InvalidExpression {
213 message: "empower_rank must be one-based".to_string(),
214 list_name: Some(list_name.to_string()),
215 action_index: Some(action_index),
216 slug: Some(slug),
217 });
218 }
219}
220
221fn validate_action_list_reference(ctx: &mut ValidationCtx<'_, '_>, action: &Action) {
222 let (Action::Call { list, .. } | Action::Run { list, .. }) = action else {
223 return;
224 };
225
226 if ctx.list_names.contains(list) {
227 ctx.used_lists.insert(list.clone());
228 } else {
229 ctx.errors
230 .push(ValidationError::UndefinedList { name: list.clone() });
231 }
232}
233
234fn validate_modify_var<'rotation>(
235 ctx: &mut ValidationCtx<'rotation, '_>,
236 action: &'rotation Action,
237 list_name: &str,
238 action_index: usize,
239) {
240 let Action::ModifyVar {
241 name, op, value, ..
242 } = action
243 else {
244 return;
245 };
246
247 if !is_supported_var_op(*op) {
248 ctx.errors.push(ValidationError::UnsupportedSyntax {
249 construct: format!("variable operator {op:?}"),
250 });
251
252 return;
253 }
254
255 if ctx.variable_names.contains(name) {
256 ctx.used_variables.insert(name);
257 } else {
258 ctx.errors
259 .push(ValidationError::UndefinedVariable { name: name.clone() });
260 }
261
262 let requires_numeric = matches!(
263 op,
264 VarOp::Add | VarOp::Sub | VarOp::Mul | VarOp::Div | VarOp::Min | VarOp::Max
265 );
266
267 if requires_numeric && matches!(value, Condition::Bool { .. }) {
268 ctx.errors.push(ValidationError::TypeMismatch {
269 name: name.clone(),
270 op: op.name().to_string(),
271 expected: "int or float".to_string(),
272 got: "bool".to_string(),
273 list_name: Some(list_name.to_string()),
274 action_index: Some(action_index),
275 });
276 }
277}
278
279fn validate_action<'rotation>(
280 ctx: &mut ValidationCtx<'rotation, '_>,
281 action: &'rotation Action,
282 list_name: &str,
283 action_index: usize,
284) {
285 if !is_supported_action(action) {
286 ctx.errors.push(ValidationError::UnsupportedSyntax {
287 construct: format!("action variant {action:?}"),
288 });
289
290 return;
291 }
292
293 validate_empower_rank(ctx.errors, action, list_name, action_index);
294 validate_action_list_reference(ctx, action);
295
296 for cond in action.conditions() {
297 validate_condition(ctx, cond);
298 }
299
300 validate_modify_var(ctx, action, list_name, action_index);
301}
302
303fn validate_condition<'rotation>(
304 ctx: &mut ValidationCtx<'rotation, '_>,
305 root: &'rotation Condition,
306) {
307 let depth = condition_depth(root);
308
309 if depth > MAX_CONDITION_DEPTH {
310 ctx.errors.push(ValidationError::MaxDepthExceeded {
311 depth,
312 max: MAX_CONDITION_DEPTH,
313 });
314 return;
317 }
318
319 let mut undefined_variables = Vec::new();
320
321 root.walk(&mut |cond| match cond {
322 Condition::Var { name } => {
323 if ctx.variable_names.contains(name) {
324 ctx.used_variables.insert(name);
325 } else {
326 undefined_variables.push(name.as_str());
327 }
328 }
329 Condition::Read { field } if ctx.table.lookup(&field.domain, &field.name).is_none() => {
330 ctx.errors.push(ValidationError::UnknownField {
331 domain: field.domain.clone(),
332 name: field.name.clone(),
333 });
334 }
335 Condition::Compare { op, .. } => match op {
336 wowlab_types::sim::CompareOp::Gt
337 | wowlab_types::sim::CompareOp::Gte
338 | wowlab_types::sim::CompareOp::Lt
339 | wowlab_types::sim::CompareOp::Lte
340 | wowlab_types::sim::CompareOp::Eq
341 | wowlab_types::sim::CompareOp::Ne => {}
342 _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
343 construct: format!("comparison operator {op:?}"),
344 }),
345 },
346 Condition::Arith { op, .. } => match op {
347 wowlab_types::sim::ArithOp::Add
348 | wowlab_types::sim::ArithOp::Sub
349 | wowlab_types::sim::ArithOp::Mul
350 | wowlab_types::sim::ArithOp::Div
351 | wowlab_types::sim::ArithOp::Mod => {}
352 _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
353 construct: format!("arithmetic operator {op:?}"),
354 }),
355 },
356 Condition::UnaryMath { op, .. } => match op {
357 wowlab_types::sim::UnaryMathOp::Floor
358 | wowlab_types::sim::UnaryMathOp::Ceil
359 | wowlab_types::sim::UnaryMathOp::Abs => {}
360 _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
361 construct: format!("unary math operator {op:?}"),
362 }),
363 },
364 Condition::MinMax { op, .. } => match op {
365 wowlab_types::sim::MinMaxOp::Min | wowlab_types::sim::MinMaxOp::Max => {}
366 _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
367 construct: format!("min/max operator {op:?}"),
368 }),
369 },
370 Condition::Read { .. }
371 | Condition::Bool { .. }
372 | Condition::Int { .. }
373 | Condition::Float { .. }
374 | Condition::And { .. }
375 | Condition::Or { .. }
376 | Condition::Not { .. }
377 | Condition::IfThenElse { .. } => {}
378 _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
379 construct: format!("condition variant {cond:?}"),
380 }),
381 });
382
383 ctx.errors
384 .extend(
385 undefined_variables
386 .into_iter()
387 .map(|name| ValidationError::UndefinedVariable {
388 name: name.to_owned(),
389 }),
390 );
391}