1use wowlab_engine_domain::{
2 dbc::{ShapeshiftForm, SpellCastTargetFlags, SpellShapeshiftFormFlags, stance_mask_allows},
3 rotation::DenseBuffer,
4};
5use wowlab_types::{
6 constants::HUNDRED,
7 sim::{ActorId, EnemyIdx, SimTime, SpellIdx},
8};
9
10use super::CastReject;
11use crate::{
12 context::{CombatView, HookView},
13 state::CombatState,
14 systems::{
15 aura_stacks_by_id, bypass_aura_active, effective_cast_time_ms, effective_spell_ids,
16 spell_health_cost,
17 },
18};
19
20fn requested_spell_enabled(buf: &DenseBuffer, requested_spell_id: u32) -> Result<(), CastReject> {
24 match buf.spell(SpellIdx(requested_spell_id)) {
25 None => Err(CastReject::UnknownSpell),
26 Some(spell) if spell.is_enabled == 0 => Err(CastReject::Disabled),
27 Some(_) => Ok(()),
28 }
29}
30
31fn has_primary_resource(
32 view: &CombatView<'_>,
33 spell_id: u32,
34 target: EnemyIdx,
35) -> Result<(), CastReject> {
36 let CombatView { state, buf, .. } = view;
37 let Some(spell) = buf.spell(SpellIdx(spell_id)) else {
38 return Err(CastReject::UnknownSpell);
39 };
40 let cost_bypassed = state.spell_data(spell_id).is_some_and(|(_, data)| {
41 bypass_aura_active(
42 state,
43 buf,
44 data.requirements.cost_bypass_aura_id,
45 ActorId::Player,
46 Some(target),
47 )
48 });
49 let have = state
50 .config
51 .base_stats
52 .resource_type
53 .and_then(|resource_type| buf.resource(resource_type))
54 .map(|resource| resource.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 spell_id: u32,
70 target: EnemyIdx,
71) -> Result<(), CastReject> {
72 let CombatView { state, buf, .. } = view;
73 let Some(spell) = buf.spell(SpellIdx(spell_id)) else {
74 return Err(CastReject::UnknownSpell);
75 };
76 let cost_bypassed = state.spell_data(spell_id).is_some_and(|(_, data)| {
77 bypass_aura_active(
78 state,
79 buf,
80 data.requirements.cost_bypass_aura_id,
81 ActorId::Player,
82 Some(target),
83 )
84 });
85 let have = state
86 .config
87 .base_stats
88 .secondary_resource_type
89 .and_then(|resource_type| buf.resource(resource_type))
90 .map(|resource| resource.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<'_>, spell_id: u32, target: EnemyIdx) -> Result<(), CastReject> {
104 let CombatView { state, buf, .. } = view;
105 let Some((_, spell)) = state.spell_data(spell_id) else {
106 return Err(CastReject::UnknownSpell);
107 };
108 let bypassed = bypass_aura_active(
109 state,
110 buf,
111 spell.requirements.cost_bypass_aura_id,
112 ActorId::Player,
113 Some(target),
114 );
115 let cost = spell_health_cost(
116 state,
117 ActorId::Player,
118 spell.cost.health_cost,
119 spell.cost.health_cost_pct,
120 );
121 let have = state.friendly_actor_health(ActorId::Player).unwrap_or(0.0);
122
123 if !bypassed && cost > 0.0 && have < cost {
124 return Err(CastReject::Health { have, cost });
125 }
126
127 Ok(())
128}
129
130fn cooldown_ready(
131 view: HookView<'_>,
132 spell_id: u32,
133 cooldown_spell_id: u32,
134) -> Result<(), CastReject> {
135 let HookView {
136 state,
137 buf,
138 target,
139 now,
140 } = view;
141 let Some(target) = target else {
142 return Err(CastReject::NoTarget);
143 };
144 let bypassed = state.spell_data(spell_id).is_some_and(|(_, data)| {
145 bypass_aura_active(
146 state,
147 buf,
148 data.requirements.cooldown_bypass_aura_id,
149 ActorId::Player,
150 Some(target),
151 )
152 });
153 let Some(cooldown) = (!bypassed)
154 .then(|| buf.cooldown(SpellIdx(cooldown_spell_id)))
155 .flatten()
156 else {
157 return Ok(());
158 };
159
160 category_cooldowns_ready(state, spell_id, now)?;
161
162 if cooldown.max_charges > 1 && cooldown.current_charges == 0 {
163 return Err(CastReject::NoCharges);
164 }
165
166 if cooldown.max_charges <= 1 && now.as_secs_f64() < cooldown.ready_at {
167 return Err(CastReject::Cooldown {
168 ready_at: cooldown.ready_at,
169 now: now.as_secs_f64(),
170 });
171 }
172
173 Ok(())
174}
175
176fn category_cooldowns_ready(
177 state: &CombatState,
178 spell_id: u32,
179 now: SimTime,
180) -> Result<(), CastReject> {
181 if let Some(category) = state
182 .spell_data(spell_id)
183 .and_then(|(_, spell)| spell.cooldown.cooldown_category)
184 {
185 if !state
186 .runtime
187 .pools
188 .cooldown_category_ready_at
189 .is_ready(&category, now)
190 {
191 let ready_at = state
192 .runtime
193 .pools
194 .cooldown_category_ready_at
195 .ready_at(&category);
196
197 return Err(CastReject::CooldownCategory {
198 category,
199 ready_at,
200 now,
201 });
202 }
203 }
204
205 if let Some(category) = state
206 .spell_data(spell_id)
207 .filter(|(_, spell)| spell.cooldown.max_charges <= 1)
208 .and_then(|(_, spell)| spell.cooldown.charge_category)
209 {
210 if !state
211 .runtime
212 .pools
213 .charge_category_ready_at
214 .is_ready(&category, now)
215 {
216 let ready_at = state
217 .runtime
218 .pools
219 .charge_category_ready_at
220 .ready_at(&category);
221
222 return Err(CastReject::ChargeCategory {
223 category,
224 ready_at,
225 now,
226 });
227 }
228 }
229
230 Ok(())
231}
232
233fn in_execute_range(view: HookView<'_>, spell_id: u32) -> Result<(), CastReject> {
234 let HookView {
235 state, target, now, ..
236 } = view;
237 let Some(target) = target else {
238 return Err(CastReject::NoTarget);
239 };
240 let Some((_, data)) = state.spell_data(spell_id) else {
241 return Ok(());
242 };
243
244 if data.requirements.execute_below_pct <= 0.0 {
245 return Ok(());
246 }
247
248 let bypassed = crate::systems::ignores_aura_state(state, spell_id);
249 let health = state.enemy_health_fraction(target, now);
250 let in_execute =
251 health.is_some_and(|health| health <= data.requirements.execute_below_pct / HUNDRED);
252
253 if !bypassed && !in_execute {
254 return Err(CastReject::ExecuteRange {
255 target_health_pct: health.unwrap_or(1.0) * HUNDRED,
256 threshold_pct: data.requirements.execute_below_pct,
257 });
258 }
259
260 Ok(())
261}
262
263fn gating_aura_active(
264 view: &CombatView<'_>,
265 spell_id: u32,
266 target: EnemyIdx,
267) -> Result<(), CastReject> {
268 let CombatView { state, buf, .. } = view;
269
270 if crate::systems::ignores_aura_state(state, spell_id) {
271 return Ok(());
272 }
273
274 let Some((_, data)) = state.spell_data(spell_id) else {
275 return Ok(());
276 };
277
278 if data.requirements.gating_aura_id == 0 {
279 return Ok(());
280 }
281
282 if !state
283 .index
284 .aura_by_id
285 .contains_key(&data.requirements.gating_aura_id)
286 {
287 return Err(CastReject::GatingAuraUnknown {
288 aura_id: data.requirements.gating_aura_id,
289 });
290 }
291
292 let active = aura_stacks_by_id(
293 state,
294 buf,
295 data.requirements.gating_aura_id,
296 ActorId::Player,
297 Some(target),
298 ) >= i32::from(data.requirements.gating_aura_min_stacks);
299
300 if !active {
301 return Err(CastReject::GatingAuraInactive {
302 aura_id: data.requirements.gating_aura_id,
303 min_stacks: i32::from(data.requirements.gating_aura_min_stacks),
304 });
305 }
306
307 Ok(())
308}
309
310fn stealth_requirement_met(view: &CombatView<'_>, spell_id: u32) -> Result<(), CastReject> {
311 let CombatView { state, buf, .. } = view;
312
313 if crate::systems::ignores_aura_state(state, spell_id) {
314 return Ok(());
315 }
316
317 let requires_stealth = state
318 .spell_data(spell_id)
319 .is_some_and(|(_, spell)| spell.requirements.form.requires_stealth);
320
321 if requires_stealth
322 && buf.player().stealthed == 0
323 && !crate::systems::active_stance_mask_exception(view, spell_id)
324 {
325 return Err(CastReject::RequiresStealth);
326 }
327
328 Ok(())
329}
330
331fn position_requirement_met(
332 state: &CombatState,
333 spell_data: &crate::state::SpellData,
334) -> Result<(), CastReject> {
335 if spell_data.requirements.equipment.requires_behind_target
336 && state.attack_position() != crate::state::AttackPosition::Behind
337 {
338 return Err(CastReject::RequiresBehindTarget);
339 }
340
341 Ok(())
342}
343
344fn shapeshift_requirement_met(view: &CombatView<'_>, spell_id: u32) -> Result<(), CastReject> {
346 let CombatView { state, buf, .. } = view;
347
348 if crate::systems::ignores_aura_state(state, spell_id) {
349 return Ok(());
350 }
351
352 let Some((_, spell)) = state.spell_data(spell_id) else {
353 return Ok(());
354 };
355
356 if crate::systems::active_stance_mask_exception(view, spell_id) {
357 return Ok(());
358 }
359
360 let form = i64::from(buf.player().shapeshift_form);
361 let current_mask = u32::try_from(form.saturating_sub(1))
362 .ok()
363 .and_then(|shift| 1_u64.checked_shl(shift))
364 .unwrap_or(0);
365 let required = spell.requirements.shapeshift_required_mask;
366
367 if spell.requirements.shapeshift_excluded_mask & current_mask != 0 {
368 return Err(CastReject::ShapeshiftExcluded { form });
369 }
370
371 let form_listed = required & current_mask != 0;
374 let stealth_listed =
375 buf.player().stealthed != 0 && stance_mask_allows(required, ShapeshiftForm::Stealth);
376
377 if form_listed || stealth_listed {
378 return Ok(());
379 }
380
381 if form == 0 {
382 if required == 0 || spell.requirements.form.allow_while_unshifted {
384 return Ok(());
385 }
386
387 return Err(CastReject::ShapeshiftRequired {
388 form,
389 required_mask: required,
390 });
391 }
392
393 let form_flags = state
394 .defs
395 .auras
396 .iter()
397 .find(|aura| i64::from(aura.shapeshift_form) == form)
398 .map(|aura| SpellShapeshiftFormFlags::from_bits_retain(aura.shapeshift_form_flags));
399 let Some(form_flags) = form_flags else {
400 return Ok(());
401 };
402
403 if form_flags.acts_as_shifted()
404 && (spell.requirements.availability.requires_unshifted
405 || form_flags.contains(SpellShapeshiftFormFlags::CAN_ONLY_CAST_SHAPESHIFT_SPELLS))
406 {
407 return Err(CastReject::RequiresUnshifted { form });
408 }
409
410 if required != 0
411 && (form_flags.acts_as_shifted() || !spell.requirements.form.allow_while_unshifted)
412 {
413 return Err(CastReject::ShapeshiftRequired {
414 form,
415 required_mask: required,
416 });
417 }
418
419 Ok(())
420}
421
422fn weapon_requirement_met(state: &CombatState, spell_id: u32) -> Result<(), CastReject> {
423 let Some((_, spell)) = state.spell_data(spell_id) else {
424 return Ok(());
425 };
426
427 if spell.requirements.equipment.requires_main_hand
428 && state.config.game_data.main_hand().speed_ms == 0
429 {
430 return Err(CastReject::RequiresMainHand);
431 }
432
433 if spell.requirements.equipment.requires_off_hand && state.config.game_data.off_hand().is_none()
434 {
435 return Err(CastReject::RequiresOffHand);
436 }
437
438 if spell
439 .requirements
440 .equipped_item_requirement
441 .is_some_and(|requirement| {
442 let hand = if spell.requirements.equipment.requires_off_hand {
443 wowlab_engine_gamedata::ResolvedDamageWeapon::OffHand
444 } else {
445 wowlab_engine_gamedata::ResolvedDamageWeapon::MainHand
446 };
447
448 !wowlab_engine_domain::dbc::equipped_weapon_matches(
449 &state.config.game_data,
450 hand,
451 requirement,
452 )
453 })
454 {
455 return Err(CastReject::EquippedItemRequirement);
456 }
457
458 Ok(())
459}
460
461fn start_recovery_ready(
462 state: &CombatState,
463 spell_data: &crate::state::SpellData,
464 now: SimTime,
465) -> Result<(), CastReject> {
466 let Some(category) = spell_data.cooldown.start_recovery_category else {
467 return Ok(());
468 };
469
470 if state
471 .runtime
472 .pools
473 .start_recovery_ready_at
474 .is_ready(&category, now)
475 {
476 return Ok(());
477 }
478
479 Err(CastReject::StartRecoveryCategory {
480 category,
481 ready_at: state
482 .runtime
483 .pools
484 .start_recovery_ready_at
485 .ready_at(&category),
486 now,
487 })
488}
489
490fn explicit_target_ready(
491 state: &CombatState,
492 buf: &DenseBuffer,
493 spell_data: &crate::state::SpellData,
494 idx: SpellIdx,
495 target: EnemyIdx,
496) -> Result<(), CastReject> {
497 let required_targets =
498 SpellCastTargetFlags::from_bits_retain(spell_data.targeting.required_explicit_target_mask);
499
500 if required_targets.requires_hostile_unit()
501 && !state.spell_target_in_range_with_buffs(buf, idx, target)
502 {
503 return Err(CastReject::OutOfRange);
504 }
505
506 Ok(())
507}
508
509fn actor_requirements_met(
510 view: HookView<'_>,
511 spell_id: u32,
512 spell_data: &crate::state::SpellData,
513) -> Result<(), CastReject> {
514 let HookView {
515 state, buf, now, ..
516 } = view;
517
518 if state.actor_cast_blocked_by_control(ActorId::Player, now) {
519 return Err(CastReject::CrowdControlled);
520 }
521
522 stealth_requirement_met(&view.combat(), spell_id)?;
523 position_requirement_met(state, spell_data)?;
524 shapeshift_requirement_met(&view.combat(), spell_id)?;
525
526 let instant_cast_active = spell_data.requirements.instant_cast_aura_id != 0
527 && aura_stacks_by_id(
528 state,
529 buf,
530 spell_data.requirements.instant_cast_aura_id,
531 ActorId::Player,
532 state.current_target(),
533 ) > 0;
534 let requires_stationary_cast = spell_data.channel.behavior.is_channel
535 || spell_data.empower_rank_count > 0
536 || (!instant_cast_active
537 && effective_cast_time_ms(&view.combat(), spell_id, spell_data.cast_time_ms) > 0);
538
539 if requires_stationary_cast
540 && state.movement_state(ActorId::Player) == crate::state::MovementState::Moving
541 && !crate::systems::can_cast_while_moving(&view.combat(), spell_id)
542 {
543 return Err(CastReject::Moving);
544 }
545
546 let schools = wowlab_engine_domain::dbc::SpellSchoolMask::from_dbc(
547 state
548 .config
549 .game_data
550 .school_mask(SpellIdx::from_raw(spell_id))
551 .unwrap_or_default(),
552 );
553
554 if state.school_locked(ActorId::Player, schools, now) {
555 return Err(CastReject::SchoolLocked);
556 }
557
558 weapon_requirement_met(state, spell_id)
559}
560
561fn aura_state_requirements_met(
562 view: HookView<'_>,
563 spell_id: u32,
564 spell_data: &crate::state::SpellData,
565) -> Result<(), CastReject> {
566 let HookView {
567 state,
568 buf,
569 target,
570 now,
571 } = view;
572
573 let Some(target) = target else {
574 return Err(CastReject::NoTarget);
575 };
576
577 if crate::systems::ignores_aura_state(state, spell_id) {
578 return Ok(());
579 }
580
581 let required = [
582 (
583 ActorId::Player,
584 spell_data.requirements.caster_aura_state,
585 true,
586 ),
587 (
588 ActorId::Enemy(target),
589 spell_data.requirements.target_aura_state,
590 true,
591 ),
592 (
593 ActorId::Player,
594 spell_data.requirements.exclude_caster_aura_state,
595 false,
596 ),
597 (
598 ActorId::Enemy(target),
599 spell_data.requirements.exclude_target_aura_state,
600 false,
601 ),
602 ];
603
604 for (actor, aura_state, must_have) in required {
605 let Some(aura_state) = aura_state else {
606 continue;
607 };
608 let present = state.has_aura_state(buf, actor, aura_state, now);
609
610 if present != must_have {
611 return Err(CastReject::AuraState);
612 }
613 }
614
615 let required_spells = [
616 (
617 ActorId::Player,
618 spell_data.requirements.caster_aura_spell,
619 true,
620 ),
621 (
622 ActorId::Enemy(target),
623 spell_data.requirements.target_aura_spell,
624 true,
625 ),
626 (
627 ActorId::Player,
628 spell_data.requirements.exclude_caster_aura_spell,
629 false,
630 ),
631 (
632 ActorId::Enemy(target),
633 spell_data.requirements.exclude_target_aura_spell,
634 false,
635 ),
636 ];
637
638 for (actor, raw, must_have) in required_spells {
639 if raw <= 0 {
640 continue;
641 }
642
643 let present = state
644 .aura_local(u32::try_from(raw).unwrap_or_default())
645 .is_some_and(|local| match actor {
646 ActorId::Player if state.aura(local).on == wowlab_types::sim::AuraOn::Player => {
647 crate::systems::is_aura_active(
648 &view.combat(),
649 local,
650 ActorId::Player,
651 Some(target),
652 )
653 }
654 ActorId::Enemy(enemy)
655 if state.aura(local).on == wowlab_types::sim::AuraOn::Target =>
656 {
657 crate::systems::is_aura_active(
658 &view.combat(),
659 local,
660 ActorId::Player,
661 Some(enemy),
662 )
663 }
664 _ => false,
665 });
666
667 if present != must_have {
668 return Err(CastReject::AuraState);
669 }
670 }
671
672 Ok(())
673}
674
675pub(in crate::handler) fn cast_rejection(
677 view: &CombatView<'_>,
678 spell_id: u32,
679 now: SimTime,
680) -> Result<(), CastReject> {
681 let CombatView { state, buf, .. } = view;
682 let target = state.current_target().ok_or(CastReject::NoTarget)?;
683 let hook_view = view.for_hook(now, Some(target));
684
685 requested_spell_enabled(buf, spell_id)?;
686
687 let (spell_id, cooldown_spell_id) = effective_spell_ids(view, spell_id, target);
688 let idx = SpellIdx(spell_id);
689 let Some(spell) = buf.spell(idx) else {
690 return Err(CastReject::UnknownSpell);
691 };
692 let Some((_, spell_data)) = state.spell_data(spell_id) else {
693 return Err(CastReject::UnknownSpell);
694 };
695
696 if spell.is_enabled == 0 {
697 return Err(CastReject::Disabled);
698 }
699
700 start_recovery_ready(state, spell_data, now)?;
701
702 has_primary_resource(view, spell_id, target)?;
703 has_secondary_resource(view, spell_id, target)?;
704 has_health(view, spell_id, target)?;
705 cooldown_ready(hook_view, spell_id, cooldown_spell_id)?;
706 in_execute_range(hook_view, spell_id)?;
707 gating_aura_active(view, spell_id, target)?;
708 aura_state_requirements_met(hook_view, spell_id, spell_data)?;
709 actor_requirements_met(hook_view, spell_id, spell_data)?;
710
711 explicit_target_ready(state, buf, spell_data, idx, target)
712}
713
714#[cfg(test)]
716pub(super) fn can_cast(view: &CombatView<'_>, spell_id: u32, now: SimTime) -> bool {
717 cast_rejection(view, spell_id, now).is_ok()
718}