Skip to main content

wowlab_engine_combat/systems/cooldowns/
charges.rs

1use super::{
2    BuffEffect, CombatState, DenseBuffer, Event, LocalSpellIdx, MS_PER_SECOND, SimTime, SpellData,
3    effective_recharge_ms, for_each_active_aura_effect, project_members,
4};
5
6pub(super) fn spell_at(state: &CombatState, local: LocalSpellIdx) -> SpellData {
7    state
8        .defs
9        .spells
10        .get(local.as_usize())
11        .copied()
12        .expect("local spell index validated during combat construction")
13}
14
15pub(super) fn charge_pool_members(
16    state: &CombatState,
17    spell_local: LocalSpellIdx,
18) -> Vec<LocalSpellIdx> {
19    let spell = spell_at(state, spell_local);
20
21    spell.cooldown.charge_category.map_or_else(
22        || vec![spell_local],
23        |category| state.index.charge_categories.members(&category).to_vec(),
24    )
25}
26
27pub(super) fn charge_pool_owner(state: &CombatState, spell_local: LocalSpellIdx) -> LocalSpellIdx {
28    let spell = spell_at(state, spell_local);
29
30    spell
31        .cooldown
32        .charge_category
33        .and_then(|category| state.index.charge_categories.owner(&category).copied())
34        .unwrap_or(spell_local)
35}
36
37pub(super) fn sync_charge_pool(
38    state: &CombatState,
39    buf: &mut DenseBuffer,
40    owner: LocalSpellIdx,
41    members: &[LocalSpellIdx],
42) {
43    let owner_spell = spell_at(state, owner);
44    let Some(source) = buf.cooldown(owner_spell.idx()).copied() else {
45        return;
46    };
47
48    for member in members.iter().copied().filter(|member| *member != owner) {
49        let member_spell = spell_at(state, member);
50
51        if let Some(target) = buf.cooldown_mut(member_spell.idx()) {
52            target.current_charges = source.current_charges;
53            target.max_charges = source.max_charges;
54            target.next_charge_at = source.next_charge_at;
55            target.recharge_time = source.recharge_time;
56            target.duration = source.duration;
57        }
58    }
59}
60
61pub(super) fn consume_charge(
62    state: &mut CombatState,
63    cd: &mut wowlab_engine_domain::rotation::CooldownSlot,
64    spell: &SpellData,
65    recharge_ms: u32,
66    now: SimTime,
67) {
68    cd.duration = f64::from(recharge_ms) / MS_PER_SECOND;
69    cd.recharge_time = cd.duration;
70
71    if cd.current_charges <= 0 {
72        return;
73    }
74
75    let new_charges = cd.current_charges - 1;
76
77    if new_charges == cd.max_charges - 1 {
78        let ready_at = now.saturating_add(SimTime::from_millis(recharge_ms));
79
80        state.schedule(Event::CooldownReady {
81            t: ready_at,
82            cooldown_key: spell.idx(),
83        });
84        cd.next_charge_at = ready_at.as_secs_f64();
85    }
86
87    cd.current_charges = new_charges;
88}
89
90/// Immediately restores charges without changing an in-flight recharge.
91pub(crate) fn grant_charges(
92    state: &CombatState,
93    buf: &mut DenseBuffer,
94    spell_local: LocalSpellIdx,
95    amount: u8,
96) {
97    let spell = spell_at(state, spell_local);
98
99    if !spell.is_charged() || amount == 0 {
100        return;
101    }
102
103    let members = charge_pool_members(state, spell_local);
104    let owner = charge_pool_owner(state, spell_local);
105    let owner_spell = spell_at(state, owner);
106    let Some(cd) = buf.cooldown_mut(owner_spell.idx()) else {
107        return;
108    };
109
110    cd.current_charges = (cd.current_charges + i32::from(amount)).min(cd.max_charges);
111
112    if cd.current_charges >= cd.max_charges {
113        cd.next_charge_at = 0.0;
114    }
115
116    sync_charge_pool(state, buf, owner, &members);
117    project_members(state, buf, &members);
118}
119
120pub(super) fn reduce_charged_cooldown(
121    state: &mut CombatState,
122    cd: &mut wowlab_engine_domain::rotation::CooldownSlot,
123    cooldown_key: wowlab_types::sim::SpellIdx,
124    recharge_ms: u32,
125    mut reduction_s: f64,
126    now: SimTime,
127) {
128    let now_s = now.as_secs_f64();
129    let interval_s = f64::from(recharge_ms) / MS_PER_SECOND;
130
131    while reduction_s > 0.0 && cd.current_charges < cd.max_charges {
132        if cd.next_charge_at <= 0.0 {
133            cd.next_charge_at = now_s + interval_s;
134        }
135
136        let remaining_s = (cd.next_charge_at - now_s).max(0.0);
137
138        if reduction_s < remaining_s {
139            cd.next_charge_at -= reduction_s;
140            state.schedule(Event::CooldownReady {
141                t: SimTime::from_secs_f64(cd.next_charge_at),
142                cooldown_key,
143            });
144
145            return;
146        }
147
148        reduction_s -= remaining_s;
149        cd.current_charges += 1;
150
151        if cd.current_charges >= cd.max_charges {
152            cd.next_charge_at = 0.0;
153
154            return;
155        }
156
157        cd.next_charge_at = now_s + interval_s;
158    }
159
160    if cd.current_charges < cd.max_charges && cd.next_charge_at > now_s {
161        state.schedule(Event::CooldownReady {
162            t: SimTime::from_secs_f64(cd.next_charge_at),
163            cooldown_key,
164        });
165    }
166}
167
168/// Applies live aura-411 category max-charge deltas to charged spells.
169pub(crate) fn sync_max_charges(state: &mut CombatState, buf: &mut DenseBuffer, now: SimTime) {
170    for i in 0..state.defs.spells.len() {
171        let Some(spell) = state.defs.spells.get(i).copied() else {
172            continue;
173        };
174
175        if !spell.is_charged() {
176            continue;
177        }
178
179        let local = LocalSpellIdx::new(u8::try_from(i).expect("spell count fits in u8"));
180
181        if charge_pool_owner(state, local) != local {
182            continue;
183        }
184
185        let Some(category) = spell.cooldown.charge_category else {
186            continue;
187        };
188        let mut delta = 0.0;
189
190        for_each_active_aura_effect(state, buf, |stacks, effect| {
191            if let BuffEffect::MaxChargesCategory(value, effect_category) = effect {
192                if *effect_category == category.raw() {
193                    delta += value * stacks;
194                }
195            }
196        });
197        let new_max = wowlab_types::numeric::f64_to_i32_saturating_round(
198            (f64::from(spell.cooldown.max_charges) + delta).max(1.0),
199        );
200        let (recharge_ms, _) = effective_recharge_ms(state, buf, &spell);
201        let mut schedule_recharge_at = None;
202
203        if let Some(cd) = buf.cooldown_mut(spell.idx()) {
204            let old_max = cd.max_charges;
205
206            if new_max == old_max {
207                continue;
208            }
209
210            if new_max > old_max {
211                cd.current_charges += new_max - old_max;
212            } else {
213                cd.current_charges = (cd.current_charges - (old_max - new_max)).max(0);
214            }
215
216            cd.max_charges = new_max;
217            let now_s = now.as_secs_f64();
218
219            if cd.current_charges < new_max && cd.next_charge_at <= now_s && recharge_ms > 0 {
220                let next = now.saturating_add(SimTime::from_millis(recharge_ms));
221
222                cd.next_charge_at = next.as_secs_f64();
223                schedule_recharge_at = Some(next);
224            }
225        }
226
227        if let Some(t) = schedule_recharge_at {
228            state.schedule(Event::CooldownReady {
229                t,
230                cooldown_key: spell.idx(),
231            });
232        }
233
234        let members = charge_pool_members(state, local);
235
236        sync_charge_pool(state, buf, local, &members);
237        project_members(state, buf, &members);
238    }
239}