1use 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
73pub 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
160pub(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
234pub(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 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
289fn 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;