Skip to main content

wowlab_engine_combat/handler/can_cast/
wait.rs

1//! Wait decisions derived from failed cast checks and resource regeneration.
2
3use wowlab_engine_ports::SpecAction;
4#[cfg(feature = "rotation-optimizations")]
5use wowlab_types::sim::ActorId;
6use wowlab_types::{constants::MS_PER_SECOND, sim::SimTime};
7
8use super::{CastCandidate, CastReject};
9use crate::{context::CombatView, state::CombatInstance, systems::rune_time_to_regen};
10
11#[cfg(any(not(feature = "rotation-optimizations"), test))]
12pub(super) const MIN_WAIT_MS: u32 = 10;
13
14fn resource_ready_at(
15    program: &crate::state::CombatProgram,
16    state: &CombatInstance,
17    now: SimTime,
18    target_cost: Option<f64>,
19) -> Option<SimTime> {
20    let (current, regen) = if let Some(resource_type) = program.config.base_stats.resource_type {
21        program
22            .buffer_resource(state, resource_type)
23            .map_or((0.0, 0.0), |resource| {
24                (resource.current, resource.regen_per_sec)
25            })
26    } else {
27        (0.0, 0.0)
28    };
29
30    let cost = match target_cost {
31        Some(cost) if cost > 0.0 => cost,
32        _ => {
33            if program.config.min_primary_cost <= 0.0 || program.config.min_primary_cost >= f64::MAX
34            {
35                return None;
36            }
37
38            program.config.min_primary_cost
39        }
40    };
41
42    if regen <= 0.0 || current >= cost {
43        return None;
44    }
45
46    let deficit = cost - current;
47
48    if program.config.base_stats.resource_type == Some(wowlab_types::combat::ResourceType::Runes) {
49        let needed = wowlab_types::numeric::f64_to_u32_saturating_ceil(deficit.max(0.0)) as usize;
50
51        if let Some(seconds) = rune_time_to_regen(program, state, needed) {
52            let wait_ms =
53                wowlab_types::numeric::f64_to_u32_saturating_ceil(seconds * MS_PER_SECOND);
54
55            return Some(
56                now.saturating_add(SimTime::from_millis(exact_or_legacy_wait_ms(wait_ms))),
57            );
58        }
59    }
60
61    let wait_ms =
62        wowlab_types::numeric::f64_to_u32_saturating_ceil((deficit / regen) * MS_PER_SECOND);
63
64    Some(now.saturating_add(SimTime::from_millis(exact_or_legacy_wait_ms(wait_ms))))
65}
66
67fn exact_or_legacy_wait_ms(wait_ms: u32) -> u32 {
68    #[cfg(feature = "rotation-optimizations")]
69    {
70        wait_ms
71    }
72
73    #[cfg(not(feature = "rotation-optimizations"))]
74    {
75        wait_ms.max(MIN_WAIT_MS)
76    }
77}
78
79#[cfg(feature = "rotation-optimizations")]
80pub(in crate::handler) fn candidate_time_horizon(
81    view: &CombatView<'_>,
82    candidate: CastCandidate,
83    now: SimTime,
84    control_ready_at: Option<SimTime>,
85    has_school_lockout: bool,
86) -> Option<SimTime> {
87    let program = view.program;
88    let state = view.instance;
89    let requested = state.buffer.spell_at(candidate.requested_slot(program));
90    let effective = state.buffer.spell_at(candidate.effective_slot(program));
91
92    if requested.is_enabled == 0 || effective.is_enabled == 0 {
93        return None;
94    }
95
96    let spell = candidate.effective(program);
97    let profile = candidate.effective_profile(program);
98    let mut ready_at = now;
99
100    if spell.cooldown.start_recovery_category.is_some() {
101        let owner = program
102            .start_recovery_category_owners
103            .get(candidate.effective_local.as_usize())
104            .copied()
105            .unwrap_or(candidate.effective_local);
106
107        ready_at = ready_at.max(state.runtime.pools.start_recovery_ready_at.ready_at(owner));
108    }
109
110    if effective.cost > 0.0 {
111        let cost_bypassed = profile.cost_bypass_aura.is_some_and(|requirement| {
112            program
113                .rotation
114                .aura_active(state, requirement, candidate.target)
115        });
116
117        if !cost_bypassed
118            && let Some(resource_ready_at) =
119                resource_ready_at(program, state, now, Some(effective.cost))
120        {
121            ready_at = ready_at.max(resource_ready_at);
122        }
123    }
124
125    let cooldown = state.buffer.cooldown_at(candidate.cooldown_slot(program));
126    let cooldown_ready_at = SimTime::from_secs_f64(cooldown.ready_at);
127
128    if cooldown_ready_at > now {
129        let cooldown_bypassed = profile.cooldown_bypass_aura.is_some_and(|requirement| {
130            program
131                .rotation
132                .aura_active(state, requirement, candidate.target)
133        });
134
135        if !cooldown_bypassed {
136            ready_at = ready_at.max(cooldown_ready_at);
137        }
138    }
139
140    if let Some(control_ready_at) = control_ready_at {
141        ready_at = ready_at.max(control_ready_at);
142    }
143
144    if has_school_lockout
145        && let Some(school_ready_at) =
146            state.school_lock_ready_at(ActorId::Player, profile.school_mask, now)
147    {
148        ready_at = ready_at.max(school_ready_at);
149    }
150
151    (ready_at > now).then_some(ready_at)
152}
153
154#[cfg(any(not(feature = "rotation-optimizations"), test))]
155fn resource_wait(
156    program: &crate::state::CombatProgram,
157    state: &CombatInstance,
158    now: SimTime,
159    target_cost: Option<f64>,
160) -> SimTime {
161    resource_ready_at(program, state, now, target_cost)
162        .unwrap_or_else(|| now.saturating_add(SimTime::from_millis(MIN_WAIT_MS)))
163}
164
165#[cfg(feature = "rotation-optimizations")]
166pub(in crate::handler) fn rejection_ready_at(
167    view: &CombatView<'_>,
168    candidate: CastCandidate,
169    reason: CastReject,
170    now: SimTime,
171) -> Option<SimTime> {
172    match reason {
173        CastReject::PrimaryResource { cost, .. } => {
174            resource_ready_at(view.program, view.instance, now, Some(cost))
175        }
176        CastReject::Cooldown { ready_at, .. } => Some(SimTime::from_secs_f64(ready_at)),
177        CastReject::CooldownCategory { ready_at, .. }
178        | CastReject::ChargeCategory { ready_at, .. }
179        | CastReject::StartRecoveryCategory { ready_at, .. }
180        | CastReject::SchoolLocked { ready_at } => Some(ready_at),
181        CastReject::CrowdControlled { ready_at } => ready_at,
182        CastReject::NoCharges => {
183            let cooldown = view
184                .instance
185                .buffer
186                .cooldown_at(candidate.cooldown_slot(view.program));
187
188            (cooldown.next_charge_at > now.as_secs_f64())
189                .then(|| SimTime::from_secs_f64(cooldown.next_charge_at))
190        }
191        _ => None,
192    }
193}
194
195#[cfg(not(feature = "rotation-optimizations"))]
196#[expect(
197    clippy::unnecessary_wraps,
198    reason = "both optimization configurations expose the same optional wait-decision contract"
199)]
200pub(in crate::handler) fn reject_to_wait(
201    view: &CombatView<'_>,
202    candidate: CastCandidate,
203    reason: Option<CastReject>,
204    now: SimTime,
205) -> Option<SpecAction> {
206    let CombatView {
207        program,
208        instance: state,
209        ..
210    } = view;
211
212    if let Some(CastReject::StartRecoveryCategory { ready_at, .. }) = reason {
213        return Some(SpecAction::Wait { until_ms: ready_at });
214    }
215
216    let cost = Some(
217        state
218            .buffer
219            .spell_at(candidate.effective_slot(program))
220            .cost,
221    )
222    .filter(|cost| *cost > 0.0);
223
224    Some(SpecAction::Wait {
225        until_ms: resource_wait(program, state, now, cost),
226    })
227}
228
229#[cfg(feature = "rotation-optimizations")]
230pub(in crate::handler) fn reject_to_wait(
231    view: &CombatView<'_>,
232    candidate: CastCandidate,
233    reason: Option<CastReject>,
234    now: SimTime,
235) -> Option<SpecAction> {
236    reason
237        .and_then(|reason| rejection_ready_at(view, candidate, reason, now))
238        .map(|until_ms| SpecAction::Wait { until_ms })
239}
240
241#[cfg(any(not(feature = "rotation-optimizations"), test))]
242pub(in crate::handler) fn pool_wait(
243    program: &crate::state::CombatProgram,
244    state: &CombatInstance,
245    now: SimTime,
246) -> SpecAction {
247    SpecAction::Wait {
248        until_ms: resource_wait(program, state, now, None),
249    }
250}