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