Skip to main content

wowlab_engine_combat/handler/
mod.rs

1// #t(file: rust_pub_api_docs) docs will be added after stabilization
2
3//! [`wowlab_engine_ports::SpecHandler`] implementation backed by `CombatInstance` + `DenseBuffer`.
4
5pub(crate) mod can_cast;
6mod introspection;
7mod orchestration;
8mod reset;
9mod spec_handler;
10
11#[cfg(test)]
12#[path = "tests.rs"]
13mod tests;
14
15#[cfg(not(feature = "rotation-optimizations"))]
16use can_cast::pool_wait;
17use can_cast::{CastCandidate, CastReject, candidate_rejection, reject_to_wait};
18#[cfg(feature = "rotation-optimizations")]
19use can_cast::{candidate_time_horizon, rejection_ready_at};
20use wowlab_engine_domain::rotation::{EvalResult, RotationEngine};
21use wowlab_engine_ports::{SpecAction, SpecRuntimeError};
22use wowlab_engine_rng::SimRng;
23use wowlab_engine_telemetry::TelemetrySink;
24use wowlab_types::sim::SimTime;
25
26use crate::{
27    context::{CombatCtx, CombatCtxRequest, HookCtx, HookCtxRequest},
28    state::{CombatInstance, CombatProgram},
29};
30
31/// Placeholder seed replaced with the real per-iteration seed in `on_sim_start` before any roll; never used to sample.
32const PLACEHOLDER_RNG: (u64, u32) = (0, 0);
33
34#[derive(Clone, Copy)]
35pub(crate) enum AuraResetScope {
36    All,
37    Player,
38}
39
40#[derive(Clone, Copy)]
41pub(crate) enum LockMode {
42    Unlocked,
43    Casting,
44    Gcd,
45    Both,
46}
47
48/// Capability token whose private constructor confines raw aura clearing to reset orchestration.
49pub(crate) struct AuraResetToken(AuraResetScope);
50
51impl AuraResetToken {
52    #[cfg(test)]
53    pub(crate) const fn test_only(scope: AuraResetScope) -> Self {
54        Self(scope)
55    }
56
57    pub(crate) fn scope(self) -> AuraResetScope {
58        self.0
59    }
60}
61
62// docref:start spec-handlers-combat-handler
63#[derive(Debug)]
64pub struct CombatHandler {
65    pub(super) program: CombatProgram,
66    pub(super) instance: CombatInstance,
67    pub(super) engine: RotationEngine,
68    pub(super) rng: SimRng,
69    /// Guards against a roll reaching the placeholder RNG before `on_sim_start` installs the real seed.
70    pub(super) rng_seeded: bool,
71    pub(super) last_resource_sync: SimTime,
72}
73// docref:end spec-handlers-combat-handler
74
75impl CombatHandler {
76    #[must_use]
77    pub fn from_built(built: crate::BuiltCombatSystem) -> Self {
78        let (program, instance, rotation) = built.into_parts();
79
80        Self {
81            program,
82            instance,
83            engine: rotation,
84            rng: SimRng::from_prefix(PLACEHOLDER_RNG.0, PLACEHOLDER_RNG.1),
85            rng_seeded: false,
86            last_resource_sync: SimTime::ZERO,
87        }
88    }
89
90    #[cfg(any(test, feature = "test-support"))]
91    #[doc(hidden)]
92    pub fn instance(&self) -> &CombatInstance {
93        &self.instance
94    }
95
96    #[cfg(feature = "test-support")]
97    #[doc(hidden)]
98    pub fn instance_mut(&mut self) -> &mut CombatInstance {
99        &mut self.instance
100    }
101
102    #[cfg(feature = "test-support")]
103    #[doc(hidden)]
104    pub fn program_and_instance_mut(&mut self) -> (&CombatProgram, &mut CombatInstance) {
105        (&self.program, &mut self.instance)
106    }
107
108    /// Test-support-only access to the program-branded event coordinate.
109    #[cfg(any(test, feature = "test-support"))]
110    #[doc(hidden)]
111    pub fn __test_scheduled_spell(
112        &self,
113        spell: wowlab_types::sim::SpellIdx,
114    ) -> wowlab_engine_ports::ScheduledSpellBinding {
115        let local = self
116            .program
117            .spell_local(spell.as_u32())
118            .expect("test spell belongs to the finalized program");
119
120        self.program.scheduled_spell(local)
121    }
122
123    /// Test-support-only diagnostic conversion for legacy port event fixtures.
124    #[cfg(any(test, feature = "test-support"))]
125    #[doc(hidden)]
126    #[must_use]
127    pub fn __test_aura_local_raw(&self, aura_id: u32) -> u8 {
128        self.program
129            .aura_local(aura_id)
130            .expect("test aura belongs to the finalized program")
131            .raw()
132    }
133
134    /// Test-support-only mint for a program-owned aura lifecycle event.
135    #[cfg(any(test, feature = "test-support"))]
136    #[doc(hidden)]
137    #[must_use]
138    pub fn __test_aura_event(
139        &self,
140        aura_id: u32,
141        key: wowlab_types::sim::AuraKey,
142        target: Option<wowlab_types::sim::EnemyIdx>,
143    ) -> wowlab_engine_ports::AuraEventRef {
144        let local = self
145            .program
146            .aura_local(aura_id)
147            .expect("test aura belongs to the finalized program");
148        let binding = self
149            .program
150            .aura_event(local, key, target)
151            .expect("test aura key belongs to the finalized program");
152
153        wowlab_engine_ports::AuraEventRef::from_binding(binding)
154    }
155
156    /// Test-support-only mint for a program-owned guardian callback event.
157    #[cfg(any(test, feature = "test-support"))]
158    #[doc(hidden)]
159    pub fn __test_guardian_event(
160        &self,
161        guardian: crate::GuardianHandle,
162        ability: Option<u16>,
163    ) -> wowlab_engine_ports::GuardianEventBinding {
164        self.program
165            .runtime_events
166            .guardian(guardian.as_u32(), guardian.generation(), ability)
167    }
168
169    #[must_use]
170    pub fn program(&self) -> crate::HookProgramView<'_> {
171        crate::HookProgramView::new(&self.program)
172    }
173
174    /// Current selected enemy, if the encounter has an eligible target.
175    #[must_use]
176    pub fn current_target(&self) -> Option<wowlab_types::sim::EnemyIdx> {
177        self.instance.current_target()
178    }
179
180    /// Read the specialization-owned runtime without exposing the combat instance.
181    #[must_use]
182    pub fn spec_runtime<T>(&self) -> Option<&T>
183    where
184        T: wowlab_engine_ports::SpecRuntime,
185    {
186        self.instance.spec_runtime::<T>()
187    }
188
189    /// Runs an operation with a combat context borrowing this handler's runtime state.
190    pub fn with_combat_ctx<R>(
191        &mut self,
192        sink: &mut TelemetrySink,
193        request: CombatCtxRequest,
194        apply: impl FnOnce(&mut CombatCtx<'_>) -> R,
195    ) -> R {
196        self.assert_rng_seeded();
197        let rng = &mut self.rng;
198        let mut rng_fn = || rng.next_f64();
199        let mut ctx = CombatCtx {
200            program: &self.program,
201            instance: &mut self.instance,
202            sink,
203            now: request.now,
204            rng: &mut rng_fn,
205            source: request.source,
206            target: request.target,
207        };
208
209        apply(&mut ctx)
210    }
211
212    /// Runs an operation with a hook context borrowing this handler's runtime state.
213    pub fn with_hook_ctx<R>(
214        &mut self,
215        sink: &mut TelemetrySink,
216        request: HookCtxRequest,
217        apply: impl FnOnce(&mut HookCtx<'_>) -> R,
218    ) -> R {
219        self.assert_rng_seeded();
220        let rng = &mut self.rng;
221        let mut rng_fn = || rng.next_f64();
222        let mut ctx = HookCtx::new(
223            crate::context::HookCtxServices {
224                program: &self.program,
225                instance: &mut self.instance,
226                sink,
227                rng: &mut rng_fn,
228            },
229            request,
230        );
231
232        apply(&mut ctx)
233    }
234
235    pub(super) fn assert_rng_seeded(&self) {
236        debug_assert!(
237            self.rng_seeded,
238            "CombatHandler RNG used before on_sim_start installed the per-iteration seed"
239        );
240    }
241
242    pub(super) fn evaluate_rotation(&mut self, now: SimTime) -> Option<SpecAction> {
243        let trace_enabled = tracing::enabled!(tracing::Level::TRACE);
244
245        self.evaluate_rotation_with_trace(now, trace_enabled)
246    }
247
248    // #t(fn: rust_cyclomatic_complexity) the diagnostic eager path and guarded lazy experiment share one public decision boundary.
249    pub(super) fn evaluate_rotation_with_trace(
250        &mut self,
251        now: SimTime,
252        trace_enabled: bool,
253    ) -> Option<SpecAction> {
254        #[cfg(feature = "rotation-optimizations")]
255        if !trace_enabled
256            && !self.engine.has_decision_trace()
257            && self.engine.prepared().lazy_cast_validation_safe()
258            && self.actor_is_ready_for_lazy_rotation(now)
259        {
260            return self.evaluate_rotation_lazily(now);
261        }
262
263        let target = self.instance.current_target();
264        let mut masked = std::mem::take(&mut self.instance.runtime.scratch.rotation_masks);
265        let mut candidates = std::mem::take(&mut self.instance.runtime.scratch.rotation_candidates);
266        let mut trace_reasons =
267            std::mem::take(&mut self.instance.runtime.scratch.rotation_trace_reasons);
268
269        masked.clear();
270        candidates.clear();
271        #[cfg(feature = "rotation-optimizations")]
272        let mut decision_horizon =
273            self.program
274                .rotation
275                .exact_decision_horizon(&self.program, &self.instance, now);
276
277        if trace_enabled {
278            trace_reasons
279                .get_or_insert_with(|| {
280                    Vec::with_capacity(self.program.rotation.availability().len())
281                })
282                .clear();
283        }
284
285        for binding in self.program.rotation.availability().iter().copied() {
286            #[cfg(test)]
287            {
288                self.instance.runtime.rotation_spells_scanned += 1;
289            };
290
291            let candidate = target
292                .map(|target| CastCandidate::resolve_planned(&self.instance, binding, target));
293            let reason = match candidate {
294                Some(candidate) => match candidate_rejection(
295                    &crate::context::CombatView::new(&self.program, &self.instance),
296                    candidate,
297                    now,
298                ) {
299                    Ok(()) => {
300                        debug_assert!(
301                            candidates.len() < candidates.capacity(),
302                            "candidate scratch capacity is smaller than the finalized availability plan"
303                        );
304                        candidates.push(candidate);
305
306                        continue;
307                    }
308                    Err(reason) => reason,
309                },
310                None => CastReject::NoTarget,
311            };
312
313            #[cfg(feature = "rotation-optimizations")]
314            if let Some(candidate) = candidate
315                && let Some(ready_at) = rejection_ready_at(
316                    &crate::context::CombatView::new(&self.program, &self.instance),
317                    candidate,
318                    reason,
319                    now,
320                )
321            {
322                decision_horizon = Some(
323                    decision_horizon.map_or(ready_at, |current: SimTime| current.min(ready_at)),
324                );
325            }
326
327            let slot = self.instance.buffer.spell_mut_at(binding.spell_slot);
328
329            if slot.is_enabled == 0 {
330                continue;
331            }
332
333            debug_assert!(
334                masked.len() < masked.capacity(),
335                "ordinary rotation mask scratch capacity is smaller than the finalized availability plan"
336            );
337            masked.push((binding.spell_slot, slot.is_enabled));
338
339            if trace_enabled && let Some(reasons) = trace_reasons.as_mut() {
340                debug_assert!(
341                    reasons.len() < reasons.capacity(),
342                    "rotation trace-reason scratch capacity is smaller than the finalized availability plan"
343                );
344                reasons.push((binding.spell.as_u32(), reason));
345            }
346
347            slot.is_enabled = 0;
348        }
349
350        self.log_mask_transitions(now, trace_reasons.as_deref(), trace_enabled);
351        let result = self.evaluate_compiled_rotation(now);
352
353        for &(handle, enabled) in &masked {
354            self.instance.buffer.spell_mut_at(handle).is_enabled = enabled;
355        }
356
357        self.instance.runtime.scratch.rotation_masks = masked;
358        self.instance.runtime.scratch.rotation_candidates = candidates;
359        self.instance.runtime.scratch.rotation_trace_reasons = trace_reasons;
360
361        #[cfg(not(feature = "rotation-optimizations"))]
362        let decision_horizon = None;
363
364        self.resolve_rotation_result(now, result, decision_horizon)
365    }
366
367    /// Evaluate a locked actor's APL with actions that cannot execute through every active lock masked.
368    pub(super) fn evaluate_rotation_while_locked(
369        &mut self,
370        lock_mode: LockMode,
371        now: SimTime,
372    ) -> Option<SpecAction> {
373        let target = self.instance.current_target();
374        let mut disabled = std::mem::take(&mut self.instance.runtime.scratch.locked_action_masks);
375
376        disabled.clear();
377
378        for binding in self.program.rotation.availability().iter().copied() {
379            #[cfg(test)]
380            {
381                self.instance.runtime.rotation_spells_scanned += 1;
382            };
383
384            let executable_while_locked = target
385                .map(|target| CastCandidate::resolve_planned(&self.instance, binding, target))
386                .is_some_and(|candidate| {
387                    let spell = candidate.effective(&self.program);
388
389                    match lock_mode {
390                        LockMode::Unlocked => true,
391                        LockMode::Casting => spell.requirements.availability.usable_while_casting,
392                        LockMode::Gcd => spell.behavior.off_gcd,
393                        LockMode::Both => {
394                            spell.requirements.availability.usable_while_casting
395                                && spell.behavior.off_gcd
396                        }
397                    }
398                });
399
400            if executable_while_locked {
401                continue;
402            }
403
404            let slot = self.instance.buffer.spell_mut_at(binding.spell_slot);
405
406            if slot.is_enabled == 0 {
407                continue;
408            }
409
410            debug_assert!(
411                disabled.len() < disabled.capacity(),
412                "locked-action mask scratch capacity is smaller than the finalized availability plan"
413            );
414            disabled.push((binding.spell_slot, slot.is_enabled));
415            slot.is_enabled = 0;
416        }
417
418        #[cfg(feature = "rotation-optimizations")]
419        let action = {
420            let trace_enabled = tracing::enabled!(tracing::Level::TRACE);
421
422            if !trace_enabled
423                && !self.engine.has_decision_trace()
424                && self.engine.prepared().lazy_cast_validation_safe()
425            {
426                self.evaluate_rotation_lazily(now)
427            } else {
428                self.evaluate_rotation_with_trace(now, trace_enabled)
429            }
430        };
431
432        #[cfg(not(feature = "rotation-optimizations"))]
433        let action = self.evaluate_rotation(now);
434
435        for &(handle, enabled) in &disabled {
436            self.instance.buffer.spell_mut_at(handle).is_enabled = enabled;
437        }
438
439        self.instance.runtime.scratch.locked_action_masks = disabled;
440
441        action
442    }
443
444    #[cfg(feature = "rotation-optimizations")]
445    fn actor_is_ready_for_lazy_rotation(&self, now: SimTime) -> bool {
446        let player = self.instance.buffer.player();
447        let ready_at = SimTime::from_secs_f64(player.gcd_end)
448            .max(SimTime::from_secs_f64(player.cast_end))
449            .max(SimTime::from_secs_f64(player.channel_end));
450
451        now >= ready_at
452    }
453
454    #[cfg(feature = "rotation-optimizations")]
455    fn evaluate_rotation_lazily(&mut self, now: SimTime) -> Option<SpecAction> {
456        let target = self.instance.current_target();
457        let mut masked = std::mem::take(&mut self.instance.runtime.scratch.rotation_masks);
458        let mut candidates = std::mem::take(&mut self.instance.runtime.scratch.rotation_candidates);
459
460        masked.clear();
461        candidates.clear();
462
463        let result = loop {
464            let result = self.evaluate_compiled_rotation(now);
465
466            if !result.is_cast() {
467                break result;
468            }
469
470            let Some(binding) = self.program.rotation.availability_binding(result.spell_id) else {
471                break result;
472            };
473
474            #[cfg(test)]
475            {
476                self.instance.runtime.rotation_spells_scanned += 1;
477            }
478
479            let Some(target) = target else {
480                if !self.mask_lazy_candidate(binding, &mut masked) {
481                    break result;
482                }
483
484                continue;
485            };
486            let candidate = CastCandidate::resolve_planned(&self.instance, binding, target);
487
488            match candidate_rejection(
489                &crate::context::CombatView::new(&self.program, &self.instance),
490                candidate,
491                now,
492            ) {
493                Ok(()) => {
494                    debug_assert!(
495                        candidates.len() < candidates.capacity(),
496                        "candidate scratch capacity is smaller than the finalized availability plan"
497                    );
498                    candidates.push(candidate);
499
500                    break result;
501                }
502                Err(_) if self.mask_lazy_candidate(binding, &mut masked) => {}
503                Err(_) => break result,
504            }
505        };
506
507        for &(handle, enabled) in &masked {
508            self.instance.buffer.spell_mut_at(handle).is_enabled = enabled;
509        }
510
511        let selected_cast = result.is_cast() && !candidates.is_empty();
512        let needs_horizon = !result.is_wait() && !result.is_use_item() && !selected_cast;
513        let decision_horizon = needs_horizon
514            .then(|| self.exact_rotation_decision_horizon(now))
515            .flatten();
516
517        self.instance.runtime.scratch.rotation_masks = masked;
518        self.instance.runtime.scratch.rotation_candidates = candidates;
519
520        self.resolve_rotation_result(now, result, decision_horizon)
521    }
522
523    #[cfg(feature = "rotation-optimizations")]
524    fn mask_lazy_candidate(
525        &mut self,
526        binding: crate::state::RotationSpellBinding,
527        masked: &mut Vec<(wowlab_engine_domain::rotation::SpellSlotHandle, i32)>,
528    ) -> bool {
529        let slot = self.instance.buffer.spell_mut_at(binding.spell_slot);
530
531        if slot.is_enabled == 0 {
532            return false;
533        }
534
535        debug_assert!(
536            masked.len() < masked.capacity(),
537            "ordinary rotation mask scratch capacity is smaller than the finalized availability plan"
538        );
539        masked.push((binding.spell_slot, slot.is_enabled));
540        slot.is_enabled = 0;
541
542        true
543    }
544
545    #[cfg(feature = "rotation-optimizations")]
546    fn exact_rotation_decision_horizon(&mut self, now: SimTime) -> Option<SimTime> {
547        let target = self.instance.current_target();
548        let control_ready_at = self
549            .instance
550            .actor_control_ready_at(wowlab_types::sim::ActorId::Player, now);
551        let has_school_lockout = self
552            .instance
553            .runtime
554            .control
555            .school_lockouts
556            .contains_key(wowlab_types::sim::ActorId::Player);
557        let mut horizon =
558            self.program
559                .rotation
560                .exact_decision_horizon(&self.program, &self.instance, now);
561
562        for binding in self.program.rotation.availability().iter().copied() {
563            #[cfg(test)]
564            {
565                self.instance.runtime.rotation_spells_scanned += 1;
566            }
567
568            let Some(target) = target else {
569                continue;
570            };
571            let candidate = CastCandidate::resolve_planned(&self.instance, binding, target);
572            let view = crate::context::CombatView::new(&self.program, &self.instance);
573            let Some(ready_at) =
574                candidate_time_horizon(&view, candidate, now, control_ready_at, has_school_lockout)
575            else {
576                continue;
577            };
578
579            horizon = Some(horizon.map_or(ready_at, |current: SimTime| current.min(ready_at)));
580        }
581
582        horizon
583    }
584
585    /// Trace each change in why a spell is masked out of rotation evaluation.
586    ///
587    /// `REJECT_CANCAST` only fires for the spell the rotation actually selected.
588    /// A spell that is unavailable all fight therefore never appears in a trace.
589    /// A `BUFFER_STATE` dump reads `is_enabled = 0` for it, because it is taken after this pre-pass.
590    /// This is the only place the real mask reason is observable.
591    fn log_mask_transitions(
592        &mut self,
593        now: SimTime,
594        masked_reasons: Option<&[(u32, CastReject)]>,
595        trace_enabled: bool,
596    ) {
597        if !trace_enabled {
598            return;
599        }
600
601        let masked_reasons = masked_reasons.unwrap_or_default();
602        let previous_reasons = self.instance.runtime.mask_reasons.get_or_insert_default();
603
604        for &(spell_id, reason) in masked_reasons {
605            let discriminant = std::mem::discriminant(&reason);
606
607            if previous_reasons.insert(spell_id, discriminant) == Some(discriminant) {
608                continue;
609            }
610
611            // #t(rust_log_in_loop) one line per mask-reason change is the whole point of the signal.
612            tracing::trace!(
613                t = %now.as_secs_f64(),
614                spell_id,
615                reason = %reason,
616                "SPELL_MASKED"
617            );
618        }
619
620        previous_reasons
621            .retain(|spell_id, _| masked_reasons.iter().any(|(masked, _)| masked == spell_id));
622    }
623
624    fn evaluate_compiled_rotation(&mut self, now: SimTime) -> EvalResult {
625        wowlab_types::bench_metrics::record_rotation_evaluation();
626        tracing::trace!(t = %now.as_secs_f64(), buffer = %self.instance.buffer.dump(), "BUFFER_STATE");
627        let result = self
628            .engine
629            .evaluate(&mut self.instance.buffer, now.as_secs_f64());
630
631        tracing::trace!(t = %now.as_secs_f64(), kind = result.kind, spell_id = result.spell_id, "EVAL_RESULT");
632
633        result
634    }
635
636    // #t(fn: rust_cyclomatic_complexity) sequential rotation result handling with multiple action kinds
637    fn resolve_rotation_result(
638        &mut self,
639        now: SimTime,
640        result: EvalResult,
641        decision_horizon: Option<SimTime>,
642    ) -> Option<SpecAction> {
643        if result.is_cast() {
644            return self.resolve_cast_result(now, result, decision_horizon);
645        }
646
647        if result.is_wait() {
648            let wait_until =
649                now.saturating_add(SimTime::from_secs_f64(f64::from(result.wait_time)));
650
651            return Some(SpecAction::Wait {
652                until_ms: wait_until,
653            });
654        }
655
656        if result.is_pool() {
657            return self.rotation_fallthrough(now, decision_horizon);
658        }
659
660        if result.is_use_item() {
661            if let Some(gear_slot) = result.item_slot() {
662                if let Some(item_use) = self.program.rotation.item_use(gear_slot) {
663                    let spell_id = item_use.action.spell.as_u32();
664                    let Some(target) = self.instance.current_target() else {
665                        return self.rotation_fallthrough(now, decision_horizon);
666                    };
667                    let candidate =
668                        CastCandidate::resolve_planned(&self.instance, item_use.action, target);
669
670                    match candidate_rejection(
671                        &crate::context::CombatView::new(&self.program, &self.instance),
672                        candidate,
673                        now,
674                    ) {
675                        Ok(()) => {
676                            self.instance.runtime.casting.selected_candidate = Some(candidate);
677                            tracing::trace!(t = %now.as_secs_f64(), spell_id, slot = ?gear_slot, "USE_ITEM_CAST");
678
679                            return Some(SpecAction::Cast {
680                                spell: self.program.scheduled_spell(candidate.requested_local),
681                                empower_rank: resolved_empower_rank(
682                                    candidate.requested(&self.program),
683                                    result.empower_rank,
684                                )?,
685                                source: wowlab_types::sim::ActorId::Player,
686                                target,
687                            });
688                        }
689                        Err(reason) => {
690                            tracing::trace!(spell_id, slot = ?gear_slot, reason = %reason, "USE_ITEM_REJECT_CANCAST");
691
692                            return reject_to_wait(
693                                &crate::context::CombatView::new(&self.program, &self.instance),
694                                candidate,
695                                Some(reason),
696                                now,
697                            );
698                        }
699                    }
700                }
701
702                tracing::trace!(slot = ?gear_slot, "use_item: no spell mapped");
703            } else {
704                tracing::warn!(raw = result.spell_id, "use_item: invalid slot");
705            }
706        }
707
708        tracing::trace!(t = %now.as_secs_f64(), "ROTATION_NONE");
709
710        self.rotation_fallthrough(now, decision_horizon)
711    }
712
713    fn resolve_cast_result(
714        &mut self,
715        now: SimTime,
716        result: EvalResult,
717        decision_horizon: Option<SimTime>,
718    ) -> Option<SpecAction> {
719        let spell_id = result.spell_id;
720        let Some(candidate) = self
721            .instance
722            .runtime
723            .scratch
724            .rotation_candidates
725            .binary_search_by_key(&spell_id, |candidate| {
726                candidate.requested_spell_id(&self.program)
727            })
728            .ok()
729            .and_then(|index| {
730                self.instance
731                    .runtime
732                    .scratch
733                    .rotation_candidates
734                    .get(index)
735                    .copied()
736            })
737        else {
738            tracing::trace!(
739                t = %now.as_secs_f64(),
740                spell_id,
741                reason = %CastReject::UnknownSpell,
742                "REJECT_CANCAST"
743            );
744
745            return self.rotation_fallthrough(now, decision_horizon);
746        };
747        let requested = candidate.requested(&self.program);
748        let Some(empower_rank) = resolved_empower_rank(requested, result.empower_rank) else {
749            self.instance
750                .record_runtime_error(SpecRuntimeError::invalid_empower_rank(
751                    spell_id,
752                    result.empower_rank,
753                    requested.empower_rank_count,
754                ));
755
756            return None;
757        };
758
759        self.instance.runtime.casting.selected_candidate = Some(candidate);
760        tracing::trace!(t = %now.as_secs_f64(), spell_id, "CAST");
761
762        Some(SpecAction::Cast {
763            spell: self.program.scheduled_spell(candidate.requested_local),
764            empower_rank,
765            source: wowlab_types::sim::ActorId::Player,
766            target: candidate.target,
767        })
768    }
769
770    #[cfg(feature = "rotation-optimizations")]
771    fn rotation_fallthrough(
772        &self,
773        _now: SimTime,
774        decision_horizon: Option<SimTime>,
775    ) -> Option<SpecAction> {
776        decision_horizon.map(|until_ms| SpecAction::Wait { until_ms })
777    }
778
779    #[cfg(not(feature = "rotation-optimizations"))]
780    #[expect(
781        clippy::unnecessary_wraps,
782        reason = "both optimization configurations expose the same optional fallthrough contract"
783    )]
784    fn rotation_fallthrough(
785        &self,
786        now: SimTime,
787        _decision_horizon: Option<SimTime>,
788    ) -> Option<SpecAction> {
789        Some(pool_wait(&self.program, &self.instance, now))
790    }
791}
792
793fn resolved_empower_rank(spell: &crate::state::SpellData, requested_rank: u8) -> Option<u8> {
794    if spell.empower_rank_count == 0 {
795        return (requested_rank == 0 || spell.max_empower_aura_id != 0).then_some(0);
796    }
797
798    let rank = requested_rank.max(1);
799
800    (rank <= spell.empower_rank_count).then_some(rank)
801}