1use wowlab_engine_domain::rotation::DenseBuffer;
4use wowlab_engine_ports::Event;
5use wowlab_engine_telemetry::TelemetrySink;
6use wowlab_types::sim::{ActorId, SimTime};
7
8use super::buffs::current_haste_mult_for;
9use crate::{
10 DamageFlags,
11 context::{ActorView, CombatCtx, HookCtx},
12 state::{
13 CombatState, GuardianAbilityState, GuardianEvent, GuardianHandle, GuardianInstance,
14 GuardianSpec,
15 },
16};
17
18fn scaled_delay_ms(view: ActorView<'_>, delay_ms: u32, hasted: bool) -> u32 {
19 if delay_ms == 0 || !hasted {
20 return delay_ms;
21 }
22
23 wowlab_types::numeric::f64_to_u32_saturating_round(
24 f64::from(delay_ms) / current_haste_mult_for(view),
25 )
26 .max(1)
27}
28
29pub(crate) fn summon_guardian(
30 state: &mut CombatState,
31 buf: &DenseBuffer,
32 spec: GuardianSpec,
33 now: SimTime,
34) -> GuardianHandle {
35 let Some(target) = state.current_target() else {
36 return GuardianHandle::new(0, 0);
37 };
38 let handle = allocate_guardian_handle(state);
39 let Some(source) = handle.actor() else {
40 return handle;
41 };
42 let stat_snapshot =
43 super::pet_actions::snapshot_owner_stats(state, buf, spec.owner_coefficients);
44 let expires_at = now.saturating_add(SimTime::from_millis(spec.duration_ms));
45 let abilities = spec
46 .abilities
47 .iter()
48 .map(|ability| {
49 let first_delay = scaled_delay_ms(
50 ActorView::new(state, buf, source),
51 ability.first_action_delay_ms,
52 ability.hasted_first_action,
53 );
54
55 GuardianAbilityState {
56 next_action_at: ability
57 .self_scheduled
58 .then(|| now.saturating_add(SimTime::from_millis(first_delay))),
59 actions_executed: 0,
60 }
61 })
62 .collect::<Vec<_>>();
63 let tag = spec.tag;
64
65 let guardian = GuardianInstance {
66 handle,
67 spec,
68 spawned_at: now,
69 expires_at,
70 abilities,
71 source,
72 target,
73 stat_snapshot,
74 };
75 let slot = handle.0 as usize;
76
77 if slot == state.runtime.companions.guardians.len() {
78 state.runtime.companions.guardians.push(Some(guardian));
79 } else if let Some(entry) = state.runtime.companions.guardians.get_mut(slot) {
80 *entry = Some(guardian);
81 }
82
83 super::pet_actions::refresh_pet_stat_snapshots(state, buf);
84 let action_times = state.runtime.companions.guardians[handle.0 as usize]
86 .as_ref()
87 .into_iter()
88 .flat_map(|guardian| guardian.abilities.iter().enumerate())
89 .filter_map(|(index, ability)| ability.next_action_at.map(|t| (index, t)))
90 .collect::<Vec<_>>();
91
92 for (ability_index, t) in action_times {
93 state.schedule(Event::GuardianAction {
94 t,
95 guardian_id: handle.0,
96 guardian_generation: handle.1,
97 ability_index,
98 source,
99 target,
100 });
101 }
102
103 state.schedule(Event::GuardianExpire {
104 t: expires_at,
105 guardian_id: handle.0,
106 guardian_generation: handle.1,
107 });
108 tracing::trace!(
109 tag,
110 slot = handle.0,
111 now_ms = now.as_millis(),
112 expires_at_ms = expires_at.as_millis(),
113 active = active_guardian_count(state, tag),
114 "guardian summoned"
115 );
116
117 handle
118}
119
120fn allocate_guardian_handle(state: &mut CombatState) -> GuardianHandle {
121 let slot = state
122 .runtime
123 .companions
124 .guardians
125 .iter()
126 .position(Option::is_none)
127 .unwrap_or(state.runtime.companions.guardians.len());
128
129 if slot == state.runtime.companions.guardian_generations.len() {
130 state.runtime.companions.guardian_generations.push(1);
131 } else if let Some(generation) = state.runtime.companions.guardian_generations.get_mut(slot) {
132 *generation = generation.wrapping_add(1).max(1);
133 }
134
135 let generation = state
136 .runtime
137 .companions
138 .guardian_generations
139 .get(slot)
140 .copied()
141 .expect("guardian generation was initialized for allocated slot");
142
143 GuardianHandle::new(
144 u32::try_from(slot).expect("guardian arena length fits in u32"),
145 generation,
146 )
147}
148
149pub(crate) fn active_guardian_count(state: &CombatState, tag: u32) -> usize {
150 state
151 .runtime
152 .companions
153 .guardians
154 .iter()
155 .flatten()
156 .filter(|guardian| guardian.spec.tag == tag)
157 .count()
158}
159
160pub(crate) fn command_guardians(ctx: &mut CombatCtx<'_>, tag: u32, ability_index: usize) -> usize {
162 let commanded = ctx
163 .state
164 .runtime
165 .companions
166 .guardians
167 .iter()
168 .flatten()
169 .filter(|guardian| guardian.spec.tag == tag)
170 .filter_map(|guardian| {
171 let ability = guardian.spec.abilities.get(ability_index).copied()?;
172
173 Some((
174 ability,
175 event_for(guardian, ability_index, 0),
176 guardian.source,
177 guardian.target,
178 guardian.spec.npc_id,
179 ))
180 })
181 .collect::<Vec<_>>();
182 let count = commanded.len();
183
184 for (ability, event, source, target, npc_id) in commanded {
185 let mut hook = HookCtx::new(
186 crate::context::HookCtxServices {
187 state: ctx.state,
188 buf: ctx.buf,
189 sink: ctx.sink,
190 rng: ctx.rng,
191 },
192 crate::context::HookCtxRequest::for_target(ctx.now, target).with_source(source),
193 )
194 .with_source_damage_flags(DamageFlags::PET | DamageFlags::GUARDIAN)
195 .with_source_npc_id(npc_id);
196
197 (ability.action)(&mut hook, event);
198 }
199
200 count
201}
202
203pub(crate) fn guardian_combat_coordinates(
204 state: &CombatState,
205 handle: GuardianHandle,
206) -> Option<(ActorId, wowlab_types::sim::EnemyIdx)> {
207 state
208 .runtime
209 .companions
210 .guardians
211 .get(handle.0 as usize)?
212 .as_ref()
213 .filter(|guardian| guardian.handle == handle)
214 .map(|guardian| (guardian.source, guardian.target))
215}
216
217pub(crate) fn extend_guardians(state: &mut CombatState, tag: u32, amount_ms: u32) {
218 let extension = SimTime::from_millis(amount_ms);
219
220 for guardian in state.runtime.companions.guardians.iter_mut().flatten() {
221 if guardian.spec.tag == tag {
222 guardian.expires_at = guardian.expires_at.saturating_add(extension);
223 }
224 }
225}
226
227pub(crate) fn dismiss_guardians(state: &mut CombatState, tag: u32, count: usize) -> usize {
229 let mut removed = 0;
230
231 for index in 0..state.runtime.companions.guardians.len() {
232 if removed >= count {
233 break;
234 }
235
236 let should_remove = state
237 .runtime
238 .companions
239 .guardians
240 .get(index)
241 .and_then(Option::as_ref)
242 .is_some_and(|guardian| guardian.spec.tag == tag);
243
244 if should_remove {
245 let guardian = state
246 .runtime
247 .companions
248 .guardians
249 .get_mut(index)
250 .and_then(Option::take)
251 .expect("matching guardian slot");
252
253 let _ = state.deactivate_pet_health(guardian.source);
254
255 removed += 1;
256 }
257 }
258
259 removed
260}
261
262const fn event_for(
263 guardian: &GuardianInstance,
264 ability_index: usize,
265 action_index: u16,
266) -> GuardianEvent {
267 GuardianEvent {
268 handle: guardian.handle,
269 tag: guardian.spec.tag,
270 ability_index,
271 action_index,
272 spawned_at: guardian.spawned_at,
273 expires_at: guardian.expires_at,
274 }
275}
276
277fn fire_demise(ctx: &mut HookCtx<'_>, guardian: &GuardianInstance) {
278 let Some(hook) = guardian.spec.on_demise else {
279 return;
280 };
281
282 hook(ctx, event_for(guardian, 0, 0));
283}
284
285struct PreparedGuardianAction {
286 ability: crate::state::GuardianAbility,
287 event: GuardianEvent,
288 source: ActorId,
289 target: wowlab_types::sim::EnemyIdx,
290 npc_id: Option<u32>,
291}
292
293#[derive(Clone, Copy)]
294struct GuardianActionKey {
295 handle: GuardianHandle,
296 ability_index: usize,
297 now: SimTime,
298}
299
300fn restore_guardian(state: &mut CombatState, guardian: GuardianInstance) {
301 let handle = guardian.handle;
302 let generation_is_current = state
303 .runtime
304 .companions
305 .guardian_generations
306 .get(handle.0 as usize)
307 .is_some_and(|generation| *generation == handle.1);
308
309 if generation_is_current
310 && let Some(slot) = state
311 .runtime
312 .companions
313 .guardians
314 .get_mut(handle.0 as usize)
315 && slot.is_none()
316 {
317 *slot = Some(guardian);
318 }
319}
320
321fn prepare_guardian_action(
322 state: &mut CombatState,
323 buf: &mut DenseBuffer,
324 handle: GuardianHandle,
325 ability_index: usize,
326 now: SimTime,
327) -> Option<PreparedGuardianAction> {
328 let slot = state
329 .runtime
330 .companions
331 .guardians
332 .get_mut(handle.0 as usize)?;
333
334 if slot.as_ref()?.handle != handle {
335 return None;
336 }
337
338 let mut guardian = slot.take()?;
339 let Some(ability) = guardian.spec.abilities.get(ability_index).copied() else {
340 restore_guardian(state, guardian);
341
342 return None;
343 };
344
345 if guardian
346 .abilities
347 .get(ability_index)
348 .is_none_or(|ability| ability.next_action_at != Some(now))
349 || now > guardian.expires_at
350 {
351 restore_guardian(state, guardian);
352
353 return None;
354 }
355
356 let target = if state.is_valid_target(guardian.target) {
357 guardian.target
358 } else {
359 state.retarget();
360 crate::builder::buffer_init::project_encounter(state, buf, now);
361 let Some(target) = state.current_target() else {
362 restore_guardian(state, guardian);
363
364 return None;
365 };
366
367 target
368 };
369
370 guardian.target = target;
371 let Some(ability_state) = guardian.abilities.get_mut(ability_index) else {
372 restore_guardian(state, guardian);
373
374 return None;
375 };
376
377 ability_state.next_action_at = None;
378 ability_state.actions_executed = ability_state.actions_executed.saturating_add(1);
379 let action_index = ability_state.actions_executed - 1;
380 let prepared = PreparedGuardianAction {
381 ability,
382 event: event_for(&guardian, ability_index, action_index),
383 source: guardian.source,
384 target,
385 npc_id: guardian.spec.npc_id,
386 };
387
388 restore_guardian(state, guardian);
389
390 Some(prepared)
391}
392
393fn finish_guardian_action(
394 state: &mut CombatState,
395 buf: &mut DenseBuffer,
396 key: GuardianActionKey,
397 ability: crate::state::GuardianAbility,
398 rng: &mut dyn FnMut() -> f64,
399 sink: &mut TelemetrySink,
400) {
401 let GuardianActionKey {
402 handle,
403 ability_index,
404 now,
405 } = key;
406 let Some(slot) = state
407 .runtime
408 .companions
409 .guardians
410 .get_mut(handle.0 as usize)
411 else {
412 return;
413 };
414
415 if slot
416 .as_ref()
417 .is_none_or(|guardian| guardian.handle != handle)
418 {
419 return;
420 }
421
422 let Some(mut guardian) = slot.take() else {
423 return;
424 };
425 let Some(actions_executed) = guardian
426 .abilities
427 .get(ability_index)
428 .map(|ability| ability.actions_executed)
429 else {
430 restore_guardian(state, guardian);
431
432 return;
433 };
434
435 if ability.max_actions > 0 && actions_executed >= ability.max_actions {
436 if ability.ends_guardian {
437 let _ = state.deactivate_pet_health(guardian.source);
438 let mut ctx = HookCtx::new(
439 crate::context::HookCtxServices {
440 state,
441 buf,
442 sink,
443 rng,
444 },
445 crate::context::HookCtxRequest::for_target(now, guardian.target)
446 .with_source(guardian.source),
447 )
448 .with_source_damage_flags(DamageFlags::PET | DamageFlags::GUARDIAN)
449 .with_source_npc_id(guardian.spec.npc_id);
450
451 fire_demise(&mut ctx, &guardian);
452 } else {
453 restore_guardian(state, guardian);
454 }
455
456 return;
457 }
458
459 let delay = scaled_delay_ms(
460 ActorView::new(state, buf, guardian.source),
461 ability.action_interval_ms,
462 ability.hasted_action_interval,
463 )
464 .max(1);
465 let next = now.saturating_add(SimTime::from_millis(delay));
466 let Some(ability_state) = guardian.abilities.get_mut(ability_index) else {
467 restore_guardian(state, guardian);
468
469 return;
470 };
471
472 if next > guardian.expires_at {
473 restore_guardian(state, guardian);
474
475 return;
476 }
477
478 ability_state.next_action_at = Some(next);
479 let source = guardian.source;
480 let target = guardian.target;
481
482 restore_guardian(state, guardian);
483 state.schedule(Event::GuardianAction {
484 t: next,
485 guardian_id: handle.0,
486 guardian_generation: handle.1,
487 ability_index,
488 source,
489 target,
490 });
491}
492
493pub(crate) fn process_guardian_action(
494 ctx: &mut CombatCtx<'_>,
495 handle: GuardianHandle,
496 ability_index: usize,
497) {
498 let Some(prepared) =
499 prepare_guardian_action(ctx.state, ctx.buf, handle, ability_index, ctx.now)
500 else {
501 return;
502 };
503 let mut hook = HookCtx::new(
504 crate::context::HookCtxServices {
505 state: ctx.state,
506 buf: ctx.buf,
507 sink: ctx.sink,
508 rng: ctx.rng,
509 },
510 crate::context::HookCtxRequest::for_target(ctx.now, prepared.target)
511 .with_source(prepared.source),
512 )
513 .with_source_damage_flags(DamageFlags::PET | DamageFlags::GUARDIAN)
514 .with_source_npc_id(prepared.npc_id);
515
516 (prepared.ability.action)(&mut hook, prepared.event);
517 finish_guardian_action(
518 ctx.state,
519 ctx.buf,
520 GuardianActionKey {
521 handle,
522 ability_index,
523 now: ctx.now,
524 },
525 prepared.ability,
526 ctx.rng,
527 ctx.sink,
528 );
529}
530
531pub(crate) fn process_guardian_expire(ctx: &mut CombatCtx<'_>, handle: GuardianHandle) {
532 let guardian_id = handle.0;
533 let guardian_generation = handle.1;
534
535 let Some(slot) = ctx
536 .state
537 .runtime
538 .companions
539 .guardians
540 .get_mut(guardian_id as usize)
541 else {
542 return;
543 };
544
545 if slot
546 .as_ref()
547 .is_none_or(|guardian| guardian.handle != handle)
548 {
549 return;
550 }
551
552 let Some(guardian) = slot.take() else {
553 return;
554 };
555
556 if ctx.now < guardian.expires_at {
557 let expires_at = guardian.expires_at;
558
559 *slot = Some(guardian);
560 ctx.state.schedule(Event::GuardianExpire {
561 t: expires_at,
562 guardian_id,
563 guardian_generation,
564 });
565
566 return;
567 }
568
569 if guardian
570 .abilities
571 .iter()
572 .any(|ability| ability.next_action_at == Some(ctx.now))
573 {
574 *slot = Some(guardian);
575 ctx.state.schedule(Event::GuardianExpire {
576 t: ctx.now.saturating_add(SimTime::from_millis(1)),
577 guardian_id,
578 guardian_generation,
579 });
580
581 return;
582 }
583
584 let _ = ctx.state.deactivate_pet_health(guardian.source);
585 let mut hook = HookCtx::new(
586 crate::context::HookCtxServices {
587 state: ctx.state,
588 buf: ctx.buf,
589 sink: ctx.sink,
590 rng: ctx.rng,
591 },
592 crate::context::HookCtxRequest::for_target(ctx.now, guardian.target)
593 .with_source(guardian.source),
594 )
595 .with_source_damage_flags(DamageFlags::PET | DamageFlags::GUARDIAN)
596 .with_source_npc_id(guardian.spec.npc_id);
597
598 fire_demise(&mut hook, &guardian);
599}
600
601#[cfg(test)]
602#[path = "guardians/tests.rs"]
603mod tests;