wowlab_engine_combat/handler/can_cast/
wait.rs1use 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}