1use 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]
20pub use wowlab_engine_event_authority::{
22 ActorMovementBinding, AuraEventBinding, ExternalBuffBinding, GuardianEventBinding,
23 HookTimerBinding, PendingImpactBinding, PetActionBinding, PullLifecycleGeneration,
24 RaidAddBinding, RaidEventBinding, ScheduledSpellBinding,
25};
26#[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#[derive(Clone, Debug)]
41pub 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#[derive(Clone, Debug)]
71pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88#[non_exhaustive]
89pub enum EnemyDespawnOutcome {
90 Despawned,
91 AlreadyInactive,
92 UnknownEnemy,
93}
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum EncounterDespawnKind {
99 Authored { counts_as_completion: bool },
100 ForcedOptional,
101}
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub enum EncounterTerminationReason {
107 FixedDuration,
108 PrimaryRequiredEnemyDead,
109 RequiredGroupsComplete,
110 AllEnemiesInRequiredGroupsDead,
111}
112
113#[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#[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#[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 #[must_use]
189 pub const fn missing_enemy(enemy: EnemyIdx) -> Self {
190 Self {
191 kind: SpecRuntimeErrorKind::MissingEnemy { enemy },
192 }
193 }
194
195 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
310pub enum DeathEventScope {
312 Unscoped,
313 ScriptedLifetime(PullLifecycleGeneration),
314}
315
316#[derive(Clone, Copy, Debug)]
318pub 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 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 #[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 #[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 }
568
569#[wowlab_engine_macros::spec_handler_delegation]
571pub trait SpecHandler: Send {
572 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 fn on_event_queue_empty(&mut self, _ctx: &mut SimContext) {}
667
668 fn encounter_termination_time(&self) -> Option<SimTime> {
670 None
671 }
672
673 fn on_despawn(&mut self, _target: EnemyIdx, _ctx: &mut SimContext) -> EnemyDespawnOutcome {
675 EnemyDespawnOutcome::UnknownEnemy
676 }
677
678 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 fn reset(&mut self) {}
699
700 fn flush_scheduled(&mut self, _push: &mut dyn FnMut(Event)) {}
701
702 fn take_runtime_error(&mut self) -> Option<SpecRuntimeError> {
704 None
705 }
706
707 fn total_damage(&self) -> f64;
708
709 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#[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}