Skip to main content

wowlab_types/types/sim/
result.rs

1use serde::{Deserialize, Serialize};
2
3/// Written to `jobs_chunks.result`.
4#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
5#[serde(rename_all = "camelCase")]
6#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
7#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
8pub struct ChunkResult {
9    pub iterations: u32,
10    pub mean_dps: f64,
11    pub std_dps: f64,
12    pub min_dps: f64,
13    pub max_dps: f64,
14    #[serde(default, skip_serializing_if = "Vec::is_empty")]
15    pub spells: Vec<ChunkSpellAggregate>,
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub auras: Vec<ChunkAuraAggregate>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub resources: Vec<ChunkResourceAggregate>,
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub cooldowns: Vec<ChunkCooldownAggregate>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub timeline: Option<ChunkTimeline>,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub metric_descriptors: Vec<ChunkMetricDescriptor>,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub metric_values: Vec<ChunkMetricValue>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub dps_percentiles: Option<DpsPercentiles>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub damage_profile: Option<ChunkDamageProfile>,
32}
33
34#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct ChunkMetricDescriptor {
37    pub id: u32,
38    pub key: String,
39    pub name: String,
40    pub unit: String,
41    pub metric_type: String,
42    pub group: String,
43    pub plugin: String,
44}
45
46#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ChunkMetricValue {
49    pub id: u32,
50    pub value: f64,
51}
52
53#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
54#[serde(rename_all = "camelCase")]
55pub struct DpsPercentiles {
56    pub p5: f64,
57    pub p25: f64,
58    pub p50: f64,
59    pub p75: f64,
60    pub p95: f64,
61}
62
63#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct ChunkSpellAggregate {
66    pub spell_id: u32,
67    pub casts: u32,
68    pub hits: u32,
69    pub crits: u32,
70    pub total_damage: f32,
71}
72
73#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct ChunkAuraAggregate {
76    pub aura_id: u32,
77    /// Uptime percentage * 100 (e.g., 8500 = 85.00%).
78    pub uptime_pct_x100: u32,
79}
80
81#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
82#[serde(rename_all = "camelCase")]
83pub struct ChunkResourceAggregate {
84    pub resource_type: u32,
85    pub total_gained: f32,
86    pub total_spent: f32,
87    pub wasted: f32,
88    /// Time-at-cap percentage * 100 (e.g., 500 = 5.00%).
89    pub time_at_cap_pct_x100: u32,
90}
91
92#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
93#[serde(rename_all = "camelCase")]
94pub struct ChunkCooldownAggregate {
95    pub spell_id: u32,
96    pub uses: u32,
97    pub possible_uses: u32,
98}
99
100/// From one representative iteration, not aggregated.
101#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
102#[serde(rename_all = "camelCase")]
103pub struct ChunkTimeline {
104    pub duration_ms: u32,
105    pub bucket_ms: u32,
106    pub dps_per_bucket: Vec<u32>,
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub markers: Vec<ChunkTimelineMarker>,
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub aura_windows: Vec<ChunkAuraWindow>,
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub cooldown_windows: Vec<ChunkCooldownWindow>,
113}
114
115#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
116#[serde(rename_all = "camelCase")]
117pub struct ChunkTimelineMarker {
118    pub time_ms: u32,
119    #[serde(default)]
120    pub sequence: u64,
121    pub kind: crate::proto::MarkerKind,
122    pub spell_or_aura_id: u32,
123    pub target: u32,
124    pub amount: u32,
125    pub is_crit: bool,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub source: Option<super::ActorId>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub pull: Option<super::PullId>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub group: Option<super::GroupId>,
132}
133
134#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
135#[serde(rename_all = "camelCase")]
136pub struct ChunkAuraWindow {
137    pub aura_id: u32,
138    pub target: u32,
139    pub start_ms: u32,
140    pub end_ms: u32,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub source: Option<super::ActorId>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub affected: Option<super::ActorId>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub pull: Option<super::PullId>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub group: Option<super::GroupId>,
149}
150
151#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
152#[serde(rename_all = "camelCase")]
153// #t(rust_similar_structs) canonical chunk telemetry is distinct from the analytics presentation view
154pub struct ChunkCooldownWindow {
155    pub spell_id: u32,
156    pub start_ms: u32,
157    pub duration_ms: u32,
158}
159
160/// Averaged across the chunk's iterations.
161#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
162#[serde(rename_all = "camelCase")]
163pub struct ChunkDamageProfile {
164    pub direct_damage: f64,
165    pub periodic_damage: f64,
166    pub pet_damage: f64,
167}
168
169/// Stored in `jobs.result`.
170#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
171#[serde(rename_all = "camelCase")]
172#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
173#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
174pub struct SimulationResult {
175    pub mean_dps: f64,
176    pub min_dps: f64,
177    pub max_dps: f64,
178    pub total_iterations: u32,
179    pub chunks_completed: u32,
180}
181
182impl SimulationResult {
183    #[must_use]
184    pub fn new() -> Self {
185        Self {
186            mean_dps: 0.0,
187            min_dps: f64::MAX,
188            max_dps: f64::MIN,
189            total_iterations: 0,
190            chunks_completed: 0,
191        }
192    }
193
194    pub fn merge_chunk(&mut self, chunk: &ChunkResult) {
195        self.min_dps = self.min_dps.min(chunk.min_dps);
196        self.max_dps = self.max_dps.max(chunk.max_dps);
197
198        let total_before = f64::from(self.total_iterations);
199        let chunk_count = f64::from(chunk.iterations);
200        let total_after = total_before + chunk_count;
201
202        if total_after > 0.0 {
203            self.mean_dps =
204                (self.mean_dps * total_before + chunk.mean_dps * chunk_count) / total_after;
205        }
206
207        self.total_iterations += chunk.iterations;
208        self.chunks_completed += 1;
209    }
210}
211
212impl Default for SimulationResult {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use googletest::prelude::*;
221
222    use super::*;
223
224    const TOL: f64 = 1e-10;
225
226    #[gtest]
227    fn test_merge_chunks() -> Result<()> {
228        let mut result = SimulationResult::new();
229
230        result.merge_chunk(&ChunkResult {
231            iterations: 1000,
232            mean_dps: 50000.0,
233            std_dps: 1000.0,
234            min_dps: 45000.0,
235            max_dps: 55000.0,
236            spells: vec![],
237            auras: vec![],
238            resources: vec![],
239            cooldowns: vec![],
240            timeline: None,
241            metric_descriptors: vec![],
242            metric_values: vec![],
243            dps_percentiles: None,
244            damage_profile: None,
245        });
246
247        wowlab_test_support::verify_all!(
248            result.total_iterations => eq(1_000),
249            result.mean_dps => near(50_000.0, TOL),
250            result.min_dps => near(45_000.0, TOL),
251            result.max_dps => near(55_000.0, TOL),
252            result.chunks_completed => eq(1),
253        )?;
254
255        result.merge_chunk(&ChunkResult {
256            iterations: 1000,
257            mean_dps: 52000.0,
258            std_dps: 1200.0,
259            min_dps: 46000.0,
260            max_dps: 58000.0,
261            spells: vec![],
262            auras: vec![],
263            resources: vec![],
264            cooldowns: vec![],
265            timeline: None,
266            metric_descriptors: vec![],
267            metric_values: vec![],
268            dps_percentiles: None,
269            damage_profile: None,
270        });
271
272        wowlab_test_support::verify_all!(
273            result.total_iterations => eq(2_000),
274            result.mean_dps => near(51_000.0, TOL),
275            result.min_dps => near(45_000.0, TOL),
276            result.max_dps => near(58_000.0, TOL),
277            result.chunks_completed => eq(2),
278        )
279    }
280}