Skip to main content

wowlab_engine_sim/
engine.rs

1use wowlab_engine_ports::{
2    AuraEventRef, Event, SimContext, SimRunError, SimState, SpecAction, SpecHandler,
3};
4use wowlab_engine_telemetry::TelemetrySink;
5use wowlab_types::{constants::MS_PER_SECOND, sim::SimTime};
6
7use crate::{Result, queue::EventQueue, telemetry::TelemetryAccumulator};
8
9const MAX_EVENTS: u32 = 500_000;
10const LAST_ENDPOINT_PRIORITY: u8 = 3;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13enum RunMode {
14    Aggregate,
15    CaptureRepresentative,
16}
17
18/// Mutable references shared across simulation iterations.
19pub struct SimEngineRefs<'a> {
20    pub telemetry: &'a mut TelemetryAccumulator,
21    pub handler: &'a mut dyn SpecHandler,
22    /// Reusable event queue, cleared by the caller before each iteration.
23    pub queue: &'a mut EventQueue,
24    /// Per-iteration telemetry sink, cleared by [`SimEngine::run`].
25    pub sink: &'a mut TelemetrySink,
26}
27
28impl std::fmt::Debug for SimEngineRefs<'_> {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("SimEngineRefs")
31            .field("telemetry", &self.telemetry)
32            .field("handler", &"<dyn SpecHandler>")
33            .field("queue", &self.queue)
34            .field("sink", &self.sink)
35            .finish()
36    }
37}
38
39/// Event loop for a single sim iteration; queue and handler are borrowed for reuse across iterations.
40// docref:start des-sim-engine-struct
41pub struct SimEngine<'a> {
42    queue: &'a mut EventQueue,
43    state: SimState,
44    telemetry: &'a mut TelemetryAccumulator,
45    handler: &'a mut dyn SpecHandler,
46    sink: &'a mut TelemetrySink,
47    encounter_end_ms: u32,
48    // Readying guard: at most one PlayerReady is ever queued, else cooldown and Wait re-arms grow the queue without bound.
49    pending_player_ready: Option<SimTime>,
50}
51// docref:end des-sim-engine-struct
52
53impl std::fmt::Debug for SimEngine<'_> {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("SimEngine")
56            .field("queue", &self.queue)
57            .field("state", &self.state)
58            .field("telemetry", &self.telemetry)
59            .field("handler", &"<dyn SpecHandler>")
60            .field("sink", &self.sink)
61            .field("encounter_end_ms", &self.encounter_end_ms)
62            .finish()
63    }
64}
65
66impl<'a> SimEngine<'a> {
67    /// Construct an iteration engine over caller-owned reusable state; the caller clears the queue and resets the handler first.
68    #[must_use]
69    pub fn new(state: SimState, refs: SimEngineRefs<'a>) -> Self {
70        let encounter_end_ms = state.encounter_end.as_millis();
71        let SimEngineRefs {
72            telemetry,
73            handler,
74            queue,
75            sink,
76        } = refs;
77
78        Self {
79            queue,
80            state,
81            telemetry,
82            handler,
83            sink,
84            encounter_end_ms,
85            pending_player_ready: None,
86        }
87    }
88
89    /// Execute one iteration to completion and record telemetry.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the event budget is exceeded or the spec handler fails.
94    pub fn run(&mut self) -> Result<()> {
95        self.run_with_mode(RunMode::Aggregate)
96    }
97
98    /// Deterministically rerun one selected iteration and install only its representative trace, leaving aggregates unchanged.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if the event budget is exceeded or the spec handler fails.
103    pub fn capture_representative(&mut self) -> Result<()> {
104        self.run_with_mode(RunMode::CaptureRepresentative)
105    }
106
107    fn run_with_mode(&mut self, mode: RunMode) -> Result<()> {
108        self.sink.clear();
109
110        self.dispatch(|handler, c| handler.on_sim_start(c))?;
111        self.push_player_ready(SimTime::ZERO);
112
113        let mut event_count: u32 = 0;
114        let mut completed_at = None;
115
116        while let Some(event) = self.queue.pop() {
117            event_count += 1;
118
119            if event_count > MAX_EVENTS {
120                return Err(SimRunError::event_budget_exceeded(event_count));
121            }
122
123            let t = event.timestamp();
124
125            if t.as_millis() > self.encounter_end_ms
126                || (t.as_millis() == self.encounter_end_ms
127                    && event.priority() > LAST_ENDPOINT_PRIORITY)
128            {
129                break;
130            }
131
132            self.state.current_time = t;
133            self.handle_event(event, t)?;
134
135            if let Some(termination_time) = self.handler.encounter_termination_time() {
136                completed_at = Some(termination_time);
137                break;
138            }
139        }
140
141        if completed_at.is_none() && self.queue.is_empty() {
142            self.dispatch(|handler, c| handler.on_event_queue_empty(c))?;
143            completed_at = self.handler.encounter_termination_time();
144        }
145
146        self.finalize(
147            completed_at.unwrap_or_else(|| SimTime::from_millis(self.encounter_end_ms)),
148            mode,
149        );
150
151        Ok(())
152    }
153
154    // A strictly earlier wake replaces the marker; the superseded later event stays queued and pops as a harmless deduped one-shot.
155    fn push_player_ready(&mut self, t: SimTime) {
156        if let Some(pending) = self.pending_player_ready {
157            if pending <= t {
158                return;
159            }
160        }
161
162        self.pending_player_ready = Some(t);
163        self.queue.push(Event::PlayerReady { t });
164    }
165
166    // #t(fn: rust_cyclomatic_complexity) the typed event dispatcher is intentionally exhaustive
167    // #t(fn: rust_max_fn_lines) keeping the exhaustive event-to-handler mapping together makes priority dispatch auditable
168    fn handle_event(&mut self, event: Event, t: SimTime) -> Result<()> {
169        match event {
170            Event::PlayerReady { .. } => self.handle_player_ready_event(t),
171            Event::OffGcdReady { .. } => self.handle_player_ready(t),
172
173            Event::CastStart {
174                t,
175                spell_id,
176                empower_rank,
177                source,
178                target,
179            } => {
180                // docref:start event-system-cast-start-arm
181                let cast_ms = self.handler.cast_time_ms(spell_id, empower_rank);
182                let complete_time = SimTime::from_millis(t.as_millis().saturating_add(cast_ms));
183                self.queue.push(Event::CastComplete {
184                    t: complete_time,
185                    spell_id,
186                    empower_rank,
187                    source,
188                    target,
189                });
190                // docref:end event-system-cast-start-arm
191
192                Ok(())
193            }
194
195            event @ Event::CastComplete { .. } => {
196                self.dispatch(|handler, c| handler.on_cast_complete(event, c))
197            }
198
199            Event::TriggeredSpell {
200                spell_id,
201                source,
202                target,
203                ..
204            } => self.dispatch(|handler, c| {
205                handler.on_triggered_spell(spell_id, source, target, c);
206            }),
207
208            Event::SpellLaunch { impact_id, .. } => {
209                self.dispatch(|handler, c| handler.on_spell_launch(impact_id, c))
210            }
211
212            Event::SpellImpact { impact_id, .. } => {
213                self.dispatch(|handler, c| handler.on_spell_impact(impact_id, c))
214            }
215
216            Event::GuardianAction {
217                guardian_id,
218                guardian_generation,
219                ability_index,
220                ..
221            } => self.dispatch(|handler, c| {
222                handler.on_guardian_action(guardian_id, guardian_generation, ability_index, c);
223            }),
224
225            Event::GuardianExpire {
226                guardian_id,
227                guardian_generation,
228                ..
229            } => self.dispatch(|handler, c| {
230                handler.on_guardian_expire(guardian_id, guardian_generation, c);
231            }),
232
233            Event::PetAction {
234                auto_attack_index, ..
235            } => self.dispatch(|handler, c| handler.on_pet_action(auto_attack_index, c)),
236
237            Event::AuraTick { key, target, .. } => {
238                self.dispatch(|handler, c| handler.on_aura_tick(AuraEventRef { key, target }, c))
239            }
240
241            Event::ChannelTick {
242                spell_id,
243                source,
244                target,
245                generation,
246                ..
247            } => self.dispatch(|handler, c| {
248                handler.on_channel_tick(spell_id, source, target, generation, c);
249            }),
250
251            Event::AutoAttack { source, target, .. } => {
252                self.dispatch(|handler, c| handler.on_auto_attack(source, target, c))
253            }
254
255            Event::EnemyAutoAttack { source, target, .. } => {
256                self.dispatch(|handler, c| handler.on_enemy_auto_attack(source, target, c))
257            }
258
259            Event::CooldownReady { cooldown_key, .. } => {
260                self.dispatch(|handler, c| handler.on_cooldown_ready(cooldown_key, c))
261            }
262
263            Event::AuraExpire { key, target, .. } => {
264                self.dispatch(|handler, c| handler.on_aura_expire(AuraEventRef { key, target }, c))
265            }
266
267            Event::ProcHeartbeat { .. } => self.dispatch(|handler, c| handler.on_proc_heartbeat(c)),
268
269            Event::Death { target, scope, .. } => {
270                self.dispatch(|handler, c| handler.on_death(target, scope, c))
271            }
272
273            Event::EncounterDespawn {
274                scope,
275                target,
276                kind,
277                ..
278            } => self.dispatch(|handler, c| handler.on_encounter_despawn(scope, target, kind, c)),
279
280            Event::GroupComplete { scope, group, .. } => {
281                self.dispatch(|handler, c| handler.on_group_complete(scope, group, c))
282            }
283
284            Event::PullComplete {
285                scope, finalize, ..
286            } => self.dispatch(|handler, c| handler.on_pull_complete(scope, finalize, c)),
287
288            Event::EncounterTerminate { reason, .. } => {
289                self.dispatch(|handler, c| handler.on_encounter_terminate(reason, c))
290            }
291
292            Event::WaveActivate { scope, wave, .. } => {
293                self.dispatch(|handler, c| handler.on_wave_activate(scope, wave, c))
294            }
295
296            Event::EnemyActivate { scope, target, .. } => {
297                self.dispatch(|handler, c| handler.on_enemy_activate(scope, target, c))
298            }
299
300            Event::Retarget { scope, .. } => {
301                self.dispatch(|handler, c| handler.on_retarget(scope, c))
302            }
303
304            Event::EncounterMove {
305                scope,
306                actor,
307                transform,
308                ..
309            } => self.dispatch(|handler, c| handler.on_encounter_move(scope, actor, transform, c)),
310
311            Event::ActorMovement {
312                actor,
313                moving,
314                generation,
315                ..
316            } => self.dispatch(|handler, c| {
317                handler.on_actor_movement(actor, moving, generation, c);
318            }),
319
320            Event::RaidEvent { index, .. } => {
321                self.dispatch(|handler, c| handler.on_raid_event(index, c))
322            }
323
324            Event::RaidAddDespawn {
325                target,
326                activation_generation,
327                encounter_generation,
328                ..
329            } => self.dispatch(|handler, c| {
330                handler.on_raid_add_despawn(target, activation_generation, encounter_generation, c);
331            }),
332
333            Event::ExternalBuff { index, .. } => {
334                self.dispatch(|handler, c| handler.on_external_buff(index, c))
335            }
336
337            Event::HookTimer {
338                timer_id,
339                source,
340                target,
341                ..
342            } => self.dispatch(|handler, c| handler.on_hook_timer(timer_id, source, target, c)),
343        }
344    }
345
346    fn handle_player_ready_event(&mut self, t: SimTime) -> Result<()> {
347        // Ignore superseded wakes: earlier wakes replace only the marker, not the already-queued event.
348        if self.pending_player_ready != Some(t) {
349            return Ok(());
350        }
351
352        self.pending_player_ready = None;
353
354        self.handle_player_ready(t)
355    }
356
357    fn handle_player_ready(&mut self, t: SimTime) -> Result<()> {
358        let action = {
359            let mut c = sim_context(&self.state, self.sink);
360
361            self.handler.on_player_ready(&mut c)
362        };
363
364        self.check_handler_error()?;
365
366        match action {
367            Some(SpecAction::Cast {
368                spell_id,
369                empower_rank,
370                source,
371                target,
372            }) => {
373                self.queue.push(Event::CastStart {
374                    t,
375                    spell_id,
376                    empower_rank,
377                    source,
378                    target,
379                });
380            }
381            Some(SpecAction::Wait { until_ms }) => {
382                // Floor at now+1ms: a Wait that does not advance time would re-evaluate identical state forever.
383                let min_next = t.saturating_add(SimTime::from_millis(1));
384                let clamped = SimTime::from_millis(
385                    until_ms
386                        .max(min_next)
387                        .as_millis()
388                        .min(self.encounter_end_ms),
389                );
390
391                self.push_player_ready(clamped);
392            }
393            None => {}
394        }
395
396        flush_handler_events(
397            &mut *self.handler,
398            &mut *self.queue,
399            &mut self.pending_player_ready,
400        );
401
402        Ok(())
403    }
404
405    fn dispatch(&mut self, f: impl FnOnce(&mut dyn SpecHandler, &mut SimContext)) -> Result<()> {
406        {
407            let mut c = sim_context(&self.state, self.sink);
408
409            f(self.handler, &mut c);
410        };
411        self.check_handler_error()?;
412        flush_handler_events(
413            &mut *self.handler,
414            &mut *self.queue,
415            &mut self.pending_player_ready,
416        );
417
418        Ok(())
419    }
420
421    fn check_handler_error(&mut self) -> Result<()> {
422        self.handler
423            .take_runtime_error()
424            .map_or(Ok(()), |source| Err(SimRunError::handler(source)))
425    }
426
427    fn finalize(&mut self, completed_at: SimTime, mode: RunMode) {
428        let total_damage = self.handler.total_damage();
429
430        #[cfg(debug_assertions)]
431        self.debug_assert_damage_telemetry_reconciled(total_damage, completed_at, mode);
432
433        let duration_secs = f64::from(completed_at.as_millis()) / MS_PER_SECOND;
434        let dps = if duration_secs > 0.0 {
435            total_damage / duration_secs
436        } else {
437            0.0
438        };
439
440        match mode {
441            RunMode::Aggregate => {
442                self.telemetry.record_sink_events(self.sink, duration_secs);
443                self.telemetry.record_iteration_for(
444                    self.state.seed_prefix,
445                    self.state.iteration,
446                    dps,
447                );
448            }
449            RunMode::CaptureRepresentative => {
450                self.telemetry
451                    .install_representative(dps, self.sink, duration_secs);
452            }
453        }
454    }
455
456    #[cfg(debug_assertions)]
457    fn debug_assert_damage_telemetry_reconciled(
458        &self,
459        combat_total: f64,
460        completed_at: SimTime,
461        mode: RunMode,
462    ) {
463        let Some(reconciliation) = damage_reconciliation(combat_total, self.sink) else {
464            return;
465        };
466
467        debug_assert!(
468            reconciliation.is_within_error_bound(),
469            "damage telemetry diverged from the authoritative combat total: combat_total={:.17e}, telemetry_total={:.17e}, direct={:.17e}, periodic={:.17e}, pet={:.17e}, absolute_delta={:.17e}, relative_delta={:.17e}, error_bound={:.17e}, event_count={}, absolute_event_sum={:.17e}, iteration={}, seed_prefix={}, completed_at_ms={}, mode={mode:?}",
470            reconciliation.combat_total,
471            reconciliation.telemetry_total,
472            reconciliation.direct,
473            reconciliation.periodic,
474            reconciliation.pet,
475            reconciliation.absolute_delta,
476            reconciliation.relative_delta,
477            reconciliation.error_bound,
478            reconciliation.event_count,
479            reconciliation.absolute_event_sum,
480            self.state.iteration,
481            self.state.seed_prefix,
482            completed_at.as_millis(),
483        );
484    }
485}
486
487#[derive(Clone, Copy, Debug)]
488#[cfg(debug_assertions)]
489struct DamageReconciliation {
490    combat_total: f64,
491    telemetry_total: f64,
492    direct: f64,
493    periodic: f64,
494    pet: f64,
495    absolute_delta: f64,
496    relative_delta: f64,
497    error_bound: f64,
498    event_count: usize,
499    absolute_event_sum: f64,
500}
501
502#[cfg(debug_assertions)]
503const TELEMETRY_CATEGORY_COMBINE_ADDITIONS: usize = 2;
504
505#[cfg(debug_assertions)]
506impl DamageReconciliation {
507    fn is_within_error_bound(self) -> bool {
508        self.absolute_delta <= self.error_bound
509    }
510}
511
512#[cfg(debug_assertions)]
513fn summation_error_coefficient(additions: usize) -> f64 {
514    let Ok(additions) = u32::try_from(additions) else {
515        return f64::INFINITY;
516    };
517    let scaled_epsilon = f64::from(additions) * f64::EPSILON;
518
519    if scaled_epsilon >= 1.0 {
520        f64::INFINITY
521    } else {
522        scaled_epsilon / (1.0 - scaled_epsilon)
523    }
524}
525
526#[cfg(debug_assertions)]
527fn damage_reconciliation(combat_total: f64, sink: &TelemetrySink) -> Option<DamageReconciliation> {
528    // Synthetic SpecHandler implementations may supply scalar damage for DPS-focused tests without
529    // emitting event telemetry. With no event ledger there is no second representation to reconcile.
530    if sink.damage.is_empty() {
531        return None;
532    }
533
534    let (direct, periodic, pet, absolute_event_sum) =
535        sink.damage
536            .iter()
537            .fold((0.0, 0.0, 0.0, 0.0), |mut totals, event| {
538                if event.is_pet {
539                    totals.2 += event.amount;
540                } else if event.is_periodic {
541                    totals.1 += event.amount;
542                } else {
543                    totals.0 += event.amount;
544                }
545
546                totals.3 += event.amount.abs();
547
548                totals
549            });
550    let telemetry_total = direct + periodic + pet;
551    let absolute_delta = (combat_total - telemetry_total).abs();
552    let scale = combat_total.abs().max(telemetry_total.abs());
553    let relative_delta = if scale > 0.0 {
554        absolute_delta / scale
555    } else {
556        0.0
557    };
558    let event_count = sink.damage.len();
559    let combat_error = summation_error_coefficient(event_count);
560    let telemetry_error = summation_error_coefficient(
561        event_count.saturating_add(TELEMETRY_CATEGORY_COMBINE_ADDITIONS),
562    );
563    let error_bound = (combat_error + telemetry_error) * absolute_event_sum;
564
565    Some(DamageReconciliation {
566        combat_total,
567        telemetry_total,
568        direct,
569        periodic,
570        pet,
571        absolute_delta,
572        relative_delta,
573        error_bound,
574        event_count,
575        absolute_event_sum,
576    })
577}
578
579fn sim_context<'a>(state: &'a SimState, sink: &'a mut TelemetrySink) -> SimContext<'a> {
580    SimContext {
581        state,
582        telemetry: sink,
583    }
584}
585
586fn flush_handler_events(
587    handler: &mut dyn SpecHandler,
588    queue: &mut EventQueue,
589    pending_player_ready: &mut Option<SimTime>,
590) {
591    handler.flush_scheduled(&mut |event| {
592        // Route handler-scheduled PlayerReady wakes through the readying guard too, else same-time cooldown wakes flood the queue.
593        if let Event::PlayerReady { t } = event {
594            if pending_player_ready.is_some_and(|pending| pending <= t) {
595                return;
596            }
597
598            *pending_player_ready = Some(t);
599        }
600
601        queue.push(event);
602    });
603}
604
605#[cfg(test)]
606#[path = "engine/tests.rs"]
607mod tests;