Skip to main content

wowlab_engine_combat/systems/cooldowns/
recharge.rs

1#[cfg(feature = "rotation-optimizations")]
2use super::CooldownReadinessSnapshot;
3use super::{
4    BuffEffect, CombatInstance, Event, LocalSpellIdx, MS_PER_SECOND, RECHARGE_EPSILON_S, SimTime,
5    SpellData, charge_pool_members, charge_pool_owner, consume_charge,
6    effective_category_cooldown_ms, effective_cooldown_ms, effective_recharge_ms,
7    for_each_planned_player_effect, grant_charges_state, reduce_charged_cooldown, spell_at,
8    start_one_charge_category, start_regular_category, sync_charge_pool,
9};
10
11fn projected_ready_at(
12    program: &crate::state::CombatProgram,
13    state: &CombatInstance,
14    spell_local: LocalSpellIdx,
15) -> SimTime {
16    let spell = spell_at(program, spell_local);
17    let own = state.runtime.pools.cooldown_ready_at.ready_at(spell_local);
18    let category = spell.cooldown.cooldown_category.map_or(SimTime::ZERO, |_| {
19        state
20            .runtime
21            .pools
22            .cooldown_category_ready_at
23            .ready_at(spell_local)
24    });
25    let charge_category = (!spell.is_charged())
26        .then_some(spell.cooldown.charge_category)
27        .flatten()
28        .map_or(SimTime::ZERO, |_| {
29            state
30                .runtime
31                .pools
32                .charge_category_ready_at
33                .ready_at(spell_local)
34        });
35
36    own.max(category).max(charge_category)
37}
38
39pub(super) fn project_ready_at(
40    program: &crate::state::CombatProgram,
41    state: &mut CombatInstance,
42    spell_local: LocalSpellIdx,
43) {
44    let spell = spell_at(program, spell_local);
45    let pooled = projected_ready_at(program, state, spell_local).as_secs_f64();
46
47    if let Some(cooldown) = program.buffer_cooldown_mut(state, spell_local) {
48        // Charged with no charges left: next recharge IS the ready time (keeps `charges > 0 => ready_at <= now`).
49        cooldown.ready_at = if spell.is_charged() && cooldown.current_charges <= 0 {
50            pooled.max(cooldown.next_charge_at)
51        } else {
52            pooled
53        };
54    }
55}
56
57pub(super) fn project_members(
58    program: &crate::state::CombatProgram,
59    state: &mut CombatInstance,
60    members: &[LocalSpellIdx],
61) {
62    for member in members.iter().copied() {
63        project_ready_at(program, state, member);
64    }
65}
66
67fn ignores_charge_cooldown(
68    program: &crate::state::CombatProgram,
69    state: &CombatInstance,
70    spell: &SpellData,
71) -> bool {
72    let Some(category) = spell.cooldown.charge_category else {
73        return false;
74    };
75    let mut ignored = false;
76
77    for_each_planned_player_effect(
78        program,
79        state,
80        crate::state::EffectFamily::IGNORED_CHARGE_COOLDOWN,
81        |_, effect| {
82            if matches!(
83                effect,
84                BuffEffect::IgnoreChargeCooldownCategory(effect_category)
85                    if *effect_category == category.raw()
86            ) {
87                ignored = true;
88            }
89        },
90    );
91
92    ignored
93}
94
95fn start_private_cooldown(
96    program: &crate::state::CombatProgram,
97    state: &mut CombatInstance,
98    spell: &SpellData,
99    spell_local: LocalSpellIdx,
100    cooldown_ms: u32,
101    now: SimTime,
102) {
103    if spell.is_charged() || cooldown_ms == 0 {
104        state.runtime.pools.cooldown_ready_at.reset(spell_local);
105
106        return;
107    }
108
109    let ready_at = state.runtime.pools.cooldown_ready_at.start_for(
110        spell_local,
111        now,
112        SimTime::from_millis(cooldown_ms),
113    );
114
115    state.schedule(Event::CooldownReady {
116        t: ready_at,
117        spell: program.scheduled_spell(spell_local),
118    });
119}
120
121pub(crate) fn start_cooldown(
122    program: &crate::state::CombatProgram,
123    state: &mut CombatInstance,
124    spell_local: LocalSpellIdx,
125    now: SimTime,
126) -> u32 {
127    start_cooldown_in_slot(program, state, spell_local, spell_local, now)
128}
129
130/// Start `slot_local`'s cooldown timers using `recovery_local`'s own recovery data.
131///
132/// An aura-332 action replacement shares the base action's slot but keeps its own recovery time.
133pub(crate) fn start_cooldown_in_slot(
134    program: &crate::state::CombatProgram,
135    state: &mut CombatInstance,
136    recovery_local: LocalSpellIdx,
137    slot_local: LocalSpellIdx,
138    now: SimTime,
139) -> u32 {
140    let spell_local = slot_local;
141    let spell = spell_at(program, slot_local);
142    let recovery = spell_at(program, recovery_local);
143    let charge_members = charge_pool_members(program, spell_local);
144    #[cfg(feature = "rotation-optimizations")]
145    let charge_readiness_before =
146        CooldownReadinessSnapshot::capture(program, state, charge_members.as_slice(), now);
147    #[cfg(feature = "rotation-optimizations")]
148    let cooldown_category_members: &[LocalSpellIdx] = program
149        .cooldown_category_members
150        .get(spell_local.as_usize())
151        .map_or(&[], Box::as_ref);
152    #[cfg(feature = "rotation-optimizations")]
153    let category_readiness_before =
154        CooldownReadinessSnapshot::capture(program, state, cooldown_category_members, now);
155    let charge_owner = charge_pool_owner(program, spell_local);
156    let cooldown_local = if spell.is_charged() || spell.cooldown.charge_category.is_some() {
157        charge_owner
158    } else {
159        spell_local
160    };
161    let (cooldown_ms, cooldown_rate) =
162        effective_cooldown_ms(program, state, recovery_local, &recovery);
163    let category_cooldown_ms =
164        effective_category_cooldown_ms(program, state, recovery_local, &recovery);
165    let (recharge_ms, recharge_rate) =
166        effective_recharge_ms(program, state, recovery_local, &recovery);
167    let ignore_charge_cooldown = ignores_charge_cooldown(program, state, &spell);
168    let applied_ms = cooldown_ms
169        .max(category_cooldown_ms)
170        .max(if spell.cooldown.max_charges > 0 {
171            recharge_ms
172        } else {
173            0
174        });
175
176    if let Some(rate) = state
177        .runtime
178        .recharge_rate_mults
179        .get_mut(cooldown_local.as_usize())
180    {
181        *rate = if spell.is_charged() {
182            recharge_rate
183        } else {
184            cooldown_rate
185        };
186    }
187
188    start_private_cooldown(program, state, &spell, spell_local, cooldown_ms, now);
189
190    let Some(handle) = program.cooldown_binding(cooldown_local) else {
191        return applied_ms;
192    };
193    let cd = state.buffer.cooldown_mut_at(handle);
194
195    if spell.is_charged() && !ignore_charge_cooldown {
196        consume_charge(
197            &mut state.runtime.pending_events,
198            cd,
199            program.scheduled_spell(cooldown_local),
200            recharge_ms,
201            now,
202        );
203    } else {
204        cd.duration = f64::from(cooldown_ms) / MS_PER_SECOND;
205    }
206
207    if spell.is_charged() {
208        sync_charge_pool(program, state, charge_owner, charge_members.as_slice());
209        project_members(program, state, charge_members.as_slice());
210    }
211
212    if !ignore_charge_cooldown {
213        start_one_charge_category(
214            program,
215            state,
216            &spell,
217            charge_members.as_slice(),
218            recharge_ms,
219            now,
220        );
221    }
222
223    start_regular_category(
224        program,
225        state,
226        spell_local,
227        &spell,
228        category_cooldown_ms,
229        now,
230    );
231    project_ready_at(program, state, spell_local);
232
233    #[cfg(feature = "rotation-optimizations")]
234    {
235        charge_readiness_before.record(program, state, charge_members.as_slice(), now);
236        category_readiness_before.record(program, state, cooldown_category_members, now);
237    }
238
239    applied_ms
240}
241
242// #t(fn: rust_max_fn_lines) private, category, and charge recovery reductions form one atomic cooldown transition.
243pub(crate) fn reduce_cooldown(
244    program: &crate::state::CombatProgram,
245    state: &mut CombatInstance,
246    spell_local: LocalSpellIdx,
247    amount_ms: u32,
248    now: SimTime,
249) {
250    let spell = spell_at(program, spell_local);
251    let members = charge_pool_members(program, spell_local);
252    #[cfg(feature = "rotation-optimizations")]
253    let readiness_before =
254        CooldownReadinessSnapshot::capture(program, state, members.as_slice(), now);
255    #[cfg(feature = "rotation-optimizations")]
256    let cooldown_category_members: &[LocalSpellIdx] = program
257        .cooldown_category_members
258        .get(spell_local.as_usize())
259        .map_or(&[], Box::as_ref);
260    #[cfg(feature = "rotation-optimizations")]
261    let category_readiness_before =
262        CooldownReadinessSnapshot::capture(program, state, cooldown_category_members, now);
263    let owner = charge_pool_owner(program, spell_local);
264    let reduction_s = f64::from(amount_ms) / MS_PER_SECOND;
265
266    if !spell.is_charged() {
267        let ready_at_before = state.runtime.pools.cooldown_ready_at.ready_at(spell_local);
268        let ready_at_after = state.runtime.pools.cooldown_ready_at.reduce(
269            spell_local,
270            SimTime::from_millis(amount_ms),
271            now,
272        );
273        let mut category_ready_at_before = SimTime::ZERO;
274        let mut category_ready_at_after = SimTime::ZERO;
275        let mut category_reduced = false;
276        let mut charge_category_ready_at_before = SimTime::ZERO;
277        let mut charge_category_ready_at_after = SimTime::ZERO;
278        let mut charge_category_reduced = false;
279
280        if spell.cooldown.charge_category.is_some() {
281            charge_category_ready_at_before = state
282                .runtime
283                .pools
284                .charge_category_ready_at
285                .ready_at(spell_local);
286            charge_category_ready_at_after = state.runtime.pools.charge_category_ready_at.reduce(
287                spell_local,
288                SimTime::from_millis(amount_ms),
289                now,
290            );
291
292            for member in members.as_slice().iter().copied() {
293                state
294                    .runtime
295                    .pools
296                    .charge_category_ready_at
297                    .start(member, charge_category_ready_at_after);
298            }
299
300            charge_category_reduced = true;
301
302            if charge_category_ready_at_after > now {
303                for member in members.as_slice().iter().copied() {
304                    state.schedule(Event::CooldownReady {
305                        t: charge_category_ready_at_after,
306                        spell: program.scheduled_spell(member),
307                    });
308                }
309            }
310
311            project_members(program, state, members.as_slice());
312        }
313
314        // Some DBC spells express their entire cooldown through CategoryRecoveryTime and
315        // leave RecoveryTime at zero. In that representation the category is the spell's
316        // effective cooldown pool, so reducing only the empty private pool is a no-op.
317
318        if spell.cooldown.cooldown_duration_ms == 0
319            && spell.cooldown.category_cooldown_duration_ms > 0
320            && spell.cooldown.cooldown_category.is_some()
321        {
322            category_ready_at_before = state
323                .runtime
324                .pools
325                .cooldown_category_ready_at
326                .ready_at(spell_local);
327            category_ready_at_after = state.runtime.pools.cooldown_category_ready_at.reduce(
328                spell_local,
329                SimTime::from_millis(amount_ms),
330                now,
331            );
332            category_reduced = true;
333
334            let category_members: &[LocalSpellIdx] = program
335                .cooldown_category_members
336                .get(spell_local.as_usize())
337                .map_or(&[], Box::as_ref);
338
339            for member in category_members.iter().copied() {
340                state
341                    .runtime
342                    .pools
343                    .cooldown_category_ready_at
344                    .start(member, category_ready_at_after);
345            }
346
347            if category_ready_at_after > now {
348                for member in category_members.iter().copied() {
349                    state.schedule(Event::CooldownReady {
350                        t: category_ready_at_after,
351                        spell: program.scheduled_spell(member),
352                    });
353                }
354            }
355
356            project_members(program, state, category_members);
357        }
358
359        project_ready_at(program, state, spell_local);
360
361        if ready_at_after > now {
362            state.schedule(Event::CooldownReady {
363                t: ready_at_after,
364                spell: program.scheduled_spell(spell_local),
365            });
366        }
367
368        tracing::trace!(
369            spell_id = spell.spell_id,
370            amount_ms,
371            ready_at_before = ready_at_before.as_secs_f64(),
372            ready_at_after = ready_at_after.as_secs_f64(),
373            category_reduced,
374            category_ready_at_before = category_ready_at_before.as_secs_f64(),
375            category_ready_at_after = category_ready_at_after.as_secs_f64(),
376            charge_category_reduced,
377            charge_category_ready_at_before = charge_category_ready_at_before.as_secs_f64(),
378            charge_category_ready_at_after = charge_category_ready_at_after.as_secs_f64(),
379            "COOLDOWN_REDUCTION"
380        );
381
382        #[cfg(feature = "rotation-optimizations")]
383        {
384            readiness_before.record(program, state, members.as_slice(), now);
385            category_readiness_before.record(program, state, cooldown_category_members, now);
386        }
387
388        return;
389    }
390
391    let (recharge_ms, _) = effective_recharge_ms(program, state, spell_local, &spell);
392    let Some(handle) = program.cooldown_binding(owner) else {
393        return;
394    };
395    let cd = state.buffer.cooldown_mut_at(handle);
396    let next_charge_at_before = cd.next_charge_at;
397    let charges_before = cd.current_charges;
398
399    reduce_charged_cooldown(
400        &mut state.runtime.pending_events,
401        cd,
402        program.scheduled_spell(owner),
403        recharge_ms,
404        reduction_s,
405        now,
406    );
407    let next_charge_at_after = cd.next_charge_at;
408    let charges_after = cd.current_charges;
409
410    sync_charge_pool(program, state, owner, members.as_slice());
411    project_members(program, state, members.as_slice());
412    tracing::trace!(
413        spell_id = spell.spell_id,
414        amount_ms,
415        next_charge_at_before,
416        next_charge_at_after,
417        charges_before,
418        charges_after,
419        "COOLDOWN_REDUCTION"
420    );
421
422    #[cfg(feature = "rotation-optimizations")]
423    readiness_before.record(program, state, members.as_slice(), now);
424}
425
426fn recharge_cooldown(
427    program: &crate::state::CombatProgram,
428    state: &mut CombatInstance,
429    spell_local: LocalSpellIdx,
430    charges: u8,
431) {
432    if charges == 0 {
433        return;
434    }
435
436    let spell = spell_at(program, spell_local);
437
438    if spell.is_charged() {
439        grant_charges_state(program, state, spell_local, charges);
440
441        return;
442    }
443
444    let Some(_) = spell.cooldown.charge_category else {
445        reset_cooldown_state(program, state, spell_local);
446
447        return;
448    };
449    let members = charge_pool_members(program, spell_local);
450
451    state
452        .runtime
453        .pools
454        .charge_category_ready_at
455        .reset(spell_local);
456
457    for member in members.as_slice() {
458        state.runtime.pools.cooldown_ready_at.reset(*member);
459        state.runtime.pools.charge_category_ready_at.reset(*member);
460    }
461
462    project_members(program, state, members.as_slice());
463}
464
465pub(crate) fn mutate_cooldown(
466    program: &crate::state::CombatProgram,
467    state: &mut CombatInstance,
468    spell_local: LocalSpellIdx,
469    operation: wowlab_engine_domain::dbc::CooldownMutation,
470    now: SimTime,
471) {
472    match operation {
473        wowlab_engine_domain::dbc::CooldownMutation::ReduceRemaining { amount_ms } => {
474            reduce_cooldown(program, state, spell_local, amount_ms, now);
475        }
476        wowlab_engine_domain::dbc::CooldownMutation::Recharge { charges } => {
477            #[cfg(feature = "rotation-optimizations")]
478            let members = charge_pool_members(program, spell_local);
479            #[cfg(feature = "rotation-optimizations")]
480            let readiness_before =
481                CooldownReadinessSnapshot::capture(program, state, members.as_slice(), now);
482
483            recharge_cooldown(program, state, spell_local, charges);
484
485            #[cfg(feature = "rotation-optimizations")]
486            readiness_before.record(program, state, members.as_slice(), now);
487        }
488    }
489}
490
491pub(crate) fn reset_cooldown(
492    program: &crate::state::CombatProgram,
493    state: &mut CombatInstance,
494    spell_local: LocalSpellIdx,
495    _now: SimTime,
496) {
497    #[cfg(feature = "rotation-optimizations")]
498    let members = charge_pool_members(program, spell_local);
499    #[cfg(feature = "rotation-optimizations")]
500    let readiness_before =
501        CooldownReadinessSnapshot::capture(program, state, members.as_slice(), _now);
502
503    reset_cooldown_state(program, state, spell_local);
504
505    #[cfg(feature = "rotation-optimizations")]
506    readiness_before.record(program, state, members.as_slice(), _now);
507}
508
509pub(crate) fn reset_cooldown_for_lifecycle(
510    program: &crate::state::CombatProgram,
511    state: &mut CombatInstance,
512    spell_local: LocalSpellIdx,
513) {
514    reset_cooldown_state(program, state, spell_local);
515}
516
517fn reset_cooldown_state(
518    program: &crate::state::CombatProgram,
519    state: &mut CombatInstance,
520    spell_local: LocalSpellIdx,
521) {
522    let spell = spell_at(program, spell_local);
523    let members = charge_pool_members(program, spell_local);
524    let owner = charge_pool_owner(program, spell_local);
525    let target_local = if spell.is_charged() {
526        owner
527    } else {
528        spell_local
529    };
530
531    if spell
532        .cooldown
533        .charge_category
534        .as_ref()
535        .is_some_and(|_| !spell.is_charged())
536    {
537        for member in members.as_slice() {
538            state.runtime.pools.cooldown_ready_at.reset(*member);
539            state.runtime.pools.charge_category_ready_at.reset(*member);
540        }
541    } else {
542        state.runtime.pools.cooldown_ready_at.reset(spell_local);
543    }
544
545    let Some(handle) = program.cooldown_binding(target_local) else {
546        return;
547    };
548    let cd = state.buffer.cooldown_mut_at(handle);
549
550    // Rehydrate the complete runtime slot from the immutable finalized spell definition.
551
552    if spell.is_charged() {
553        let recharge_s = f64::from(spell.cooldown.recharge_ms) / MS_PER_SECOND;
554
555        cd.duration = recharge_s;
556        cd.current_charges = i32::from(spell.cooldown.max_charges);
557        cd.max_charges = i32::from(spell.cooldown.max_charges);
558        cd.next_charge_at = 0.0;
559        cd.recharge_time = recharge_s;
560    } else {
561        cd.duration = f64::from(spell.cooldown.cooldown_duration_ms) / MS_PER_SECOND;
562        cd.max_charges = i32::from(spell.cooldown.max_charges);
563    }
564
565    if spell.is_charged() {
566        sync_charge_pool(program, state, owner, members.as_slice());
567        project_members(program, state, members.as_slice());
568    } else if spell.cooldown.charge_category.is_some() {
569        project_members(program, state, members.as_slice());
570    } else {
571        project_ready_at(program, state, spell_local);
572    }
573}
574
575pub(crate) fn check_recharge(
576    program: &crate::state::CombatProgram,
577    state: &mut CombatInstance,
578    spell_local: LocalSpellIdx,
579    now: SimTime,
580) {
581    let spell = spell_at(program, spell_local);
582
583    if !spell.is_charged() {
584        return;
585    }
586
587    let members = charge_pool_members(program, spell_local);
588    let owner = charge_pool_owner(program, spell_local);
589
590    if spell_local != owner {
591        return;
592    }
593
594    #[cfg(feature = "rotation-optimizations")]
595    let readiness_before =
596        CooldownReadinessSnapshot::capture(program, state, members.as_slice(), now);
597
598    let (recharge_ms, recharge_rate) = effective_recharge_ms(program, state, spell_local, &spell);
599    let recharge_duration_s = f64::from(recharge_ms) / MS_PER_SECOND;
600
601    if let Some(rate) = state.runtime.recharge_rate_mults.get_mut(owner.as_usize()) {
602        *rate = recharge_rate;
603    }
604
605    let scheduled_spell = program.scheduled_spell(owner);
606
607    let Some(handle) = program.cooldown_binding(owner) else {
608        return;
609    };
610    let cd = state.buffer.cooldown_mut_at(handle);
611
612    let charges = cd.current_charges;
613    // The slot max is authoritative: live auras can raise it above the def max.
614    let max = cd.max_charges;
615
616    if charges >= max {
617        return;
618    }
619
620    if now.as_secs_f64() + RECHARGE_EPSILON_S < cd.next_charge_at {
621        return;
622    }
623
624    let new_charges = charges + 1;
625    // Re-arm below maximum or later charges never recover.
626    let next_at = if new_charges < max {
627        cd.duration = recharge_duration_s;
628        cd.recharge_time = recharge_duration_s;
629        let next_ready = now.saturating_add(SimTime::from_millis(recharge_ms));
630
631        state.runtime.pending_events.push(Event::CooldownReady {
632            t: next_ready,
633            spell: scheduled_spell,
634        });
635
636        next_ready.as_secs_f64()
637    } else {
638        0.0
639    };
640
641    cd.current_charges = new_charges;
642    cd.next_charge_at = next_at;
643    sync_charge_pool(program, state, owner, members.as_slice());
644
645    #[cfg(feature = "rotation-optimizations")]
646    readiness_before.record(program, state, members.as_slice(), now);
647}