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    pub fn clear(&mut self) {
305        self.damage.clear();
306        self.auras.clear();
307        self.resources.clear();
308        self.cooldowns.clear();
309        self.casts.clear();
310        self.encounter.clear();
311        self.targets.clear();
312        self.timeline_order.clear();
313    }
314
315    pub fn emit_damage(&mut self, event: &DamageEvent) {
316        self.timeline_order
317            .push(TimelineEventIndex::Damage(self.damage.len()));
318        self.damage.push(event.clone());
319    }
320
321    pub fn emit_aura_apply(
322        &mut self,
323        aura_id: u32,
324        stacks: u8,
325        time_ms: u32,
326        scope: AuraTelemetryScope,
327    ) {
328        self.emit_aura(AuraEvent::new(
329            AuraObservation {
330                aura_id,
331                kind: AuraEventKind::Apply { stacks },
332            },
333            time_ms,
334            scope,
335        ));
336    }
337
338    pub fn emit_aura_refresh(
339        &mut self,
340        aura_id: u32,
341        stacks: u8,
342        time_ms: u32,
343        scope: AuraTelemetryScope,
344    ) {
345        self.emit_aura(AuraEvent::new(
346            AuraObservation {
347                aura_id,
348                kind: AuraEventKind::Refresh { stacks },
349            },
350            time_ms,
351            scope,
352        ));
353    }
354
355    pub fn emit_aura_expire(&mut self, aura_id: u32, time_ms: u32, scope: AuraTelemetryScope) {
356        self.emit_aura(AuraEvent::new(
357            AuraObservation {
358                aura_id,
359                kind: AuraEventKind::Expire,
360            },
361            time_ms,
362            scope,
363        ));
364    }
365
366    pub fn emit_resource(&mut self, event: &ResourceEvent) {
367        self.resources.push(event.clone());
368    }
369
370    pub fn emit_cooldown_start(&mut self, spell_id: u32, duration_ms: u32, time_ms: u32) {
371        self.cooldowns.push(CooldownEvent {
372            spell_id,
373            duration_ms,
374            time_ms,
375        });
376    }
377
378    pub fn emit_cast(&mut self, spell_id: u32, time_ms: u32, gcd_ms: u32, scope: TelemetryScope) {
379        self.timeline_order
380            .push(TimelineEventIndex::Cast(self.casts.len()));
381        self.casts.push(CastEvent {
382            spell_id,
383            time_ms,
384            gcd_ms,
385            scope,
386        });
387    }
388
389    pub fn emit_encounter(&mut self, event: EncounterTelemetryEvent) {
390        self.timeline_order
391            .push(TimelineEventIndex::Encounter(self.encounter.len()));
392        self.encounter.push(event);
393    }
394
395    /// Stable insertion order shared by casts, damage, and encounter lifecycle events.
396    #[must_use]
397    pub fn timeline_order(&self) -> &[TimelineEventIndex] {
398        &self.timeline_order
399    }
400
401    /// Install the deterministic encounter target dictionary for this iteration.
402    pub fn set_targets(&mut self, targets: Vec<TargetMetadata>) {
403        self.targets = targets;
404    }
405
406    fn emit_aura(&mut self, event: AuraEvent) {
407        self.auras.push(event);
408    }
409}
410
411impl Default for TelemetrySink {
412    fn default() -> Self {
413        Self::new()
414    }
415}
416
417#[cfg(test)]
418mod tests;