Skip to main content

wowlab_engine_telemetry/
lib.rs

1//! Per-iteration event collector for handler observability.
2
3use wowlab_types::sim::{ActorId, EnemyIdx, GroupId, PullId, SpatialTransform, TargetIdx};
4
5/// Typed encounter scope carried by target-aware telemetry events.
6#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
7pub struct TelemetryScope {
8    pub source: ActorId,
9    pub target: EnemyIdx,
10    pub pull: PullId,
11    pub group: GroupId,
12}
13
14/// Typed identity and encounter ownership for one aura instance.
15#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct AuraTelemetryScope {
17    pub source: ActorId,
18    pub affected: ActorId,
19    pub pull: PullId,
20    pub group: Option<GroupId>,
21}
22
23/// Classification flags carried by a damage observation.
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub struct DamageEventFlags {
26    pub is_crit: bool,
27    pub is_periodic: bool,
28    pub is_pet: bool,
29}
30
31/// Damage payload independent of encounter time and scope.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct DamageObservation {
34    pub spell_id: u32,
35    pub amount: f64,
36    pub flags: DamageEventFlags,
37}
38
39impl DamageEventFlags {
40    #[must_use]
41    pub const fn with_crit(mut self, is_crit: bool) -> Self {
42        self.is_crit = is_crit;
43
44        self
45    }
46
47    #[must_use]
48    pub const fn with_periodic(mut self, is_periodic: bool) -> Self {
49        self.is_periodic = is_periodic;
50
51        self
52    }
53
54    #[must_use]
55    pub const fn with_pet(mut self, is_pet: bool) -> Self {
56        self.is_pet = is_pet;
57
58        self
59    }
60}
61
62/// Immutable target metadata used to build the protobuf target dictionary.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct TargetMetadata {
65    pub id: TargetIdx,
66    pub npc_id: Option<u32>,
67    pub display_name: String,
68    pub group: GroupId,
69    pub enemy_tags: Vec<Box<str>>,
70    pub group_tags: Vec<Box<str>>,
71}
72
73#[derive(Clone, Debug)]
74#[non_exhaustive]
75pub struct DamageEvent {
76    pub spell_id: u32,
77    pub amount: f64,
78    pub is_crit: bool,
79    pub is_periodic: bool,
80    pub is_pet: bool,
81    pub time_ms: u32,
82    pub scope: TelemetryScope,
83}
84
85impl DamageEvent {
86    #[must_use]
87    pub fn new(observation: DamageObservation, time_ms: u32, scope: TelemetryScope) -> Self {
88        Self {
89            spell_id: observation.spell_id,
90            amount: observation.amount,
91            is_crit: observation.flags.is_crit,
92            is_periodic: observation.flags.is_periodic,
93            is_pet: observation.flags.is_pet,
94            time_ms,
95            scope,
96        }
97    }
98}
99
100/// Aura identity and transition kind independent of encounter time and scope.
101#[derive(Clone, Copy, Debug)]
102pub struct AuraObservation {
103    pub aura_id: u32,
104    pub kind: AuraEventKind,
105}
106
107#[derive(Clone, Debug)]
108#[non_exhaustive]
109pub struct AuraEvent {
110    pub aura_id: u32,
111    pub kind: AuraEventKind,
112    pub time_ms: u32,
113    pub source: ActorId,
114    pub affected: ActorId,
115    pub pull: PullId,
116    pub group: Option<GroupId>,
117}
118
119impl AuraEvent {
120    #[must_use]
121    pub const fn new(
122        observation: AuraObservation,
123        time_ms: u32,
124        scope: AuraTelemetryScope,
125    ) -> Self {
126        Self {
127            aura_id: observation.aura_id,
128            kind: observation.kind,
129            time_ms,
130            source: scope.source,
131            affected: scope.affected,
132            pull: scope.pull,
133            group: scope.group,
134        }
135    }
136}
137
138/// Resource values captured by one gain or spend event.
139#[derive(Clone, Copy, Debug, PartialEq)]
140pub struct ResourceValues {
141    pub amount: f64,
142    pub current: f64,
143    pub maximum: f64,
144}
145
146/// Resource identity and values independent of event time.
147#[derive(Clone, Copy, Debug)]
148pub struct ResourceObservation {
149    pub kind: ResourceEventKind,
150    pub resource_type: u8,
151    pub values: ResourceValues,
152}
153
154#[derive(Clone, Copy, Debug)]
155// #t(rust_non_exhaustive_on_public) internal port enum matched exhaustively within workspace
156pub enum AuraEventKind {
157    Apply { stacks: u8 },
158    Refresh { stacks: u8 },
159    Expire,
160}
161
162/// [`ResourceEvent::source_spell_id`] for a gain no spell accounts for.
163pub const RESOURCE_SOURCE_UNATTRIBUTED: u32 = 0;
164
165/// [`ResourceEvent::source_spell_id`] for a gain driven by an auto-attack swing.
166pub const RESOURCE_SOURCE_AUTO_ATTACK: u32 = 1;
167
168/// [`ResourceEvent::source_spell_id`] for haste-scaled passive regeneration, which has no casting spell.
169pub const RESOURCE_SOURCE_PASSIVE_REGEN: u32 = u32::MAX;
170
171#[derive(Clone, Debug)]
172#[non_exhaustive]
173pub struct ResourceEvent {
174    pub kind: ResourceEventKind,
175    pub resource_type: u8,
176    pub amount: f64,
177    pub current: f64,
178    pub max: f64,
179    pub time_ms: u32,
180    /// Gain/spend attribution: a spell id, or one of the reserved `RESOURCE_SOURCE_*` values.
181    pub source_spell_id: u32,
182}
183
184impl ResourceEvent {
185    #[must_use]
186    pub fn new(observation: ResourceObservation, time_ms: u32) -> Self {
187        Self {
188            kind: observation.kind,
189            resource_type: observation.resource_type,
190            amount: observation.values.amount,
191            current: observation.values.current,
192            max: observation.values.maximum,
193            time_ms,
194            source_spell_id: RESOURCE_SOURCE_UNATTRIBUTED,
195        }
196    }
197
198    /// Attach the gaining/spending spell for per-source economy views.
199    #[must_use]
200    pub const fn with_source(mut self, source_spell_id: u32) -> Self {
201        self.source_spell_id = source_spell_id;
202
203        self
204    }
205}
206
207#[derive(Clone, Copy, Debug)]
208// #t(rust_non_exhaustive_on_public) internal port enum matched exhaustively within workspace
209pub enum ResourceEventKind {
210    Spend,
211    Gain { wasted: f64 },
212}
213
214#[derive(Clone, Debug)]
215#[non_exhaustive]
216pub struct CooldownEvent {
217    pub spell_id: u32,
218    pub duration_ms: u32,
219    pub time_ms: u32,
220}
221
222#[derive(Clone, Debug)]
223#[non_exhaustive]
224pub struct CastEvent {
225    pub spell_id: u32,
226    pub time_ms: u32,
227    pub gcd_ms: u32,
228    pub scope: TelemetryScope,
229}
230
231/// Encounter lifecycle event kind captured in the representative timeline.
232#[derive(Clone, Copy, Debug, Eq, PartialEq)]
233// #t(rust_non_exhaustive_on_public) internal port enum matched exhaustively within workspace
234pub enum EncounterTelemetryEventKind {
235    Spawn,
236    Movement,
237    Death,
238    Despawn,
239    TargetChange,
240    PullTransition,
241}
242
243/// One typed encounter lifecycle observation.
244#[derive(Clone, Debug, PartialEq)]
245pub struct EncounterTelemetryEvent {
246    pub kind: EncounterTelemetryEventKind,
247    pub time_ms: u32,
248    pub actor: ActorId,
249    pub previous_target: Option<EnemyIdx>,
250    pub target: Option<EnemyIdx>,
251    pub pull: PullId,
252    pub group: Option<GroupId>,
253    pub transform: Option<SpatialTransform>,
254}
255
256/// Insertion order for the event families merged into the representative timeline.
257#[derive(Clone, Copy, Debug, Eq, PartialEq)]
258#[non_exhaustive]
259pub enum TimelineEventIndex {
260    Cast(usize),
261    Damage(usize),
262    Encounter(usize),
263}
264
265/// Collects per-iteration combat events for post-simulation analysis.
266#[derive(Debug)]
267// docref:start metrics-telemetry-sink
268pub struct TelemetrySink {
269    pub damage: Vec<DamageEvent>,
270    pub auras: Vec<AuraEvent>,
271    pub resources: Vec<ResourceEvent>,
272    pub cooldowns: Vec<CooldownEvent>,
273    pub casts: Vec<CastEvent>,
274    pub encounter: Vec<EncounterTelemetryEvent>,
275    pub targets: Vec<TargetMetadata>,
276    timeline_order: Vec<TimelineEventIndex>,
277}
278// docref:end metrics-telemetry-sink
279
280impl TelemetrySink {
281    const DAMAGE_CAPACITY: usize = 1024;
282    const AURA_CAPACITY: usize = 256;
283    const RESOURCE_CAPACITY: usize = 512;
284    const COOLDOWN_CAPACITY: usize = 128;
285    const CAST_CAPACITY: usize = 512;
286    const ENCOUNTER_CAPACITY: usize = 128;
287
288    #[must_use]
289    pub fn new() -> Self {
290        Self {
291            damage: Vec::with_capacity(Self::DAMAGE_CAPACITY),
292            auras: Vec::with_capacity(Self::AURA_CAPACITY),
293            resources: Vec::with_capacity(Self::RESOURCE_CAPACITY),
294            cooldowns: Vec::with_capacity(Self::COOLDOWN_CAPACITY),
295            casts: Vec::with_capacity(Self::CAST_CAPACITY),
296            encounter: Vec::with_capacity(Self::ENCOUNTER_CAPACITY),
297            targets: Vec::new(),
298            timeline_order: Vec::with_capacity(
299                Self::CAST_CAPACITY + Self::DAMAGE_CAPACITY + Self::ENCOUNTER_CAPACITY,
300            ),
301        }
302    }
303
304    /// Clear per-iteration observations while retaining target metadata and every allocation.
305    pub fn clear_events(&mut self) {
306        self.damage.clear();
307        self.auras.clear();
308        self.resources.clear();
309        self.cooldowns.clear();
310        self.casts.clear();
311        self.encounter.clear();
312        self.timeline_order.clear();
313    }
314
315    /// Clear observations and immutable encounter metadata for full sink teardown.
316    pub fn clear_all(&mut self) {
317        self.clear_events();
318        self.targets.clear();
319    }
320
321    pub fn emit_damage(&mut self, event: &DamageEvent) {
322        wowlab_types::bench_metrics::record_damage_event();
323        self.timeline_order
324            .push(TimelineEventIndex::Damage(self.damage.len()));
325        self.damage.push(event.clone());
326    }
327
328    pub fn emit_aura_apply(
329        &mut self,
330        aura_id: u32,
331        stacks: u8,
332        time_ms: u32,
333        scope: AuraTelemetryScope,
334    ) {
335        self.emit_aura(AuraEvent::new(
336            AuraObservation {
337                aura_id,
338                kind: AuraEventKind::Apply { stacks },
339            },
340            time_ms,
341            scope,
342        ));
343    }
344
345    pub fn emit_aura_refresh(
346        &mut self,
347        aura_id: u32,
348        stacks: u8,
349        time_ms: u32,
350        scope: AuraTelemetryScope,
351    ) {
352        self.emit_aura(AuraEvent::new(
353            AuraObservation {
354                aura_id,
355                kind: AuraEventKind::Refresh { stacks },
356            },
357            time_ms,
358            scope,
359        ));
360    }
361
362    pub fn emit_aura_expire(&mut self, aura_id: u32, time_ms: u32, scope: AuraTelemetryScope) {
363        self.emit_aura(AuraEvent::new(
364            AuraObservation {
365                aura_id,
366                kind: AuraEventKind::Expire,
367            },
368            time_ms,
369            scope,
370        ));
371    }
372
373    pub fn emit_resource(&mut self, event: &ResourceEvent) {
374        self.resources.push(event.clone());
375    }
376
377    pub fn emit_cooldown_start(&mut self, spell_id: u32, duration_ms: u32, time_ms: u32) {
378        self.cooldowns.push(CooldownEvent {
379            spell_id,
380            duration_ms,
381            time_ms,
382        });
383    }
384
385    pub fn emit_cast(&mut self, spell_id: u32, time_ms: u32, gcd_ms: u32, scope: TelemetryScope) {
386        self.timeline_order
387            .push(TimelineEventIndex::Cast(self.casts.len()));
388        self.casts.push(CastEvent {
389            spell_id,
390            time_ms,
391            gcd_ms,
392            scope,
393        });
394    }
395
396    pub fn emit_encounter(&mut self, event: EncounterTelemetryEvent) {
397        self.timeline_order
398            .push(TimelineEventIndex::Encounter(self.encounter.len()));
399        self.encounter.push(event);
400    }
401
402    /// Stable insertion order shared by casts, damage, and encounter lifecycle events.
403    #[must_use]
404    pub fn timeline_order(&self) -> &[TimelineEventIndex] {
405        &self.timeline_order
406    }
407
408    /// Install the deterministic target dictionary for the sink's encounter.
409    pub fn install_targets(&mut self, targets: &[TargetMetadata]) {
410        self.targets.clear();
411        self.targets.extend_from_slice(targets);
412    }
413
414    fn emit_aura(&mut self, event: AuraEvent) {
415        wowlab_types::bench_metrics::record_aura_event();
416        self.auras.push(event);
417    }
418}
419
420impl Default for TelemetrySink {
421    fn default() -> Self {
422        Self::new()
423    }
424}
425
426#[cfg(test)]
427mod tests;