Skip to main content

wowlab_engine_ports/
spec_handler.rs

1use wowlab_engine_telemetry::TelemetrySink;
2use wowlab_types::{
3    data::WeaponStats,
4    game::GearSlot,
5    sim::{
6        ActorId, AuraKey, EnemyIdx, GroupId, PositionedActorRef, PullId, SimTime, SpatialTransform,
7        SpellIdx, WaveId,
8    },
9};
10
11use crate::{
12    combat_stats::CombatStats, decision_trace::DecisionTraceSink, sim_state::SimState,
13    spatial::SpatialQueryError,
14};
15
16/// Character sheet snapshot returned by [`SpecHandler::paperdoll`].
17#[derive(Clone, Debug, serde::Serialize)]
18#[non_exhaustive]
19pub struct Paperdoll {
20    pub stats: CombatStats,
21    pub introspection: wowlab_types::game::SpecIntrospection,
22    pub precombat_aura_ids: Vec<u32>,
23    pub talent_ranks: Vec<(u32, u8)>,
24    pub item_use_spells: Vec<(GearSlot, u32)>,
25    pub weapon_main: WeaponStats,
26    pub weapon_off: Option<WeaponStats>,
27}
28
29/// Loadout-derived fields in a character-sheet snapshot.
30#[derive(Clone, Debug)]
31// #t(rust_similar_structs) loadout input intentionally excludes computed paperdoll statistics and introspection
32pub struct PaperdollLoadout {
33    pub precombat_aura_ids: Vec<u32>,
34    pub talent_ranks: Vec<(u32, u8)>,
35    pub item_use_spells: Vec<(GearSlot, u32)>,
36    pub weapon_main: WeaponStats,
37    pub weapon_off: Option<WeaponStats>,
38}
39
40impl Paperdoll {
41    #[must_use]
42    pub fn new(
43        stats: CombatStats,
44        introspection: wowlab_types::game::SpecIntrospection,
45        loadout: PaperdollLoadout,
46    ) -> Self {
47        Self {
48            stats,
49            introspection,
50            precombat_aura_ids: loadout.precombat_aura_ids,
51            talent_ranks: loadout.talent_ranks,
52            item_use_spells: loadout.item_use_spells,
53            weapon_main: loadout.weapon_main,
54            weapon_off: loadout.weapon_off,
55        }
56    }
57}
58
59/// Action returned by a spec handler during rotation evaluation.
60#[derive(Clone, Debug)]
61// #t(block: rust_non_exhaustive_on_public) internal port enum matched exhaustively within workspace
62// docref:start event-system-spec-action
63pub enum SpecAction {
64    Cast {
65        spell_id: SpellIdx,
66        empower_rank: u8,
67        source: ActorId,
68        target: EnemyIdx,
69    },
70    Wait {
71        until_ms: SimTime,
72    },
73}
74// docref:end event-system-spec-action
75
76/// Typed result of requesting the combat layer to despawn one exact enemy.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78#[non_exhaustive]
79pub enum EnemyDespawnOutcome {
80    Despawned,
81    AlreadyInactive,
82    UnknownEnemy,
83}
84
85/// Why a derived encounter despawn is being processed.
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87// #t(rust_non_exhaustive_on_public) internal port enum matched exhaustively within workspace
88pub enum EncounterDespawnKind {
89    Authored { counts_as_completion: bool },
90    ForcedOptional,
91}
92
93/// The policy condition that ended an encounter iteration.
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95// #t(rust_non_exhaustive_on_public) internal port enum matched exhaustively within workspace
96pub enum EncounterTerminationReason {
97    FixedDuration,
98    PrimaryRequiredEnemyDead,
99    RequiredGroupsComplete,
100    AllEnemiesInRequiredGroupsDead,
101}
102
103/// Aura identity and optional enemy target delivered to aura lifecycle handlers.
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub struct AuraEventRef {
106    pub key: AuraKey,
107    pub target: Option<EnemyIdx>,
108}
109
110/// Pull identity and activation epoch shared by encounter lifecycle events.
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub struct PullScope {
113    pub pull: PullId,
114    pub epoch: u32,
115}
116
117wowlab_engine_macros::define_error! {
118/// Fatal typed failure raised by a spec handler while processing one simulation event.
119#[derive(Debug)]
120pub struct SpecRuntimeError {
121    #[source]
122    kind: SpecRuntimeErrorKind,
123}
124
125#[derive(Debug, thiserror::Error)]
126enum SpecRuntimeErrorKind {
127    #[error("enemy {enemy:?} is absent from canonical encounter state")]
128    MissingEnemy { enemy: EnemyIdx },
129    #[error("failed to remove dying enemy {enemy:?} from the spatial index")]
130    EnemyDeathSpatialRemoval {
131        enemy: EnemyIdx,
132        #[source]
133        source: SpatialQueryError,
134    },
135    #[error(
136        "encounter stalled in pull {pull:?}: incomplete required groups {incomplete_required_groups:?}, inactive/live enemies {inactive_live_enemies:?}"
137    )]
138    EncounterStalled {
139        pull: PullId,
140        incomplete_required_groups: Vec<GroupId>,
141        inactive_live_enemies: Vec<EnemyIdx>,
142    },
143    #[error("invalid encounter script event in pull {pull:?}: {message}")]
144    EncounterScript { pull: PullId, message: String },
145    #[error(
146        "spell {spell_id} requested empower rank {requested_rank}, but its structured stage data supports ranks 1 through {max_rank}"
147    )]
148    InvalidEmpowerRank {
149        spell_id: u32,
150        requested_rank: u8,
151        max_rank: u8,
152    },
153}
154}
155
156impl SpecRuntimeError {
157    /// Reports an enemy absent from canonical encounter state.
158    #[must_use]
159    pub const fn missing_enemy(enemy: EnemyIdx) -> Self {
160        Self {
161            kind: SpecRuntimeErrorKind::MissingEnemy { enemy },
162        }
163    }
164
165    /// Wraps a spatial removal failure during enemy death.
166    #[must_use]
167    pub const fn enemy_death_spatial_removal(enemy: EnemyIdx, source: SpatialQueryError) -> Self {
168        Self {
169            kind: SpecRuntimeErrorKind::EnemyDeathSpatialRemoval { enemy, source },
170        }
171    }
172
173    /// Reports a pull that cannot make further progress.
174    #[must_use]
175    pub fn encounter_stalled(
176        pull: PullId,
177        incomplete_required_groups: Vec<GroupId>,
178        inactive_live_enemies: Vec<EnemyIdx>,
179    ) -> Self {
180        Self {
181            kind: SpecRuntimeErrorKind::EncounterStalled {
182                pull,
183                incomplete_required_groups,
184                inactive_live_enemies,
185            },
186        }
187    }
188
189    /// Reports an invalid authored encounter script event.
190    #[must_use]
191    pub fn encounter_script(pull: PullId, message: impl Into<String>) -> Self {
192        Self {
193            kind: SpecRuntimeErrorKind::EncounterScript {
194                pull,
195                message: message.into(),
196            },
197        }
198    }
199
200    /// Reports a cast action whose selected empower rank is outside the spell's stage data.
201    #[must_use]
202    pub const fn invalid_empower_rank(spell_id: u32, requested_rank: u8, max_rank: u8) -> Self {
203        Self {
204            kind: SpecRuntimeErrorKind::InvalidEmpowerRank {
205                spell_id,
206                requested_rank,
207                max_rank,
208            },
209        }
210    }
211
212    /// Returns the enemy and spatial cause for a death-removal failure.
213    #[must_use]
214    pub const fn enemy_death_spatial_removal_details(
215        &self,
216    ) -> Option<(EnemyIdx, SpatialQueryError)> {
217        match self.kind {
218            SpecRuntimeErrorKind::EnemyDeathSpatialRemoval { enemy, source } => {
219                Some((enemy, source))
220            }
221            _ => None,
222        }
223    }
224
225    /// Returns the authored pull and incomplete encounter state for a stall failure.
226    #[must_use]
227    pub fn encounter_stalled_details(&self) -> Option<(PullId, &[GroupId], &[EnemyIdx])> {
228        match &self.kind {
229            SpecRuntimeErrorKind::EncounterStalled {
230                pull,
231                incomplete_required_groups,
232                inactive_live_enemies,
233            } => Some((
234                *pull,
235                incomplete_required_groups.as_slice(),
236                inactive_live_enemies.as_slice(),
237            )),
238            _ => None,
239        }
240    }
241}
242
243/// Pull-local encounter generation (pull epoch plus lifecycle epoch) so prior events cannot match a newly active actor with the same identity.
244#[derive(Clone, Copy, Debug, Eq, PartialEq)]
245pub struct PullLifecycleGeneration {
246    pull: PullId,
247    pull_epoch: u32,
248    lifecycle_epoch: u64,
249}
250
251impl PullLifecycleGeneration {
252    #[must_use]
253    pub const fn new(pull: PullId, pull_epoch: u32, lifecycle_epoch: u64) -> Self {
254        Self {
255            pull,
256            pull_epoch,
257            lifecycle_epoch,
258        }
259    }
260
261    #[must_use]
262    pub const fn pull(self) -> PullId {
263        self.pull
264    }
265
266    #[must_use]
267    pub const fn pull_epoch(self) -> u32 {
268        self.pull_epoch
269    }
270
271    #[must_use]
272    pub const fn lifecycle_epoch(self) -> u64 {
273        self.lifecycle_epoch
274    }
275}
276
277/// Scheduling scope for the shared target-aware death event.
278#[derive(Clone, Copy, Debug, Eq, PartialEq)]
279// #t(rust_non_exhaustive_on_public) internal port enum matched exhaustively within workspace
280pub enum DeathEventScope {
281    Unscoped,
282    ScriptedLifetime(PullLifecycleGeneration),
283}
284
285/// Unified simulation event type.
286#[derive(Clone, Copy, Debug)]
287// #t(rust_non_exhaustive_on_public) internal port enum matched exhaustively within workspace
288pub enum Event {
289    PlayerReady {
290        t: SimTime,
291    },
292    OffGcdReady {
293        t: SimTime,
294    },
295    CastStart {
296        t: SimTime,
297        spell_id: SpellIdx,
298        empower_rank: u8,
299        source: ActorId,
300        target: EnemyIdx,
301    },
302    CastComplete {
303        t: SimTime,
304        spell_id: SpellIdx,
305        empower_rank: u8,
306        source: ActorId,
307        target: EnemyIdx,
308    },
309    /// Server-triggered cast retaining the exact affected actor identity.
310    TriggeredSpell {
311        t: SimTime,
312        spell_id: SpellIdx,
313        source: ActorId,
314        target: ActorId,
315    },
316    SpellLaunch {
317        t: SimTime,
318        impact_id: u32,
319        source: ActorId,
320        target: EnemyIdx,
321    },
322    SpellImpact {
323        t: SimTime,
324        impact_id: u32,
325        source: ActorId,
326        target: EnemyIdx,
327    },
328    GuardianAction {
329        t: SimTime,
330        guardian_id: u32,
331        guardian_generation: u32,
332        ability_index: usize,
333        source: ActorId,
334        target: EnemyIdx,
335    },
336    GuardianExpire {
337        t: SimTime,
338        guardian_id: u32,
339        guardian_generation: u32,
340    },
341    PetAction {
342        t: SimTime,
343        auto_attack_index: usize,
344        source: ActorId,
345        target: EnemyIdx,
346    },
347    AuraTick {
348        t: SimTime,
349        key: AuraKey,
350        target: Option<EnemyIdx>,
351    },
352    ChannelTick {
353        t: SimTime,
354        spell_id: SpellIdx,
355        source: ActorId,
356        target: EnemyIdx,
357        generation: u64,
358    },
359    AuraExpire {
360        t: SimTime,
361        key: AuraKey,
362        target: Option<EnemyIdx>,
363    },
364    ProcHeartbeat {
365        t: SimTime,
366    },
367    Death {
368        t: SimTime,
369        target: EnemyIdx,
370        scope: DeathEventScope,
371    },
372    EncounterDespawn {
373        t: SimTime,
374        scope: PullScope,
375        target: EnemyIdx,
376        kind: EncounterDespawnKind,
377    },
378    GroupComplete {
379        t: SimTime,
380        scope: PullScope,
381        group: GroupId,
382    },
383    PullComplete {
384        t: SimTime,
385        scope: PullScope,
386        finalize: bool,
387    },
388    EncounterTerminate {
389        t: SimTime,
390        reason: EncounterTerminationReason,
391    },
392    WaveActivate {
393        t: SimTime,
394        scope: PullScope,
395        wave: WaveId,
396    },
397    EnemyActivate {
398        t: SimTime,
399        scope: PullScope,
400        target: EnemyIdx,
401    },
402    Retarget {
403        t: SimTime,
404        scope: PullScope,
405    },
406    EncounterMove {
407        t: SimTime,
408        scope: PullScope,
409        actor: PositionedActorRef,
410        transform: SpatialTransform,
411    },
412    ActorMovement {
413        t: SimTime,
414        actor: ActorId,
415        moving: bool,
416        generation: u64,
417    },
418    RaidEvent {
419        t: SimTime,
420        index: usize,
421    },
422    RaidAddDespawn {
423        t: SimTime,
424        target: EnemyIdx,
425        activation_generation: u64,
426        encounter_generation: PullLifecycleGeneration,
427    },
428    ExternalBuff {
429        t: SimTime,
430        index: usize,
431    },
432    HookTimer {
433        t: SimTime,
434        timer_id: u32,
435        source: ActorId,
436        target: Option<EnemyIdx>,
437    },
438    CooldownReady {
439        t: SimTime,
440        cooldown_key: SpellIdx,
441    },
442    AutoAttack {
443        t: SimTime,
444        source: ActorId,
445        target: EnemyIdx,
446    },
447    EnemyAutoAttack {
448        t: SimTime,
449        source: EnemyIdx,
450        target: ActorId,
451    },
452}
453
454const EVENT_PRIORITY_FINALIZATION: u8 = 0;
455const EVENT_PRIORITY_GROUP_COMPLETION: u8 = 1;
456const EVENT_PRIORITY_PULL_COMPLETION: u8 = 2;
457const EVENT_PRIORITY_TERMINATION: u8 = 3;
458const EVENT_PRIORITY_ACTIVATION: u8 = 4;
459const EVENT_PRIORITY_RETARGET: u8 = 5;
460const EVENT_PRIORITY_MOVEMENT: u8 = 6;
461const EVENT_PRIORITY_CAST: u8 = 7;
462const EVENT_PRIORITY_IMPACT: u8 = 8;
463const EVENT_PRIORITY_AURA_EXPIRE: u8 = 9;
464const EVENT_PRIORITY_READY: u8 = 10;
465
466impl Event {
467    /// Returns the stable same-timestamp priority, lower first; insertion order breaks ties within a priority.
468    #[inline]
469    #[must_use]
470    pub const fn priority(&self) -> u8 {
471        match self {
472            Event::Death { .. }
473            | Event::EncounterDespawn { .. }
474            | Event::RaidAddDespawn { .. }
475            | Event::GuardianExpire { .. } => EVENT_PRIORITY_FINALIZATION,
476            Event::GroupComplete { .. } => EVENT_PRIORITY_GROUP_COMPLETION,
477            Event::PullComplete { .. } => EVENT_PRIORITY_PULL_COMPLETION,
478            Event::EncounterTerminate { .. } => EVENT_PRIORITY_TERMINATION,
479            Event::WaveActivate { .. } | Event::EnemyActivate { .. } => EVENT_PRIORITY_ACTIVATION,
480            Event::Retarget { .. } => EVENT_PRIORITY_RETARGET,
481            Event::EncounterMove { .. } | Event::ActorMovement { .. } | Event::RaidEvent { .. } => {
482                EVENT_PRIORITY_MOVEMENT
483            }
484            Event::CastComplete { .. }
485            | Event::TriggeredSpell { .. }
486            | Event::SpellLaunch { .. } => EVENT_PRIORITY_CAST,
487            Event::SpellImpact { .. }
488            | Event::GuardianAction { .. }
489            | Event::PetAction { .. }
490            | Event::AuraTick { .. }
491            | Event::ChannelTick { .. }
492            | Event::ProcHeartbeat { .. }
493            | Event::HookTimer { .. }
494            | Event::AutoAttack { .. }
495            | Event::EnemyAutoAttack { .. }
496            | Event::ExternalBuff { .. } => EVENT_PRIORITY_IMPACT,
497            Event::AuraExpire { .. } => EVENT_PRIORITY_AURA_EXPIRE,
498            Event::PlayerReady { .. }
499            | Event::OffGcdReady { .. }
500            | Event::CastStart { .. }
501            | Event::CooldownReady { .. } => EVENT_PRIORITY_READY,
502        }
503    }
504
505    #[inline]
506    // docref:start event-system-timestamp
507    #[must_use]
508    pub fn timestamp(&self) -> SimTime {
509        match self {
510            Event::PlayerReady { t }
511            | Event::OffGcdReady { t }
512            | Event::CastStart { t, .. }
513            | Event::CastComplete { t, .. }
514            | Event::TriggeredSpell { t, .. }
515            | Event::SpellLaunch { t, .. }
516            | Event::SpellImpact { t, .. }
517            | Event::GuardianAction { t, .. }
518            | Event::GuardianExpire { t, .. }
519            | Event::PetAction { t, .. }
520            | Event::AuraTick { t, .. }
521            | Event::ChannelTick { t, .. }
522            | Event::AuraExpire { t, .. }
523            | Event::ProcHeartbeat { t }
524            | Event::Death { t, .. }
525            | Event::EncounterDespawn { t, .. }
526            | Event::GroupComplete { t, .. }
527            | Event::PullComplete { t, .. }
528            | Event::EncounterTerminate { t, .. }
529            | Event::WaveActivate { t, .. }
530            | Event::EnemyActivate { t, .. }
531            | Event::Retarget { t, .. }
532            | Event::EncounterMove { t, .. }
533            | Event::ActorMovement { t, .. }
534            | Event::RaidEvent { t, .. }
535            | Event::RaidAddDespawn { t, .. }
536            | Event::ExternalBuff { t, .. }
537            | Event::HookTimer { t, .. }
538            | Event::CooldownReady { t, .. }
539            | Event::AutoAttack { t, .. }
540            | Event::EnemyAutoAttack { t, .. } => *t,
541        }
542    }
543    // docref:end event-system-timestamp
544}
545
546/// Executor contract: all spec interaction goes through this trait.
547#[wowlab_engine_macros::spec_handler_delegation]
548pub trait SpecHandler: Send {
549    fn on_player_ready(&mut self, ctx: &mut SimContext) -> Option<SpecAction>;
550
551    fn on_cast_complete(&mut self, event: Event, ctx: &mut SimContext);
552
553    fn on_triggered_spell(
554        &mut self,
555        _spell_id: SpellIdx,
556        _source: ActorId,
557        _target: ActorId,
558        _ctx: &mut SimContext,
559    ) {
560    }
561
562    fn on_spell_impact(&mut self, _impact_id: u32, _ctx: &mut SimContext) {}
563
564    fn on_spell_launch(&mut self, _impact_id: u32, _ctx: &mut SimContext) {}
565
566    fn on_guardian_action(
567        &mut self,
568        _guardian_id: u32,
569        _guardian_generation: u32,
570        _ability_index: usize,
571        _ctx: &mut SimContext,
572    ) {
573    }
574
575    fn on_guardian_expire(
576        &mut self,
577        _guardian_id: u32,
578        _guardian_generation: u32,
579        _ctx: &mut SimContext,
580    ) {
581    }
582
583    fn on_pet_action(&mut self, _auto_attack_index: usize, _ctx: &mut SimContext) {}
584
585    fn on_aura_tick(&mut self, _event: AuraEventRef, _ctx: &mut SimContext) {}
586
587    fn on_channel_tick(
588        &mut self,
589        _spell_id: SpellIdx,
590        _source: ActorId,
591        _target: EnemyIdx,
592        _generation: u64,
593        _ctx: &mut SimContext,
594    ) {
595    }
596
597    fn on_aura_expire(&mut self, _event: AuraEventRef, _ctx: &mut SimContext) {}
598
599    fn on_proc_heartbeat(&mut self, _ctx: &mut SimContext) {}
600
601    fn on_death(&mut self, _target: EnemyIdx, _scope: DeathEventScope, _ctx: &mut SimContext) {}
602
603    fn on_encounter_despawn(
604        &mut self,
605        _scope: PullScope,
606        _target: EnemyIdx,
607        _kind: EncounterDespawnKind,
608        _ctx: &mut SimContext,
609    ) {
610    }
611
612    fn on_group_complete(&mut self, _scope: PullScope, _group: GroupId, _ctx: &mut SimContext) {}
613
614    fn on_pull_complete(&mut self, _scope: PullScope, _finalize: bool, _ctx: &mut SimContext) {}
615
616    fn on_encounter_terminate(
617        &mut self,
618        _reason: EncounterTerminationReason,
619        _ctx: &mut SimContext,
620    ) {
621    }
622
623    fn on_wave_activate(&mut self, _scope: PullScope, _wave: WaveId, _ctx: &mut SimContext) {}
624
625    fn on_enemy_activate(&mut self, _scope: PullScope, _target: EnemyIdx, _ctx: &mut SimContext) {}
626
627    fn on_retarget(&mut self, _scope: PullScope, _ctx: &mut SimContext) {}
628
629    fn on_encounter_move(
630        &mut self,
631        _scope: PullScope,
632        _actor: PositionedActorRef,
633        _transform: SpatialTransform,
634        _ctx: &mut SimContext,
635    ) {
636    }
637
638    fn on_actor_movement(
639        &mut self,
640        _actor: ActorId,
641        _moving: bool,
642        _generation: u64,
643        _ctx: &mut SimContext,
644    ) {
645    }
646
647    fn on_raid_event(&mut self, _index: usize, _ctx: &mut SimContext) {}
648
649    fn on_raid_add_despawn(
650        &mut self,
651        _target: EnemyIdx,
652        _activation_generation: u64,
653        _encounter_generation: PullLifecycleGeneration,
654        _ctx: &mut SimContext,
655    ) {
656    }
657
658    fn on_external_buff(&mut self, _index: usize, _ctx: &mut SimContext) {}
659
660    /// Called only when the shared event queue is empty.
661    fn on_event_queue_empty(&mut self, _ctx: &mut SimContext) {}
662
663    /// Exact termination timestamp once the encounter policy is satisfied.
664    fn encounter_termination_time(&self) -> Option<SimTime> {
665        None
666    }
667
668    /// Despawn an enemy through the canonical combat cleanup boundary.
669    fn on_despawn(&mut self, _target: EnemyIdx, _ctx: &mut SimContext) -> EnemyDespawnOutcome {
670        EnemyDespawnOutcome::UnknownEnemy
671    }
672
673    /// A `HookTimer` scheduled via the combat layer's `schedule_hook` came due.
674    fn on_hook_timer(
675        &mut self,
676        _timer_id: u32,
677        _source: ActorId,
678        _target: Option<EnemyIdx>,
679        _ctx: &mut SimContext,
680    ) {
681    }
682
683    fn on_auto_attack(&mut self, _source: ActorId, _target: EnemyIdx, _ctx: &mut SimContext) {}
684
685    fn on_enemy_auto_attack(&mut self, _source: EnemyIdx, _target: ActorId, _ctx: &mut SimContext) {
686    }
687
688    fn on_cooldown_ready(&mut self, _cooldown_key: SpellIdx, _ctx: &mut SimContext) {}
689
690    fn on_sim_start(&mut self, ctx: &mut SimContext);
691
692    /// Reset mutable per-iteration state without reallocating.
693    fn reset(&mut self) {}
694
695    fn flush_scheduled(&mut self, _push: &mut dyn FnMut(Event)) {}
696
697    /// Take a fatal event-processing failure, if the handler recorded one.
698    fn take_runtime_error(&mut self) -> Option<SpecRuntimeError> {
699        None
700    }
701
702    fn total_damage(&self) -> f64;
703
704    /// Cast time in milliseconds; 0 for instants.
705    fn cast_time_ms(&self, spell_id: SpellIdx, empower_rank: u8) -> u32;
706
707    fn paperdoll(&self) -> Option<Paperdoll> {
708        None
709    }
710
711    fn introspect(&self) -> wowlab_types::game::SpecIntrospection {
712        wowlab_types::game::SpecIntrospection::default()
713    }
714
715    fn attach_decision_trace(&mut self, _sink: std::sync::Arc<dyn DecisionTraceSink>) {}
716}
717
718/// Simulation state and telemetry sink passed to spec handler callbacks.
719#[derive(Debug)]
720pub struct SimContext<'a> {
721    pub state: &'a SimState,
722    pub telemetry: &'a mut TelemetrySink,
723}
724
725#[cfg(test)]
726mod tests {
727    use googletest::prelude::*;
728    use rstest::rstest;
729
730    use super::*;
731
732    fn spell(id: u32) -> SpellIdx {
733        SpellIdx::from_raw(id)
734    }
735
736    fn key(id: u32) -> AuraKey {
737        AuraKey::new(
738            wowlab_types::sim::AuraIdx(id),
739            ActorId::Player,
740            ActorId::Enemy(EnemyIdx::PRIMARY),
741            wowlab_types::sim::AuraOn::Target,
742        )
743    }
744
745    fn transform() -> SpatialTransform {
746        SpatialTransform {
747            layer: wowlab_types::sim::SpatialLayerId(0),
748            position: wowlab_types::sim::Position2::new(1.0, 2.0),
749            heading: 0.5,
750        }
751    }
752
753    #[gtest]
754    #[rstest]
755    #[case::player_ready(Event::PlayerReady { t: SimTime::from_millis(1) }, 1)]
756    #[case::off_gcd_ready(Event::OffGcdReady { t: SimTime::from_millis(2) }, 2)]
757    #[case::cast_start(Event::CastStart { t: SimTime::from_millis(3), spell_id: spell(10), empower_rank: 0, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 3)]
758    #[case::cast_complete(Event::CastComplete { t: SimTime::from_millis(4), spell_id: spell(10), empower_rank: 0, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 4)]
759    #[case::spell_launch(Event::SpellLaunch { t: SimTime::from_millis(14), impact_id: 6, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 14)]
760    #[case::spell_impact(Event::SpellImpact { t: SimTime::from_millis(5), impact_id: 7, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 5)]
761    #[case::guardian_action(Event::GuardianAction { t: SimTime::from_millis(10), guardian_id: 1, guardian_generation: 1, ability_index: 0, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 10)]
762    #[case::guardian_expire(Event::GuardianExpire { t: SimTime::from_millis(11), guardian_id: 1, guardian_generation: 1 }, 11)]
763    #[case::aura_tick(Event::AuraTick { t: SimTime::from_millis(6), key: key(20), target: Some(EnemyIdx::PRIMARY) }, 6)]
764    #[case::channel_tick(Event::ChannelTick { t: SimTime::from_millis(23), spell_id: spell(20), source: ActorId::Player, target: EnemyIdx::PRIMARY, generation: 1 }, 23)]
765    #[case::aura_expire(Event::AuraExpire { t: SimTime::from_millis(7), key: key(20), target: Some(EnemyIdx::PRIMARY) }, 7)]
766    #[case::death(Event::Death { t: SimTime::from_millis(13), target: EnemyIdx::PRIMARY, scope: DeathEventScope::Unscoped }, 13)]
767    #[case::encounter_despawn(Event::EncounterDespawn { t: SimTime::from_millis(15), scope: PullScope { pull: PullId(0), epoch: 1 }, target: EnemyIdx::PRIMARY, kind: EncounterDespawnKind::ForcedOptional }, 15)]
768    #[case::group_complete(Event::GroupComplete { t: SimTime::from_millis(16), scope: PullScope { pull: PullId(0), epoch: 1 }, group: GroupId(0) }, 16)]
769    #[case::pull_complete(Event::PullComplete { t: SimTime::from_millis(17), scope: PullScope { pull: PullId(0), epoch: 1 }, finalize: false }, 17)]
770    #[case::encounter_terminate(Event::EncounterTerminate { t: SimTime::from_millis(18), reason: EncounterTerminationReason::RequiredGroupsComplete }, 18)]
771    #[case::wave_activate(Event::WaveActivate { t: SimTime::from_millis(19), scope: PullScope { pull: PullId(0), epoch: 1 }, wave: WaveId(0) }, 19)]
772    #[case::enemy_activate(Event::EnemyActivate { t: SimTime::from_millis(20), scope: PullScope { pull: PullId(0), epoch: 1 }, target: EnemyIdx::PRIMARY }, 20)]
773    #[case::retarget(Event::Retarget { t: SimTime::from_millis(21), scope: PullScope { pull: PullId(0), epoch: 1 } }, 21)]
774    #[case::encounter_move(Event::EncounterMove { t: SimTime::from_millis(22), scope: PullScope { pull: PullId(0), epoch: 1 }, actor: PositionedActorRef::Player, transform: transform() }, 22)]
775    #[case::hook_timer(Event::HookTimer {
776        t: SimTime::from_millis(12),
777        timer_id: 3,
778        source: ActorId::Player,
779        target: Some(EnemyIdx::PRIMARY),
780    }, 12)]
781    #[case::cooldown_ready(Event::CooldownReady { t: SimTime::from_millis(8), cooldown_key: spell(30) }, 8)]
782    #[case::auto_attack(Event::AutoAttack { t: SimTime::from_millis(9), source: ActorId::Player, target: EnemyIdx::PRIMARY }, 9)]
783    fn event_timestamp_reads_every_variant(
784        #[case] event: Event,
785        #[case] expected_ms: u32,
786    ) -> Result<()> {
787        verify_that!(event.timestamp(), eq(SimTime::from_millis(expected_ms)))
788    }
789
790    #[gtest]
791    #[rstest]
792    #[case::player_ready(Event::PlayerReady { t: SimTime::ZERO }, 10)]
793    #[case::off_gcd_ready(Event::OffGcdReady { t: SimTime::ZERO }, 10)]
794    #[case::cast_start(Event::CastStart { t: SimTime::ZERO, spell_id: spell(10), empower_rank: 0, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 10)]
795    #[case::cast_complete(Event::CastComplete { t: SimTime::ZERO, spell_id: spell(10), empower_rank: 0, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 7)]
796    #[case::spell_launch(Event::SpellLaunch { t: SimTime::ZERO, impact_id: 6, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 7)]
797    #[case::spell_impact(Event::SpellImpact { t: SimTime::ZERO, impact_id: 7, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 8)]
798    #[case::guardian_action(Event::GuardianAction { t: SimTime::ZERO, guardian_id: 1, guardian_generation: 1, ability_index: 0, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 8)]
799    #[case::guardian_expire(Event::GuardianExpire { t: SimTime::ZERO, guardian_id: 1, guardian_generation: 1 }, 0)]
800    #[case::aura_tick(Event::AuraTick { t: SimTime::ZERO, key: key(20), target: Some(EnemyIdx::PRIMARY) }, 8)]
801    #[case::channel_tick(Event::ChannelTick { t: SimTime::ZERO, spell_id: spell(20), source: ActorId::Player, target: EnemyIdx::PRIMARY, generation: 1 }, 8)]
802    #[case::aura_expire(Event::AuraExpire { t: SimTime::ZERO, key: key(20), target: Some(EnemyIdx::PRIMARY) }, 9)]
803    #[case::death(Event::Death { t: SimTime::ZERO, target: EnemyIdx::PRIMARY, scope: DeathEventScope::Unscoped }, 0)]
804    #[case::encounter_despawn(Event::EncounterDespawn { t: SimTime::ZERO, scope: PullScope { pull: PullId(0), epoch: 1 }, target: EnemyIdx::PRIMARY, kind: EncounterDespawnKind::ForcedOptional }, 0)]
805    #[case::group_complete(Event::GroupComplete { t: SimTime::ZERO, scope: PullScope { pull: PullId(0), epoch: 1 }, group: GroupId(0) }, 1)]
806    #[case::pull_complete(Event::PullComplete { t: SimTime::ZERO, scope: PullScope { pull: PullId(0), epoch: 1 }, finalize: false }, 2)]
807    #[case::encounter_terminate(Event::EncounterTerminate { t: SimTime::ZERO, reason: EncounterTerminationReason::RequiredGroupsComplete }, 3)]
808    #[case::wave_activate(Event::WaveActivate { t: SimTime::ZERO, scope: PullScope { pull: PullId(0), epoch: 1 }, wave: WaveId(0) }, 4)]
809    #[case::enemy_activate(Event::EnemyActivate { t: SimTime::ZERO, scope: PullScope { pull: PullId(0), epoch: 1 }, target: EnemyIdx::PRIMARY }, 4)]
810    #[case::retarget(Event::Retarget { t: SimTime::ZERO, scope: PullScope { pull: PullId(0), epoch: 1 } }, 5)]
811    #[case::encounter_move(Event::EncounterMove { t: SimTime::ZERO, scope: PullScope { pull: PullId(0), epoch: 1 }, actor: PositionedActorRef::Player, transform: transform() }, 6)]
812    #[case::hook_timer(Event::HookTimer {
813        t: SimTime::ZERO,
814        timer_id: 3,
815        source: ActorId::Player,
816        target: Some(EnemyIdx::PRIMARY),
817    }, 8)]
818    #[case::cooldown_ready(Event::CooldownReady { t: SimTime::ZERO, cooldown_key: spell(30) }, 10)]
819    #[case::auto_attack(Event::AutoAttack { t: SimTime::ZERO, source: ActorId::Player, target: EnemyIdx::PRIMARY }, 8)]
820    fn event_priority_matches_same_timestamp_contract(
821        #[case] event: Event,
822        #[case] expected_priority: u8,
823    ) -> Result<()> {
824        verify_that!(event.priority(), eq(expected_priority))
825    }
826
827    #[gtest]
828    fn event_reference_value_types_preserve_fields() -> Result<()> {
829        let aura = AuraEventRef {
830            key: key(44),
831            target: Some(EnemyIdx(3)),
832        };
833        let scope = PullScope {
834            pull: PullId(7),
835            epoch: 11,
836        };
837
838        verify_that!(
839            aura,
840            eq(AuraEventRef {
841                key: key(44),
842                target: Some(EnemyIdx(3)),
843            })
844        )?;
845
846        verify_that!(
847            scope,
848            eq(PullScope {
849                pull: PullId(7),
850                epoch: 11,
851            })
852        )
853    }
854}