Skip to main content

wowlab_engine_ports/
spec_handler.rs

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