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