1use std::sync::Arc;
8
9#[cfg(feature = "rotation-optimizations")]
10use action_dependencies::collect_action_dependencies;
11use bitflags::bitflags;
12use wowlab_types::{
13 game::GearSlot,
14 sim::{Condition, Rotation, RotationAction, SpellIdx},
15};
16
17#[cfg(feature = "rotation-optimizations")]
18use super::condition::ResolvedFieldRead;
19use super::{
20 backend::RotationBackend,
21 buffer::{BufferRequirements, DescriptorTable, EvalKind},
22 condition::{ResolvedKey, resolve_field_read},
23 context::ContextSchema,
24 error::Result,
25 lower,
26 resolver::SpecResolver,
27};
28
29#[cfg(feature = "rotation-optimizations")]
30mod action_dependencies {
31 use wowlab_types::{
34 combat::ResourceType,
35 sim::{AuraIdx, AuraOn, CompareOp, Condition, Rotation, RotationAction, SpellIdx},
36 };
37
38 use super::{
39 DescriptorTable, EvalKind, ResolvedFieldRead, ResolvedKey, Result, SpecResolver,
40 resolve_field_read,
41 };
42 use crate::rotation::condition::domain;
43
44 #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
46 #[non_exhaustive]
47 pub enum PreparedRotationChangeKey {
48 SpellCooldownReady(SpellIdx),
50 ResourceThreshold {
52 resource: ResourceType,
53 value_bits: u64,
54 strict: bool,
55 },
56 CooldownRemainingThreshold {
58 spell: SpellIdx,
59 seconds_bits: u64,
60 strict: bool,
61 },
62 AuraState,
64 TargetHealthThreshold { percent_bits: u64, strict: bool },
66 AuraRemainingThreshold {
68 aura: AuraIdx,
69 on: AuraOn,
70 seconds_bits: u64,
71 strict: bool,
72 },
73 Spatial,
75 EncounterPopulation,
77 SpellState,
79 EquipmentState,
81 HistoryState,
83 }
84
85 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
87 #[non_exhaustive]
88 pub enum PreparedRotationChangeDirection {
89 Rise,
91 Fall,
93 }
94
95 #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
97 pub struct PreparedRotationChange {
98 key: PreparedRotationChangeKey,
99 direction: PreparedRotationChangeDirection,
100 }
101
102 impl PreparedRotationChange {
103 #[must_use]
105 pub const fn key(self) -> PreparedRotationChangeKey {
106 self.key
107 }
108
109 #[must_use]
111 pub const fn direction(self) -> PreparedRotationChangeDirection {
112 self.direction
113 }
114 }
115
116 #[derive(Clone, Debug, Eq, PartialEq)]
118 pub struct PreparedActionDependencyPlan {
119 spell: SpellIdx,
120 changes: Box<[PreparedRotationChange]>,
121 complete: bool,
122 }
123
124 impl PreparedActionDependencyPlan {
125 #[must_use]
127 pub const fn spell(&self) -> SpellIdx {
128 self.spell
129 }
130
131 #[must_use]
133 pub fn changes(&self) -> &[PreparedRotationChange] {
134 &self.changes
135 }
136
137 #[must_use]
139 pub const fn complete(&self) -> bool {
140 self.complete
141 }
142 }
143
144 pub(super) struct CollectedActionDependencies {
145 pub(super) plans: Box<[PreparedActionDependencyPlan]>,
146 pub(super) complete: bool,
147 }
148
149 pub(super) fn collect_action_dependencies(
150 rotation: &Rotation,
151 resolver: &SpecResolver,
152 table: &DescriptorTable,
153 ) -> Result<CollectedActionDependencies> {
154 let mut collector = DependencyCollector {
155 rotation,
156 resolver,
157 table,
158 plans: Vec::new(),
159 complete: true,
160 };
161
162 collector.collect_actions(&rotation.actions, true)?;
163 collector.complete &= collector
164 .plans
165 .iter()
166 .all(PreparedActionDependencyPlan::complete);
167
168 Ok(CollectedActionDependencies {
169 plans: collector.plans.into_boxed_slice(),
170 complete: collector.complete,
171 })
172 }
173
174 struct DependencyCollector<'a> {
175 rotation: &'a Rotation,
176 resolver: &'a SpecResolver,
177 table: &'a DescriptorTable,
178 plans: Vec<PreparedActionDependencyPlan>,
179 complete: bool,
180 }
181
182 impl DependencyCollector<'_> {
183 fn collect_actions(
184 &mut self,
185 actions: &[RotationAction],
186 inherited_complete: bool,
187 ) -> Result<()> {
188 for action in actions.iter().filter(|action| action.enabled()) {
189 match action {
190 RotationAction::Cast {
191 spell, condition, ..
192 } => self.collect_cast(spell, condition.as_ref(), inherited_complete)?,
193 RotationAction::Call {
194 list, condition, ..
195 } => {
196 let call_complete = inherited_complete && condition.is_none();
197
198 if condition.is_some() {
199 self.complete = false;
200 }
201
202 if let Some(actions) = self.rotation.lists.get(list) {
203 self.collect_actions(actions, call_complete)?;
204 }
205 }
206 RotationAction::Wait {
207 condition: None, ..
208 } => {}
209 RotationAction::Run { .. }
210 | RotationAction::SetVar { .. }
211 | RotationAction::ModifyVar { .. }
212 | RotationAction::Wait { .. }
213 | RotationAction::WaitUntil { .. }
214 | RotationAction::Pool { .. }
215 | RotationAction::UseTrinket { .. }
216 | RotationAction::UseItem { .. }
217 | _ => self.complete = false,
218 }
219 }
220
221 Ok(())
222 }
223
224 fn collect_cast(
225 &mut self,
226 spell_name: &str,
227 condition: Option<&Condition>,
228 inherited_complete: bool,
229 ) -> Result<()> {
230 let spell = self.resolver.resolve_spell(spell_name)?;
231 let mut analysis = match condition {
232 Some(condition) => self.analyze_condition(condition, spell, true)?,
233 None => ConditionAnalysis::complete(),
234 };
235
236 analysis.complete &= inherited_complete;
237 analysis.changes.sort_unstable_by_key(change_sort_key);
238 analysis.changes.dedup();
239 self.plans.push(PreparedActionDependencyPlan {
240 spell,
241 changes: analysis.changes.into_boxed_slice(),
242 complete: analysis.complete,
243 });
244
245 Ok(())
246 }
247
248 fn analyze_condition(
249 &self,
250 condition: &Condition,
251 action_spell: SpellIdx,
252 positive: bool,
253 ) -> Result<ConditionAnalysis> {
254 match condition {
255 Condition::Read { field } => self.analyze_read(field, action_spell, positive),
256 Condition::Bool { .. } | Condition::Int { .. } | Condition::Float { .. } => {
257 Ok(ConditionAnalysis::complete())
258 }
259 Condition::Var { name } => match self.rotation.variables.get(name) {
260 Some(value) => self.analyze_condition(value, action_spell, positive),
261 None => Ok(ConditionAnalysis::incomplete()),
262 },
263 Condition::Compare { op, left, right } => {
264 self.analyze_compare(*op, left, right, positive)
265 }
266 Condition::And { operands } | Condition::Or { operands } => {
267 let mut analysis = ConditionAnalysis::complete();
268
269 for operand in operands {
270 analysis.merge(self.analyze_condition(operand, action_spell, positive)?);
271 }
272
273 Ok(analysis)
274 }
275 Condition::Not { operand } => {
276 self.analyze_condition(operand, action_spell, !positive)
277 }
278 Condition::Arith { .. }
279 | Condition::UnaryMath { .. }
280 | Condition::MinMax { .. }
281 | Condition::IfThenElse { .. }
282 | _ => Ok(ConditionAnalysis::incomplete()),
283 }
284 }
285
286 fn analyze_read(
287 &self,
288 field: &wowlab_types::sim::FieldRead,
289 action_spell: SpellIdx,
290 positive: bool,
291 ) -> Result<ConditionAnalysis> {
292 let resolved = resolve_field_read(field, self.table, self.resolver)?;
293 let descriptor = self.table.get(resolved.descriptor_id);
294
295 if let Some(change) = cooldown_read_change(field, resolved.key.as_ref(), positive) {
296 return Ok(ConditionAnalysis::change(change));
297 }
298
299 if let Some(change) = semantic_direct_change(field, positive) {
300 return Ok(ConditionAnalysis::change(change));
301 }
302
303 if let Some(analysis) = spell_read_analysis(
304 field,
305 resolved.key.as_ref(),
306 action_spell,
307 descriptor.map(|descriptor| descriptor.eval_kind),
308 positive,
309 ) {
310 return Ok(analysis);
311 }
312
313 if let Some(analysis) = other_read_analysis(field, resolved.key.as_ref(), positive) {
314 return Ok(analysis);
315 }
316
317 Ok(ConditionAnalysis::incomplete())
318 }
319
320 fn analyze_compare(
321 &self,
322 op: CompareOp,
323 left: &Condition,
324 right: &Condition,
325 positive: bool,
326 ) -> Result<ConditionAnalysis> {
327 let left = self.classify_comparison_operand(left)?;
328 let right = self.classify_comparison_operand(right)?;
329
330 match (left, right) {
331 (ComparisonOperand::Dynamic(dynamic), ComparisonOperand::Static(threshold)) => {
332 Ok(dynamic_comparison(dynamic, threshold, op, positive))
333 }
334 (ComparisonOperand::Static(threshold), ComparisonOperand::Dynamic(dynamic)) => {
335 Ok(match transform_compare(op, CompareTransform::Reverse) {
336 Some(reversed) => {
337 dynamic_comparison(dynamic, threshold, reversed, positive)
338 }
339 None => ConditionAnalysis::incomplete(),
340 })
341 }
342 (ComparisonOperand::Static(_), ComparisonOperand::Static(_)) => {
343 Ok(ConditionAnalysis::complete())
344 }
345 (ComparisonOperand::Dynamic(_), ComparisonOperand::Dynamic(_))
346 | (ComparisonOperand::Unknown, _)
347 | (_, ComparisonOperand::Unknown) => Ok(ConditionAnalysis::incomplete()),
348 }
349 }
350
351 fn classify_comparison_operand(&self, condition: &Condition) -> Result<ComparisonOperand> {
352 match condition {
353 Condition::Bool { .. } => Ok(ComparisonOperand::Static(None)),
354 Condition::Int { value } => Ok(ComparisonOperand::Static(Some(
355 wowlab_types::numeric::i64_to_f64(*value),
356 ))),
357 Condition::Float { value } => Ok(ComparisonOperand::Static(Some(*value))),
358 Condition::Var { name } => match self.rotation.variables.get(name) {
359 Some(value) => self.classify_comparison_operand(value),
360 None => Ok(ComparisonOperand::Unknown),
361 },
362 Condition::Read { field } => self.classify_read_operand(field),
363 _ => Ok(ComparisonOperand::Unknown),
364 }
365 }
366
367 fn classify_read_operand(
368 &self,
369 field: &wowlab_types::sim::FieldRead,
370 ) -> Result<ComparisonOperand> {
371 let resolved = resolve_field_read(field, self.table, self.resolver)?;
372
373 Ok(classify_resolved_read(field, &resolved))
374 }
375 }
376
377 fn cooldown_read_change(
378 field: &wowlab_types::sim::FieldRead,
379 key: Option<&ResolvedKey>,
380 positive: bool,
381 ) -> Option<PreparedRotationChange> {
382 let Some(ResolvedKey::Spell(spell)) = key else {
383 return None;
384 };
385
386 (field.domain == domain::COOLDOWN && field.name == "is_ready").then_some(
387 PreparedRotationChange {
388 key: PreparedRotationChangeKey::SpellCooldownReady(*spell),
389 direction: polarity_direction(positive),
390 },
391 )
392 }
393
394 fn spell_read_analysis(
395 field: &wowlab_types::sim::FieldRead,
396 key: Option<&ResolvedKey>,
397 action_spell: SpellIdx,
398 eval_kind: Option<EvalKind>,
399 positive: bool,
400 ) -> Option<ConditionAnalysis> {
401 let Some(ResolvedKey::Spell(spell)) = key else {
402 return None;
403 };
404
405 if eval_kind == Some(EvalKind::SpellUsable) && *spell == action_spell {
406 return Some(ConditionAnalysis::complete());
407 }
408
409 let dependency = match (field.domain.as_str(), field.name.as_str()) {
410 (domain::SPELL, "is_enabled") => PreparedRotationChangeKey::AuraState,
411 (domain::SPELL, "is_channeling" | "is_in_flight") => {
412 PreparedRotationChangeKey::SpellState
413 }
414 _ => return None,
415 };
416
417 Some(ConditionAnalysis::change(PreparedRotationChange {
418 key: dependency,
419 direction: polarity_direction(positive),
420 }))
421 }
422
423 fn other_read_analysis(
424 field: &wowlab_types::sim::FieldRead,
425 key: Option<&ResolvedKey>,
426 positive: bool,
427 ) -> Option<ConditionAnalysis> {
428 if field.domain == domain::WEAPON_IMBUE
429 && matches!(field.name.as_str(), "is_active" | "is_inactive")
430 {
431 let active = field.name == "is_active";
432
433 return Some(ConditionAnalysis::change(PreparedRotationChange {
434 key: PreparedRotationChangeKey::EquipmentState,
435 direction: polarity_direction(positive == active),
436 }));
437 }
438
439 if field.domain == domain::HISTORY
440 && matches!(field.name.as_str(), "prev_gcd" | "prev_off_gcd")
441 && matches!(key, Some(ResolvedKey::Spell(_)))
442 {
443 return Some(ConditionAnalysis::change(PreparedRotationChange {
444 key: PreparedRotationChangeKey::HistoryState,
445 direction: polarity_direction(positive),
446 }));
447 }
448
449 (field.domain == domain::PET && field.name == "is_active"
450 || is_static_domain(&field.domain))
451 .then(ConditionAnalysis::complete)
452 }
453
454 fn classify_resolved_read(
455 field: &wowlab_types::sim::FieldRead,
456 resolved: &ResolvedFieldRead,
457 ) -> ComparisonOperand {
458 if let Some(dynamic) = keyed_dynamic_operand(field, resolved.key.as_ref()) {
459 return ComparisonOperand::dynamic(dynamic);
460 }
461
462 if let Some(dynamic) =
463 semantic_comparison_operand(field, resolved.key.as_ref(), resolved.on)
464 {
465 return ComparisonOperand::Dynamic(dynamic);
466 }
467
468 if field.domain == domain::PET && field.name == "is_active"
469 || is_static_domain(&field.domain)
470 {
471 return ComparisonOperand::Static(None);
472 }
473
474 ComparisonOperand::Unknown
475 }
476
477 fn keyed_dynamic_operand(
478 field: &wowlab_types::sim::FieldRead,
479 resolved_key: Option<&ResolvedKey>,
480 ) -> Option<DynamicField> {
481 match (field.domain.as_str(), field.name.as_str(), resolved_key?) {
482 (domain::RESOURCE, "current", ResolvedKey::Resource(resource)) => {
483 Some(DynamicField::Resource(*resource))
484 }
485 (domain::COOLDOWN, "charges", ResolvedKey::Spell(spell)) => Some(DynamicField::Change(
486 PreparedRotationChangeKey::SpellCooldownReady(*spell),
487 )),
488 (domain::COOLDOWN, "remaining", ResolvedKey::Spell(spell)) => {
489 Some(DynamicField::CooldownRemaining { spell: *spell })
490 }
491 _ => None,
492 }
493 }
494
495 #[derive(Clone, Copy)]
496 enum ComparisonOperand {
497 Dynamic(DynamicComparisonOperand),
498 Static(Option<f64>),
499 Unknown,
500 }
501
502 impl ComparisonOperand {
503 const fn dynamic(field: DynamicField) -> Self {
504 Self::Dynamic(DynamicComparisonOperand {
505 field,
506 direction: FieldDirection::Normal,
507 })
508 }
509 }
510
511 #[derive(Clone, Copy)]
512 struct DynamicComparisonOperand {
513 field: DynamicField,
514 direction: FieldDirection,
515 }
516
517 #[derive(Clone, Copy)]
518 enum DynamicField {
519 Change(PreparedRotationChangeKey),
520 Resource(ResourceType),
521 TargetHealth,
522 AuraRemaining { aura: AuraIdx, on: AuraOn },
523 CooldownRemaining { spell: SpellIdx },
524 }
525
526 #[derive(Clone, Copy)]
527 enum FieldDirection {
528 Normal,
529 Inverted,
530 }
531
532 struct ConditionAnalysis {
533 changes: Vec<PreparedRotationChange>,
534 complete: bool,
535 }
536
537 impl ConditionAnalysis {
538 const fn complete() -> Self {
539 Self {
540 changes: Vec::new(),
541 complete: true,
542 }
543 }
544
545 const fn incomplete() -> Self {
546 Self {
547 changes: Vec::new(),
548 complete: false,
549 }
550 }
551
552 fn change(change: PreparedRotationChange) -> Self {
553 Self {
554 changes: vec![change],
555 complete: true,
556 }
557 }
558
559 fn merge(&mut self, mut other: Self) {
560 self.changes.append(&mut other.changes);
561 self.complete &= other.complete;
562 }
563 }
564
565 fn dynamic_comparison(
566 dynamic: DynamicComparisonOperand,
567 threshold: Option<f64>,
568 op: CompareOp,
569 positive: bool,
570 ) -> ConditionAnalysis {
571 let mut analysis = ConditionAnalysis::complete();
572 let effective_op = if positive {
573 Some(op)
574 } else {
575 transform_compare(op, CompareTransform::Negate)
576 };
577 let (observed_direction, strict) = match effective_op {
578 Some(CompareOp::Gt) => (Some(PreparedRotationChangeDirection::Rise), true),
579 Some(CompareOp::Gte) => (Some(PreparedRotationChangeDirection::Rise), false),
580 Some(CompareOp::Lt) => (Some(PreparedRotationChangeDirection::Fall), true),
581 Some(CompareOp::Lte) => (Some(PreparedRotationChangeDirection::Fall), false),
582 Some(CompareOp::Eq) => (None, false),
583 Some(CompareOp::Ne) => (None, true),
584 _ => return ConditionAnalysis::incomplete(),
585 };
586 let Some(key) = dynamic_change_key(dynamic.field, threshold, strict) else {
587 return ConditionAnalysis::incomplete();
588 };
589 let direction = observed_direction.map(|direction| match dynamic.direction {
590 FieldDirection::Normal => direction,
591 FieldDirection::Inverted => reverse_direction(direction),
592 });
593
594 match direction {
595 Some(direction) => analysis
596 .changes
597 .push(PreparedRotationChange { key, direction }),
598 None => analysis.changes.extend([
599 PreparedRotationChange {
600 key,
601 direction: PreparedRotationChangeDirection::Rise,
602 },
603 PreparedRotationChange {
604 key,
605 direction: PreparedRotationChangeDirection::Fall,
606 },
607 ]),
608 }
609
610 analysis
611 }
612
613 fn dynamic_change_key(
614 field: DynamicField,
615 threshold: Option<f64>,
616 strict: bool,
617 ) -> Option<PreparedRotationChangeKey> {
618 match field {
619 DynamicField::Change(key) => Some(key),
620 DynamicField::Resource(resource) => {
621 threshold.map(|value| PreparedRotationChangeKey::ResourceThreshold {
622 resource,
623 value_bits: value.to_bits(),
624 strict,
625 })
626 }
627 DynamicField::TargetHealth => {
628 threshold.map(|percent| PreparedRotationChangeKey::TargetHealthThreshold {
629 percent_bits: percent.to_bits(),
630 strict,
631 })
632 }
633 DynamicField::AuraRemaining { aura, on } => {
634 threshold.map(
635 |seconds| PreparedRotationChangeKey::AuraRemainingThreshold {
636 aura,
637 on,
638 seconds_bits: seconds.to_bits(),
639 strict,
640 },
641 )
642 }
643 DynamicField::CooldownRemaining { spell } => {
644 threshold.map(
645 |seconds| PreparedRotationChangeKey::CooldownRemainingThreshold {
646 spell,
647 seconds_bits: seconds.to_bits(),
648 strict,
649 },
650 )
651 }
652 }
653 }
654
655 fn semantic_comparison_operand(
656 field: &wowlab_types::sim::FieldRead,
657 resolved_key: Option<&ResolvedKey>,
658 resolved_on: Option<AuraOn>,
659 ) -> Option<DynamicComparisonOperand> {
660 let (field, direction) = match (field.domain.as_str(), field.name.as_str()) {
661 (domain::AURA, "is_active" | "stacks") => (
662 DynamicField::Change(PreparedRotationChangeKey::AuraState),
663 FieldDirection::Normal,
664 ),
665 (domain::AURA, "is_inactive") => (
666 DynamicField::Change(PreparedRotationChangeKey::AuraState),
667 FieldDirection::Inverted,
668 ),
669 (domain::AURA, "remaining") => {
670 let ResolvedKey::Aura(aura) = resolved_key? else {
671 return None;
672 };
673
674 (
675 DynamicField::AuraRemaining {
676 aura: *aura,
677 on: resolved_on.unwrap_or(AuraOn::Player),
678 },
679 FieldDirection::Normal,
680 )
681 }
682 (domain::SPELL, "is_in_range") => (
683 DynamicField::Change(PreparedRotationChangeKey::Spatial),
684 FieldDirection::Inverted,
685 ),
686 (domain::UNIT, "distance") => (
687 DynamicField::Change(PreparedRotationChangeKey::Spatial),
688 FieldDirection::Normal,
689 ),
690 (domain::UNIT, "health_pct") => (DynamicField::TargetHealth, FieldDirection::Normal),
691 (domain::COMBAT, "enemy_count") => (
692 DynamicField::Change(PreparedRotationChangeKey::EncounterPopulation),
693 FieldDirection::Normal,
694 ),
695 (domain::SPELL, "in_flight_count" | "last_used") => (
696 DynamicField::Change(PreparedRotationChangeKey::SpellState),
697 FieldDirection::Normal,
698 ),
699 (domain::WEAPON_IMBUE, "is_active") => (
700 DynamicField::Change(PreparedRotationChangeKey::EquipmentState),
701 FieldDirection::Normal,
702 ),
703 (domain::WEAPON_IMBUE, "is_inactive") => (
704 DynamicField::Change(PreparedRotationChangeKey::EquipmentState),
705 FieldDirection::Inverted,
706 ),
707 _ => return None,
708 };
709
710 Some(DynamicComparisonOperand { field, direction })
711 }
712
713 fn semantic_direct_change(
714 field: &wowlab_types::sim::FieldRead,
715 positive: bool,
716 ) -> Option<PreparedRotationChange> {
717 let (key, positive) = match (field.domain.as_str(), field.name.as_str()) {
718 (domain::AURA, "is_active") => (PreparedRotationChangeKey::AuraState, positive),
719 (domain::AURA, "is_inactive") => (PreparedRotationChangeKey::AuraState, !positive),
720 (domain::SPELL, "is_in_range") => (PreparedRotationChangeKey::Spatial, !positive),
721 _ => return None,
722 };
723
724 Some(PreparedRotationChange {
725 key,
726 direction: polarity_direction(positive),
727 })
728 }
729
730 const fn polarity_direction(positive: bool) -> PreparedRotationChangeDirection {
731 if positive {
732 PreparedRotationChangeDirection::Rise
733 } else {
734 PreparedRotationChangeDirection::Fall
735 }
736 }
737
738 const fn reverse_direction(
739 direction: PreparedRotationChangeDirection,
740 ) -> PreparedRotationChangeDirection {
741 match direction {
742 PreparedRotationChangeDirection::Rise => PreparedRotationChangeDirection::Fall,
743 PreparedRotationChangeDirection::Fall => PreparedRotationChangeDirection::Rise,
744 }
745 }
746
747 #[derive(Clone, Copy)]
748 enum CompareTransform {
749 Negate,
750 Reverse,
751 }
752
753 const fn transform_compare(op: CompareOp, transform: CompareTransform) -> Option<CompareOp> {
754 match transform {
755 CompareTransform::Negate => match op {
756 CompareOp::Gt => Some(CompareOp::Lte),
757 CompareOp::Gte => Some(CompareOp::Lt),
758 CompareOp::Lt => Some(CompareOp::Gte),
759 CompareOp::Lte => Some(CompareOp::Gt),
760 CompareOp::Eq => Some(CompareOp::Ne),
761 CompareOp::Ne => Some(CompareOp::Eq),
762 _ => None,
763 },
764 CompareTransform::Reverse => match op {
765 CompareOp::Gt => Some(CompareOp::Lt),
766 CompareOp::Gte => Some(CompareOp::Lte),
767 CompareOp::Lt => Some(CompareOp::Gt),
768 CompareOp::Lte => Some(CompareOp::Gte),
769 CompareOp::Eq => Some(CompareOp::Eq),
770 CompareOp::Ne => Some(CompareOp::Ne),
771 _ => None,
772 },
773 }
774 }
775
776 fn is_static_domain(domain_name: &str) -> bool {
777 matches!(
778 domain_name,
779 domain::TALENT | domain::HERO_TREE | domain::EQUIPMENT | domain::SET_BONUS
780 )
781 }
782
783 fn change_sort_key(
785 change: &PreparedRotationChange,
786 ) -> (u8, u64, u64, PreparedRotationChangeDirection) {
787 let (kind, identity, threshold) = match change.key {
788 PreparedRotationChangeKey::SpellCooldownReady(spell) => (0, u64::from(spell.0), 0),
789 PreparedRotationChangeKey::ResourceThreshold {
790 resource,
791 value_bits,
792 strict,
793 } => (
794 1,
795 (u64::from(u8::from(resource)) << 1) | u64::from(strict),
796 value_bits,
797 ),
798 PreparedRotationChangeKey::CooldownRemainingThreshold {
799 spell,
800 seconds_bits,
801 strict,
802 } => (
803 2,
804 (u64::from(spell.0) << 1) | u64::from(strict),
805 seconds_bits,
806 ),
807 PreparedRotationChangeKey::AuraState => (3, 0, 0),
808 PreparedRotationChangeKey::TargetHealthThreshold {
809 percent_bits,
810 strict,
811 } => (4, u64::from(strict), percent_bits),
812 PreparedRotationChangeKey::AuraRemainingThreshold {
813 aura,
814 on,
815 seconds_bits,
816 strict,
817 } => (
818 5,
819 (u64::from(aura.0) << (u8::BITS + 1))
820 | (u64::from(aura_on_rank(on)) << 1)
821 | u64::from(strict),
822 seconds_bits,
823 ),
824 PreparedRotationChangeKey::Spatial => (6, 0, 0),
825 PreparedRotationChangeKey::EncounterPopulation => (7, 0, 0),
826 PreparedRotationChangeKey::SpellState => (8, 0, 0),
827 PreparedRotationChangeKey::EquipmentState => (9, 0, 0),
828 PreparedRotationChangeKey::HistoryState => (10, 0, 0),
829 };
830
831 (kind, identity, threshold, change.direction)
832 }
833
834 const PET_AURA_RANK: u8 = 2;
835
836 const fn aura_on_rank(on: AuraOn) -> u8 {
837 match on {
838 AuraOn::Player => 0,
839 AuraOn::Target => 1,
840 AuraOn::Pet => PET_AURA_RANK,
841 }
842 }
843
844 #[cfg(test)]
845 mod tests {
846 use googletest::{Result as GtestResult, prelude::*};
847
848 use super::*;
849 use crate::rotation::{RotationPreparation, parse_and_validate};
850
851 fn preparation(json: &str) -> RotationPreparation {
852 let rotation = parse_and_validate(json).expect("parse dependency fixture");
853 let resolver = SpecResolver::new("dependencies")
854 .spell("primary", 10)
855 .spell("secondary", 20)
856 .spell("foreign", 30)
857 .aura("known_aura", 40)
858 .talent("known_talent", true);
859
860 RotationPreparation::prepare(&rotation, &resolver).expect("prepare dependency fixture")
861 }
862
863 fn expected(
864 key: PreparedRotationChangeKey,
865 direction: PreparedRotationChangeDirection,
866 ) -> PreparedRotationChange {
867 PreparedRotationChange { key, direction }
868 }
869
870 #[gtest]
871 fn cooldown_polarity_maps_to_rise_and_fall_in_source_order() -> GtestResult<()> {
872 let prepared = preparation(
873 r#"{"version":1,"name":"polarity","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"read","domain":"cooldown","key":"primary","name":"is_ready"}},{"type":"cast","spell":"secondary","condition":{"type":"not","operand":{"type":"read","domain":"cooldown","key":"secondary","name":"is_ready"}}}],"lists":{}}"#,
874 );
875 let plans = prepared.action_dependency_plans();
876
877 verify_that!(plans[0].spell(), eq(SpellIdx(10)))?;
878 verify_that!(
879 plans[0].changes(),
880 elements_are![eq(&expected(
881 PreparedRotationChangeKey::SpellCooldownReady(SpellIdx(10)),
882 PreparedRotationChangeDirection::Rise
883 ))]
884 )?;
885 verify_that!(plans[1].spell(), eq(SpellIdx(20)))?;
886 verify_that!(
887 plans[1].changes(),
888 elements_are![eq(&expected(
889 PreparedRotationChangeKey::SpellCooldownReady(SpellIdx(20)),
890 PreparedRotationChangeDirection::Fall
891 ))]
892 )?;
893
894 verify_that!(prepared.action_dependencies_complete(), is_true())
895 }
896
897 #[gtest]
898 fn cooldown_charge_threshold_subscribes_to_the_exact_spell() -> GtestResult<()> {
899 let prepared = preparation(
900 r#"{"version":1,"name":"charges","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"compare","op":"gte","left":{"type":"read","domain":"cooldown","key":"primary","name":"charges"},"right":{"type":"int","value":2}}}],"lists":{}}"#,
901 );
902 let plan = &prepared.action_dependency_plans()[0];
903
904 verify_that!(
905 plan.changes(),
906 elements_are![eq(&expected(
907 PreparedRotationChangeKey::SpellCooldownReady(SpellIdx(10)),
908 PreparedRotationChangeDirection::Rise
909 ))]
910 )?;
911
912 verify_true!(plan.complete())
913 }
914
915 #[gtest]
916 fn cooldown_remaining_threshold_tracks_time_falling_below_value() -> GtestResult<()> {
917 let prepared = preparation(
918 r#"{"version":1,"name":"remaining","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"compare","op":"lte","left":{"type":"read","domain":"cooldown","key":"secondary","name":"remaining"},"right":{"type":"float","value":5.0}}}],"lists":{}}"#,
919 );
920 let plan = &prepared.action_dependency_plans()[0];
921
922 verify_that!(
923 plan.changes(),
924 elements_are![eq(&expected(
925 PreparedRotationChangeKey::CooldownRemainingThreshold {
926 spell: SpellIdx(20),
927 seconds_bits: 5.0_f64.to_bits(),
928 strict: false,
929 },
930 PreparedRotationChangeDirection::Fall
931 ))]
932 )?;
933
934 verify_true!(plan.complete())
935 }
936
937 #[gtest]
938 fn recursive_combo_point_threshold_is_enabled_by_a_fall() -> GtestResult<()> {
939 let prepared = preparation(
940 r#"{"version":1,"name":"resource","variables":{"points":{"type":"read","domain":"resource","key":"combo_points","name":"current"}},"actions":[{"type":"cast","spell":"primary","condition":{"type":"compare","op":"lte","left":{"type":"var","name":"points"},"right":{"type":"int","value":3}}}],"lists":{}}"#,
941 );
942
943 verify_that!(
944 prepared.action_dependency_plans()[0].changes(),
945 elements_are![eq(&expected(
946 PreparedRotationChangeKey::ResourceThreshold {
947 resource: ResourceType::ComboPoints,
948 value_bits: 3.0_f64.to_bits(),
949 strict: false,
950 },
951 PreparedRotationChangeDirection::Fall
952 ))]
953 )
954 }
955
956 #[gtest]
957 fn reversed_resource_comparison_reverses_direction() -> GtestResult<()> {
958 let prepared = preparation(
959 r#"{"version":1,"name":"reversed","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"compare","op":"lt","left":{"type":"int","value":3},"right":{"type":"read","domain":"resource","key":"combo_points","name":"current"}}}],"lists":{}}"#,
960 );
961
962 verify_that!(
963 prepared.action_dependency_plans()[0].changes(),
964 elements_are![eq(&expected(
965 PreparedRotationChangeKey::ResourceThreshold {
966 resource: ResourceType::ComboPoints,
967 value_bits: 3.0_f64.to_bits(),
968 strict: true,
969 },
970 PreparedRotationChangeDirection::Rise
971 ))]
972 )
973 }
974
975 #[gtest]
976 fn static_reads_and_own_usability_add_no_changes() -> GtestResult<()> {
977 let prepared = preparation(
978 r#"{"version":1,"name":"static","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"and","operands":[{"type":"read","domain":"talent","key":"known_talent","name":"is_enabled"},{"type":"read","domain":"hero_tree","key":"tree","name":"is_active"},{"type":"read","domain":"equipment","key":"item","name":"is_equipped"},{"type":"read","domain":"set_bonus","key":"set","name":"is_active"},{"type":"read","domain":"spell","key":"primary","name":"is_usable"}]}}],"lists":{}}"#,
979 );
980
981 verify_that!(prepared.action_dependency_plans()[0].changes(), is_empty())?;
982
983 verify_that!(prepared.action_dependencies_complete(), is_true())
984 }
985
986 #[gtest]
987 fn direct_semantic_state_reads_preserve_enabling_polarity() -> GtestResult<()> {
988 let prepared = preparation(
989 r#"{"version":1,"name":"semantic_reads","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"read","domain":"aura","key":"known_aura","name":"is_active"}},{"type":"cast","spell":"secondary","condition":{"type":"read","domain":"aura","key":"known_aura","name":"is_inactive"}},{"type":"cast","spell":"foreign","condition":{"type":"read","domain":"spell","key":"primary","name":"is_in_range"}},{"type":"cast","spell":"primary","condition":{"type":"not","operand":{"type":"read","domain":"pet","name":"is_active"}}}],"lists":{}}"#,
990 );
991 let plans = prepared.action_dependency_plans();
992
993 verify_that!(
994 plans[0].changes(),
995 elements_are![eq(&expected(
996 PreparedRotationChangeKey::AuraState,
997 PreparedRotationChangeDirection::Rise
998 ))]
999 )?;
1000 verify_that!(
1001 plans[1].changes(),
1002 elements_are![eq(&expected(
1003 PreparedRotationChangeKey::AuraState,
1004 PreparedRotationChangeDirection::Fall
1005 ))]
1006 )?;
1007 verify_that!(
1008 plans[2].changes(),
1009 elements_are![eq(&expected(
1010 PreparedRotationChangeKey::Spatial,
1011 PreparedRotationChangeDirection::Fall
1012 ))]
1013 )?;
1014 verify_that!(plans[3].changes(), is_empty())?;
1015
1016 verify_that!(prepared.action_dependencies_complete(), is_true())
1017 }
1018
1019 #[gtest]
1020 fn semantic_thresholds_cover_aura_health_spatial_and_population_changes() -> GtestResult<()>
1021 {
1022 let prepared = preparation(
1023 r#"{"version":1,"name":"semantic_thresholds","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"and","operands":[{"type":"compare","op":"gte","left":{"type":"read","domain":"aura","key":"known_aura","name":"stacks"},"right":{"type":"int","value":3}},{"type":"compare","op":"lte","left":{"type":"read","domain":"unit","key":"target","name":"health_pct"},"right":{"type":"int","value":20}},{"type":"compare","op":"lte","left":{"type":"read","domain":"unit","key":"target","name":"distance"},"right":{"type":"int","value":8}},{"type":"compare","op":"gte","left":{"type":"read","domain":"combat","name":"enemy_count"},"right":{"type":"int","value":3}}]}}],"lists":{}}"#,
1024 );
1025
1026 verify_that!(
1027 prepared.action_dependency_plans()[0].changes(),
1028 elements_are![
1029 eq(&expected(
1030 PreparedRotationChangeKey::AuraState,
1031 PreparedRotationChangeDirection::Rise
1032 )),
1033 eq(&expected(
1034 PreparedRotationChangeKey::TargetHealthThreshold {
1035 percent_bits: 20.0_f64.to_bits(),
1036 strict: false,
1037 },
1038 PreparedRotationChangeDirection::Fall
1039 )),
1040 eq(&expected(
1041 PreparedRotationChangeKey::Spatial,
1042 PreparedRotationChangeDirection::Fall
1043 )),
1044 eq(&expected(
1045 PreparedRotationChangeKey::EncounterPopulation,
1046 PreparedRotationChangeDirection::Rise
1047 )),
1048 ]
1049 )?;
1050
1051 verify_that!(prepared.action_dependencies_complete(), is_true())
1052 }
1053
1054 #[gtest]
1055 fn aura_presence_comparisons_map_active_and_inactive_to_presence_direction()
1056 -> GtestResult<()> {
1057 let prepared = preparation(
1058 r#"{"version":1,"name":"aura_presence","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"compare","op":"gt","left":{"type":"read","domain":"aura","key":"known_aura","name":"is_active"},"right":{"type":"float","value":0.0}}},{"type":"cast","spell":"secondary","condition":{"type":"compare","op":"gt","left":{"type":"read","domain":"aura","key":"known_aura","name":"is_inactive"},"right":{"type":"float","value":0.0}}}],"lists":{}}"#,
1059 );
1060
1061 verify_that!(
1062 prepared.action_dependency_plans()[0].changes(),
1063 elements_are![eq(&expected(
1064 PreparedRotationChangeKey::AuraState,
1065 PreparedRotationChangeDirection::Rise
1066 ))]
1067 )?;
1068 verify_that!(
1069 prepared.action_dependency_plans()[1].changes(),
1070 elements_are![eq(&expected(
1071 PreparedRotationChangeKey::AuraState,
1072 PreparedRotationChangeDirection::Fall
1073 ))]
1074 )?;
1075
1076 verify_that!(prepared.action_dependencies_complete(), is_true())
1077 }
1078
1079 #[gtest]
1080 fn aura_remaining_preserves_its_exact_time_threshold() -> GtestResult<()> {
1081 let prepared = preparation(
1082 r#"{"version":1,"name":"time_dependent","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"compare","op":"lte","left":{"type":"read","domain":"aura","key":"known_aura","name":"remaining"},"right":{"type":"float","value":2.0}}}],"lists":{}}"#,
1083 );
1084
1085 verify_that!(
1086 prepared.action_dependency_plans()[0].changes(),
1087 elements_are![eq(&expected(
1088 PreparedRotationChangeKey::AuraRemainingThreshold {
1089 aura: AuraIdx(40),
1090 on: AuraOn::Player,
1091 seconds_bits: 2.0_f64.to_bits(),
1092 strict: false,
1093 },
1094 PreparedRotationChangeDirection::Fall
1095 ))]
1096 )?;
1097
1098 verify_that!(prepared.action_dependencies_complete(), is_true())
1099 }
1100
1101 #[gtest]
1102 fn arithmetic_and_foreign_usability_are_incomplete() -> GtestResult<()> {
1103 let prepared = preparation(
1104 r#"{"version":1,"name":"unknown","variables":{},"actions":[{"type":"cast","spell":"primary","condition":{"type":"compare","op":"gt","left":{"type":"arith","op":"add","left":{"type":"read","domain":"resource","key":"energy","name":"current"},"right":{"type":"int","value":1}},"right":{"type":"int","value":50}}},{"type":"cast","spell":"secondary","condition":{"type":"read","domain":"spell","key":"foreign","name":"is_usable"}}],"lists":{}}"#,
1105 );
1106
1107 verify_that!(
1108 prepared.action_dependency_plans(),
1109 each(matches_pattern!(PreparedActionDependencyPlan {
1110 complete: is_false(),
1111 ..
1112 }))
1113 )?;
1114
1115 verify_that!(prepared.action_dependencies_complete(), is_false())
1116 }
1117
1118 #[gtest]
1119 fn conditioned_run_marks_rotation_incomplete() {
1120 let prepared = preparation(
1121 r#"{"version":1,"name":"run","variables":{},"actions":[{"type":"run","list":"nested","condition":{"type":"bool","value":true}},{"type":"cast","spell":"primary"}],"lists":{"nested":[{"type":"cast","spell":"secondary"}]}}"#,
1122 );
1123
1124 expect_that!(prepared.action_dependency_plans(), len(eq(1)));
1125 expect_that!(prepared.action_dependencies_complete(), is_false());
1126 }
1127 }
1128}
1129
1130#[cfg(feature = "rotation-optimizations")]
1131pub use action_dependencies::{
1132 PreparedActionDependencyPlan, PreparedRotationChange, PreparedRotationChangeDirection,
1133 PreparedRotationChangeKey,
1134};
1135
1136bitflags! {
1137 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1139 pub struct SpellReferenceUsage: u8 {
1140 const CAST = 1 << 0;
1142 const FIELD_READ = 1 << 1;
1144 const AVAILABILITY = 1 << 2;
1146 }
1147}
1148
1149bitflags! {
1150 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1152 pub struct SpellProjectionUsage: u8 {
1153 const RANGE = 1 << 0;
1155 const TRAVEL = 1 << 1;
1157 }
1158}
1159
1160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1162pub struct ResolvedSpellReference {
1163 spell: SpellIdx,
1164 usage: SpellReferenceUsage,
1165 projection_usage: SpellProjectionUsage,
1166}
1167
1168impl ResolvedSpellReference {
1169 #[must_use]
1170 pub const fn spell(self) -> SpellIdx {
1171 self.spell
1172 }
1173
1174 #[must_use]
1175 pub const fn usage(self) -> SpellReferenceUsage {
1176 self.usage
1177 }
1178
1179 #[must_use]
1180 pub const fn projection_usage(self) -> SpellProjectionUsage {
1181 self.projection_usage
1182 }
1183}
1184
1185#[derive(Debug)]
1190pub struct RotationPreparation {
1191 rotation: Rotation,
1192 resolver: SpecResolver,
1193 table: DescriptorTable,
1194 spell_references: Box<[ResolvedSpellReference]>,
1195 item_use_slots: Box<[GearSlot]>,
1196 lazy_cast_validation_safe: bool,
1197 #[cfg(feature = "rotation-optimizations")]
1198 action_dependency_plans: Box<[PreparedActionDependencyPlan]>,
1199 #[cfg(feature = "rotation-optimizations")]
1200 action_dependencies_complete: bool,
1201}
1202
1203#[derive(Debug)]
1205pub struct PreparedRotation {
1206 rotation: Rotation,
1207 resolver: SpecResolver,
1208 schema: Arc<ContextSchema>,
1209 table: DescriptorTable,
1210 spell_references: Box<[ResolvedSpellReference]>,
1211 item_use_slots: Box<[GearSlot]>,
1212 lazy_cast_validation_safe: bool,
1213 spell_usability_prevalidated: bool,
1214 #[cfg(feature = "rotation-optimizations")]
1215 action_dependency_plans: Box<[PreparedActionDependencyPlan]>,
1216 #[cfg(feature = "rotation-optimizations")]
1217 action_dependencies_complete: bool,
1218}
1219
1220impl PreparedRotation {
1221 #[must_use]
1222 pub fn schema(&self) -> &Arc<ContextSchema> {
1223 &self.schema
1224 }
1225
1226 #[must_use]
1227 pub fn spell_references(&self) -> &[ResolvedSpellReference] {
1228 &self.spell_references
1229 }
1230
1231 #[must_use]
1233 pub fn item_use_slots(&self) -> &[GearSlot] {
1234 &self.item_use_slots
1235 }
1236
1237 #[must_use]
1239 pub const fn lazy_cast_validation_safe(&self) -> bool {
1240 self.lazy_cast_validation_safe
1241 }
1242
1243 #[cfg(feature = "rotation-optimizations")]
1245 #[must_use]
1246 pub fn action_dependency_plans(&self) -> &[PreparedActionDependencyPlan] {
1247 &self.action_dependency_plans
1248 }
1249
1250 #[cfg(feature = "rotation-optimizations")]
1252 #[must_use]
1253 pub const fn action_dependencies_complete(&self) -> bool {
1254 self.action_dependencies_complete
1255 }
1256
1257 #[cfg(feature = "rotation-optimizations")]
1262 #[must_use]
1263 pub fn with_prevalidated_spell_usability(mut self) -> Self {
1264 self.spell_usability_prevalidated = true;
1265
1266 self
1267 }
1268
1269 pub(super) fn lower<B>(&self, backend: &mut B)
1270 where
1271 B: RotationBackend,
1272 {
1273 lower::lower_rotation(
1274 backend,
1275 &self.rotation,
1276 &self.schema,
1277 &self.resolver,
1278 &self.table,
1279 self.spell_usability_prevalidated,
1280 );
1281 }
1282
1283 pub(super) const fn rotation(&self) -> &Rotation {
1284 &self.rotation
1285 }
1286
1287 pub(super) const fn resolver(&self) -> &SpecResolver {
1288 &self.resolver
1289 }
1290
1291 pub(super) const fn table(&self) -> &DescriptorTable {
1292 &self.table
1293 }
1294}
1295
1296impl RotationPreparation {
1297 pub fn prepare(rotation: &Rotation, resolver: &SpecResolver) -> Result<Self> {
1302 if !rotation.is_empty() {
1303 super::ast::validate(rotation)?;
1304 }
1305
1306 let table = DescriptorTable::build();
1307 let spell_references = collect_spell_references(rotation, resolver, &table)?;
1308 let item_use_slots = collect_item_use_slots(rotation);
1309 let lazy_cast_validation_safe = lazy_cast_validation_safe(rotation, resolver, &table)?;
1310 #[cfg(feature = "rotation-optimizations")]
1311 let action_dependencies = collect_action_dependencies(rotation, resolver, &table)?;
1312
1313 Ok(Self {
1314 rotation: rotation.clone(),
1315 resolver: resolver.clone(),
1316 table,
1317 spell_references,
1318 item_use_slots,
1319 lazy_cast_validation_safe,
1320 #[cfg(feature = "rotation-optimizations")]
1321 action_dependency_plans: action_dependencies.plans,
1322 #[cfg(feature = "rotation-optimizations")]
1323 action_dependencies_complete: action_dependencies.complete,
1324 })
1325 }
1326
1327 #[must_use]
1329 pub fn spell_references(&self) -> &[ResolvedSpellReference] {
1330 &self.spell_references
1331 }
1332
1333 #[cfg(feature = "rotation-optimizations")]
1335 #[must_use]
1336 pub fn action_dependency_plans(&self) -> &[PreparedActionDependencyPlan] {
1337 &self.action_dependency_plans
1338 }
1339
1340 #[cfg(feature = "rotation-optimizations")]
1342 #[must_use]
1343 pub const fn action_dependencies_complete(&self) -> bool {
1344 self.action_dependencies_complete
1345 }
1346
1347 pub fn bind(self, requirements: &BufferRequirements) -> Result<PreparedRotation> {
1352 let schema = lower::prepare(&self.rotation, &self.resolver, &self.table, requirements)?;
1353
1354 schema.validate(&self.table)?;
1355
1356 Ok(PreparedRotation {
1357 rotation: self.rotation,
1358 resolver: self.resolver,
1359 schema: Arc::new(schema),
1360 table: self.table,
1361 spell_references: self.spell_references,
1362 item_use_slots: self.item_use_slots,
1363 lazy_cast_validation_safe: self.lazy_cast_validation_safe,
1364 spell_usability_prevalidated: false,
1365 #[cfg(feature = "rotation-optimizations")]
1366 action_dependency_plans: self.action_dependency_plans,
1367 #[cfg(feature = "rotation-optimizations")]
1368 action_dependencies_complete: self.action_dependencies_complete,
1369 })
1370 }
1371}
1372
1373fn collect_item_use_slots(rotation: &Rotation) -> Box<[GearSlot]> {
1374 let mut slots = Vec::new();
1375
1376 for actions in std::iter::once(rotation.actions.as_slice())
1377 .chain(rotation.lists.values().map(Vec::as_slice))
1378 {
1379 for action in actions.iter().filter(|action| action.enabled()) {
1380 let slot = match action {
1381 RotationAction::UseTrinket { slot, .. } => GearSlot::trinket_from_index(*slot),
1382 RotationAction::UseItem { name, .. } => GearSlot::from_slug(name),
1383 _ => None,
1384 };
1385
1386 if let Some(slot) = slot {
1387 slots.push(slot);
1388 }
1389 }
1390 }
1391
1392 slots.sort_unstable_by_key(|slot| *slot as u8);
1394 slots.dedup();
1395
1396 slots.into_boxed_slice()
1397}
1398
1399fn lazy_cast_validation_safe(
1401 rotation: &Rotation,
1402 resolver: &SpecResolver,
1403 table: &DescriptorTable,
1404) -> Result<bool> {
1405 for condition in rotation.variables.values() {
1406 if condition_contains_availability(condition, resolver, table)? {
1407 return Ok(false);
1408 }
1409 }
1410
1411 for actions in std::iter::once(rotation.actions.as_slice())
1412 .chain(rotation.lists.values().map(Vec::as_slice))
1413 {
1414 for action in actions.iter().filter(|action| action.enabled()) {
1415 match action {
1416 RotationAction::Cast {
1417 spell,
1418 condition,
1419 target_if,
1420 ..
1421 } => {
1422 let own_spell = resolver.resolve_spell(spell)?;
1423
1424 if let Some(condition) = condition
1425 && !condition_availability_is_positive_for_spell(
1426 condition, own_spell, resolver, table,
1427 )?
1428 {
1429 return Ok(false);
1430 }
1431
1432 if let Some(target) = target_if
1433 && condition_contains_availability(&target.expr, resolver, table)?
1434 {
1435 return Ok(false);
1436 }
1437 }
1438 RotationAction::SetVar { .. } | RotationAction::ModifyVar { .. } => {
1439 return Ok(false);
1440 }
1441 _ => {
1442 for condition in action.conditions() {
1443 if condition_contains_availability(condition, resolver, table)? {
1444 return Ok(false);
1445 }
1446 }
1447 }
1448 }
1449 }
1450 }
1451
1452 Ok(true)
1453}
1454
1455fn condition_availability_is_positive_for_spell(
1456 condition: &Condition,
1457 own_spell: SpellIdx,
1458 resolver: &SpecResolver,
1459 table: &DescriptorTable,
1460) -> Result<bool> {
1461 let mut pending = vec![condition];
1462
1463 while let Some(condition) = pending.pop() {
1464 match condition {
1465 Condition::Read { field } => {
1466 let resolved_read = resolve_field_read(field, table, resolver)?;
1467 let descriptor = table.get(resolved_read.descriptor_id).ok_or_else(|| {
1468 super::error::Error::validation("resolved spell field has no descriptor")
1469 })?;
1470
1471 if field_is_availability(field, descriptor.eval_kind)
1472 && resolved_read.key != Some(ResolvedKey::Spell(own_spell))
1473 {
1474 return Ok(false);
1475 }
1476 }
1477 Condition::And { operands } | Condition::Or { operands } => {
1478 pending.extend(operands);
1479 }
1480 _ if condition_contains_availability(condition, resolver, table)? => {
1481 return Ok(false);
1482 }
1483 _ => {}
1484 }
1485 }
1486
1487 Ok(true)
1488}
1489
1490fn condition_contains_availability(
1491 condition: &Condition,
1492 resolver: &SpecResolver,
1493 table: &DescriptorTable,
1494) -> Result<bool> {
1495 for field in condition.field_reads() {
1496 let resolved_read = resolve_field_read(field, table, resolver)?;
1497 let descriptor = table.get(resolved_read.descriptor_id).ok_or_else(|| {
1498 super::error::Error::validation("resolved spell field has no descriptor")
1499 })?;
1500
1501 if field_is_availability(field, descriptor.eval_kind) {
1502 return Ok(true);
1503 }
1504 }
1505
1506 Ok(false)
1507}
1508
1509fn field_is_availability(field: &wowlab_types::sim::FieldRead, eval_kind: EvalKind) -> bool {
1511 eval_kind == EvalKind::SpellUsable
1512 || (field.domain == super::condition::domain::SPELL && field.name == "is_enabled")
1513}
1514
1515fn collect_spell_references(
1516 rotation: &Rotation,
1517 resolver: &SpecResolver,
1518 table: &DescriptorTable,
1519) -> Result<Box<[ResolvedSpellReference]>> {
1520 let mut references = Vec::new();
1521
1522 for condition in rotation.variables.values() {
1523 collect_condition_references(condition, resolver, table, &mut references)?;
1524 }
1525
1526 collect_action_references(&rotation.actions, resolver, table, &mut references)?;
1527
1528 for actions in rotation.lists.values() {
1529 collect_action_references(actions, resolver, table, &mut references)?;
1530 }
1531
1532 references.sort_unstable_by_key(|reference| reference.spell.0);
1533
1534 Ok(references.into_boxed_slice())
1535}
1536
1537fn collect_action_references(
1538 actions: &[RotationAction],
1539 resolver: &SpecResolver,
1540 table: &DescriptorTable,
1541 references: &mut Vec<ResolvedSpellReference>,
1542) -> Result<()> {
1543 for action in actions.iter().filter(|action| action.enabled()) {
1544 if let RotationAction::Cast { spell, .. } = action {
1545 merge_reference(
1546 references,
1547 resolver.resolve_spell(spell)?,
1548 SpellReferenceUsage::CAST | SpellReferenceUsage::AVAILABILITY,
1549 SpellProjectionUsage::empty(),
1550 );
1551 }
1552
1553 for condition in action.conditions() {
1554 collect_condition_references(condition, resolver, table, references)?;
1555 }
1556 }
1557
1558 Ok(())
1559}
1560
1561fn collect_condition_references(
1562 condition: &Condition,
1563 resolver: &SpecResolver,
1564 table: &DescriptorTable,
1565 references: &mut Vec<ResolvedSpellReference>,
1566) -> Result<()> {
1567 for field in condition.field_reads() {
1568 let resolved_read = resolve_field_read(field, table, resolver)?;
1569
1570 let Some(ResolvedKey::Spell(spell)) = resolved_read.key else {
1571 continue;
1572 };
1573 let descriptor = table.get(resolved_read.descriptor_id).ok_or_else(|| {
1574 super::error::Error::validation("resolved spell field has no descriptor")
1575 })?;
1576 let mut usage = SpellReferenceUsage::FIELD_READ;
1577 let mut spell_projection_usage = SpellProjectionUsage::empty();
1578
1579 if descriptor.eval_kind == EvalKind::SpellUsable
1580 || (field.domain == super::condition::domain::SPELL && field.name == "is_enabled")
1581 {
1582 usage |= SpellReferenceUsage::AVAILABILITY;
1583 }
1584
1585 match field.name.as_str() {
1586 "range" | "is_in_range" => spell_projection_usage |= SpellProjectionUsage::RANGE,
1587 "travel_time" => spell_projection_usage |= SpellProjectionUsage::TRAVEL,
1588 _ => {}
1589 }
1590
1591 if descriptor.eval_kind == EvalKind::SpellUsable {
1592 spell_projection_usage |= SpellProjectionUsage::RANGE;
1593 }
1594
1595 merge_reference(references, spell, usage, spell_projection_usage);
1596 }
1597
1598 Ok(())
1599}
1600
1601fn merge_reference(
1602 references: &mut Vec<ResolvedSpellReference>,
1603 spell: SpellIdx,
1604 usage: SpellReferenceUsage,
1605 projection_usage: SpellProjectionUsage,
1606) {
1607 if let Some(reference) = references
1608 .iter_mut()
1609 .find(|reference| reference.spell == spell)
1610 {
1611 reference.usage |= usage;
1612 reference.projection_usage |= projection_usage;
1613 } else {
1614 references.push(ResolvedSpellReference {
1615 spell,
1616 usage,
1617 projection_usage,
1618 });
1619 }
1620}
1621
1622#[cfg(test)]
1623mod tests {
1624 use googletest::prelude::*;
1625
1626 use super::*;
1627 use crate::rotation::parse_and_validate;
1628
1629 fn prepared_rotation(json: &str) -> PreparedRotation {
1630 let rotation = parse_and_validate(json).expect("parse lazy-validation fixture");
1631 let resolver = SpecResolver::new("lazy-validation")
1632 .spell("primary", 20)
1633 .spell("secondary", 10);
1634
1635 RotationPreparation::prepare(&rotation, &resolver)
1636 .expect("prepare lazy-validation fixture")
1637 .bind(&BufferRequirements::new())
1638 .expect("bind lazy-validation fixture")
1639 }
1640
1641 #[gtest]
1642 fn own_spell_availability_in_positive_boolean_positions_is_lazy_safe() {
1643 let prepared = prepared_rotation(
1644 r#"{
1645 "version":1,
1646 "name":"lazy-positive-own-spell",
1647 "variables":{},
1648 "actions":[{
1649 "type":"cast",
1650 "spell":"primary",
1651 "condition":{
1652 "type":"and",
1653 "operands":[
1654 {"type":"read","domain":"spell","key":"primary","name":"is_enabled"},
1655 {"type":"read","domain":"spell","key":"primary","name":"is_usable"}
1656 ]
1657 }
1658 }],
1659 "lists":{}
1660 }"#,
1661 );
1662
1663 expect_that!(prepared.lazy_cast_validation_safe(), is_true());
1664 }
1665
1666 #[gtest]
1667 fn cross_spell_availability_read_disables_lazy_validation() {
1668 let prepared = prepared_rotation(
1669 r#"{
1670 "version":1,
1671 "name":"lazy-cross-spell",
1672 "variables":{},
1673 "actions":[{
1674 "type":"cast",
1675 "spell":"primary",
1676 "condition":{"type":"read","domain":"spell","key":"secondary","name":"is_enabled"}
1677 }],
1678 "lists":{}
1679 }"#,
1680 );
1681
1682 expect_that!(prepared.lazy_cast_validation_safe(), is_false());
1683 }
1684
1685 #[gtest]
1686 fn nonpositive_availability_read_disables_lazy_validation() {
1687 let prepared = prepared_rotation(
1688 r#"{
1689 "version":1,
1690 "name":"lazy-negated-availability",
1691 "variables":{},
1692 "actions":[{
1693 "type":"cast",
1694 "spell":"primary",
1695 "condition":{
1696 "type":"not",
1697 "operand":{"type":"read","domain":"spell","key":"primary","name":"is_enabled"}
1698 }
1699 }],
1700 "lists":{}
1701 }"#,
1702 );
1703
1704 expect_that!(prepared.lazy_cast_validation_safe(), is_false());
1705 }
1706
1707 #[gtest]
1708 fn availability_dependent_variables_and_action_side_effects_disable_lazy_validation() {
1709 let availability_variable = prepared_rotation(
1710 r#"{
1711 "version":1,
1712 "name":"lazy-variable-availability",
1713 "variables":{
1714 "ready":{"type":"read","domain":"spell","key":"primary","name":"is_enabled"}
1715 },
1716 "actions":[{"type":"cast","spell":"primary"}],
1717 "lists":{}
1718 }"#,
1719 );
1720 let side_effect = prepared_rotation(
1721 r#"{
1722 "version":1,
1723 "name":"lazy-side-effect",
1724 "variables":{"counter":{"type":"int","value":0}},
1725 "actions":[
1726 {"type":"set_var","name":"counter","value":{"type":"int","value":1}},
1727 {"type":"cast","spell":"primary"}
1728 ],
1729 "lists":{}
1730 }"#,
1731 );
1732
1733 expect_that!(
1734 availability_variable.lazy_cast_validation_safe(),
1735 is_false()
1736 );
1737 expect_that!(side_effect.lazy_cast_validation_safe(), is_false());
1738 }
1739
1740 #[gtest]
1741 fn item_action_metadata_tracks_authored_equipment_slots() {
1742 let prepared = prepared_rotation(
1743 r#"{
1744 "version":1,
1745 "name":"locked-action-metadata",
1746 "variables":{},
1747 "actions":[
1748 {"type":"use_trinket","slot":2},
1749 {"type":"use_item","name":"trinket_1"},
1750 {"type":"use_item","name":"trinket_1"},
1751 {"type":"use_item","name":"main_hand","enabled":false},
1752 {"type":"cast","spell":"primary"}
1753 ],
1754 "lists":{}
1755 }"#,
1756 );
1757
1758 expect_that!(
1759 prepared.item_use_slots(),
1760 eq([GearSlot::Trinket1, GearSlot::Trinket2].as_slice())
1761 );
1762 }
1763
1764 #[gtest]
1765 fn resolved_spell_inventory_is_complete_sorted_and_usage_aware() {
1766 let rotation = parse_and_validate(
1767 r#"{
1768 "version":1,
1769 "name":"reference-inventory",
1770 "variables":{"ready":{"type":"read","domain":"spell","key":"secondary","name":"is_enabled"}},
1771 "actions":[
1772 {"type":"cast","spell":"primary"},
1773 {"type":"cast","spell":"disabled","enabled":false}
1774 ],
1775 "lists":{}
1776 }"#,
1777 )
1778 .expect("parse reference fixture");
1779 let resolver = SpecResolver::new("reference-inventory")
1780 .spell("primary", 20)
1781 .spell("secondary", 10)
1782 .spell("disabled", 30);
1783 let prepared = RotationPreparation::prepare(&rotation, &resolver)
1784 .expect("prepare reference fixture")
1785 .bind(&BufferRequirements::new())
1786 .expect("bind reference fixture");
1787
1788 expect_that!(
1789 prepared.spell_references(),
1790 eq([
1791 ResolvedSpellReference {
1792 spell: SpellIdx(10),
1793 usage: SpellReferenceUsage::FIELD_READ | SpellReferenceUsage::AVAILABILITY,
1794 projection_usage: SpellProjectionUsage::empty(),
1795 },
1796 ResolvedSpellReference {
1797 spell: SpellIdx(20),
1798 usage: SpellReferenceUsage::CAST | SpellReferenceUsage::AVAILABILITY,
1799 projection_usage: SpellProjectionUsage::empty(),
1800 },
1801 ]
1802 .as_slice())
1803 );
1804 }
1805}