1use wowlab_engine_domain::dbc::{ShapeshiftForm, SpellShapeshiftFormFlags, stance_mask_allows};
2use wowlab_types::{
3 constants::HUNDRED,
4 sim::{ActorId, SimTime},
5};
6
7use super::{CastCandidate, CastReject};
8use crate::{
9 context::{CombatView, HookView},
10 state::{CombatInstance, LocalSpellIdx},
11 systems::{effective_cast_time_ms, spell_health_cost},
12};
13
14fn requested_spell_enabled(
18 view: &CombatView<'_>,
19 candidate: CastCandidate,
20) -> Result<(), CastReject> {
21 if view
22 .instance
23 .buffer
24 .spell_at(candidate.requested_slot(view.program))
25 .is_enabled
26 == 0
27 {
28 return Err(CastReject::Disabled);
29 }
30
31 Ok(())
32}
33
34fn has_primary_resource(view: &CombatView<'_>, candidate: CastCandidate) -> Result<(), CastReject> {
35 let CombatView {
36 program,
37 instance: state,
38 ..
39 } = view;
40 let spell = state.buffer.spell_at(candidate.effective_slot(program));
41 let profile = candidate.effective_profile(program);
42
43 if spell.cost <= 0.0 {
44 return Ok(());
45 }
46
47 let cost_bypassed = profile.cost_bypass_aura.is_some_and(|requirement| {
48 program
49 .rotation
50 .aura_active(state, requirement, candidate.target)
51 });
52 let have = profile
53 .primary_resource
54 .map(|handle| state.buffer.resource_at(handle).current);
55 let available = have.is_none_or(|current| current >= spell.cost);
56
57 if !cost_bypassed && spell.cost > 0.0 && !available {
58 return Err(CastReject::PrimaryResource {
59 have: have.unwrap_or(0.0),
60 cost: spell.cost,
61 });
62 }
63
64 Ok(())
65}
66
67fn has_secondary_resource(
68 view: &CombatView<'_>,
69 candidate: CastCandidate,
70) -> Result<(), CastReject> {
71 let CombatView {
72 program,
73 instance: state,
74 ..
75 } = view;
76 let spell = state.buffer.spell_at(candidate.effective_slot(program));
77 let profile = candidate.effective_profile(program);
78
79 if spell.secondary_cost <= 0.0 {
80 return Ok(());
81 }
82
83 let cost_bypassed = profile.cost_bypass_aura.is_some_and(|requirement| {
84 program
85 .rotation
86 .aura_active(state, requirement, candidate.target)
87 });
88 let have = profile
89 .secondary_resource
90 .map(|handle| state.buffer.resource_at(handle).current);
91 let available = have.is_none_or(|current| current >= spell.secondary_cost);
92
93 if !cost_bypassed && spell.secondary_cost > 0.0 && !available {
94 return Err(CastReject::SecondaryResource {
95 have: have.unwrap_or(0.0),
96 cost: spell.secondary_cost,
97 });
98 }
99
100 Ok(())
101}
102
103fn has_health(view: &CombatView<'_>, candidate: CastCandidate) -> Result<(), CastReject> {
104 let CombatView {
105 program,
106 instance: state,
107 ..
108 } = view;
109 let spell = candidate.effective(program);
110 let profile = candidate.effective_profile(program);
111
112 if spell.cost.health_cost <= 0.0 && spell.cost.health_cost_pct <= 0.0 {
113 return Ok(());
114 }
115
116 let bypassed = profile.cost_bypass_aura.is_some_and(|requirement| {
117 program
118 .rotation
119 .aura_active(state, requirement, candidate.target)
120 });
121 let cost = spell_health_cost(
122 state,
123 ActorId::Player,
124 spell.cost.health_cost,
125 spell.cost.health_cost_pct,
126 );
127 let have = state.friendly_actor_health(ActorId::Player).unwrap_or(0.0);
128
129 if !bypassed && cost > 0.0 && have < cost {
130 return Err(CastReject::Health { have, cost });
131 }
132
133 Ok(())
134}
135
136fn cooldown_ready(view: HookView<'_>, candidate: CastCandidate) -> Result<(), CastReject> {
137 let HookView {
138 program,
139 instance: state,
140 now,
141 ..
142 } = view;
143 let bypassed = candidate
144 .effective_profile(program)
145 .cooldown_bypass_aura
146 .is_some_and(|requirement| {
147 program
148 .rotation
149 .aura_active(state, requirement, candidate.target)
150 });
151
152 if bypassed {
153 return Ok(());
154 }
155
156 let cooldown = state.buffer.cooldown_at(candidate.cooldown_slot(program));
157
158 category_cooldowns_ready(program, state, candidate, now)?;
159
160 if cooldown.max_charges > 1 && cooldown.current_charges == 0 {
161 return Err(CastReject::NoCharges);
162 }
163
164 if cooldown.max_charges <= 1 && now.as_secs_f64() < cooldown.ready_at {
165 return Err(CastReject::Cooldown {
166 ready_at: cooldown.ready_at,
167 now: now.as_secs_f64(),
168 });
169 }
170
171 Ok(())
172}
173
174fn category_cooldowns_ready(
175 program: &crate::state::CombatProgram,
176 state: &CombatInstance,
177 candidate: CastCandidate,
178 now: SimTime,
179) -> Result<(), CastReject> {
180 let spell = candidate.effective(program);
181
182 if let Some(category) = spell.cooldown.cooldown_category
183 && !state
184 .runtime
185 .pools
186 .cooldown_category_ready_at
187 .is_ready(candidate.effective_local, now)
188 {
189 let ready_at = state
190 .runtime
191 .pools
192 .cooldown_category_ready_at
193 .ready_at(candidate.effective_local);
194
195 return Err(CastReject::CooldownCategory {
196 category,
197 ready_at,
198 now,
199 });
200 }
201
202 if spell.cooldown.max_charges <= 1
203 && let Some(category) = spell.cooldown.charge_category
204 && !state
205 .runtime
206 .pools
207 .charge_category_ready_at
208 .is_ready(candidate.effective_local, now)
209 {
210 let ready_at = state
211 .runtime
212 .pools
213 .charge_category_ready_at
214 .ready_at(candidate.effective_local);
215
216 return Err(CastReject::ChargeCategory {
217 category,
218 ready_at,
219 now,
220 });
221 }
222
223 Ok(())
224}
225
226fn in_execute_range(view: HookView<'_>, candidate: CastCandidate) -> Result<(), CastReject> {
227 let HookView {
228 program,
229 instance: state,
230 now,
231 ..
232 } = view;
233 let data = candidate.effective(program);
234
235 if data.requirements.execute_below_pct <= 0.0 {
236 return Ok(());
237 }
238
239 let bypassed = state
240 .runtime
241 .pools
242 .spell_gates
243 .ignores_aura_state(candidate.effective_local);
244 let health = state.enemy_health_fraction(program, candidate.target, now);
245 let in_execute =
246 health.is_some_and(|health| health <= data.requirements.execute_below_pct / HUNDRED);
247
248 if !bypassed && !in_execute {
249 return Err(CastReject::ExecuteRange {
250 target_health_pct: health.unwrap_or(1.0) * HUNDRED,
251 threshold_pct: data.requirements.execute_below_pct,
252 });
253 }
254
255 Ok(())
256}
257
258fn gating_aura_active(view: &CombatView<'_>, candidate: CastCandidate) -> Result<(), CastReject> {
259 let CombatView {
260 program,
261 instance: state,
262 ..
263 } = view;
264 let Some(requirement) = candidate.effective_profile(program).gating_aura else {
265 return Ok(());
266 };
267
268 if state
269 .runtime
270 .pools
271 .spell_gates
272 .ignores_aura_state(candidate.effective_local)
273 {
274 return Ok(());
275 }
276
277 let active = program
278 .rotation
279 .aura_active(state, requirement, candidate.target);
280
281 if !active {
282 return Err(CastReject::GatingAuraInactive {
283 aura_id: requirement.aura_id,
284 min_stacks: i32::from(requirement.min_stacks),
285 });
286 }
287
288 Ok(())
289}
290
291fn stealth_requirement_met(
292 view: &CombatView<'_>,
293 candidate: CastCandidate,
294) -> Result<(), CastReject> {
295 let state = view.instance;
296
297 let profile = candidate.effective_profile(view.program);
298
299 if !profile.requires_stealth {
300 return Ok(());
301 }
302
303 if state
304 .runtime
305 .pools
306 .spell_gates
307 .ignores_aura_state(candidate.effective_local)
308 {
309 return Ok(());
310 }
311
312 if state.buffer.player().stealthed == 0
313 && !view
314 .program
315 .rotation
316 .stance_exception_active(state, profile)
317 {
318 return Err(CastReject::RequiresStealth);
319 }
320
321 Ok(())
322}
323
324fn position_requirement_met(
325 state: &CombatInstance,
326 spell_data: &crate::state::SpellData,
327) -> Result<(), CastReject> {
328 if spell_data.requirements.equipment.requires_behind_target
329 && state.attack_position() != crate::state::AttackPosition::Behind
330 {
331 return Err(CastReject::RequiresBehindTarget);
332 }
333
334 Ok(())
335}
336
337fn shapeshift_requirement_met(
339 view: &CombatView<'_>,
340 candidate: CastCandidate,
341) -> Result<(), CastReject> {
342 let CombatView {
343 program,
344 instance: state,
345 ..
346 } = view;
347 let profile = candidate.effective_profile(program);
348
349 if profile.shapeshift_required_mask == 0
350 && profile.shapeshift_excluded_mask == 0
351 && !profile.requires_unshifted
352 {
353 return Ok(());
354 }
355
356 if state
357 .runtime
358 .pools
359 .spell_gates
360 .ignores_aura_state(candidate.effective_local)
361 || program.rotation.stance_exception_active(state, profile)
362 {
363 return Ok(());
364 }
365
366 let form = i64::from(state.buffer.player().shapeshift_form);
367 let current_mask = u32::try_from(form.saturating_sub(1))
368 .ok()
369 .and_then(|shift| 1_u64.checked_shl(shift))
370 .unwrap_or(0);
371 let required = profile.shapeshift_required_mask;
372
373 if profile.shapeshift_excluded_mask & current_mask != 0 {
374 return Err(CastReject::ShapeshiftExcluded { form });
375 }
376
377 let form_listed = required & current_mask != 0;
380 let stealth_listed = state.buffer.player().stealthed != 0
381 && stance_mask_allows(required, ShapeshiftForm::Stealth);
382
383 if form_listed || stealth_listed {
384 return Ok(());
385 }
386
387 if form == 0 {
388 if required == 0 || profile.allow_while_unshifted {
390 return Ok(());
391 }
392
393 return Err(CastReject::ShapeshiftRequired {
394 form,
395 required_mask: required,
396 });
397 }
398
399 let Ok(form_index) = u8::try_from(form) else {
400 return Ok(());
401 };
402 let form_flags = program.rotation.shapeshift_form_flags(form_index);
403
404 if form_flags.is_empty() {
405 return Ok(());
406 }
407
408 if form_flags.acts_as_shifted()
409 && (profile.requires_unshifted
410 || form_flags.contains(SpellShapeshiftFormFlags::CAN_ONLY_CAST_SHAPESHIFT_SPELLS))
411 {
412 return Err(CastReject::RequiresUnshifted { form });
413 }
414
415 if required != 0 && (form_flags.acts_as_shifted() || !profile.allow_while_unshifted) {
416 return Err(CastReject::ShapeshiftRequired {
417 form,
418 required_mask: required,
419 });
420 }
421
422 Ok(())
423}
424
425fn weapon_requirement_met(
426 program: &crate::state::CombatProgram,
427 candidate: CastCandidate,
428) -> Result<(), CastReject> {
429 let spell = candidate.effective(program);
430 let profile = candidate.effective_profile(program);
431
432 if !spell.requirements.equipment.requires_main_hand
433 && !spell.requirements.equipment.requires_off_hand
434 && spell.requirements.equipped_item_requirement.is_none()
435 {
436 return Ok(());
437 }
438
439 if spell.requirements.equipment.requires_main_hand && !profile.main_hand_available {
440 return Err(CastReject::RequiresMainHand);
441 }
442
443 if spell.requirements.equipment.requires_off_hand && !profile.off_hand_available {
444 return Err(CastReject::RequiresOffHand);
445 }
446
447 if spell.requirements.equipped_item_requirement.is_some() && !profile.equipped_item_matches {
448 return Err(CastReject::EquippedItemRequirement);
449 }
450
451 Ok(())
452}
453
454fn start_recovery_ready(
455 program: &crate::state::CombatProgram,
456 state: &CombatInstance,
457 spell_local: LocalSpellIdx,
458 spell_data: &crate::state::SpellData,
459 now: SimTime,
460) -> Result<(), CastReject> {
461 let Some(category) = spell_data.cooldown.start_recovery_category else {
462 return Ok(());
463 };
464 let owner = program
465 .start_recovery_category_owners
466 .get(spell_local.as_usize())
467 .copied()
468 .unwrap_or(spell_local);
469
470 if state
471 .runtime
472 .pools
473 .start_recovery_ready_at
474 .is_ready(owner, now)
475 {
476 return Ok(());
477 }
478
479 Err(CastReject::StartRecoveryCategory {
480 category,
481 ready_at: state.runtime.pools.start_recovery_ready_at.ready_at(owner),
482 now,
483 })
484}
485
486fn explicit_target_ready(
487 program: &crate::state::CombatProgram,
488 state: &CombatInstance,
489 candidate: CastCandidate,
490) -> Result<(), CastReject> {
491 let profile = candidate.effective_profile(program);
492
493 if !profile.requires_hostile_target {
494 return Ok(());
495 }
496
497 if !state.spell_target_in_range_profile_with_buffs(program, profile, candidate.target) {
498 return Err(CastReject::OutOfRange);
499 }
500
501 Ok(())
502}
503
504fn actor_requirements_met(view: HookView<'_>, candidate: CastCandidate) -> Result<(), CastReject> {
505 let HookView {
506 program,
507 instance: state,
508 now,
509 ..
510 } = view;
511
512 if state.actor_cast_blocked_by_control(ActorId::Player, now) {
513 return Err(CastReject::CrowdControlled {
514 ready_at: state.actor_control_ready_at(ActorId::Player, now),
515 });
516 }
517
518 let spell_data = candidate.effective(program);
519
520 stealth_requirement_met(&view.combat(), candidate)?;
521 position_requirement_met(state, spell_data)?;
522 shapeshift_requirement_met(&view.combat(), candidate)?;
523
524 if state.movement_state(ActorId::Player) == crate::state::MovementState::Moving
525 && stationary_cast_required(view, candidate)
526 && !crate::systems::can_cast_while_moving(
527 &view.combat(),
528 candidate.effective_local,
529 spell_data.requirements.availability.usable_while_moving,
530 )
531 {
532 return Err(CastReject::Moving);
533 }
534
535 school_and_weapon_requirements(program, state, candidate, now)
536}
537
538fn stationary_cast_required(view: HookView<'_>, candidate: CastCandidate) -> bool {
539 let spell = candidate.effective(view.program);
540 let instant_cast_active = candidate
541 .effective_profile(view.program)
542 .instant_cast_aura
543 .is_some_and(|requirement| {
544 view.program
545 .rotation
546 .aura_active(view.instance, requirement, candidate.target)
547 });
548
549 spell.channel.behavior.is_channel
550 || spell.empower_rank_count > 0
551 || (!instant_cast_active
552 && effective_cast_time_ms(
553 &view.combat(),
554 candidate.effective_local,
555 spell.cast_time_ms,
556 ) > 0)
557}
558
559fn school_and_weapon_requirements(
560 program: &crate::state::CombatProgram,
561 state: &CombatInstance,
562 candidate: CastCandidate,
563 now: SimTime,
564) -> Result<(), CastReject> {
565 if !state
566 .runtime
567 .control
568 .school_lockouts
569 .contains_key(ActorId::Player)
570 {
571 return weapon_requirement_met(program, candidate);
572 }
573
574 let schools = candidate.effective_profile(program).school_mask;
575
576 if let Some(ready_at) = state.school_lock_ready_at(ActorId::Player, schools, now) {
577 return Err(CastReject::SchoolLocked { ready_at });
578 }
579
580 weapon_requirement_met(program, candidate)
581}
582
583fn aura_state_requirements_met(
584 view: HookView<'_>,
585 candidate: CastCandidate,
586) -> Result<(), CastReject> {
587 let state = view.instance;
588 let profile = candidate.effective_profile(view.program);
589 let has_state_requirements = [
590 profile.aura_states.required_caster,
591 profile.aura_states.required_target,
592 profile.aura_states.excluded_caster,
593 profile.aura_states.excluded_target,
594 ]
595 .into_iter()
596 .any(|requirement| requirement.is_some());
597 let has_spell_requirements = [
598 profile.required_caster_aura,
599 profile.required_target_aura,
600 profile.excluded_caster_aura,
601 profile.excluded_target_aura,
602 ]
603 .into_iter()
604 .any(|requirement| requirement.is_some());
605
606 if !has_state_requirements && !has_spell_requirements {
607 return Ok(());
608 }
609
610 if state
611 .runtime
612 .pools
613 .spell_gates
614 .ignores_aura_state(candidate.effective_local)
615 {
616 return Ok(());
617 }
618
619 aura_state_flags_met(view, candidate)?;
620
621 aura_spell_flags_met(view, candidate)
622}
623
624fn aura_state_flags_met(view: HookView<'_>, candidate: CastCandidate) -> Result<(), CastReject> {
625 let requirements = candidate.effective_profile(view.program).aura_states;
626 let required = [
627 (ActorId::Player, requirements.required_caster, true),
628 (
629 ActorId::Enemy(candidate.target),
630 requirements.required_target,
631 true,
632 ),
633 (ActorId::Player, requirements.excluded_caster, false),
634 (
635 ActorId::Enemy(candidate.target),
636 requirements.excluded_target,
637 false,
638 ),
639 ];
640
641 for (actor, aura_state, must_have) in required {
642 let Some(aura_state) = aura_state else {
643 continue;
644 };
645 let present = view
646 .instance
647 .has_aura_state(view.program, actor, aura_state, view.now);
648
649 if present != must_have {
650 return Err(CastReject::AuraState);
651 }
652 }
653
654 Ok(())
655}
656
657fn aura_spell_flags_met(view: HookView<'_>, candidate: CastCandidate) -> Result<(), CastReject> {
658 let profile = candidate.effective_profile(view.program);
659 let required_spells = [
660 (profile.required_caster_aura, true),
661 (profile.required_target_aura, true),
662 (profile.excluded_caster_aura, false),
663 (profile.excluded_target_aura, false),
664 ];
665
666 for (requirement, must_have) in required_spells {
667 let Some(requirement) = requirement else {
668 continue;
669 };
670 let present =
671 view.program
672 .rotation
673 .aura_active(view.instance, requirement, candidate.target);
674
675 if present != must_have {
676 return Err(CastReject::AuraState);
677 }
678 }
679
680 Ok(())
681}
682
683pub(in crate::handler) fn candidate_rejection(
685 view: &CombatView<'_>,
686 candidate: CastCandidate,
687 now: SimTime,
688) -> Result<(), CastReject> {
689 let CombatView {
690 program,
691 instance: state,
692 ..
693 } = view;
694 let hook_view = view.for_hook(now, Some(candidate.target));
695
696 debug_assert_eq!(
697 program.spell(candidate.requested_local).spell_id,
698 candidate.requested_spell_id(program),
699 "candidate requested identity changed after finalization"
700 );
701 debug_assert_eq!(
702 candidate.requested_profile(program).spell_id,
703 candidate.requested_spell_id(program),
704 "candidate requested cast profile changed after finalization"
705 );
706 debug_assert_eq!(
707 program.buffer_bindings.cooldown(candidate.cooldown_local),
708 candidate.cooldown_slot(program),
709 "candidate cooldown binding changed after finalization"
710 );
711
712 requested_spell_enabled(view, candidate)?;
713
714 if state
715 .buffer
716 .spell_at(candidate.effective_slot(program))
717 .is_enabled
718 == 0
719 {
720 return Err(CastReject::Disabled);
721 }
722
723 start_recovery_ready(
724 program,
725 state,
726 candidate.effective_local,
727 candidate.effective(program),
728 now,
729 )?;
730
731 has_primary_resource(view, candidate)?;
732 has_secondary_resource(view, candidate)?;
733 has_health(view, candidate)?;
734 cooldown_ready(hook_view, candidate)?;
735 in_execute_range(hook_view, candidate)?;
736 gating_aura_active(view, candidate)?;
737 aura_state_requirements_met(hook_view, candidate)?;
738 actor_requirements_met(hook_view, candidate)?;
739
740 explicit_target_ready(program, state, candidate)
741}
742
743#[cfg(test)]
745pub(super) fn can_cast(view: &CombatView<'_>, spell_id: u32, now: SimTime) -> bool {
746 let Some(target) = view.instance.current_target() else {
747 return false;
748 };
749 let Some(local) = view.program.spell_local(spell_id) else {
750 return false;
751 };
752 let binding = view.program.rotation.local_binding(local);
753 let candidate = CastCandidate::resolve_planned(view.instance, binding, target);
754
755 candidate_rejection(view, candidate, now).is_ok()
756}