Skip to main content

wowlab_engine_combat/systems/cooldowns/
rates.rs

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}