Skip to main content

wowlab_engine_combat/systems/
resources.rs

1//! Primary/secondary resource spend, gain, and haste-scaled regeneration.
2
3use wowlab_engine_telemetry::{RESOURCE_SOURCE_PASSIVE_REGEN, ResourceEventKind, TelemetrySink};
4use wowlab_types::{
5    combat::ResourceType,
6    constants::{HUNDRED, MS_PER_SECOND},
7    sim::{ActorId, SimTime},
8};
9
10use super::buffs::current_stat_haste_mult;
11#[cfg(feature = "rotation-optimizations")]
12use crate::state::runtime::RotationMutationDirection;
13use crate::state::{CombatInstance, ResourceGain, ResourceGainScaling};
14
15#[cfg(feature = "rotation-optimizations")]
16fn record_resource_current_transition(
17    state: &mut CombatInstance,
18    resource: ResourceType,
19    before: f64,
20    after: f64,
21    now: SimTime,
22) {
23    let direction = if after > before {
24        RotationMutationDirection::Rise
25    } else if after < before {
26        RotationMutationDirection::Fall
27    } else {
28        return;
29    };
30
31    state
32        .runtime
33        .rotation_mutations
34        .record_resource(resource, direction, now);
35}
36
37#[must_use]
38pub(crate) fn spell_health_cost(
39    state: &CombatInstance,
40    actor: ActorId,
41    flat: f64,
42    percent_of_maximum: f64,
43) -> f64 {
44    let maximum = state.friendly_actor_max_health(actor).unwrap_or(0.0);
45
46    flat.max(0.0) + maximum * percent_of_maximum.max(0.0) / HUNDRED
47}
48
49pub(crate) fn spend_health_cost(
50    state: &mut CombatInstance,
51    actor: ActorId,
52    flat: f64,
53    percent_of_maximum: f64,
54    _now: SimTime,
55) -> f64 {
56    let cost = spell_health_cost(state, actor, flat, percent_of_maximum);
57    let spent = state
58        .apply_friendly_actor_damage(actor, cost)
59        .unwrap_or(0.0);
60
61    #[cfg(feature = "rotation-optimizations")]
62    if actor == ActorId::Player && spent > 0.0 {
63        state.runtime.rotation_mutations.record_semantic(
64            crate::state::runtime::RotationSemanticMutation::PlayerHealth,
65            RotationMutationDirection::Fall,
66            _now,
67        );
68    }
69
70    spent
71}
72
73/// Override the primary resource's base regeneration and project its effective live rate.
74pub fn set_primary_resource_base_regen(
75    program: &crate::state::CombatProgram,
76    state: &mut CombatInstance,
77    base_regen: f64,
78    _now: SimTime,
79) {
80    let (effective_regen, _) = effective_primary_regen(program, state, base_regen, true);
81
82    state.runtime.resources.primary_base_regen_override = Some(base_regen);
83
84    if let Some(rt) = program.config.base_stats.resource_type
85        && let Some(res) = program.buffer_resource_mut(state, rt)
86    {
87        #[cfg(feature = "rotation-optimizations")]
88        let previous_regen = res.regen_per_sec;
89
90        res.regen_per_sec = effective_regen;
91
92        #[cfg(feature = "rotation-optimizations")]
93        if previous_regen.to_bits() != effective_regen.to_bits() {
94            state.runtime.rotation_mutations.record_resource(
95                rt,
96                RotationMutationDirection::Rise,
97                _now,
98            );
99            state.runtime.rotation_mutations.record_resource(
100                rt,
101                RotationMutationDirection::Fall,
102                _now,
103            );
104        }
105    }
106}
107
108fn effective_primary_regen(
109    program: &crate::state::CombatProgram,
110    state: &CombatInstance,
111    base_regen: f64,
112    haste_scaled: bool,
113) -> (f64, f64) {
114    let view = crate::CombatView::new(program, state);
115    let totals = view.buff_totals();
116    let mut flat_regen_per_second = 0.0;
117    let primary_type = program.config.base_stats.resource_type;
118    let mut interrupted = false;
119    let mut interrupted_percent = 0.0;
120    let mut prevented_mask = 0_u64;
121
122    super::buffs::for_each_planned_player_effect(
123        program,
124        state,
125        crate::state::EffectFamily::RESOURCE_REGENERATION,
126        |stacks, effect| match effect {
127            crate::BuffEffect::ResourceRegenPerFiveSeconds(value, resource_type) => {
128                if Some(*resource_type) == primary_type {
129                    flat_regen_per_second +=
130                        value * stacks / wowlab_engine_domain::dbc::POWER_REGEN_PERIOD_SECONDS;
131                }
132            }
133            crate::BuffEffect::InterruptRegen => interrupted = true,
134            crate::BuffEffect::InterruptedRegenPercent(value) => {
135                interrupted_percent += value * stacks;
136            }
137            crate::BuffEffect::PreventResourceRegen(mask) => prevented_mask |= mask,
138            _ => {}
139        },
140    );
141
142    let haste_multiplier = if haste_scaled {
143        current_stat_haste_mult(&view)
144    } else {
145        1.0
146    };
147    let scaled_base = base_regen * haste_multiplier * (1.0 + totals.regen_pct / HUNDRED);
148
149    let effective = apply_regen_interruptions(
150        scaled_base + flat_regen_per_second,
151        primary_type,
152        interrupted,
153        interrupted_percent,
154        prevented_mask,
155    );
156
157    (effective, totals.max_resource_flat)
158}
159
160/// Immediately project aura-dependent primary maximum and regeneration fields.
161pub(crate) fn refresh_primary_resource_projection(
162    program: &crate::state::CombatProgram,
163    state: &mut CombatInstance,
164    _now: SimTime,
165) {
166    if program.config.base_stats.resource_type == Some(ResourceType::Runes) {
167        return;
168    }
169
170    let dynamic_base_regen = state.runtime.resources.primary_base_regen_override;
171    let base_regen = dynamic_base_regen.unwrap_or(program.config.base_stats.base_regen);
172    let (effective_regen, max_resource_flat) = effective_primary_regen(
173        program,
174        state,
175        base_regen,
176        program.config.haste_regen || dynamic_base_regen.is_some(),
177    );
178
179    #[cfg(feature = "rotation-optimizations")]
180    let mut resource_transition = None;
181
182    if let Some(resource_type) = program.config.base_stats.resource_type
183        && let Some(resource) = program.buffer_resource_mut(state, resource_type)
184    {
185        #[cfg(feature = "rotation-optimizations")]
186        let current_before = resource.current;
187
188        resource.max = program.config.base_stats.resource_max + max_resource_flat;
189        resource.current = resource.current.min(resource.max);
190        resource.regen_per_sec = effective_regen;
191
192        #[cfg(feature = "rotation-optimizations")]
193        {
194            resource_transition = Some((resource_type, current_before, resource.current));
195        }
196    }
197
198    #[cfg(feature = "rotation-optimizations")]
199    if let Some((resource, before, after)) = resource_transition {
200        record_resource_current_transition(state, resource, before, after, _now);
201    }
202}
203
204fn apply_regen_interruptions(
205    rate: f64,
206    primary_type: Option<ResourceType>,
207    interrupted: bool,
208    interrupted_percent: f64,
209    prevented_mask: u64,
210) -> f64 {
211    let resource_prevented = primary_type.is_some_and(|resource_type| {
212        let bit = u32::from(u8::from(resource_type));
213
214        1_u64
215            .checked_shl(bit)
216            .is_some_and(|mask| prevented_mask & mask != 0)
217    });
218
219    if resource_prevented {
220        0.0
221    } else if interrupted {
222        let continuation = if primary_type == Some(ResourceType::Mana) {
223            interrupted_percent.max(0.0) / HUNDRED
224        } else {
225            0.0
226        };
227
228        rate * continuation
229    } else {
230        rate
231    }
232}
233
234// docref:start resources-sync-resource
235pub(crate) fn sync_resource(
236    program: &crate::state::CombatProgram,
237    state: &mut CombatInstance,
238    now: SimTime,
239    last_sync: &mut SimTime,
240    sink: &mut TelemetrySink,
241) {
242    if now <= *last_sync {
243        return;
244    }
245    // docref:end resources-sync-resource
246
247    if program.config.base_stats.resource_type == Some(ResourceType::Runes) {
248        sync_runes(program, state, now, last_sync, sink);
249
250        return;
251    }
252
253    let elapsed_s = f64::from(now.as_millis() - last_sync.as_millis()) / MS_PER_SECOND;
254
255    let mut gained = 0.0;
256    let mut wasted = 0.0;
257    #[cfg(feature = "rotation-optimizations")]
258    let mut resource_transition = None;
259
260    if let Some(rt) = program.config.base_stats.resource_type
261        && let Some(res) = program.buffer_resource_mut(state, rt)
262    {
263        #[cfg(feature = "rotation-optimizations")]
264        let current_before = res.current;
265
266        let uncapped = res.current + res.regen_per_sec * elapsed_s;
267        let capped = uncapped.min(res.max);
268
269        gained = capped - res.current;
270        wasted = uncapped - capped;
271        res.current = capped;
272
273        #[cfg(feature = "rotation-optimizations")]
274        {
275            resource_transition = Some((rt, current_before, res.current));
276        }
277    }
278
279    #[cfg(feature = "rotation-optimizations")]
280    if let Some((resource, before, after)) = resource_transition {
281        record_resource_current_transition(state, resource, before, after, now);
282    }
283
284    emit_passive_regen(program, state, now, sink, gained, wasted);
285
286    *last_sync = now;
287}
288
289/// Report one sync step's haste-scaled passive regeneration so resource-income views account for it.
290fn emit_passive_regen(
291    program: &crate::state::CombatProgram,
292    state: &CombatInstance,
293    now: SimTime,
294    sink: &mut TelemetrySink,
295    gained: f64,
296    wasted: f64,
297) {
298    if gained <= 0.0 && wasted <= 0.0 {
299        return;
300    }
301
302    super::procs::emit_resource(
303        program,
304        state,
305        sink,
306        super::procs::ResourceTelemetry {
307            kind: ResourceEventKind::Gain { wasted },
308            amount: gained,
309            secondary: false,
310            now,
311            source_spell_id: RESOURCE_SOURCE_PASSIVE_REGEN,
312        },
313    );
314}
315
316fn sync_runes(
317    program: &crate::state::CombatProgram,
318    state: &mut CombatInstance,
319    now: SimTime,
320    last_sync: &mut SimTime,
321    sink: &mut TelemetrySink,
322) {
323    let current = program
324        .buffer_resource(state, ResourceType::Runes)
325        .map_or(0.0, |resource| resource.current);
326    let rune_count = u32::try_from(crate::state::RUNE_COUNT).expect("rune count fits u32");
327    let represented = u32::try_from(state.runtime.resources.runes.full_count())
328        .expect("full rune count fits u32");
329
330    if (current - f64::from(represented)).abs() > f64::EPSILON {
331        let requested = wowlab_types::numeric::f64_to_u32_saturating_round(current).min(rune_count);
332
333        state.runtime.resources.runes.reset_to(
334            usize::try_from(requested).expect("requested rune count fits usize"),
335            *last_sync,
336        );
337    }
338
339    let elapsed_s = f64::from(now.as_millis() - last_sync.as_millis()) / MS_PER_SECOND;
340    let (rate_multiplier, _) = effective_primary_regen(program, state, 1.0, true);
341
342    let banked_before = u32::try_from(state.runtime.resources.runes.full_count())
343        .expect("full rune count fits u32");
344
345    state
346        .runtime
347        .resources
348        .runes
349        .advance(elapsed_s * rate_multiplier);
350    state.runtime.resources.runes.updated_at = now;
351
352    let mut banked = 0.0;
353
354    if let Some(handle) = program.resource_binding(ResourceType::Runes) {
355        let full_count = u32::try_from(state.runtime.resources.runes.full_count())
356            .expect("full rune count fits u32");
357        let regenerating_count = u32::try_from(state.runtime.resources.runes.regenerating_count())
358            .expect("regenerating rune count fits u32");
359        let resource = state.buffer.resource_mut_at(handle);
360
361        resource.max = f64::from(rune_count);
362        resource.current = f64::from(full_count);
363        resource.regen_per_sec =
364            f64::from(regenerating_count) * rate_multiplier / crate::state::RUNE_RECHARGE_SECONDS;
365        banked = f64::from(full_count.saturating_sub(banked_before));
366    }
367
368    #[cfg(feature = "rotation-optimizations")]
369    {
370        let current_after = program
371            .buffer_resource(state, ResourceType::Runes)
372            .map_or(0.0, |resource| resource.current);
373
374        record_resource_current_transition(state, ResourceType::Runes, current, current_after, now);
375    }
376
377    emit_passive_regen(program, state, now, sink, banked, 0.0);
378
379    *last_sync = now;
380}
381
382pub(crate) fn rune_time_to_regen(
383    program: &crate::state::CombatProgram,
384    state: &CombatInstance,
385    needed: usize,
386) -> Option<f64> {
387    let (rate_multiplier, _) = effective_primary_regen(program, state, 1.0, true);
388
389    state
390        .runtime
391        .resources
392        .runes
393        .time_to_regen(needed, rate_multiplier)
394}
395
396#[inline]
397const fn resource_type(
398    program: &crate::state::CombatProgram,
399    _state: &CombatInstance,
400    secondary: bool,
401) -> Option<ResourceType> {
402    if secondary {
403        program.config.base_stats.secondary_resource_type
404    } else {
405        program.config.base_stats.resource_type
406    }
407}
408
409pub(crate) fn resource_pool_for_type(
410    program: &crate::state::CombatProgram,
411    _state: &CombatInstance,
412    resource_type: ResourceType,
413) -> Option<bool> {
414    if program.config.base_stats.resource_type == Some(resource_type) {
415        Some(false)
416    } else if program.config.base_stats.secondary_resource_type == Some(resource_type) {
417        Some(true)
418    } else {
419        None
420    }
421}
422
423fn spend_resource_impl(
424    program: &crate::state::CombatProgram,
425    state: &mut CombatInstance,
426    amount: f64,
427    secondary: bool,
428    _now: SimTime,
429) -> bool {
430    if let Some(rt) = resource_type(program, state, secondary)
431        && let Some(handle) = program.resource_binding(rt)
432    {
433        let res = state.buffer.resource_mut_at(handle);
434
435        #[cfg(feature = "rotation-optimizations")]
436        let current_before = res.current;
437
438        if !res.spend(amount) {
439            return false;
440        }
441
442        #[cfg(feature = "rotation-optimizations")]
443        let current_after = res.current;
444
445        let spent = if rt == ResourceType::Runes {
446            state.runtime.resources.runes.consume(
447                wowlab_types::numeric::f64_to_u32_saturating_round(amount.max(0.0)) as usize,
448            )
449        } else {
450            true
451        };
452
453        #[cfg(feature = "rotation-optimizations")]
454        record_resource_current_transition(state, rt, current_before, current_after, _now);
455
456        return spent;
457    }
458
459    false
460}
461
462fn gain_resource_impl(
463    program: &crate::state::CombatProgram,
464    state: &mut CombatInstance,
465    gain: ResourceGain,
466    secondary: bool,
467    _now: SimTime,
468) -> (f64, f64) {
469    let amount = if secondary || gain.scaling == ResourceGainScaling::Unmodified {
470        gain.amount
471    } else {
472        gain.amount
473            * super::buffs::current_resource_gain_mult(
474                &crate::CombatView::new(program, state),
475                gain.source,
476            )
477    };
478
479    tracing::trace!(?gain.source, base_amount = gain.amount, amount, secondary, "RESOURCE_GAIN");
480
481    if let Some(rt) = resource_type(program, state, secondary)
482        && let Some(handle) = program.resource_binding(rt)
483    {
484        let res = state.buffer.resource_mut_at(handle);
485
486        #[cfg(feature = "rotation-optimizations")]
487        let current_before = res.current;
488
489        let wasted = res.gain(amount);
490        let actual = amount - wasted;
491
492        if rt == ResourceType::Runes {
493            let gained = wowlab_types::numeric::f64_to_u32_saturating_round(actual.max(0.0));
494
495            state
496                .runtime
497                .resources
498                .runes
499                .fill(usize::try_from(gained).expect("gained rune count fits usize"));
500            let full_count = u32::try_from(state.runtime.resources.runes.full_count())
501                .expect("full rune count fits u32");
502
503            res.current = f64::from(full_count);
504        }
505
506        #[cfg(feature = "rotation-optimizations")]
507        let current_after = res.current;
508
509        #[cfg(feature = "rotation-optimizations")]
510        record_resource_current_transition(state, rt, current_before, current_after, _now);
511
512        return (actual, wasted);
513    }
514
515    (0.0, amount)
516}
517
518pub(crate) fn spend_resource(
519    program: &crate::state::CombatProgram,
520    state: &mut CombatInstance,
521    amount: f64,
522    now: SimTime,
523) -> bool {
524    spend_resource_impl(program, state, amount, false, now)
525}
526
527pub(crate) fn gain_resource_from(
528    program: &crate::state::CombatProgram,
529    state: &mut CombatInstance,
530    gain: ResourceGain,
531    now: SimTime,
532) -> (f64, f64) {
533    gain_resource_impl(program, state, gain, false, now)
534}
535
536pub(crate) fn spend_secondary_resource(
537    program: &crate::state::CombatProgram,
538    state: &mut CombatInstance,
539    amount: f64,
540    now: SimTime,
541) -> bool {
542    spend_resource_impl(program, state, amount, true, now)
543}
544
545pub(crate) fn secondary_spends_all_points(
546    program: &crate::state::CombatProgram,
547    state: &CombatInstance,
548) -> bool {
549    matches!(
550        resource_type(program, state, true),
551        Some(ResourceType::ComboPoints)
552    )
553}
554
555pub(crate) fn spend_all_secondary_resource(
556    program: &crate::state::CombatProgram,
557    state: &mut CombatInstance,
558    cost: f64,
559    _now: SimTime,
560) -> f64 {
561    if let Some(rt) = resource_type(program, state, true)
562        && let Some(res) = program.buffer_resource_mut(state, rt)
563    {
564        let available = res.current;
565
566        if available >= cost && res.spend(available) {
567            #[cfg(feature = "rotation-optimizations")]
568            record_resource_current_transition(state, rt, available, 0.0, _now);
569
570            return available;
571        }
572    }
573
574    0.0
575}
576
577pub(crate) fn gain_secondary_resource_from(
578    program: &crate::state::CombatProgram,
579    state: &mut CombatInstance,
580    gain: ResourceGain,
581    now: SimTime,
582) -> (f64, f64) {
583    gain_resource_impl(program, state, gain, true, now)
584}
585
586#[cfg(test)]
587#[path = "resources/tests.rs"]
588mod tests;