Skip to main content

wowlab_engine_combat/systems/cooldowns/
charges.rs

1#[cfg(feature = "rotation-optimizations")]
2use super::CooldownReadinessSnapshot;
3use super::{
4    BuffEffect, CombatInstance, Event, LocalSpellIdx, MS_PER_SECOND, SimTime, SpellData,
5    effective_recharge_ms, for_each_planned_player_effect, project_members,
6};
7
8pub(super) fn spell_at(program: &crate::state::CombatProgram, local: LocalSpellIdx) -> SpellData {
9    program
10        .spells
11        .get(local.as_usize())
12        .copied()
13        .expect("local spell index validated during combat construction")
14}
15
16pub(super) struct ChargePoolMembers<'a> {
17    single: [LocalSpellIdx; 1],
18    shared: Option<&'a [LocalSpellIdx]>,
19}
20
21impl ChargePoolMembers<'_> {
22    pub(super) fn as_slice(&self) -> &[LocalSpellIdx] {
23        self.shared.unwrap_or(&self.single)
24    }
25}
26
27pub(super) fn charge_pool_members(
28    program: &crate::state::CombatProgram,
29    spell_local: LocalSpellIdx,
30) -> ChargePoolMembers<'_> {
31    let shared = program
32        .charge_category_members
33        .get(spell_local.as_usize())
34        .map(Box::as_ref)
35        .filter(|members| !members.is_empty());
36
37    ChargePoolMembers {
38        single: [spell_local],
39        shared,
40    }
41}
42
43pub(super) fn charge_pool_owner(
44    program: &crate::state::CombatProgram,
45    spell_local: LocalSpellIdx,
46) -> LocalSpellIdx {
47    program
48        .charge_category_owners
49        .get(spell_local.as_usize())
50        .copied()
51        .unwrap_or(spell_local)
52}
53
54pub(super) fn sync_charge_pool(
55    program: &crate::state::CombatProgram,
56    state: &mut CombatInstance,
57    owner: LocalSpellIdx,
58    members: &[LocalSpellIdx],
59) {
60    let Some(source) = program.buffer_cooldown(state, owner).copied() else {
61        return;
62    };
63
64    for member in members.iter().copied().filter(|member| *member != owner) {
65        if let Some(target) = program.buffer_cooldown_mut(state, member) {
66            target.current_charges = source.current_charges;
67            target.max_charges = source.max_charges;
68            target.next_charge_at = source.next_charge_at;
69            target.recharge_time = source.recharge_time;
70            target.duration = source.duration;
71        }
72    }
73}
74
75pub(super) fn consume_charge(
76    pending_events: &mut Vec<Event>,
77    cd: &mut wowlab_engine_domain::rotation::CooldownSlot,
78    spell: wowlab_engine_ports::ScheduledSpellBinding,
79    recharge_ms: u32,
80    now: SimTime,
81) {
82    cd.duration = f64::from(recharge_ms) / MS_PER_SECOND;
83    cd.recharge_time = cd.duration;
84
85    if cd.current_charges <= 0 {
86        return;
87    }
88
89    let new_charges = cd.current_charges - 1;
90
91    if new_charges == cd.max_charges - 1 {
92        let ready_at = now.saturating_add(SimTime::from_millis(recharge_ms));
93
94        pending_events.push(Event::CooldownReady { t: ready_at, spell });
95        cd.next_charge_at = ready_at.as_secs_f64();
96    }
97
98    cd.current_charges = new_charges;
99}
100
101/// Immediately restores charges without changing an in-flight recharge.
102pub(crate) fn grant_charges(
103    program: &crate::state::CombatProgram,
104    state: &mut CombatInstance,
105    spell_local: LocalSpellIdx,
106    amount: u8,
107    _now: SimTime,
108) {
109    #[cfg(feature = "rotation-optimizations")]
110    let members = charge_pool_members(program, spell_local);
111    #[cfg(feature = "rotation-optimizations")]
112    let readiness_before =
113        CooldownReadinessSnapshot::capture(program, state, members.as_slice(), _now);
114
115    grant_charges_state(program, state, spell_local, amount);
116
117    #[cfg(feature = "rotation-optimizations")]
118    readiness_before.record(program, state, members.as_slice(), _now);
119}
120
121pub(super) fn grant_charges_state(
122    program: &crate::state::CombatProgram,
123    state: &mut CombatInstance,
124    spell_local: LocalSpellIdx,
125    amount: u8,
126) {
127    let spell = spell_at(program, spell_local);
128
129    if !spell.is_charged() || amount == 0 {
130        return;
131    }
132
133    let members = charge_pool_members(program, spell_local);
134    let owner = charge_pool_owner(program, spell_local);
135    let Some(cd) = program.buffer_cooldown_mut(state, owner) else {
136        return;
137    };
138
139    cd.current_charges = (cd.current_charges + i32::from(amount)).min(cd.max_charges);
140
141    if cd.current_charges >= cd.max_charges {
142        cd.next_charge_at = 0.0;
143    }
144
145    sync_charge_pool(program, state, owner, members.as_slice());
146    project_members(program, state, members.as_slice());
147}
148
149pub(super) fn reduce_charged_cooldown(
150    pending_events: &mut Vec<Event>,
151    cd: &mut wowlab_engine_domain::rotation::CooldownSlot,
152    spell: wowlab_engine_ports::ScheduledSpellBinding,
153    recharge_ms: u32,
154    mut reduction_s: f64,
155    now: SimTime,
156) {
157    let now_s = now.as_secs_f64();
158    let interval_s = f64::from(recharge_ms) / MS_PER_SECOND;
159
160    while reduction_s > 0.0 && cd.current_charges < cd.max_charges {
161        if cd.next_charge_at <= 0.0 {
162            cd.next_charge_at = now_s + interval_s;
163        }
164
165        let remaining_s = (cd.next_charge_at - now_s).max(0.0);
166
167        if reduction_s < remaining_s {
168            cd.next_charge_at -= reduction_s;
169            pending_events.push(Event::CooldownReady {
170                t: SimTime::from_secs_f64(cd.next_charge_at),
171                spell,
172            });
173
174            return;
175        }
176
177        reduction_s -= remaining_s;
178        cd.current_charges += 1;
179
180        if cd.current_charges >= cd.max_charges {
181            cd.next_charge_at = 0.0;
182
183            return;
184        }
185
186        cd.next_charge_at = now_s + interval_s;
187    }
188
189    if cd.current_charges < cd.max_charges && cd.next_charge_at > now_s {
190        pending_events.push(Event::CooldownReady {
191            t: SimTime::from_secs_f64(cd.next_charge_at),
192            spell,
193        });
194    }
195}
196
197/// Applies live aura-411 category max-charge deltas to charged spells.
198pub(crate) fn refresh_maximum_charges_for(
199    program: &crate::state::CombatProgram,
200    state: &mut CombatInstance,
201    now: SimTime,
202    locals: &[LocalSpellIdx],
203) {
204    for &local in locals {
205        let spell = *program.spell(local);
206
207        if !spell.is_charged() {
208            continue;
209        }
210
211        if charge_pool_owner(program, local) != local {
212            continue;
213        }
214
215        #[cfg(feature = "rotation-optimizations")]
216        let readiness_members = charge_pool_members(program, local);
217        #[cfg(feature = "rotation-optimizations")]
218        let readiness_before =
219            CooldownReadinessSnapshot::capture(program, state, readiness_members.as_slice(), now);
220
221        let Some(category) = spell.cooldown.charge_category else {
222            continue;
223        };
224        let mut delta = 0.0;
225
226        for_each_planned_player_effect(
227            program,
228            state,
229            crate::state::EffectFamily::MAXIMUM_CHARGES,
230            |stacks, effect| {
231                if let BuffEffect::MaxChargesCategory(value, effect_category) = effect
232                    && *effect_category == category.raw()
233                {
234                    delta += value * stacks;
235                }
236            },
237        );
238        let new_max = wowlab_types::numeric::f64_to_i32_saturating_round(
239            (f64::from(spell.cooldown.max_charges) + delta).max(1.0),
240        );
241        let (recharge_ms, _) = effective_recharge_ms(program, state, local, &spell);
242        let mut schedule_recharge_at = None;
243
244        if let Some(cd) = program.buffer_cooldown_mut(state, local) {
245            let old_max = cd.max_charges;
246
247            if new_max == old_max {
248                continue;
249            }
250
251            if new_max > old_max {
252                cd.current_charges += new_max - old_max;
253            } else {
254                cd.current_charges = (cd.current_charges - (old_max - new_max)).max(0);
255            }
256
257            cd.max_charges = new_max;
258            let now_s = now.as_secs_f64();
259
260            if cd.current_charges < new_max && cd.next_charge_at <= now_s && recharge_ms > 0 {
261                let next = now.saturating_add(SimTime::from_millis(recharge_ms));
262
263                cd.next_charge_at = next.as_secs_f64();
264                schedule_recharge_at = Some(next);
265            }
266        }
267
268        if let Some(t) = schedule_recharge_at {
269            state.schedule(Event::CooldownReady {
270                t,
271                spell: program.scheduled_spell(local),
272            });
273        }
274
275        let members = charge_pool_members(program, local);
276
277        sync_charge_pool(program, state, local, members.as_slice());
278        project_members(program, state, members.as_slice());
279
280        #[cfg(feature = "rotation-optimizations")]
281        readiness_before.record(program, state, readiness_members.as_slice(), now);
282    }
283}