1pub(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
31const 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
48pub(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#[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 pub(super) rng_seeded: bool,
71 pub(super) last_resource_sync: SimTime,
72}
73impl 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 #[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 #[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 #[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 #[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 #[must_use]
176 pub fn current_target(&self) -> Option<wowlab_types::sim::EnemyIdx> {
177 self.instance.current_target()
178 }
179
180 #[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 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 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 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 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 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 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 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}