Skip to main content

wowlab_engine_combat/systems/
resources.rs

1//! Primary/secondary resource spend, gain, and haste-scaled regeneration.
2
3use wowlab_engine_domain::rotation::DenseBuffer;
4use wowlab_engine_telemetry::{RESOURCE_SOURCE_PASSIVE_REGEN, ResourceEventKind, TelemetrySink};
5use wowlab_types::{
6    combat::ResourceType,
7    constants::{HUNDRED, MS_PER_SECOND},
8    sim::{ActorId, SimTime},
9};
10
11use super::buffs::current_stat_haste_mult;
12use crate::state::{CombatState, ResourceGain, ResourceGainScaling};
13
14#[must_use]
15pub(crate) fn spell_health_cost(
16    state: &CombatState,
17    actor: ActorId,
18    flat: f64,
19    percent_of_maximum: f64,
20) -> f64 {
21    let maximum = state.friendly_actor_max_health(actor).unwrap_or(0.0);
22
23    flat.max(0.0) + maximum * percent_of_maximum.max(0.0) / HUNDRED
24}
25
26pub(crate) fn spend_health_cost(
27    state: &mut CombatState,
28    actor: ActorId,
29    flat: f64,
30    percent_of_maximum: f64,
31) -> f64 {
32    let cost = spell_health_cost(state, actor, flat, percent_of_maximum);
33
34    state
35        .apply_friendly_actor_damage(actor, cost)
36        .unwrap_or(0.0)
37}
38
39/// Override the primary resource's base regeneration and project its effective live rate.
40pub fn set_primary_resource_base_regen(
41    state: &mut CombatState,
42    buf: &mut DenseBuffer,
43    base_regen: f64,
44) {
45    let (effective_regen, _) = effective_primary_regen(state, buf, base_regen, true);
46
47    state.runtime.resources.primary_base_regen_override = Some(base_regen);
48
49    if let Some(rt) = state.config.base_stats.resource_type {
50        if let Some(res) = buf.resource_mut(rt) {
51            res.regen_per_sec = effective_regen;
52        }
53    }
54}
55
56fn effective_primary_regen(
57    state: &CombatState,
58    buf: &DenseBuffer,
59    base_regen: f64,
60    haste_scaled: bool,
61) -> (f64, f64) {
62    let view = crate::CombatView::new(state, buf);
63    let totals = view.buff_totals();
64    let mut flat_regen_per_second = 0.0;
65    let primary_type = state.config.base_stats.resource_type;
66    let mut interrupted = false;
67    let mut interrupted_percent = 0.0;
68    let mut prevented_mask = 0_u64;
69
70    super::buffs::for_each_active_aura_effect(state, buf, |stacks, effect| match effect {
71        crate::BuffEffect::ResourceRegenPerFiveSeconds(value, resource_type) => {
72            if Some(*resource_type) == primary_type {
73                flat_regen_per_second +=
74                    value * stacks / wowlab_engine_domain::dbc::POWER_REGEN_PERIOD_SECONDS;
75            }
76        }
77        crate::BuffEffect::InterruptRegen => interrupted = true,
78        crate::BuffEffect::InterruptedRegenPercent(value) => {
79            interrupted_percent += value * stacks;
80        }
81        crate::BuffEffect::PreventResourceRegen(mask) => prevented_mask |= mask,
82        _ => {}
83    });
84
85    let haste_multiplier = if haste_scaled {
86        current_stat_haste_mult(&view)
87    } else {
88        1.0
89    };
90    let scaled_base = base_regen * haste_multiplier * (1.0 + totals.regen_pct / HUNDRED);
91
92    let effective = apply_regen_interruptions(
93        scaled_base + flat_regen_per_second,
94        primary_type,
95        interrupted,
96        interrupted_percent,
97        prevented_mask,
98    );
99
100    (effective, totals.max_resource_flat)
101}
102
103fn apply_regen_interruptions(
104    rate: f64,
105    primary_type: Option<ResourceType>,
106    interrupted: bool,
107    interrupted_percent: f64,
108    prevented_mask: u64,
109) -> f64 {
110    let resource_prevented = primary_type.is_some_and(|resource_type| {
111        let bit = u32::from(u8::from(resource_type));
112
113        1_u64
114            .checked_shl(bit)
115            .is_some_and(|mask| prevented_mask & mask != 0)
116    });
117
118    if resource_prevented {
119        0.0
120    } else if interrupted {
121        let continuation = if primary_type == Some(ResourceType::Mana) {
122            interrupted_percent.max(0.0) / HUNDRED
123        } else {
124            0.0
125        };
126
127        rate * continuation
128    } else {
129        rate
130    }
131}
132
133// docref:start resources-sync-resource
134pub(crate) fn sync_resource(
135    state: &mut CombatState,
136    buf: &mut DenseBuffer,
137    now: SimTime,
138    last_sync: &mut SimTime,
139    sink: &mut TelemetrySink,
140) {
141    if now <= *last_sync {
142        return;
143    }
144    // docref:end resources-sync-resource
145
146    if state.config.base_stats.resource_type == Some(ResourceType::Runes) {
147        sync_runes(state, buf, now, last_sync, sink);
148
149        return;
150    }
151
152    let elapsed_s = f64::from(now.as_millis() - last_sync.as_millis()) / MS_PER_SECOND;
153
154    let dynamic_base_regen = state.runtime.resources.primary_base_regen_override;
155    let base_regen = dynamic_base_regen.unwrap_or(state.config.base_stats.base_regen);
156    let (effective_regen, max_resource_flat) = effective_primary_regen(
157        state,
158        buf,
159        base_regen,
160        state.config.haste_regen || dynamic_base_regen.is_some(),
161    );
162    let base_max = state.config.base_stats.resource_max;
163    let mut gained = 0.0;
164    let mut wasted = 0.0;
165
166    if let Some(rt) = state.config.base_stats.resource_type {
167        if let Some(res) = buf.resource_mut(rt) {
168            res.max = base_max + max_resource_flat;
169
170            let uncapped = res.current + effective_regen * elapsed_s;
171            let capped = uncapped.min(res.max);
172
173            gained = capped - res.current;
174            wasted = uncapped - capped;
175            res.current = capped;
176            res.regen_per_sec = effective_regen;
177        }
178    }
179
180    emit_passive_regen(state, buf, now, sink, gained, wasted);
181
182    *last_sync = now;
183}
184
185/// Report one sync step's haste-scaled passive regeneration so resource-income views account for it.
186fn emit_passive_regen(
187    state: &CombatState,
188    buf: &DenseBuffer,
189    now: SimTime,
190    sink: &mut TelemetrySink,
191    gained: f64,
192    wasted: f64,
193) {
194    if gained <= 0.0 && wasted <= 0.0 {
195        return;
196    }
197
198    super::procs::emit_resource(
199        state,
200        buf,
201        sink,
202        super::procs::ResourceTelemetry {
203            kind: ResourceEventKind::Gain { wasted },
204            amount: gained,
205            secondary: false,
206            now,
207            source_spell_id: RESOURCE_SOURCE_PASSIVE_REGEN,
208        },
209    );
210}
211
212fn sync_runes(
213    state: &mut CombatState,
214    buf: &mut DenseBuffer,
215    now: SimTime,
216    last_sync: &mut SimTime,
217    sink: &mut TelemetrySink,
218) {
219    let current = buf
220        .resource(ResourceType::Runes)
221        .map_or(0.0, |resource| resource.current);
222    let rune_count = u32::try_from(crate::state::RUNE_COUNT).expect("rune count fits u32");
223    let represented = u32::try_from(state.runtime.resources.runes.full_count())
224        .expect("full rune count fits u32");
225
226    if (current - f64::from(represented)).abs() > f64::EPSILON {
227        let requested = wowlab_types::numeric::f64_to_u32_saturating_round(current).min(rune_count);
228
229        state.runtime.resources.runes.reset_to(
230            usize::try_from(requested).expect("requested rune count fits usize"),
231            *last_sync,
232        );
233    }
234
235    let elapsed_s = f64::from(now.as_millis() - last_sync.as_millis()) / MS_PER_SECOND;
236    let (rate_multiplier, _) = effective_primary_regen(state, buf, 1.0, true);
237
238    let banked_before = u32::try_from(state.runtime.resources.runes.full_count())
239        .expect("full rune count fits u32");
240
241    state
242        .runtime
243        .resources
244        .runes
245        .advance(elapsed_s * rate_multiplier);
246    state.runtime.resources.runes.updated_at = now;
247
248    let mut banked = 0.0;
249
250    if let Some(resource) = buf.resource_mut(ResourceType::Runes) {
251        let full_count = u32::try_from(state.runtime.resources.runes.full_count())
252            .expect("full rune count fits u32");
253        let regenerating_count = u32::try_from(state.runtime.resources.runes.regenerating_count())
254            .expect("regenerating rune count fits u32");
255
256        resource.max = f64::from(rune_count);
257        resource.current = f64::from(full_count);
258        resource.regen_per_sec =
259            f64::from(regenerating_count) * rate_multiplier / crate::state::RUNE_RECHARGE_SECONDS;
260        banked = f64::from(full_count.saturating_sub(banked_before));
261    }
262
263    emit_passive_regen(state, buf, now, sink, banked, 0.0);
264
265    *last_sync = now;
266}
267
268pub(crate) fn rune_time_to_regen(
269    state: &CombatState,
270    buf: &DenseBuffer,
271    needed: usize,
272) -> Option<f64> {
273    let (rate_multiplier, _) = effective_primary_regen(state, buf, 1.0, true);
274
275    state
276        .runtime
277        .resources
278        .runes
279        .time_to_regen(needed, rate_multiplier)
280}
281
282#[inline]
283const fn resource_type(state: &CombatState, secondary: bool) -> Option<ResourceType> {
284    if secondary {
285        state.config.base_stats.secondary_resource_type
286    } else {
287        state.config.base_stats.resource_type
288    }
289}
290
291pub(crate) fn resource_pool_for_type(
292    state: &CombatState,
293    resource_type: ResourceType,
294) -> Option<bool> {
295    if state.config.base_stats.resource_type == Some(resource_type) {
296        Some(false)
297    } else if state.config.base_stats.secondary_resource_type == Some(resource_type) {
298        Some(true)
299    } else {
300        None
301    }
302}
303
304fn spend_resource_impl(
305    state: &mut CombatState,
306    buf: &mut DenseBuffer,
307    amount: f64,
308    secondary: bool,
309) -> bool {
310    if let Some(rt) = resource_type(state, secondary) {
311        if let Some(res) = buf.resource_mut(rt) {
312            if !res.spend(amount) {
313                return false;
314            }
315
316            if rt == ResourceType::Runes {
317                return state.runtime.resources.runes.consume(
318                    wowlab_types::numeric::f64_to_u32_saturating_round(amount.max(0.0)) as usize,
319                );
320            }
321
322            return true;
323        }
324    }
325
326    false
327}
328
329fn gain_resource_impl(
330    state: &mut CombatState,
331    buf: &mut DenseBuffer,
332    gain: ResourceGain,
333    secondary: bool,
334) -> (f64, f64) {
335    let amount = if secondary || gain.scaling == ResourceGainScaling::Unmodified {
336        gain.amount
337    } else {
338        gain.amount
339            * super::buffs::current_resource_gain_mult(
340                &crate::CombatView::new(state, buf),
341                gain.source,
342            )
343    };
344
345    tracing::trace!(?gain.source, base_amount = gain.amount, amount, secondary, "RESOURCE_GAIN");
346
347    if let Some(rt) = resource_type(state, secondary) {
348        if let Some(res) = buf.resource_mut(rt) {
349            let wasted = res.gain(amount);
350            let actual = amount - wasted;
351
352            if rt == ResourceType::Runes {
353                let gained = wowlab_types::numeric::f64_to_u32_saturating_round(actual.max(0.0));
354
355                state
356                    .runtime
357                    .resources
358                    .runes
359                    .fill(usize::try_from(gained).expect("gained rune count fits usize"));
360                let full_count = u32::try_from(state.runtime.resources.runes.full_count())
361                    .expect("full rune count fits u32");
362
363                res.current = f64::from(full_count);
364            }
365
366            return (actual, wasted);
367        }
368    }
369
370    (0.0, amount)
371}
372
373pub(crate) fn spend_resource(state: &mut CombatState, buf: &mut DenseBuffer, amount: f64) -> bool {
374    spend_resource_impl(state, buf, amount, false)
375}
376
377pub(crate) fn gain_resource_from(
378    state: &mut CombatState,
379    buf: &mut DenseBuffer,
380    gain: ResourceGain,
381) -> (f64, f64) {
382    gain_resource_impl(state, buf, gain, false)
383}
384
385pub(crate) fn spend_secondary_resource(
386    state: &mut CombatState,
387    buf: &mut DenseBuffer,
388    amount: f64,
389) -> bool {
390    spend_resource_impl(state, buf, amount, true)
391}
392
393pub(crate) fn secondary_spends_all_points(state: &CombatState) -> bool {
394    matches!(resource_type(state, true), Some(ResourceType::ComboPoints))
395}
396
397pub(crate) fn spend_all_secondary_resource(
398    state: &mut CombatState,
399    buf: &mut DenseBuffer,
400    cost: f64,
401) -> f64 {
402    if let Some(rt) = resource_type(state, true) {
403        if let Some(res) = buf.resource_mut(rt) {
404            let available = res.current;
405
406            if available >= cost && res.spend(available) {
407                return available;
408            }
409        }
410    }
411
412    0.0
413}
414
415pub(crate) fn gain_secondary_resource_from(
416    state: &mut CombatState,
417    buf: &mut DenseBuffer,
418    gain: ResourceGain,
419) -> (f64, f64) {
420    gain_resource_impl(state, buf, gain, true)
421}
422
423#[cfg(test)]
424#[path = "resources/tests.rs"]
425mod tests;