Skip to main content

wowlab_engine_domain/rotation/
prepared.rs

1// #t(file: rust_similar_structs) rotation preparation and its bound successor intentionally share immutable lowering inputs across the state-machine transition.
2// #t(file: rust_too_many_lines_in_file) preparation owns the feature-gated cold dependency metadata pass beside its immutable output.
3// #t(file: rust_inline_test_module_size) dependency grammar tests require private collector state that is intentionally absent from the public API.
4
5//! Validated rotation source, prepared schema, and complete resolved references.
6
7use 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    //! Mutation dependencies that can make a cast action selectable at a decision boundary.
32
33    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    /// Runtime field whose mutation can make a prepared cast action selectable.
45    #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
46    #[non_exhaustive]
47    pub enum PreparedRotationChangeKey {
48        /// The ready state of one resolved spell cooldown.
49        SpellCooldownReady(SpellIdx),
50        /// One exact current-resource threshold observed by the rotation.
51        ResourceThreshold {
52            resource: ResourceType,
53            value_bits: u64,
54            strict: bool,
55        },
56        /// One exact spell-cooldown remaining-time threshold observed by the rotation.
57        CooldownRemainingThreshold {
58            spell: SpellIdx,
59            seconds_bits: u64,
60            strict: bool,
61        },
62        /// Aura presence or stack state observed by the rotation.
63        AuraState,
64        /// One exact current-target health-percent threshold observed by the rotation.
65        TargetHealthThreshold { percent_bits: u64, strict: bool },
66        /// One exact aura-remaining threshold observed by the rotation.
67        AuraRemainingThreshold {
68            aura: AuraIdx,
69            on: AuraOn,
70            seconds_bits: u64,
71            strict: bool,
72        },
73        /// Target distance underlying spatial eligibility observed by the rotation.
74        Spatial,
75        /// Current encounter population observed by the rotation.
76        EncounterPopulation,
77        /// Spell lifecycle state observed by the rotation.
78        SpellState,
79        /// Persistent equipped-item state observed by the rotation.
80        EquipmentState,
81        /// Previous player-cast history observed by the rotation.
82        HistoryState,
83    }
84
85    /// Mutation direction that can make a prepared cast action selectable.
86    #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
87    #[non_exhaustive]
88    pub enum PreparedRotationChangeDirection {
89        /// The semantic value represented by the key rises.
90        Rise,
91        /// The semantic value represented by the key falls.
92        Fall,
93    }
94
95    /// One directional mutation that can enable a prepared cast action.
96    #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
97    pub struct PreparedRotationChange {
98        key: PreparedRotationChangeKey,
99        direction: PreparedRotationChangeDirection,
100    }
101
102    impl PreparedRotationChange {
103        /// Runtime field whose mutation can enable the action.
104        #[must_use]
105        pub const fn key(self) -> PreparedRotationChangeKey {
106            self.key
107        }
108
109        /// Enabling direction for the runtime field.
110        #[must_use]
111        pub const fn direction(self) -> PreparedRotationChangeDirection {
112            self.direction
113        }
114    }
115
116    /// Dependency plan for one reachable terminal cast action.
117    #[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        /// Spell selected by this terminal cast action.
126        #[must_use]
127        pub const fn spell(&self) -> SpellIdx {
128            self.spell
129        }
130
131        /// Directional mutations that can make this action selectable.
132        #[must_use]
133        pub fn changes(&self) -> &[PreparedRotationChange] {
134            &self.changes
135        }
136
137        /// Whether the v1 dependency grammar describes every enabling mutation.
138        #[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    // #t(fn: rust_magic_numbers) tuple ordinals define the stable public dependency ordering.
784    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    /// How one resolved spell identity is consumed by the authored rotation.
1138    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1139    pub struct SpellReferenceUsage: u8 {
1140        /// The spell is selected by an enabled cast action.
1141        const CAST = 1 << 0;
1142        /// An authored condition reads a field keyed by the spell.
1143        const FIELD_READ = 1 << 1;
1144        /// The spell participates in cast availability or an explicit usability/enabled read.
1145        const AVAILABILITY = 1 << 2;
1146    }
1147}
1148
1149bitflags! {
1150    /// Target-dependent spell-slot fields observed by one resolved spell reference.
1151    #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1152    pub struct SpellProjectionUsage: u8 {
1153        /// Effective range or current in-range state is read.
1154        const RANGE = 1 << 0;
1155        /// Target-distance-derived travel time is read.
1156        const TRAVEL = 1 << 1;
1157    }
1158}
1159
1160/// One resolved spell identity and all of its authored uses.
1161#[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/// Layout-free validated rotation preparation that cannot be emitted.
1186///
1187/// Backend emission accepts only [`PreparedRotation`].
1188/// Binding this artifact to the complete finalized buffer requirements produces it.
1189#[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/// Canonical lowering input shared by portable bytecode, JIT, and explicit trace evaluation.
1204#[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    /// Enabled equipment actions authored by the rotation.
1232    #[must_use]
1233    pub fn item_use_slots(&self) -> &[GearSlot] {
1234        &self.item_use_slots
1235    }
1236
1237    /// Whether cast availability may be validated only when that cast is selected.
1238    #[must_use]
1239    pub const fn lazy_cast_validation_safe(&self) -> bool {
1240        self.lazy_cast_validation_safe
1241    }
1242
1243    /// Dependency plans for reachable terminal cast actions in source order.
1244    #[cfg(feature = "rotation-optimizations")]
1245    #[must_use]
1246    pub fn action_dependency_plans(&self) -> &[PreparedActionDependencyPlan] {
1247        &self.action_dependency_plans
1248    }
1249
1250    /// Whether dependency plans cover every reachable decision behavior.
1251    #[cfg(feature = "rotation-optimizations")]
1252    #[must_use]
1253    pub const fn action_dependencies_complete(&self) -> bool {
1254        self.action_dependencies_complete
1255    }
1256
1257    /// Use the externally maintained eligibility bit for `spell.is_usable` reads.
1258    ///
1259    /// The caller must fully validate every availability binding before evaluation.
1260    /// Alternatively, it must validate selected casts and retry with rejected slots disabled.
1261    #[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    /// Validate and resolve one canonical rotation without freezing a buffer layout.
1298    ///
1299    /// # Errors
1300    /// Returns an error for invalid structure or unresolved semantic identities.
1301    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    /// Complete referenced-spell inventory available before layout freeze.
1328    #[must_use]
1329    pub fn spell_references(&self) -> &[ResolvedSpellReference] {
1330        &self.spell_references
1331    }
1332
1333    /// Dependency plans for reachable terminal cast actions in source order.
1334    #[cfg(feature = "rotation-optimizations")]
1335    #[must_use]
1336    pub fn action_dependency_plans(&self) -> &[PreparedActionDependencyPlan] {
1337        &self.action_dependency_plans
1338    }
1339
1340    /// Whether dependency plans cover every reachable decision behavior.
1341    #[cfg(feature = "rotation-optimizations")]
1342    #[must_use]
1343    pub const fn action_dependencies_complete(&self) -> bool {
1344        self.action_dependencies_complete
1345    }
1346
1347    /// Freeze one complete buffer layout and produce the sole backend-lowering input.
1348    ///
1349    /// # Errors
1350    /// Returns an error for invalid resources, fields, or buffer coordinates.
1351    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    // #t(rust_lossy_cast) `GearSlot` is `repr(u8)`; this ordering conversion is exact.
1393    slots.sort_unstable_by_key(|slot| *slot as u8);
1394    slots.dedup();
1395
1396    slots.into_boxed_slice()
1397}
1398
1399// #t(fn: rust_cyclomatic_complexity) cold validation deliberately enumerates every action shape whose availability reads affect retry semantics.
1400fn 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
1509// #t(fn: rust_const_fn_candidate) equality for owned field-name strings is not const-compatible
1510fn 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}