1use 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 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 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 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 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 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;