1use wowlab_engine_ports::Event;
4use wowlab_engine_telemetry::TelemetrySink;
5use wowlab_types::sim::{ActorId, SimTime};
6
7use super::buffs::current_haste_mult_for;
8use crate::{
9 DamageFlags,
10 context::{ActorView, CombatCtx, HookCtx},
11 state::{
12 CombatInstance, GuardianAbilityState, GuardianEvent, GuardianHandle, GuardianInstance,
13 GuardianSpec, GuardianSummonError,
14 },
15};
16
17pub(crate) fn reproject_guardian_action_speeds(
18 program: &crate::state::CombatProgram,
19 state: &mut CombatInstance,
20 now: SimTime,
21) {
22 for visit in 0..state.runtime.companions.active_guardian_slots.len() {
23 wowlab_types::bench_metrics::record_planned_guardian_slot_visit();
24 let guardian_index = usize::from(state.runtime.companions.active_guardian_slots[visit]);
26 let ability_count = state
27 .runtime
28 .companions
29 .guardians
30 .get(guardian_index)
31 .and_then(Option::as_ref)
32 .map_or(0, |guardian| guardian.spec.ability_count());
33
34 for ability_index in 0..ability_count {
35 let Some((handle, source, target, expires_at, definition, runtime)) = state
36 .runtime
37 .companions
38 .guardians
39 .get(guardian_index)
40 .and_then(Option::as_ref)
41 .and_then(|guardian| {
42 Some((
43 guardian.handle,
44 guardian.source,
45 guardian.target,
46 guardian.expires_at,
47 *guardian.spec.abilities().get(ability_index)?,
48 *guardian.abilities.get(ability_index)?,
49 ))
50 })
51 else {
52 continue;
53 };
54
55 if runtime
56 .next_action_at
57 .is_none_or(|scheduled| scheduled <= now)
58 {
59 continue;
60 }
61
62 let (base_delay, hasted) = if runtime.actions_executed == 0 {
63 (
64 definition.first_action_delay_ms,
65 definition.hasted_first_action,
66 )
67 } else {
68 (
69 definition.action_interval_ms,
70 definition.hasted_action_interval,
71 )
72 };
73
74 if !hasted {
75 continue;
76 }
77
78 let delay =
79 scaled_delay_ms(ActorView::new(program, state, source), base_delay, hasted).max(1);
80 let next = now.saturating_add(SimTime::from_millis(delay));
81
82 if next > expires_at {
83 continue;
84 }
85
86 if let Some(guardian) = state
87 .runtime
88 .companions
89 .guardians
90 .get_mut(guardian_index)
91 .and_then(Option::as_mut)
92 && guardian.handle == handle
93 && let Some(ability) = guardian.abilities.get_mut(ability_index)
94 {
95 ability.next_action_at = Some(next);
96 }
97
98 state.schedule(Event::GuardianAction {
99 t: next,
100 guardian: program.runtime_events.guardian(
101 handle.0,
102 handle.1,
103 u16::try_from(ability_index).ok(),
104 ),
105 source,
106 target,
107 });
108 }
109 }
110}
111
112fn scaled_delay_ms(view: ActorView<'_>, delay_ms: u32, hasted: bool) -> u32 {
113 if delay_ms == 0 || !hasted {
114 return delay_ms;
115 }
116
117 wowlab_types::numeric::f64_to_u32_saturating_round(
118 f64::from(delay_ms) / current_haste_mult_for(view),
119 )
120 .max(1)
121}
122
123pub(crate) fn summon_guardian(
124 program: &crate::state::CombatProgram,
125 state: &mut CombatInstance,
126 spec: &GuardianSpec,
127 now: SimTime,
128) -> Result<GuardianHandle, GuardianSummonError> {
129 let Some(target) = state.current_target() else {
130 return Ok(GuardianHandle::new(0, 0));
131 };
132 let handle = allocate_guardian_handle(state)?;
133 let Some(source) = handle.actor() else {
134 return Err(GuardianSummonError::saturated());
135 };
136 let stat_snapshot =
137 super::pet_actions::snapshot_owner_stats(program, state, spec.owner_coefficients);
138 let expires_at = now.saturating_add(SimTime::from_millis(spec.duration_ms));
139 let mut abilities = [GuardianAbilityState {
140 next_action_at: None,
141 actions_executed: 0,
142 }; crate::state::defs::guardians::MAX_GUARDIAN_ABILITIES];
143
144 for (ability_state, ability) in abilities.iter_mut().zip(spec.abilities()) {
145 let first_delay = scaled_delay_ms(
146 ActorView::new(program, state, source),
147 ability.first_action_delay_ms,
148 ability.hasted_first_action,
149 );
150
151 *ability_state = GuardianAbilityState {
152 next_action_at: ability
153 .self_scheduled
154 .then(|| now.saturating_add(SimTime::from_millis(first_delay))),
155 actions_executed: 0,
156 };
157 }
158
159 let tag = spec.tag;
160
161 let guardian = GuardianInstance {
162 handle,
163 spec: *spec,
164 spawned_at: now,
165 expires_at,
166 abilities,
167 source,
168 target,
169 stat_snapshot,
170 };
171 let slot = handle.0 as usize;
172
173 if let Some(entry) = state.runtime.companions.guardians.get_mut(slot) {
174 *entry = Some(guardian);
175 }
176
177 let Ok(active_slot) = u16::try_from(slot) else {
178 return Err(GuardianSummonError::saturated());
179 };
180
181 state
182 .runtime
183 .companions
184 .active_guardian_slots
185 .push(active_slot);
186
187 super::pet_actions::refresh_pet_stat_snapshots(program, state);
188 let ability_count = state
189 .runtime
190 .companions
191 .guardians
192 .get(handle.0 as usize)
193 .and_then(Option::as_ref)
194 .map_or(0, |guardian| guardian.spec.ability_count());
195
196 for ability_index in 0..ability_count {
197 let next = state
198 .runtime
199 .companions
200 .guardians
201 .get(handle.0 as usize)
202 .and_then(Option::as_ref)
203 .and_then(|guardian| {
204 guardian
205 .abilities
206 .get(ability_index)
207 .and_then(|ability| ability.next_action_at)
208 });
209
210 if let Some(t) = next {
211 state.schedule(Event::GuardianAction {
212 t,
213 guardian: program.runtime_events.guardian(
214 handle.0,
215 handle.1,
216 u16::try_from(ability_index).ok(),
217 ),
218 source,
219 target,
220 });
221 }
222 }
223
224 state.schedule(Event::GuardianExpire {
225 t: expires_at,
226 guardian: program.runtime_events.guardian(handle.0, handle.1, None),
227 });
228 tracing::trace!(
229 tag,
230 slot = handle.0,
231 now_ms = now.as_millis(),
232 expires_at_ms = expires_at.as_millis(),
233 active = active_guardian_count(state, tag),
234 "guardian summoned"
235 );
236
237 Ok(handle)
238}
239
240fn allocate_guardian_handle(
241 state: &mut CombatInstance,
242) -> Result<GuardianHandle, GuardianSummonError> {
243 let slot = state
244 .runtime
245 .companions
246 .free_guardian_slots
247 .pop()
248 .map(usize::from)
249 .ok_or_else(GuardianSummonError::saturated)?;
250
251 let Some(guardian) = state.runtime.companions.guardians.get(slot) else {
252 return Err(GuardianSummonError::saturated());
253 };
254
255 assert!(
256 guardian.is_none(),
257 "free guardian slot {slot} must not contain a live guardian"
258 );
259
260 let Some(generation) = state.runtime.companions.guardian_generations.get_mut(slot) else {
261 return Err(GuardianSummonError::saturated());
262 };
263
264 *generation = generation.wrapping_add(1).max(1);
265 let generation = *generation;
266 let Ok(slot) = u32::try_from(slot) else {
267 return Err(GuardianSummonError::saturated());
268 };
269
270 Ok(GuardianHandle::new(slot, generation))
271}
272
273pub(crate) fn active_guardian_count(state: &CombatInstance, tag: u32) -> usize {
274 state
275 .runtime
276 .companions
277 .active_guardian_slots
278 .iter()
279 .filter_map(|slot| {
280 state.runtime.companions.guardians[usize::from(*slot)].as_ref()
282 })
283 .filter(|guardian| guardian.spec.tag == tag)
284 .count()
285}
286
287pub(crate) fn command_guardians(ctx: &mut CombatCtx<'_>, tag: u32, ability_index: usize) -> usize {
289 #[derive(Clone, Copy)]
290 struct Command {
291 ability: crate::state::GuardianAbility,
292 event: GuardianEvent,
293 source: ActorId,
294 target: wowlab_types::sim::EnemyIdx,
295 npc_id: Option<u32>,
296 stat_inheritance: crate::state::PetStatInheritance,
297 }
298
299 let mut commands =
300 Vec::with_capacity(ctx.instance.runtime.companions.active_guardian_slots.len());
301
302 for slot in &ctx.instance.runtime.companions.active_guardian_slots {
303 let guardian = ctx.instance.runtime.companions.guardians[usize::from(*slot)]
305 .as_ref()
306 .expect("active guardian plan references a live slot");
307
308 if guardian.spec.tag != tag {
309 continue;
310 }
311
312 let Some(ability) = guardian.spec.abilities().get(ability_index).copied() else {
313 continue;
314 };
315
316 commands.push(Command {
317 ability,
318 event: event_for(guardian, ability_index, 0),
319 source: guardian.source,
320 target: guardian.target,
321 npc_id: guardian.spec.npc_id,
322 stat_inheritance: guardian.spec.stat_inheritance,
323 });
324 }
325
326 let count = commands.len();
327
328 for command in commands {
329 let mut hook = HookCtx::new(
330 crate::context::HookCtxServices {
331 program: ctx.program,
332 instance: ctx.instance,
333 sink: ctx.sink,
334 rng: ctx.rng,
335 },
336 crate::context::HookCtxRequest::for_target(ctx.now, command.target)
337 .with_source(command.source),
338 )
339 .with_source_damage_flags(DamageFlags::PET | DamageFlags::GUARDIAN)
340 .with_source_npc_id(command.npc_id)
341 .with_pet_stat_inheritance(command.stat_inheritance);
342
343 (command.ability.action)(&mut hook, command.event);
344 }
345
346 count
347}
348
349pub(crate) fn guardian_combat_coordinates(
350 state: &CombatInstance,
351 handle: GuardianHandle,
352) -> Option<(ActorId, wowlab_types::sim::EnemyIdx)> {
353 state
354 .runtime
355 .companions
356 .guardians
357 .get(handle.0 as usize)?
358 .as_ref()
359 .filter(|guardian| guardian.handle == handle)
360 .map(|guardian| (guardian.source, guardian.target))
361}
362
363pub(crate) fn extend_guardians(
364 _program: &crate::state::CombatProgram,
365 state: &mut CombatInstance,
366 tag: u32,
367 amount_ms: u32,
368) {
369 let extension = SimTime::from_millis(amount_ms);
370
371 for visit in 0..state.runtime.companions.active_guardian_slots.len() {
372 let slot = usize::from(state.runtime.companions.active_guardian_slots[visit]);
374 let guardian = state.runtime.companions.guardians[slot]
376 .as_mut()
377 .expect("active guardian plan references a live slot");
378
379 if guardian.spec.tag == tag {
380 guardian.expires_at = guardian.expires_at.saturating_add(extension);
381 }
382 }
383}
384
385pub(crate) fn dismiss_guardians(
387 _program: &crate::state::CombatProgram,
388 state: &mut CombatInstance,
389 tag: u32,
390 count: usize,
391) -> usize {
392 let mut removed = 0;
393
394 let mut visit = 0;
395
396 while visit < state.runtime.companions.active_guardian_slots.len() {
397 if removed >= count {
398 break;
399 }
400
401 let index = usize::from(state.runtime.companions.active_guardian_slots[visit]);
404
405 let should_remove = state
406 .runtime
407 .companions
408 .guardians
409 .get(index)
410 .and_then(Option::as_ref)
411 .is_some_and(|guardian| guardian.spec.tag == tag);
412
413 if should_remove {
414 let guardian = state
415 .runtime
416 .companions
417 .guardians
418 .get_mut(index)
419 .and_then(Option::take)
420 .expect("matching guardian slot");
421
422 let _ = state.deactivate_pet_health(guardian.source);
423
424 deactivate_guardian_slot(state, index);
425
426 removed += 1;
427 } else {
428 visit += 1;
429 }
430 }
431
432 removed
433}
434
435const fn event_for(
436 guardian: &GuardianInstance,
437 ability_index: usize,
438 action_index: u16,
439) -> GuardianEvent {
440 GuardianEvent {
441 handle: guardian.handle,
442 tag: guardian.spec.tag,
443 ability_index,
444 action_index,
445 spawned_at: guardian.spawned_at,
446 expires_at: guardian.expires_at,
447 }
448}
449
450fn fire_demise(ctx: &mut HookCtx<'_>, guardian: &GuardianInstance) {
451 let Some(hook) = guardian.spec.on_demise else {
452 return;
453 };
454
455 hook(ctx, event_for(guardian, 0, 0));
456}
457
458struct PreparedGuardianAction {
460 ability: crate::state::GuardianAbility,
461 event: GuardianEvent,
462 source: ActorId,
463 target: wowlab_types::sim::EnemyIdx,
464 npc_id: Option<u32>,
465 stat_inheritance: crate::state::PetStatInheritance,
466}
467
468#[derive(Clone, Copy)]
469struct GuardianActionKey {
470 handle: GuardianHandle,
471 ability_index: usize,
472 now: SimTime,
473}
474
475fn restore_guardian(state: &mut CombatInstance, guardian: GuardianInstance) {
476 let handle = guardian.handle;
477 let generation_is_current = state
478 .runtime
479 .companions
480 .guardian_generations
481 .get(handle.0 as usize)
482 .is_some_and(|generation| *generation == handle.1);
483
484 if generation_is_current
485 && let Some(slot) = state
486 .runtime
487 .companions
488 .guardians
489 .get_mut(handle.0 as usize)
490 && slot.is_none()
491 {
492 *slot = Some(guardian);
493 }
494}
495
496fn prepare_guardian_action(
497 program: &crate::state::CombatProgram,
498 state: &mut CombatInstance,
499 handle: GuardianHandle,
500 ability_index: usize,
501 now: SimTime,
502) -> Option<PreparedGuardianAction> {
503 let slot = state
504 .runtime
505 .companions
506 .guardians
507 .get_mut(handle.0 as usize)?;
508
509 if slot.as_ref()?.handle != handle {
510 return None;
511 }
512
513 let mut guardian = slot.take()?;
514 let Some(ability) = guardian.spec.abilities().get(ability_index).copied() else {
515 restore_guardian(state, guardian);
516
517 return None;
518 };
519
520 if guardian
521 .abilities
522 .get(ability_index)
523 .is_none_or(|ability| ability.next_action_at != Some(now))
524 || now > guardian.expires_at
525 {
526 restore_guardian(state, guardian);
527
528 return None;
529 }
530
531 let target = if state.is_valid_target(guardian.target) {
532 guardian.target
533 } else {
534 state.retarget(program, now);
535 let Some(target) = state.current_target() else {
536 restore_guardian(state, guardian);
537
538 return None;
539 };
540
541 target
542 };
543
544 guardian.target = target;
545 let Some(ability_state) = guardian.abilities.get_mut(ability_index) else {
546 restore_guardian(state, guardian);
547
548 return None;
549 };
550
551 ability_state.next_action_at = None;
552 ability_state.actions_executed = ability_state.actions_executed.saturating_add(1);
553 let action_index = ability_state.actions_executed - 1;
554 let prepared = PreparedGuardianAction {
555 ability,
556 event: event_for(&guardian, ability_index, action_index),
557 source: guardian.source,
558 target,
559 npc_id: guardian.spec.npc_id,
560 stat_inheritance: guardian.spec.stat_inheritance,
561 };
562
563 restore_guardian(state, guardian);
564
565 Some(prepared)
566}
567
568fn finish_guardian_action(
569 program: &crate::state::CombatProgram,
570 state: &mut CombatInstance,
571 key: GuardianActionKey,
572 ability: crate::state::GuardianAbility,
573 rng: &mut dyn FnMut() -> f64,
574 sink: &mut TelemetrySink,
575) {
576 let GuardianActionKey {
577 handle,
578 ability_index,
579 now,
580 } = key;
581 let Some(slot) = state
582 .runtime
583 .companions
584 .guardians
585 .get_mut(handle.0 as usize)
586 else {
587 return;
588 };
589
590 if slot
591 .as_ref()
592 .is_none_or(|guardian| guardian.handle != handle)
593 {
594 return;
595 }
596
597 let Some(mut guardian) = slot.take() else {
598 return;
599 };
600 let Some(actions_executed) = guardian
601 .abilities
602 .get(ability_index)
603 .map(|ability| ability.actions_executed)
604 else {
605 restore_guardian(state, guardian);
606
607 return;
608 };
609
610 if ability.max_actions > 0 && actions_executed >= ability.max_actions {
611 if ability.ends_guardian {
612 let _ = state.deactivate_pet_health(guardian.source);
613
614 deactivate_guardian_slot(state, handle.0 as usize);
615 let mut ctx = HookCtx::new(
616 crate::context::HookCtxServices {
617 program,
618 instance: state,
619 sink,
620 rng,
621 },
622 crate::context::HookCtxRequest::for_target(now, guardian.target)
623 .with_source(guardian.source),
624 )
625 .with_source_damage_flags(DamageFlags::PET | DamageFlags::GUARDIAN)
626 .with_source_npc_id(guardian.spec.npc_id)
627 .with_pet_stat_inheritance(guardian.spec.stat_inheritance);
628
629 fire_demise(&mut ctx, &guardian);
630 } else {
631 restore_guardian(state, guardian);
632 }
633
634 return;
635 }
636
637 let delay = scaled_delay_ms(
638 ActorView::new(program, state, guardian.source),
639 ability.action_interval_ms,
640 ability.hasted_action_interval,
641 )
642 .max(1);
643 let next = now.saturating_add(SimTime::from_millis(delay));
644 let Some(ability_state) = guardian.abilities.get_mut(ability_index) else {
645 restore_guardian(state, guardian);
646
647 return;
648 };
649
650 if next > guardian.expires_at {
651 restore_guardian(state, guardian);
652
653 return;
654 }
655
656 ability_state.next_action_at = Some(next);
657 let source = guardian.source;
658 let target = guardian.target;
659
660 restore_guardian(state, guardian);
661 state.schedule(Event::GuardianAction {
662 t: next,
663 guardian: program.runtime_events.guardian(
664 handle.0,
665 handle.1,
666 u16::try_from(ability_index).ok(),
667 ),
668 source,
669 target,
670 });
671}
672
673pub(crate) fn process_guardian_action(
674 ctx: &mut CombatCtx<'_>,
675 handle: GuardianHandle,
676 ability_index: usize,
677) {
678 let Some(prepared) =
679 prepare_guardian_action(ctx.program, ctx.instance, handle, ability_index, ctx.now)
680 else {
681 return;
682 };
683 let mut hook = HookCtx::new(
684 crate::context::HookCtxServices {
685 program: ctx.program,
686 instance: ctx.instance,
687 sink: ctx.sink,
688 rng: ctx.rng,
689 },
690 crate::context::HookCtxRequest::for_target(ctx.now, prepared.target)
691 .with_source(prepared.source),
692 )
693 .with_source_damage_flags(DamageFlags::PET | DamageFlags::GUARDIAN)
694 .with_source_npc_id(prepared.npc_id)
695 .with_pet_stat_inheritance(prepared.stat_inheritance);
696
697 (prepared.ability.action)(&mut hook, prepared.event);
698 finish_guardian_action(
699 ctx.program,
700 ctx.instance,
701 GuardianActionKey {
702 handle,
703 ability_index,
704 now: ctx.now,
705 },
706 prepared.ability,
707 ctx.rng,
708 ctx.sink,
709 );
710}
711
712pub(crate) fn process_guardian_expire(ctx: &mut CombatCtx<'_>, handle: GuardianHandle) {
713 let guardian_id = handle.0;
714 let guardian_generation = handle.1;
715
716 let Some(slot) = ctx
717 .instance
718 .runtime
719 .companions
720 .guardians
721 .get_mut(guardian_id as usize)
722 else {
723 return;
724 };
725
726 if slot
727 .as_ref()
728 .is_none_or(|guardian| guardian.handle != handle)
729 {
730 return;
731 }
732
733 let Some(guardian) = slot.take() else {
734 return;
735 };
736
737 if ctx.now < guardian.expires_at {
738 let expires_at = guardian.expires_at;
739
740 *slot = Some(guardian);
741 ctx.instance.schedule(Event::GuardianExpire {
742 t: expires_at,
743 guardian: ctx
744 .program
745 .runtime_events
746 .guardian(guardian_id, guardian_generation, None),
747 });
748
749 return;
750 }
751
752 if guardian
753 .ability_states()
754 .iter()
755 .any(|ability| ability.next_action_at == Some(ctx.now))
756 {
757 *slot = Some(guardian);
758 ctx.instance.schedule(Event::GuardianExpire {
759 t: ctx.now.saturating_add(SimTime::from_millis(1)),
760 guardian: ctx
761 .program
762 .runtime_events
763 .guardian(guardian_id, guardian_generation, None),
764 });
765
766 return;
767 }
768
769 let _ = ctx.instance.deactivate_pet_health(guardian.source);
770
771 deactivate_guardian_slot(ctx.instance, guardian_id as usize);
772 let mut hook = HookCtx::new(
773 crate::context::HookCtxServices {
774 program: ctx.program,
775 instance: ctx.instance,
776 sink: ctx.sink,
777 rng: ctx.rng,
778 },
779 crate::context::HookCtxRequest::for_target(ctx.now, guardian.target)
780 .with_source(guardian.source),
781 )
782 .with_source_damage_flags(DamageFlags::PET | DamageFlags::GUARDIAN)
783 .with_source_npc_id(guardian.spec.npc_id)
784 .with_pet_stat_inheritance(guardian.spec.stat_inheritance);
785
786 fire_demise(&mut hook, &guardian);
787}
788
789fn deactivate_guardian_slot(state: &mut CombatInstance, slot: usize) {
790 if let Some(visit) = state
791 .runtime
792 .companions
793 .active_guardian_slots
794 .iter()
795 .position(|active| usize::from(*active) == slot)
796 {
797 state
798 .runtime
799 .companions
800 .active_guardian_slots
801 .swap_remove(visit);
802 state
803 .runtime
804 .companions
805 .free_guardian_slots
806 .push(u16::try_from(slot).expect("guardian slot fits u16"));
807 }
808}
809
810#[cfg(test)]
811#[path = "guardians/tests.rs"]
812mod tests;