Skip to main content

wowlab_engine_combat/systems/
guardians.rs

1//! Temporary guardian lifecycle and action scheduling.
2
3use 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    // BOUNDS: handle is the index returned immediately before pushing this guardian slot.
85    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
160/// Drives one `on_demand` ability on every active guardian carrying `tag`, leaving scheduled timelines untouched.
161pub(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
227/// Remove up to `count` matching actors without firing their natural-demise hook.
228pub(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;