Skip to main content

wowlab_engine_combat/systems/
pet_actions.rs

1//! Persistent-pet resource, priority-list, and global-cooldown scheduling.
2
3use wowlab_engine_ports::Event;
4#[cfg(test)]
5use wowlab_engine_telemetry::TelemetrySink;
6use wowlab_types::{
7    constants::{HUNDRED, MS_PER_SECOND},
8    sim::{ActorId, PetIdx, SimTime},
9};
10
11use super::{actor_buff_totals, is_aura_active};
12use crate::{
13    DamageFlags,
14    context::{CombatCtx, HookCtx},
15    state::{
16        CombatInstance, PetAbilityCondition, PetActionState, PetOwnerCoefficients, PetStatSnapshot,
17    },
18};
19
20pub(crate) const PROC_HEARTBEAT_INITIAL_MIN_MS: u32 = 1;
21pub(crate) const PROC_HEARTBEAT_INITIAL_MAX_EXCLUSIVE_MS: u32 = 5_249;
22pub(crate) const PROC_HEARTBEAT_MEAN_MS: f64 = 5_250.0;
23pub(crate) const PROC_HEARTBEAT_STANDARD_DEVIATION_MS: f64 = 25.0;
24
25pub(crate) fn snapshot_owner_stats(
26    program: &crate::state::CombatProgram,
27    state: &CombatInstance,
28    coefficients: PetOwnerCoefficients,
29) -> PetStatSnapshot {
30    let view = crate::CombatView::new(program, state);
31    let totals = view.buff_totals();
32    let (attack_power, spell_power) =
33        super::damage_pipeline::dynamic_base_powers(program, state, &totals);
34    let owner_health = state
35        .friendly_actor_max_health(ActorId::Player)
36        .unwrap_or(0.0);
37
38    PetStatSnapshot {
39        attack_power: attack_power * coefficients.attack_power_from_attack_power
40            + spell_power * coefficients.attack_power_from_spell_power,
41        spell_power: attack_power * coefficients.spell_power_from_attack_power
42            + spell_power * coefficients.spell_power_from_spell_power,
43        armor: state.buffer.player().armor * coefficients.armor,
44        health: owner_health * coefficients.health,
45    }
46}
47
48fn apply_snapshot_health(state: &mut CombatInstance, actor: ActorId, snapshot: PetStatSnapshot) {
49    let _ = state.activate_pet_health(actor, snapshot.health);
50}
51
52pub(crate) fn refresh_pet_stat_snapshots(
53    program: &crate::state::CombatProgram,
54    state: &mut CombatInstance,
55) {
56    for index in program.pet_action_plans.iter().copied().map(usize::from) {
57        let Some(bar) = program
58            .auto_attacks
59            .get(index)
60            .expect("bounded auto-attack index")
61            .pet_action_bar
62        else {
63            continue;
64        };
65        let snapshot = snapshot_owner_stats(program, state, bar.owner_coefficients);
66
67        if let Some(runtime) = state
68            .runtime
69            .companions
70            .pet_actions
71            .get_mut(index)
72            .and_then(Option::as_mut)
73        {
74            runtime.stat_snapshot = snapshot;
75        }
76
77        apply_snapshot_health(state, ActorId::Pet(PetIdx::PRIMARY), snapshot);
78    }
79
80    for visit in 0..state.runtime.companions.active_guardian_slots.len() {
81        // BOUNDS: `visit` is bounded by the active-slot slice length.
82        let index = usize::from(state.runtime.companions.active_guardian_slots[visit]);
83        let Some((source, coefficients)) = state
84            .runtime
85            .companions
86            .guardians
87            .get(index)
88            .and_then(Option::as_ref)
89            .map(|guardian| (guardian.source, guardian.spec.owner_coefficients))
90        else {
91            continue;
92        };
93        let snapshot = snapshot_owner_stats(program, state, coefficients);
94
95        if let Some(guardian) = state
96            .runtime
97            .companions
98            .guardians
99            .get_mut(index)
100            .and_then(Option::as_mut)
101        {
102            guardian.stat_snapshot = snapshot;
103        }
104
105        apply_snapshot_health(state, source, snapshot);
106    }
107}
108
109pub(crate) fn pet_stat_snapshot(
110    program: &crate::state::CombatProgram,
111    state: &CombatInstance,
112    source: ActorId,
113    _npc_id: Option<u32>,
114) -> Option<PetStatSnapshot> {
115    let ActorId::Pet(pet) = source else {
116        return None;
117    };
118
119    if pet.is_primary() {
120        let index = usize::from(program.primary_pet_action_auto?);
121
122        return state
123            .runtime
124            .companions
125            .pet_actions
126            .get(index)?
127            .as_ref()
128            .map(|runtime| runtime.stat_snapshot);
129    }
130
131    let slot = pet.as_usize().checked_sub(1)?;
132
133    state
134        .runtime
135        .companions
136        .guardians
137        .get(slot)?
138        .as_ref()
139        .filter(|guardian| guardian.source == source)
140        .map(|guardian| guardian.stat_snapshot)
141}
142
143pub(crate) fn ensure_initial_proc_heartbeat(
144    state: &mut CombatInstance,
145    now: SimTime,
146    rng: &mut wowlab_engine_rng::SimRng,
147) {
148    if state.runtime.procs.proc_heartbeat_scheduled_at.is_some() {
149        return;
150    }
151
152    let delay = rng.range_u32(
153        PROC_HEARTBEAT_INITIAL_MIN_MS,
154        PROC_HEARTBEAT_INITIAL_MAX_EXCLUSIVE_MS,
155    );
156
157    schedule_proc_heartbeat(state, now, delay);
158}
159
160pub(crate) fn ensure_recurring_proc_heartbeat(
161    state: &mut CombatInstance,
162    now: SimTime,
163    rng: &mut wowlab_engine_rng::SimRng,
164) {
165    if state.runtime.procs.proc_heartbeat_scheduled_at.is_some() {
166        return;
167    }
168
169    let sampled = rng.gaussian(PROC_HEARTBEAT_MEAN_MS, PROC_HEARTBEAT_STANDARD_DEVIATION_MS);
170    let delay = wowlab_types::numeric::f64_to_u32_saturating_round(sampled).max(1);
171
172    schedule_proc_heartbeat(state, now, delay);
173}
174
175fn schedule_proc_heartbeat(state: &mut CombatInstance, now: SimTime, delay_ms: u32) {
176    let at = now.saturating_add(SimTime::from_millis(delay_ms));
177
178    state.runtime.procs.proc_heartbeat_scheduled_at = Some(at);
179    state.schedule(Event::ProcHeartbeat { t: at });
180}
181
182pub(crate) fn begin_proc_heartbeat(
183    _program: &crate::state::CombatProgram,
184    state: &mut CombatInstance,
185    now: SimTime,
186) -> bool {
187    if state.runtime.procs.proc_heartbeat_scheduled_at != Some(now) {
188        return false;
189    }
190
191    state.runtime.procs.proc_heartbeat_scheduled_at = None;
192
193    true
194}
195
196fn condition_matches(
197    program: &crate::state::CombatProgram,
198    state: &CombatInstance,
199    condition: PetAbilityCondition,
200) -> bool {
201    match condition {
202        PetAbilityCondition::Always => true,
203        PetAbilityCondition::OwnerAuraActive(aura) => is_aura_active(
204            &crate::context::CombatView::new(program, state),
205            aura,
206            ActorId::Player,
207            state.current_target(),
208        ),
209        PetAbilityCondition::OwnerAuraInactive(aura) => !is_aura_active(
210            &crate::context::CombatView::new(program, state),
211            aura,
212            ActorId::Player,
213            state.current_target(),
214        ),
215    }
216}
217
218fn sync_resource(runtime: &mut PetActionState, resource_max: f64, now: SimTime) {
219    let elapsed = now
220        .as_secs_f64()
221        .max(runtime.resource_updated_at.as_secs_f64())
222        - runtime.resource_updated_at.as_secs_f64();
223
224    runtime.resource = (runtime.resource + elapsed * runtime.regen_per_second).min(resource_max);
225    runtime.resource_updated_at = now;
226}
227
228fn resource_ready_at(now: SimTime, current: f64, required: f64, regen: f64) -> Option<SimTime> {
229    if current >= required {
230        return Some(now);
231    }
232
233    if regen <= 0.0 {
234        return None;
235    }
236
237    let wait_ms = wowlab_types::numeric::f64_to_u32_saturating_ceil(
238        (required - current) / regen * MS_PER_SECOND,
239    );
240
241    Some(now.saturating_add(SimTime::from_millis(wait_ms.max(1))))
242}
243
244fn schedule_action(
245    program: &crate::state::CombatProgram,
246    state: &mut CombatInstance,
247    auto_attack_index: usize,
248    at: SimTime,
249) {
250    let Some(target) = state.current_target() else {
251        return;
252    };
253    let Some(runtime) = state
254        .runtime
255        .companions
256        .pet_actions
257        .get_mut(auto_attack_index)
258    else {
259        return;
260    };
261    let Some(runtime) = runtime.as_mut() else {
262        return;
263    };
264    let at = at.max(runtime.gcd_ready_at);
265
266    if runtime.next_action_at == Some(at) {
267        return;
268    }
269
270    runtime.next_action_at = Some(at);
271    state.schedule(Event::PetAction {
272        t: at,
273        action: program.runtime_events.pet_action(
274            // #t(rust_unchecked_indexing) scheduler receives an index from the finalized pet-plan slice.
275            program.pet_action_plan_by_auto[auto_attack_index]
276                .expect("scheduled auto attack has a finalized pet plan"),
277        ),
278        source: ActorId::Pet(PetIdx::PRIMARY),
279        target,
280    });
281}
282
283fn pet_regen_per_second(
284    program: &crate::state::CombatProgram,
285    state: &CombatInstance,
286    base_regen_per_second: f64,
287) -> f64 {
288    let pet = ActorId::Pet(PetIdx::PRIMARY);
289    let buffs = actor_buff_totals(
290        crate::context::ActorView::new(program, state, pet),
291        state.current_target(),
292    );
293
294    base_regen_per_second * (1.0 + buffs.regen_pct / HUNDRED).max(0.0)
295}
296
297fn next_decision_at(
298    program: &crate::state::CombatProgram,
299    state: &CombatInstance,
300    bar: crate::state::PetActionBar,
301    runtime: PetActionState,
302    now: SimTime,
303) -> Option<SimTime> {
304    let ready_at = bar
305        .abilities
306        .iter()
307        .filter(|ability| condition_matches(program, state, ability.condition))
308        .filter_map(|ability| {
309            resource_ready_at(
310                now,
311                runtime.resource,
312                ability.minimum_resource,
313                runtime.regen_per_second,
314            )
315        })
316        .min()?;
317
318    Some(ready_at.max(runtime.gcd_ready_at))
319}
320
321pub(crate) fn initialize_pet_actions(
322    program: &crate::state::CombatProgram,
323    state: &mut CombatInstance,
324) {
325    debug_assert_eq!(
326        state.runtime.companions.pet_actions.len(),
327        program.auto_attacks.len(),
328        "pet action runtime length must match the finalized auto-attack plan"
329    );
330
331    for index in program.pet_action_plans.iter().copied().map(usize::from) {
332        let runtime = program
333            .auto_attacks
334            .get(index)
335            .expect("bounded auto-attack index")
336            .pet_action_bar
337            .map(|bar| PetActionState {
338                resource: bar.resource_initial.min(bar.resource_max),
339                regen_per_second: pet_regen_per_second(
340                    program,
341                    state,
342                    bar.resource_regen_per_second,
343                ),
344                resource_updated_at: SimTime::ZERO,
345                gcd_ready_at: SimTime::ZERO,
346                next_action_at: None,
347                stat_snapshot: snapshot_owner_stats(program, state, bar.owner_coefficients),
348            });
349
350        *state
351            .runtime
352            .companions
353            .pet_actions
354            .get_mut(index)
355            .expect("finalized pet-action index") = runtime;
356
357        if runtime.is_some() {
358            schedule_action(program, state, index, SimTime::ZERO);
359        }
360    }
361
362    refresh_pet_stat_snapshots(program, state);
363}
364
365pub(crate) fn refresh_pet_actions_after_aura_change(
366    program: &crate::state::CombatProgram,
367    state: &mut CombatInstance,
368    now: SimTime,
369) {
370    for index in program.pet_action_plans.iter().copied().map(usize::from) {
371        // BOUNDS: index is produced from this same auto_attacks length.
372        let Some(bar) = program.auto_attacks[index].pet_action_bar else {
373            continue;
374        };
375        let regen = pet_regen_per_second(program, state, bar.resource_regen_per_second);
376        let Some(runtime) = state
377            .runtime
378            .companions
379            .pet_actions
380            .get_mut(index)
381            .and_then(Option::as_mut)
382        else {
383            continue;
384        };
385
386        sync_resource(runtime, bar.resource_max, now);
387        runtime.regen_per_second = regen;
388        runtime.next_action_at = None;
389        let runtime = *runtime;
390
391        if let Some(next) = next_decision_at(program, state, bar, runtime, now) {
392            schedule_action(program, state, index, next);
393        }
394    }
395}
396
397pub(crate) fn gain_pet_resource(
398    program: &crate::state::CombatProgram,
399    state: &mut CombatInstance,
400    binding: crate::PetResourceBinding,
401    amount: f64,
402    now: SimTime,
403) -> f64 {
404    if amount <= 0.0 {
405        return 0.0;
406    }
407
408    if !binding.belongs_to(program.spec_identity_brand.unwrap_or(0)) {
409        return 0.0;
410    }
411
412    let index = usize::from(binding.auto_attack());
413    let Some(bar) = program
414        .auto_attacks
415        .get(index)
416        .and_then(|attack| attack.pet_action_bar)
417    else {
418        return 0.0;
419    };
420    let Some(runtime) = state
421        .runtime
422        .companions
423        .pet_actions
424        .get_mut(index)
425        .and_then(Option::as_mut)
426    else {
427        return 0.0;
428    };
429
430    sync_resource(runtime, bar.resource_max, now);
431    let before = runtime.resource;
432
433    runtime.resource = (runtime.resource + amount).min(bar.resource_max);
434    runtime.next_action_at = None;
435
436    let gained = runtime.resource - before;
437    let runtime = *runtime;
438
439    if let Some(next) = next_decision_at(program, state, bar, runtime, now) {
440        schedule_action(program, state, index, next);
441    }
442
443    gained
444}
445
446pub(crate) fn process_pet_action(ctx: &mut CombatCtx<'_>, auto_attack_index: usize) {
447    let now = ctx.now;
448    let program = ctx.program;
449    let state = &mut *ctx.instance;
450    let Some(attack) = program.auto_attacks.get(auto_attack_index).copied() else {
451        return;
452    };
453    let Some(bar) = attack.pet_action_bar else {
454        return;
455    };
456    let Some(auto_profile) = program.damage.auto_attack_profile(auto_attack_index) else {
457        return;
458    };
459    let Some(runtime) = state
460        .runtime
461        .companions
462        .pet_actions
463        .get_mut(auto_attack_index)
464        .and_then(Option::as_mut)
465    else {
466        return;
467    };
468
469    if runtime.next_action_at != Some(now) {
470        return;
471    }
472
473    runtime.next_action_at = None;
474    sync_resource(runtime, bar.resource_max, now);
475    let resource = runtime.resource;
476
477    let selected = bar.abilities.iter().position(|ability| {
478        resource >= ability.minimum_resource && condition_matches(program, state, ability.condition)
479    });
480
481    if let Some(ability_index) = selected {
482        // BOUNDS: ability_index comes from position() over bar.abilities.
483        let ability = bar.abilities[ability_index];
484
485        if let Some(runtime) = state
486            .runtime
487            .companions
488            .pet_actions
489            .get_mut(auto_attack_index)
490            .and_then(Option::as_mut)
491        {
492            runtime.resource = (runtime.resource - ability.cost).max(0.0);
493            let gcd_ms = ability.gcd_ms.unwrap_or(bar.gcd_ms);
494
495            runtime.gcd_ready_at = now.saturating_add(SimTime::from_millis(gcd_ms.max(1)));
496        }
497
498        let mut hook = HookCtx::new(
499            crate::context::HookCtxServices {
500                program,
501                instance: state,
502                sink: ctx.sink,
503                rng: ctx.rng,
504            },
505            crate::context::HookCtxRequest::for_target(now, ctx.target)
506                .with_source(ActorId::Pet(PetIdx::PRIMARY)),
507        )
508        .with_source_damage_flags(DamageFlags::PET)
509        .with_source_npc_id(attack.npc_id)
510        .with_pet_stat_inheritance(auto_profile.stat_inheritance);
511
512        (ability.action)(&mut hook);
513        schedule_action(program, state, auto_attack_index, now);
514
515        return;
516    }
517
518    // BOUNDS: auto_attack_index was validated against the corresponding auto-attack definition and runtime table above.
519
520    let runtime = state.runtime.companions.pet_actions[auto_attack_index]
521        .expect("configured pet action bar has runtime state");
522    let next = next_decision_at(program, state, bar, runtime, now);
523
524    if let Some(next) = next {
525        schedule_action(program, state, auto_attack_index, next);
526    }
527}
528
529#[cfg(test)]
530#[path = "pet_actions/tests.rs"]
531mod tests;