Skip to main content

wowlab_engine_combat/handler/can_cast/
predicates.rs

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
20/// Reject a spell whose own slot is disabled, before any aura-332 override is resolved.
21///
22/// An override changes what the requested button casts, never whether the character has it.
23fn 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
344// #t(fn: rust_cyclomatic_complexity) mirrors the independent allowed/excluded/form-zero rules
345fn 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    // 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).
372
373    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        // 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).
383        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
675/// Full cast-eligibility check returning the reason a spell cannot be cast, or `Ok(())` when castable.
676pub(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/// Yes/no cast eligibility. Thin wrapper over [`cast_rejection`].
715#[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}