1use wowlab_engine_domain::dbc::ModifierPropertyKind;
2use wowlab_types::sim::ActorId;
3
4#[cfg(feature = "rotation-optimizations")]
5use super::CooldownReadinessSnapshot;
6use super::{
7 BuffEffect, CombatInstance, Event, HUNDRED, LocalSpellIdx, MS_PER_SECOND, SimTime, SpellData,
8 charge_pool_members, charge_pool_owner, current_haste_mult, dbc_effect_points, project_members,
9 project_ready_at, sync_charge_pool,
10};
11use crate::{context::ActorView, systems::buffs::for_each_active_spell_modifier_for_action};
12
13#[derive(Clone, Copy)]
14#[cfg(feature = "rotation-optimizations")]
15struct CooldownProjection {
16 rate_mult: f64,
17 duration_mult: f64,
18}
19
20#[cfg(feature = "rotation-optimizations")]
21const MISSING_COOLDOWN_RATE_PROJECTION: &str = "finalized spell has a cooldown-rate projection";
22
23#[cfg(not(feature = "rotation-optimizations"))]
24fn current_cooldown_rate_mult(
25 program: &crate::state::CombatProgram,
26 state: &CombatInstance,
27 local: LocalSpellIdx,
28 spell: &SpellData,
29) -> f64 {
30 let mut rate = if spell.cooldown.cooldown_hasted {
31 current_haste_mult(&crate::CombatView::new(program, state))
32 } else {
33 1.0
34 };
35 let category_cooldown = spell.is_charged() || spell.cooldown.charge_category.is_some();
36 let target = program.damage.spell_action(local);
37
38 for_each_active_spell_modifier_for_action(
39 ActorView::new(program, state, ActorId::Player),
40 target,
41 ModifierPropertyKind::Cooldown,
42 |stacks, effect, _binding| match effect {
43 BuffEffect::RechargeRatePercent(percent, _) => {
44 rate *= 1.0 + percent * stacks / HUNDRED;
45 }
46 BuffEffect::CooldownRechargeRateFromEffect {
47 percent,
48 source_spell_id,
49 effect_index,
50 } if !category_cooldown => {
51 rate *= 1.0 + percent * stacks / HUNDRED;
52 }
53 BuffEffect::ChargeRechargeRateCategory {
54 percent, category, ..
55 } if category_cooldown
56 && spell
57 .cooldown
58 .charge_category
59 .is_some_and(|spell_category| spell_category.raw() == *category) =>
60 {
61 rate *= 1.0 + percent * stacks / HUNDRED;
62 }
63 _ => {}
64 },
65 );
66
67 rate.max(f64::EPSILON)
68}
69
70#[cfg(feature = "rotation-optimizations")]
71fn resolve_cooldown_projection(
72 program: &crate::state::CombatProgram,
73 state: &CombatInstance,
74 local: LocalSpellIdx,
75 spell: &SpellData,
76) -> CooldownProjection {
77 let mut projection = CooldownProjection {
78 rate_mult: if spell.cooldown.cooldown_hasted {
79 current_haste_mult(&crate::CombatView::new(program, state))
80 } else {
81 1.0
82 },
83 duration_mult: 1.0,
84 };
85 let category_cooldown = spell.is_charged() || spell.cooldown.charge_category.is_some();
86 let target = program.damage.spell_action(local);
87
88 for_each_active_spell_modifier_for_action(
89 ActorView::new(program, state, ActorId::Player),
90 target,
91 ModifierPropertyKind::Cooldown,
92 |stacks, effect, binding| match effect {
93 BuffEffect::RechargeRatePercent(percent, _) => {
94 projection.rate_mult *= 1.0 + percent * stacks / HUNDRED;
95 }
96 BuffEffect::CooldownRechargeRateFromEffect { percent, .. } if !category_cooldown => {
97 projection.rate_mult *= 1.0 + percent * stacks / HUNDRED;
98 }
99 BuffEffect::ChargeRechargeRateCategory {
100 percent, category, ..
101 } if category_cooldown
102 && spell
103 .cooldown
104 .charge_category
105 .is_some_and(|spell_category| spell_category.raw() == *category) =>
106 {
107 projection.rate_mult *= 1.0 + percent * stacks / HUNDRED;
108 }
109 BuffEffect::CooldownDurationPercent { percent, .. } => {
110 let points = dbc_effect_points(
111 program,
112 state,
113 binding
114 .source_effect
115 .expect("matched source binding exists"),
116 stacks,
117 );
118
119 projection.duration_mult *= (1.0 + percent * points / HUNDRED).max(0.0);
120 }
121 _ => {}
122 },
123 );
124
125 projection.rate_mult = projection.rate_mult.max(f64::EPSILON);
126
127 projection
128}
129
130#[cfg(feature = "rotation-optimizations")]
131fn projected_cooldown(state: &CombatInstance, local: LocalSpellIdx) -> CooldownProjection {
132 let index = local.as_usize();
133
134 CooldownProjection {
135 rate_mult: state
136 .runtime
137 .recharge_rate_mults
138 .get(index)
139 .copied()
140 .expect(MISSING_COOLDOWN_RATE_PROJECTION),
141 duration_mult: state
142 .runtime
143 .cooldown_duration_mults
144 .get(index)
145 .copied()
146 .expect("finalized spell has a cooldown-duration projection"),
147 }
148}
149
150#[cfg(feature = "rotation-optimizations")]
151fn write_cooldown_projection(
152 state: &mut CombatInstance,
153 local: LocalSpellIdx,
154 projection: CooldownProjection,
155) {
156 let index = local.as_usize();
157 let rate = state
158 .runtime
159 .recharge_rate_mults
160 .get_mut(index)
161 .expect(MISSING_COOLDOWN_RATE_PROJECTION);
162 let duration = state
163 .runtime
164 .cooldown_duration_mults
165 .get_mut(index)
166 .expect("finalized spell has a cooldown-duration projection");
167
168 *rate = projection.rate_mult;
169 *duration = projection.duration_mult;
170}
171
172#[cfg(feature = "rotation-optimizations")]
173pub(crate) fn initialize_cooldown_projections(
174 program: &crate::state::CombatProgram,
175 state: &mut CombatInstance,
176) {
177 for index in 0..program.spells.len() {
178 let local = LocalSpellIdx::new(
179 u8::try_from(index).expect("finalized spell count fits local index"),
180 );
181 let spell = program.spell(local);
182
183 if !spell.cooldown.has_cooldown {
184 continue;
185 }
186
187 let projection = resolve_cooldown_projection(program, state, local, spell);
188
189 write_cooldown_projection(state, local, projection);
190 }
191
192 state
193 .runtime
194 .initial_recharge_rate_mults
195 .copy_from_slice(&state.runtime.recharge_rate_mults);
196 state
197 .runtime
198 .initial_cooldown_duration_mults
199 .copy_from_slice(&state.runtime.cooldown_duration_mults);
200}
201
202#[cfg(feature = "rotation-optimizations")]
203pub(crate) fn reset_cooldown_projections(state: &mut CombatInstance) {
204 state
205 .runtime
206 .recharge_rate_mults
207 .copy_from_slice(&state.runtime.initial_recharge_rate_mults);
208 state
209 .runtime
210 .cooldown_duration_mults
211 .copy_from_slice(&state.runtime.initial_cooldown_duration_mults);
212}
213
214#[cfg_attr(
215 feature = "rotation-optimizations",
216 expect(
217 unused_variables,
218 reason = "optimized cooldown projections replace the program lookup"
219 )
220)]
221pub(super) fn effective_recharge_ms(
222 program: &crate::state::CombatProgram,
223 state: &CombatInstance,
224 local: LocalSpellIdx,
225 spell: &SpellData,
226) -> (u32, f64) {
227 #[cfg(not(feature = "rotation-optimizations"))]
228 let rate = current_cooldown_rate_mult(program, state, local, spell);
229 #[cfg(feature = "rotation-optimizations")]
230 let rate = projected_cooldown(state, local).rate_mult;
231 let duration = wowlab_types::numeric::f64_to_u32_saturating_trunc(
232 f64::from(spell.cooldown.recharge_ms) / rate,
233 );
234
235 (
236 duration.max(u32::from(spell.cooldown.recharge_ms > 0)),
237 rate,
238 )
239}
240
241#[cfg_attr(
242 feature = "rotation-optimizations",
243 expect(
244 unused_variables,
245 reason = "optimized cooldown projections replace the program and spell lookups"
246 )
247)]
248pub(super) fn effective_cooldown_duration_ms(
249 program: &crate::state::CombatProgram,
250 state: &CombatInstance,
251 local: LocalSpellIdx,
252 spell: &SpellData,
253 base_duration_ms: u32,
254) -> (u32, f64) {
255 #[cfg(not(feature = "rotation-optimizations"))]
256 let rate = current_cooldown_rate_mult(program, state, local, spell);
257 #[cfg(feature = "rotation-optimizations")]
258 let projection = projected_cooldown(state, local);
259 #[cfg(feature = "rotation-optimizations")]
260 let rate = projection.rate_mult;
261 #[cfg(feature = "rotation-optimizations")]
262 let duration_mult = projection.duration_mult;
263 #[cfg(not(feature = "rotation-optimizations"))]
264 let mut duration_mult = 1.0;
265 #[cfg(not(feature = "rotation-optimizations"))]
266 let target = program.damage.spell_action(local);
267
268 #[cfg(not(feature = "rotation-optimizations"))]
269 for_each_active_spell_modifier_for_action(
270 ActorView::new(program, state, ActorId::Player),
271 target,
272 ModifierPropertyKind::Cooldown,
273 |stacks, effect, binding| {
274 if let BuffEffect::CooldownDurationPercent { percent, .. } = effect {
275 let points = dbc_effect_points(
276 program,
277 state,
278 binding
279 .source_effect
280 .expect("matched source binding exists"),
281 stacks,
282 );
283
284 duration_mult *= (1.0 + percent * points / HUNDRED).max(0.0);
285 }
286 },
287 );
288 let duration = wowlab_types::numeric::f64_to_u32_saturating_trunc(
289 f64::from(base_duration_ms) * duration_mult / rate,
290 );
291
292 (duration, rate)
293}
294
295pub(super) fn effective_cooldown_ms(
296 program: &crate::state::CombatProgram,
297 state: &CombatInstance,
298 local: LocalSpellIdx,
299 spell: &SpellData,
300) -> (u32, f64) {
301 effective_cooldown_duration_ms(
302 program,
303 state,
304 local,
305 spell,
306 spell.cooldown.cooldown_duration_ms,
307 )
308}
309
310pub(super) fn effective_category_cooldown_ms(
311 program: &crate::state::CombatProgram,
312 state: &CombatInstance,
313 local: LocalSpellIdx,
314 spell: &SpellData,
315) -> u32 {
316 effective_cooldown_duration_ms(
317 program,
318 state,
319 local,
320 spell,
321 spell.cooldown.category_cooldown_duration_ms,
322 )
323 .0
324}
325
326fn reproject_deadline(deadline_s: f64, now_s: f64, old_rate: f64, new_rate: f64) -> Option<f64> {
327 (deadline_s > now_s).then(|| now_s + (deadline_s - now_s) * old_rate / new_rate)
328}
329
330#[derive(Clone, Copy)]
331struct ActiveCooldownDeadlines {
332 private: SimTime,
333 charge: Option<(wowlab_types::data::CooldownCategoryId, SimTime)>,
334}
335
336#[derive(Clone, Copy)]
337struct RechargeRateChange {
338 now_s: f64,
339 old: f64,
340 new: f64,
341}
342
343fn update_recharge_slot(
344 program: &crate::state::CombatProgram,
345 state: &mut CombatInstance,
346 local: LocalSpellIdx,
347 spell: &SpellData,
348 rate: RechargeRateChange,
349 deadlines: ActiveCooldownDeadlines,
350) -> (Option<f64>, Option<f64>) {
351 let Some(cd) = program.buffer_cooldown_mut(state, local) else {
352 return (None, None);
353 };
354
355 if spell.is_charged() {
356 let base_recharge_s = f64::from(spell.cooldown.recharge_ms) / MS_PER_SECOND;
357
358 cd.duration = base_recharge_s / rate.new;
359 cd.recharge_time = cd.duration;
360 let next_at = (cd.current_charges < cd.max_charges)
361 .then(|| reproject_deadline(cd.next_charge_at, rate.now_s, rate.old, rate.new))
362 .flatten();
363
364 if let Some(next_at) = next_at {
365 cd.next_charge_at = next_at;
366 }
367
368 return (None, next_at);
369 }
370
371 let base_duration_s = f64::from(spell.cooldown.cooldown_duration_ms) / MS_PER_SECOND;
372
373 cd.duration = base_duration_s / rate.new;
374 let private = reproject_deadline(
375 deadlines.private.as_secs_f64(),
376 rate.now_s,
377 rate.old,
378 rate.new,
379 );
380 let charge = deadlines.charge.and_then(|(_, ready_at)| {
381 reproject_deadline(ready_at.as_secs_f64(), rate.now_s, rate.old, rate.new)
382 });
383
384 (private, charge)
385}
386
387fn sync_spell_recharge_rate(
388 program: &crate::state::CombatProgram,
389 state: &mut CombatInstance,
390 local: LocalSpellIdx,
391 spell: &SpellData,
392 now: SimTime,
393) {
394 #[cfg(feature = "rotation-optimizations")]
395 let readiness_members = charge_pool_members(program, local);
396 #[cfg(feature = "rotation-optimizations")]
397 let readiness_before =
398 CooldownReadinessSnapshot::capture(program, state, readiness_members.as_slice(), now);
399
400 let now_s = now.as_secs_f64();
401 let i = local.as_usize();
402 #[cfg(not(feature = "rotation-optimizations"))]
403 let new_rate = current_cooldown_rate_mult(program, state, local, spell);
404 #[cfg(feature = "rotation-optimizations")]
405 let projection = resolve_cooldown_projection(program, state, local, spell);
406 #[cfg(feature = "rotation-optimizations")]
407 let new_rate = projection.rate_mult;
408 #[cfg(feature = "rotation-optimizations")]
409 let old_rate = state
410 .runtime
411 .recharge_rate_mults
412 .get(i)
413 .copied()
414 .expect(MISSING_COOLDOWN_RATE_PROJECTION)
415 .max(f64::EPSILON);
416 #[cfg(not(feature = "rotation-optimizations"))]
417 let old_rate = state
418 .runtime
419 .recharge_rate_mults
420 .get(i)
421 .copied()
422 .unwrap_or(1.0)
423 .max(f64::EPSILON);
424
425 #[cfg(feature = "rotation-optimizations")]
426 write_cooldown_projection(state, local, projection);
427
428 #[cfg(not(feature = "rotation-optimizations"))]
429 if let Some(rate) = state.runtime.recharge_rate_mults.get_mut(i) {
430 *rate = new_rate;
431 }
432
433 #[cfg(feature = "rotation-optimizations")]
434 if old_rate.to_bits() == new_rate.to_bits() {
435 return;
436 }
437
438 let private_ready_at = state.runtime.pools.cooldown_ready_at.ready_at(local);
439 let charge_ready_at = spell.cooldown.charge_category.map(|category| {
440 (
441 category,
442 state.runtime.pools.charge_category_ready_at.ready_at(local),
443 )
444 });
445 let deadlines = ActiveCooldownDeadlines {
446 private: private_ready_at,
447 charge: charge_ready_at,
448 };
449 let rate = RechargeRateChange {
450 now_s,
451 old: old_rate,
452 new: new_rate,
453 };
454 let (private_rescheduled, charge_rescheduled) =
455 update_recharge_slot(program, state, local, spell, rate, deadlines);
456
457 if !spell.is_charged() {
458 if let Some(ready_at) = private_rescheduled {
459 state
460 .runtime
461 .pools
462 .cooldown_ready_at
463 .start(local, SimTime::from_secs_f64(ready_at));
464 state.schedule(Event::CooldownReady {
465 t: SimTime::from_secs_f64(ready_at),
466 spell: program.scheduled_spell(local),
467 });
468 }
469
470 if let (Some((_, _)), Some(ready_at)) = (charge_ready_at, charge_rescheduled) {
471 let members = charge_pool_members(program, local);
472
473 for member in members.as_slice().iter().copied() {
474 state
475 .runtime
476 .pools
477 .charge_category_ready_at
478 .start(member, SimTime::from_secs_f64(ready_at));
479 state.schedule(Event::CooldownReady {
480 t: SimTime::from_secs_f64(ready_at),
481 spell: program.scheduled_spell(member),
482 });
483 }
484
485 project_members(program, state, members.as_slice());
486 } else {
487 project_ready_at(program, state, local);
488 }
489 }
490
491 if spell.is_charged() {
492 let members = charge_pool_members(program, local);
493
494 sync_charge_pool(program, state, local, members.as_slice());
495 project_members(program, state, members.as_slice());
496 }
497
498 if spell.is_charged()
499 && let Some(next_at) = charge_rescheduled
500 {
501 state.schedule(Event::CooldownReady {
502 t: SimTime::from_secs_f64(next_at),
503 spell: program.scheduled_spell(local),
504 });
505 }
506
507 #[cfg(feature = "rotation-optimizations")]
508 readiness_before.record(program, state, readiness_members.as_slice(), now);
509}
510
511pub(crate) fn refresh_recharge_rates_for(
512 program: &crate::state::CombatProgram,
513 state: &mut CombatInstance,
514 now: SimTime,
515 locals: &[LocalSpellIdx],
516) {
517 for &local in locals {
518 let spell = *program.spell(local);
519
520 if !spell.cooldown.has_cooldown {
521 continue;
522 }
523
524 if (spell.is_charged() || spell.cooldown.charge_category.is_some())
525 && charge_pool_owner(program, local) != local
526 {
527 continue;
528 }
529
530 sync_spell_recharge_rate(program, state, local, &spell, now);
531 }
532}