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_events();
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            wowlab_types::bench_metrics::record_event();
118            event_count += 1;
119
120            if event_count > MAX_EVENTS {
121                return Err(SimRunError::event_budget_exceeded(event_count));
122            }
123
124            let t = SimTime::from_millis(self.queue.current_time_ms());
125
126            if t.as_millis() > self.encounter_end_ms
127                || (t.as_millis() == self.encounter_end_ms
128                    && event.priority() > LAST_ENDPOINT_PRIORITY)
129            {
130                break;
131            }
132
133            self.state.current_time = t;
134            self.handle_event(event, t)?;
135
136            if let Some(termination_time) = self.handler.encounter_termination_time() {
137                completed_at = Some(termination_time);
138                break;
139            }
140        }
141
142        if completed_at.is_none() && self.queue.is_empty() {
143            self.dispatch(|handler, c| handler.on_event_queue_empty(c))?;
144            completed_at = self.handler.encounter_termination_time();
145        }
146
147        self.finalize(
148            completed_at.unwrap_or_else(|| SimTime::from_millis(self.encounter_end_ms)),
149            mode,
150        );
151
152        Ok(())
153    }
154
155    // A strictly earlier wake replaces the marker; the superseded later event stays queued and pops as a harmless deduped one-shot.
156    fn push_player_ready(&mut self, t: SimTime) {
157        if let Some(pending) = self.pending_player_ready
158            && pending <= t
159        {
160            return;
161        }
162
163        self.pending_player_ready = Some(t);
164        self.queue.push(Event::PlayerReady { t });
165    }
166
167    // #t(fn: rust_cyclomatic_complexity) the typed event dispatcher is intentionally exhaustive
168    // #t(fn: rust_max_fn_lines) keeping the exhaustive event-to-handler mapping together makes priority dispatch auditable
169    fn handle_event(&mut self, event: Event, t: SimTime) -> Result<()> {
170        match event {
171            Event::PlayerReady { .. } => self.handle_player_ready_event(t),
172            Event::OffGcdReady { .. } => self.handle_player_ready(t),
173
174            Event::CastStart {
175                spell,
176                empower_rank,
177                source,
178                target,
179                ..
180            } => {
181                // docref:start event-system-cast-start-arm
182                let cast_ms = self.handler.cast_time_ms(spell, empower_rank);
183                let complete_time = SimTime::from_millis(t.as_millis().saturating_add(cast_ms));
184                self.queue.push(Event::CastComplete {
185                    t: complete_time,
186                    spell,
187                    empower_rank,
188                    source,
189                    target,
190                });
191                // docref:end event-system-cast-start-arm
192
193                Ok(())
194            }
195
196            event @ Event::CastComplete { .. } => {
197                self.dispatch(|handler, c| handler.on_cast_complete(event, c))
198            }
199
200            Event::TriggeredSpell {
201                spell,
202                source,
203                target,
204                ..
205            } => self.dispatch(|handler, c| {
206                handler.on_triggered_spell(spell, source, target, c);
207            }),
208
209            Event::SpellLaunch { impact, .. } => {
210                self.dispatch(|handler, c| handler.on_spell_launch(impact, c))
211            }
212
213            Event::SpellImpact { impact, .. } => {
214                self.dispatch(|handler, c| handler.on_spell_impact(impact, c))
215            }
216
217            Event::GuardianAction { guardian, .. } => {
218                self.dispatch(|handler, c| handler.on_guardian_action(guardian, c))
219            }
220
221            Event::GuardianExpire { guardian, .. } => {
222                self.dispatch(|handler, c| handler.on_guardian_expire(guardian, c))
223            }
224
225            Event::PetAction { action, .. } => {
226                self.dispatch(|handler, c| handler.on_pet_action(action, c))
227            }
228
229            Event::AuraTick { aura, .. } => self
230                .dispatch(|handler, c| handler.on_aura_tick(AuraEventRef::from_binding(aura), c)),
231
232            Event::ChannelTick {
233                spell,
234                source,
235                target,
236                generation,
237                ..
238            } => self.dispatch(|handler, c| {
239                handler.on_channel_tick(spell, source, target, generation, c);
240            }),
241
242            Event::AutoAttack { source, target, .. } => {
243                self.dispatch(|handler, c| handler.on_auto_attack(source, target, c))
244            }
245
246            Event::EnemyAutoAttack { source, target, .. } => {
247                self.dispatch(|handler, c| handler.on_enemy_auto_attack(source, target, c))
248            }
249
250            Event::CooldownReady { spell, .. } => {
251                self.dispatch(|handler, c| handler.on_cooldown_ready(spell, c))
252            }
253
254            Event::AuraExpire { aura, .. } => self
255                .dispatch(|handler, c| handler.on_aura_expire(AuraEventRef::from_binding(aura), c)),
256
257            Event::ProcHeartbeat { .. } => self.dispatch(|handler, c| handler.on_proc_heartbeat(c)),
258
259            Event::Death { target, scope, .. } => {
260                self.dispatch(|handler, c| handler.on_death(target, scope, c))
261            }
262
263            Event::EncounterDespawn {
264                scope,
265                target,
266                kind,
267                ..
268            } => self.dispatch(|handler, c| handler.on_encounter_despawn(scope, target, kind, c)),
269
270            Event::GroupComplete { scope, group, .. } => {
271                self.dispatch(|handler, c| handler.on_group_complete(scope, group, c))
272            }
273
274            Event::PullComplete {
275                scope, finalize, ..
276            } => self.dispatch(|handler, c| handler.on_pull_complete(scope, finalize, c)),
277
278            Event::EncounterTerminate { reason, .. } => {
279                self.dispatch(|handler, c| handler.on_encounter_terminate(reason, c))
280            }
281
282            Event::WaveActivate { scope, wave, .. } => {
283                self.dispatch(|handler, c| handler.on_wave_activate(scope, wave, c))
284            }
285
286            Event::EnemyActivate { scope, target, .. } => {
287                self.dispatch(|handler, c| handler.on_enemy_activate(scope, target, c))
288            }
289
290            Event::Retarget { scope, .. } => {
291                self.dispatch(|handler, c| handler.on_retarget(scope, c))
292            }
293
294            Event::EncounterMove {
295                scope,
296                actor,
297                transform,
298                ..
299            } => self.dispatch(|handler, c| handler.on_encounter_move(scope, actor, transform, c)),
300
301            Event::ActorMovement {
302                movement, moving, ..
303            } => self.dispatch(|handler, c| {
304                handler.on_actor_movement(movement, moving, c);
305            }),
306
307            Event::RaidEvent { event, .. } => {
308                self.dispatch(|handler, c| handler.on_raid_event(event, c))
309            }
310
311            Event::RaidAddDespawn { add, .. } => self.dispatch(|handler, c| {
312                handler.on_raid_add_despawn(add, c);
313            }),
314
315            Event::ExternalBuff { buff, .. } => {
316                self.dispatch(|handler, c| handler.on_external_buff(buff, c))
317            }
318
319            Event::HookTimer {
320                timer,
321                source,
322                target,
323                ..
324            } => self.dispatch(|handler, c| handler.on_hook_timer(timer, source, target, c)),
325        }
326    }
327
328    fn handle_player_ready_event(&mut self, t: SimTime) -> Result<()> {
329        // Ignore superseded wakes: earlier wakes replace only the marker, not the already-queued event.
330        if self.pending_player_ready != Some(t) {
331            return Ok(());
332        }
333
334        self.pending_player_ready = None;
335
336        self.handle_player_ready(t)
337    }
338
339    fn handle_player_ready(&mut self, t: SimTime) -> Result<()> {
340        let action = {
341            let mut c = sim_context(&self.state, self.sink);
342
343            self.handler.on_player_ready(&mut c)
344        };
345
346        self.check_handler_error()?;
347
348        match action {
349            Some(SpecAction::Cast {
350                spell,
351                empower_rank,
352                source,
353                target,
354            }) => {
355                self.queue.push(Event::CastStart {
356                    t,
357                    spell,
358                    empower_rank,
359                    source,
360                    target,
361                });
362            }
363            Some(SpecAction::Wait { until_ms }) => {
364                // Floor at now+1ms: a Wait that does not advance time would re-evaluate identical state forever.
365                let min_next = t.saturating_add(SimTime::from_millis(1));
366                let clamped = SimTime::from_millis(
367                    until_ms
368                        .max(min_next)
369                        .as_millis()
370                        .min(self.encounter_end_ms),
371                );
372
373                self.push_player_ready(clamped);
374            }
375            None => {}
376        }
377
378        flush_handler_events(
379            &mut *self.handler,
380            &mut *self.queue,
381            &mut self.pending_player_ready,
382        );
383
384        Ok(())
385    }
386
387    fn dispatch(&mut self, f: impl FnOnce(&mut dyn SpecHandler, &mut SimContext)) -> Result<()> {
388        {
389            let mut c = sim_context(&self.state, self.sink);
390
391            f(self.handler, &mut c);
392        };
393        self.check_handler_error()?;
394        flush_handler_events(
395            &mut *self.handler,
396            &mut *self.queue,
397            &mut self.pending_player_ready,
398        );
399
400        Ok(())
401    }
402
403    fn check_handler_error(&mut self) -> Result<()> {
404        self.handler
405            .take_runtime_error()
406            .map_or(Ok(()), |source| Err(SimRunError::handler(source)))
407    }
408
409    fn finalize(&mut self, completed_at: SimTime, mode: RunMode) {
410        let total_damage = self.handler.total_damage();
411
412        #[cfg(debug_assertions)]
413        self.debug_assert_damage_telemetry_reconciled(total_damage, completed_at, mode);
414
415        let duration_secs = f64::from(completed_at.as_millis()) / MS_PER_SECOND;
416        let dps = if duration_secs > 0.0 {
417            total_damage / duration_secs
418        } else {
419            0.0
420        };
421
422        match mode {
423            RunMode::Aggregate => {
424                self.telemetry.record_sink_events(self.sink, duration_secs);
425                self.telemetry.record_iteration_for(
426                    self.state.seed_prefix,
427                    self.state.iteration,
428                    dps,
429                );
430            }
431            RunMode::CaptureRepresentative => {
432                self.telemetry
433                    .install_representative(dps, self.sink, duration_secs);
434            }
435        }
436    }
437
438    #[cfg(debug_assertions)]
439    fn debug_assert_damage_telemetry_reconciled(
440        &self,
441        combat_total: f64,
442        completed_at: SimTime,
443        mode: RunMode,
444    ) {
445        let Some(reconciliation) = damage_reconciliation(combat_total, self.sink) else {
446            return;
447        };
448
449        debug_assert!(
450            reconciliation.is_within_error_bound(),
451            "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:?}",
452            reconciliation.combat_total,
453            reconciliation.telemetry_total,
454            reconciliation.direct,
455            reconciliation.periodic,
456            reconciliation.pet,
457            reconciliation.absolute_delta,
458            reconciliation.relative_delta,
459            reconciliation.error_bound,
460            reconciliation.event_count,
461            reconciliation.absolute_event_sum,
462            self.state.iteration,
463            self.state.seed_prefix,
464            completed_at.as_millis(),
465        );
466    }
467}
468
469#[derive(Clone, Copy, Debug)]
470#[cfg(debug_assertions)]
471struct DamageReconciliation {
472    combat_total: f64,
473    telemetry_total: f64,
474    direct: f64,
475    periodic: f64,
476    pet: f64,
477    absolute_delta: f64,
478    relative_delta: f64,
479    error_bound: f64,
480    event_count: usize,
481    absolute_event_sum: f64,
482}
483
484#[cfg(debug_assertions)]
485const TELEMETRY_CATEGORY_COMBINE_ADDITIONS: usize = 2;
486
487#[cfg(debug_assertions)]
488impl DamageReconciliation {
489    fn is_within_error_bound(self) -> bool {
490        self.absolute_delta <= self.error_bound
491    }
492}
493
494#[cfg(debug_assertions)]
495fn summation_error_coefficient(additions: usize) -> f64 {
496    let Ok(additions) = u32::try_from(additions) else {
497        return f64::INFINITY;
498    };
499    let scaled_epsilon = f64::from(additions) * f64::EPSILON;
500
501    if scaled_epsilon >= 1.0 {
502        f64::INFINITY
503    } else {
504        scaled_epsilon / (1.0 - scaled_epsilon)
505    }
506}
507
508#[cfg(debug_assertions)]
509fn damage_reconciliation(combat_total: f64, sink: &TelemetrySink) -> Option<DamageReconciliation> {
510    // Synthetic SpecHandler implementations may supply scalar damage for DPS-focused tests without
511    // emitting event telemetry. With no event ledger there is no second representation to reconcile.
512    if sink.damage.is_empty() {
513        return None;
514    }
515
516    let (direct, periodic, pet, absolute_event_sum) =
517        sink.damage
518            .iter()
519            .fold((0.0, 0.0, 0.0, 0.0), |mut totals, event| {
520                if event.is_pet {
521                    totals.2 += event.amount;
522                } else if event.is_periodic {
523                    totals.1 += event.amount;
524                } else {
525                    totals.0 += event.amount;
526                }
527
528                totals.3 += event.amount.abs();
529
530                totals
531            });
532    let telemetry_total = direct + periodic + pet;
533    let absolute_delta = (combat_total - telemetry_total).abs();
534    let scale = combat_total.abs().max(telemetry_total.abs());
535    let relative_delta = if scale > 0.0 {
536        absolute_delta / scale
537    } else {
538        0.0
539    };
540    let event_count = sink.damage.len();
541    let combat_error = summation_error_coefficient(event_count);
542    let telemetry_error = summation_error_coefficient(
543        event_count.saturating_add(TELEMETRY_CATEGORY_COMBINE_ADDITIONS),
544    );
545    let error_bound = (combat_error + telemetry_error) * absolute_event_sum;
546
547    Some(DamageReconciliation {
548        combat_total,
549        telemetry_total,
550        direct,
551        periodic,
552        pet,
553        absolute_delta,
554        relative_delta,
555        error_bound,
556        event_count,
557        absolute_event_sum,
558    })
559}
560
561fn sim_context<'a>(state: &'a SimState, sink: &'a mut TelemetrySink) -> SimContext<'a> {
562    SimContext {
563        state,
564        telemetry: sink,
565    }
566}
567
568fn flush_handler_events(
569    handler: &mut dyn SpecHandler,
570    queue: &mut EventQueue,
571    pending_player_ready: &mut Option<SimTime>,
572) {
573    handler.flush_scheduled(&mut |event| {
574        // Route handler-scheduled PlayerReady wakes through the readying guard too, else same-time cooldown wakes flood the queue.
575        if let Event::PlayerReady { t } = event {
576            if pending_player_ready.is_some_and(|pending| pending <= t) {
577                return;
578            }
579
580            *pending_player_ready = Some(t);
581        }
582
583        queue.push(event);
584    });
585}
586
587#[cfg(test)]
588#[path = "engine/tests.rs"]
589mod tests;