Skip to main content

wowlab_engine_combat/systems/cooldowns/
recharge.rs

1use super::{
2    BuffEffect, CombatState, DenseBuffer, Event, LocalSpellIdx, MS_PER_SECOND, RECHARGE_EPSILON_S,
3    SimTime, SpellData, charge_pool_members, charge_pool_owner, consume_charge,
4    effective_category_cooldown_ms, effective_cooldown_ms, effective_recharge_ms,
5    for_each_active_aura_effect, grant_charges, reduce_charged_cooldown, spell_at,
6    start_one_charge_category, start_regular_category, sync_charge_pool,
7};
8
9fn projected_ready_at(state: &CombatState, spell_local: LocalSpellIdx) -> SimTime {
10    let spell = spell_at(state, spell_local);
11    let own = state.runtime.pools.cooldown_ready_at.ready_at(&spell_local);
12    let category = spell
13        .cooldown
14        .cooldown_category
15        .map_or(SimTime::ZERO, |category| {
16            state
17                .runtime
18                .pools
19                .cooldown_category_ready_at
20                .ready_at(&category)
21        });
22    let charge_category = (!spell.is_charged())
23        .then_some(spell.cooldown.charge_category)
24        .flatten()
25        .map_or(SimTime::ZERO, |category| {
26            state
27                .runtime
28                .pools
29                .charge_category_ready_at
30                .ready_at(&category)
31        });
32
33    own.max(category).max(charge_category)
34}
35
36pub(super) fn project_ready_at(
37    state: &CombatState,
38    buf: &mut DenseBuffer,
39    spell_local: LocalSpellIdx,
40) {
41    let spell = spell_at(state, spell_local);
42    let pooled = projected_ready_at(state, spell_local).as_secs_f64();
43
44    if let Some(cooldown) = buf.cooldown_mut(spell.idx()) {
45        // Charged with no charges left: next recharge IS the ready time (keeps `charges > 0 => ready_at <= now`).
46        cooldown.ready_at = if spell.is_charged() && cooldown.current_charges <= 0 {
47            pooled.max(cooldown.next_charge_at)
48        } else {
49            pooled
50        };
51    }
52}
53
54pub(super) fn project_members(
55    state: &CombatState,
56    buf: &mut DenseBuffer,
57    members: &[LocalSpellIdx],
58) {
59    for member in members.iter().copied() {
60        project_ready_at(state, buf, member);
61    }
62}
63
64fn ignores_charge_cooldown(state: &CombatState, buf: &DenseBuffer, spell: &SpellData) -> bool {
65    let Some(category) = spell.cooldown.charge_category else {
66        return false;
67    };
68    let mut ignored = false;
69
70    for_each_active_aura_effect(state, buf, |_, effect| {
71        if matches!(
72            effect,
73            BuffEffect::IgnoreChargeCooldownCategory(effect_category)
74                if *effect_category == category.raw()
75        ) {
76            ignored = true;
77        }
78    });
79
80    ignored
81}
82
83fn start_private_cooldown(
84    state: &mut CombatState,
85    spell: &SpellData,
86    spell_local: LocalSpellIdx,
87    cooldown_ms: u32,
88    now: SimTime,
89) {
90    if spell.is_charged() || cooldown_ms == 0 {
91        state.runtime.pools.cooldown_ready_at.reset(&spell_local);
92
93        return;
94    }
95
96    let ready_at = state.runtime.pools.cooldown_ready_at.start_for(
97        spell_local,
98        now,
99        SimTime::from_millis(cooldown_ms),
100    );
101
102    state.schedule(Event::CooldownReady {
103        t: ready_at,
104        cooldown_key: spell.idx(),
105    });
106}
107
108pub(crate) fn start_cooldown(
109    state: &mut CombatState,
110    buf: &mut DenseBuffer,
111    spell_local: LocalSpellIdx,
112    now: SimTime,
113) -> u32 {
114    start_cooldown_in_slot(state, buf, spell_local, spell_local, now)
115}
116
117/// Start `slot_local`'s cooldown timers using `recovery_local`'s own recovery data.
118///
119/// An aura-332 action replacement shares the base action's slot but keeps its own recovery time.
120/// `SimC` hands it the base's shared `cooldown_t` (`sc_demon_hunter.cpp:6927`).
121/// It then writes the executing spell's own duration in on every cast (`:7002`).
122pub(crate) fn start_cooldown_in_slot(
123    state: &mut CombatState,
124    buf: &mut DenseBuffer,
125    recovery_local: LocalSpellIdx,
126    slot_local: LocalSpellIdx,
127    now: SimTime,
128) -> u32 {
129    let spell_local = slot_local;
130    let spell = spell_at(state, slot_local);
131    let recovery = spell_at(state, recovery_local);
132    let charge_members = charge_pool_members(state, spell_local);
133    let charge_owner = charge_pool_owner(state, spell_local);
134    let cooldown_local = if spell.is_charged() || spell.cooldown.charge_category.is_some() {
135        charge_owner
136    } else {
137        spell_local
138    };
139    let cooldown_spell = spell_at(state, cooldown_local);
140    let idx = cooldown_spell.idx();
141    let (cooldown_ms, cooldown_rate) = effective_cooldown_ms(state, buf, &recovery);
142    let category_cooldown_ms = effective_category_cooldown_ms(state, buf, &recovery);
143    let (recharge_ms, recharge_rate) = effective_recharge_ms(state, buf, &recovery);
144    let ignore_charge_cooldown = ignores_charge_cooldown(state, buf, &spell);
145    let applied_ms = cooldown_ms
146        .max(category_cooldown_ms)
147        .max(if spell.cooldown.max_charges > 0 {
148            recharge_ms
149        } else {
150            0
151        });
152
153    if let Some(rate) = state
154        .runtime
155        .recharge_rate_mults
156        .get_mut(cooldown_local.as_usize())
157    {
158        *rate = if spell.is_charged() {
159            recharge_rate
160        } else {
161            cooldown_rate
162        };
163    }
164
165    start_private_cooldown(state, &spell, spell_local, cooldown_ms, now);
166
167    let Some(cd) = buf.cooldown_mut(idx) else {
168        return applied_ms;
169    };
170
171    if spell.is_charged() && !ignore_charge_cooldown {
172        consume_charge(state, cd, &spell, recharge_ms, now);
173    } else {
174        cd.duration = f64::from(cooldown_ms) / MS_PER_SECOND;
175    }
176
177    if spell.is_charged() {
178        sync_charge_pool(state, buf, charge_owner, &charge_members);
179        project_members(state, buf, &charge_members);
180    }
181
182    if !ignore_charge_cooldown {
183        start_one_charge_category(state, buf, &spell, &charge_members, recharge_ms, now);
184    }
185
186    start_regular_category(state, buf, &spell, category_cooldown_ms, now);
187    project_ready_at(state, buf, spell_local);
188
189    applied_ms
190}
191
192pub(crate) fn reduce_cooldown(
193    state: &mut CombatState,
194    buf: &mut DenseBuffer,
195    spell_local: LocalSpellIdx,
196    amount_ms: u32,
197    now: SimTime,
198) {
199    let spell = spell_at(state, spell_local);
200    let members = charge_pool_members(state, spell_local);
201    let owner = charge_pool_owner(state, spell_local);
202    let reduction_s = f64::from(amount_ms) / MS_PER_SECOND;
203
204    if !spell.is_charged() {
205        let ready_at_before = state.runtime.pools.cooldown_ready_at.ready_at(&spell_local);
206        let ready_at_after = state.runtime.pools.cooldown_ready_at.reduce(
207            spell_local,
208            SimTime::from_millis(amount_ms),
209            now,
210        );
211        let mut category_ready_at_before = SimTime::ZERO;
212        let mut category_ready_at_after = SimTime::ZERO;
213        let mut category_reduced = false;
214        let mut charge_category_ready_at_before = SimTime::ZERO;
215        let mut charge_category_ready_at_after = SimTime::ZERO;
216        let mut charge_category_reduced = false;
217
218        if let Some(category) = spell.cooldown.charge_category {
219            charge_category_ready_at_before = state
220                .runtime
221                .pools
222                .charge_category_ready_at
223                .ready_at(&category);
224            charge_category_ready_at_after = state.runtime.pools.charge_category_ready_at.reduce(
225                category,
226                SimTime::from_millis(amount_ms),
227                now,
228            );
229            charge_category_reduced = true;
230
231            if charge_category_ready_at_after > now {
232                for member in members.iter().copied() {
233                    state.schedule(Event::CooldownReady {
234                        t: charge_category_ready_at_after,
235                        cooldown_key: spell_at(state, member).idx(),
236                    });
237                }
238            }
239
240            project_members(state, buf, &members);
241        }
242
243        // Some DBC spells express their entire cooldown through CategoryRecoveryTime and
244        // leave RecoveryTime at zero. In that representation the category is the spell's
245        // effective cooldown pool, so reducing only the empty private pool is a no-op.
246
247        if spell.cooldown.cooldown_duration_ms == 0
248            && spell.cooldown.category_cooldown_duration_ms > 0
249            && let Some(category) = spell.cooldown.cooldown_category
250        {
251            category_ready_at_before = state
252                .runtime
253                .pools
254                .cooldown_category_ready_at
255                .ready_at(&category);
256            category_ready_at_after = state.runtime.pools.cooldown_category_ready_at.reduce(
257                category,
258                SimTime::from_millis(amount_ms),
259                now,
260            );
261            category_reduced = true;
262
263            let category_members = state.index.cooldown_categories.members(&category).to_vec();
264
265            if category_ready_at_after > now {
266                for member in category_members.iter().copied() {
267                    state.schedule(Event::CooldownReady {
268                        t: category_ready_at_after,
269                        cooldown_key: spell_at(state, member).idx(),
270                    });
271                }
272            }
273
274            project_members(state, buf, &category_members);
275        }
276
277        project_ready_at(state, buf, spell_local);
278
279        if ready_at_after > now {
280            state.schedule(Event::CooldownReady {
281                t: ready_at_after,
282                cooldown_key: spell.idx(),
283            });
284        }
285
286        tracing::trace!(
287            spell_id = spell.spell_id,
288            amount_ms,
289            ready_at_before = ready_at_before.as_secs_f64(),
290            ready_at_after = ready_at_after.as_secs_f64(),
291            category_reduced,
292            category_ready_at_before = category_ready_at_before.as_secs_f64(),
293            category_ready_at_after = category_ready_at_after.as_secs_f64(),
294            charge_category_reduced,
295            charge_category_ready_at_before = charge_category_ready_at_before.as_secs_f64(),
296            charge_category_ready_at_after = charge_category_ready_at_after.as_secs_f64(),
297            "COOLDOWN_REDUCTION"
298        );
299
300        return;
301    }
302
303    let owner_spell = spell_at(state, owner);
304    let (recharge_ms, _) = effective_recharge_ms(state, buf, &spell);
305    let Some(cd) = buf.cooldown_mut(owner_spell.idx()) else {
306        return;
307    };
308    let next_charge_at_before = cd.next_charge_at;
309    let charges_before = cd.current_charges;
310
311    reduce_charged_cooldown(state, cd, owner_spell.idx(), recharge_ms, reduction_s, now);
312    let next_charge_at_after = cd.next_charge_at;
313    let charges_after = cd.current_charges;
314
315    sync_charge_pool(state, buf, owner, &members);
316    project_members(state, buf, &members);
317    tracing::trace!(
318        spell_id = spell.spell_id,
319        amount_ms,
320        next_charge_at_before,
321        next_charge_at_after,
322        charges_before,
323        charges_after,
324        "COOLDOWN_REDUCTION"
325    );
326}
327
328fn recharge_cooldown(
329    state: &mut CombatState,
330    buf: &mut DenseBuffer,
331    spell_local: LocalSpellIdx,
332    charges: u8,
333) {
334    if charges == 0 {
335        return;
336    }
337
338    let spell = spell_at(state, spell_local);
339
340    if spell.is_charged() {
341        grant_charges(state, buf, spell_local, charges);
342
343        return;
344    }
345
346    let Some(category) = spell.cooldown.charge_category else {
347        reset_cooldown(state, buf, spell_local);
348
349        return;
350    };
351    let members = charge_pool_members(state, spell_local);
352
353    state
354        .runtime
355        .pools
356        .charge_category_ready_at
357        .reset(&category);
358
359    for member in &members {
360        state.runtime.pools.cooldown_ready_at.reset(member);
361    }
362
363    project_members(state, buf, &members);
364}
365
366pub(crate) fn mutate_cooldown(
367    state: &mut CombatState,
368    buf: &mut DenseBuffer,
369    spell_local: LocalSpellIdx,
370    operation: wowlab_engine_domain::dbc::CooldownMutation,
371    now: SimTime,
372) {
373    match operation {
374        wowlab_engine_domain::dbc::CooldownMutation::ReduceRemaining { amount_ms } => {
375            reduce_cooldown(state, buf, spell_local, amount_ms, now);
376        }
377        wowlab_engine_domain::dbc::CooldownMutation::Recharge { charges } => {
378            recharge_cooldown(state, buf, spell_local, charges);
379        }
380    }
381}
382
383pub(crate) fn reset_cooldown(
384    state: &mut CombatState,
385    buf: &mut DenseBuffer,
386    spell_local: LocalSpellIdx,
387) {
388    let spell = spell_at(state, spell_local);
389    let members = charge_pool_members(state, spell_local);
390    let owner = charge_pool_owner(state, spell_local);
391    let target_local = if spell.is_charged() {
392        owner
393    } else {
394        spell_local
395    };
396    let target_spell = spell_at(state, target_local);
397    let idx = target_spell.idx();
398
399    if let Some(category) = spell
400        .cooldown
401        .charge_category
402        .filter(|_| !spell.is_charged())
403    {
404        state
405            .runtime
406            .pools
407            .charge_category_ready_at
408            .reset(&category);
409
410        for member in &members {
411            state.runtime.pools.cooldown_ready_at.reset(member);
412        }
413    } else {
414        state.runtime.pools.cooldown_ready_at.reset(&spell_local);
415    }
416
417    let Some(cd) = buf.cooldown_mut(idx) else {
418        return;
419    };
420
421    // Re-derive the full slot from defs: post-build charge registration (talent fns) must reach the slot read by the rotation.
422
423    if spell.is_charged() {
424        let recharge_s = f64::from(spell.cooldown.recharge_ms) / MS_PER_SECOND;
425
426        cd.duration = recharge_s;
427        cd.current_charges = i32::from(spell.cooldown.max_charges);
428        cd.max_charges = i32::from(spell.cooldown.max_charges);
429        cd.next_charge_at = 0.0;
430        cd.recharge_time = recharge_s;
431    } else {
432        cd.duration = f64::from(spell.cooldown.cooldown_duration_ms) / MS_PER_SECOND;
433        cd.max_charges = i32::from(spell.cooldown.max_charges);
434    }
435
436    if spell.is_charged() {
437        sync_charge_pool(state, buf, owner, &members);
438        project_members(state, buf, &members);
439    } else if spell.cooldown.charge_category.is_some() {
440        project_members(state, buf, &members);
441    } else {
442        project_ready_at(state, buf, spell_local);
443    }
444}
445
446pub(crate) fn check_recharge(
447    state: &mut CombatState,
448    buf: &mut DenseBuffer,
449    spell_local: LocalSpellIdx,
450    now: SimTime,
451) {
452    let spell = spell_at(state, spell_local);
453
454    if !spell.is_charged() {
455        return;
456    }
457
458    let members = charge_pool_members(state, spell_local);
459    let owner = charge_pool_owner(state, spell_local);
460
461    if spell_local != owner {
462        return;
463    }
464
465    let owner_spell = spell_at(state, owner);
466    let idx = owner_spell.idx();
467    let (recharge_ms, recharge_rate) = effective_recharge_ms(state, buf, &spell);
468    let recharge_duration_s = f64::from(recharge_ms) / MS_PER_SECOND;
469
470    if let Some(rate) = state.runtime.recharge_rate_mults.get_mut(owner.as_usize()) {
471        *rate = recharge_rate;
472    }
473
474    let Some(cd) = buf.cooldown_mut(idx) else {
475        return;
476    };
477
478    let charges = cd.current_charges;
479    // The slot max is authoritative: live auras can raise it above the def max.
480    let max = cd.max_charges;
481
482    if charges >= max {
483        return;
484    }
485
486    if now.as_secs_f64() + RECHARGE_EPSILON_S < cd.next_charge_at {
487        return;
488    }
489
490    let new_charges = charges + 1;
491    // Re-arm below maximum or later charges never recover.
492    let next_at = if new_charges < max {
493        cd.duration = recharge_duration_s;
494        cd.recharge_time = recharge_duration_s;
495        let next_ready = now.saturating_add(SimTime::from_millis(recharge_ms));
496
497        state.schedule(Event::CooldownReady {
498            t: next_ready,
499            cooldown_key: idx,
500        });
501
502        next_ready.as_secs_f64()
503    } else {
504        0.0
505    };
506
507    cd.current_charges = new_charges;
508    cd.next_charge_at = next_at;
509    sync_charge_pool(state, buf, owner, &members);
510}