Skip to main content

wowlab_engine_combat/handler/can_cast/
predicates.rs

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
14/// Reject a spell whose own slot is disabled, before any aura-332 override is resolved.
15///
16/// An override changes what the requested button casts, never whether the character has it.
17fn 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
337// #t(fn: rust_cyclomatic_complexity) mirrors the independent allowed/excluded/form-zero rules
338fn 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    // Stance mask is authoritative: a listed current form (or the stealth-stance bridge) grants the cast regardless of the `Not Shapeshifted` attribute (how Moonkin casts Sunfire).
378
379    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        // Unshifted casts require either no form or `Allow While Not Shapeshifted`; `Not Shapeshifted` alone does not grant it (Ambush still needs the stealth stance above).
389        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
683/// Evaluate one fully resolved candidate and return its first cast-rejection reason.
684pub(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/// Yes/no cast eligibility for focused predicate tests.
744#[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}