Skip to main content

wowlab_engine_combat/systems/
guardians.rs

1//! Temporary guardian lifecycle and action scheduling.
2
3use 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        // BOUNDS: visit remains below the active-slot list length captured by this loop.
25        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            // BOUNDS: active slots are inserted only after allocating this guardian arena.
281            state.runtime.companions.guardians[usize::from(*slot)].as_ref()
282        })
283        .filter(|guardian| guardian.spec.tag == tag)
284        .count()
285}
286
287/// Drives one `on_demand` ability on every active guardian carrying `tag`, leaving scheduled timelines untouched.
288pub(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        // BOUNDS: active slots are inserted only after allocating this guardian arena.
304        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        // BOUNDS: `visit` is bounded by the active-slot slice length.
373        let slot = usize::from(state.runtime.companions.active_guardian_slots[visit]);
374        // BOUNDS: active slots are inserted only after allocating this guardian arena.
375        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
385/// Remove up to `count` matching actors without firing their natural-demise hook.
386pub(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        // BOUNDS: `visit` remains below the current active-slot slice length.
402
403        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
458// #t(rust_similar_structs) prepared actions and queued commands are separate lifecycle stages with distinct ownership.
459struct 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;