1use serde::Serialize;
2use wowlab_common::output::{self, fmt_integer, fmt_number, fmt_pct};
3use wowlab_types::{constants::PROTO_DPS_SCALE, game::SpecId, proto};
4
5use super::super::presentation;
6
7#[derive(Debug, Serialize)]
9pub(super) struct SimSummary {
10 spec: String,
11 duration_s: f64,
12 iterations: u32,
13 threads: usize,
14 elapsed_s: f64,
15 throughput_per_sec: f64,
16 mean_dps: f64,
17 std_dps: f64,
18 min_dps: f64,
19 max_dps: f64,
20 spells: Vec<SpellSummary>,
21 auras: Vec<AuraSummary>,
22 resources: Vec<ResourceSummary>,
23 damage_profile: Option<DamageProfileSummary>,
24}
25
26#[derive(Debug, Serialize)]
27struct SpellSummary {
28 spell_id: u32,
29 dps: f64,
30 damage_pct: f64,
31 casts: u64,
32 crit_pct: f64,
33}
34
35#[derive(Debug, Serialize)]
36struct AuraSummary {
37 aura_id: u32,
38 uptime_ms: u64,
39}
40
41#[derive(Debug, Serialize)]
42struct ResourceSummary {
43 resource_type: u32,
44 gained: f64,
45 spent: f64,
46 wasted: f64,
47 waste_pct: f64,
48}
49
50#[derive(Debug, Serialize)]
51struct DamageProfileSummary {
52 direct: f64,
53 periodic: f64,
54 pet: f64,
55}
56
57#[expect(
58 clippy::cast_precision_loss,
59 reason = "CLI summaries render integer telemetry counters as approximate floating-point ratios"
60)]
61const fn counter_as_f64(value: u64) -> f64 {
62 value as f64
63}
64
65impl SimSummary {
66 pub(super) fn build(
67 t: &proto::ChunkTelemetry,
68 spec: SpecId,
69 duration: f64,
70 iterations: u32,
71 elapsed: std::time::Duration,
72 threads: usize,
73 ) -> Self {
74 let elapsed_s = elapsed.as_secs_f64();
75
76 Self {
77 spec: presentation::spec_display_name(spec),
78 duration_s: duration,
79 iterations,
80 threads,
81 elapsed_s,
82 throughput_per_sec: if elapsed_s > 0.0 {
83 f64::from(iterations) / elapsed_s
84 } else {
85 0.0
86 },
87 mean_dps: t.mean_dps(),
88 std_dps: t.std_dps(),
89 min_dps: t.min_dps(),
90 max_dps: t.max_dps(),
91 spells: spell_summaries(t, duration),
92 auras: aura_summaries(t),
93 resources: resource_summaries(t),
94 damage_profile: damage_profile_summary(t),
95 }
96 }
97}
98
99fn spell_summaries(t: &proto::ChunkTelemetry, duration: f64) -> Vec<SpellSummary> {
100 let total_damage: f64 = t
101 .actions
102 .iter()
103 .map(|action| counter_as_f64(action.total_damage_x10))
104 .sum();
105 let mut spells: Vec<SpellSummary> = t
106 .actions
107 .iter()
108 .map(|action| {
109 let dmg_x10 = counter_as_f64(action.total_damage_x10);
110 let damage_pct = if total_damage > 0.0 {
111 dmg_x10 / total_damage
112 } else {
113 0.0
114 };
115 let dps = (dmg_x10 / PROTO_DPS_SCALE) / duration;
116 let crit_pct = if action.direct_hits > 0 {
117 counter_as_f64(action.crits) / counter_as_f64(action.direct_hits)
118 } else {
119 0.0
120 };
121
122 SpellSummary {
123 spell_id: action.spell_id,
124 dps,
125 damage_pct,
126 casts: action.casts,
127 crit_pct,
128 }
129 })
130 .collect();
131
132 spells.sort_by(|a, b| b.dps.total_cmp(&a.dps));
133
134 spells
135}
136
137fn aura_summaries(t: &proto::ChunkTelemetry) -> Vec<AuraSummary> {
138 t.auras
139 .iter()
140 .map(|aura| AuraSummary {
141 aura_id: aura.aura_id,
142 uptime_ms: aura.uptime_ms,
143 })
144 .collect()
145}
146
147fn resource_summaries(t: &proto::ChunkTelemetry) -> Vec<ResourceSummary> {
148 t.resources
149 .iter()
150 .map(|res| {
151 let gained = res.gained();
152 let waste_pct = if gained > 0.0 {
153 res.wasted() / gained
154 } else {
155 0.0
156 };
157
158 ResourceSummary {
159 resource_type: res.resource_type,
160 gained,
161 spent: res.spent(),
162 wasted: res.wasted(),
163 waste_pct,
164 }
165 })
166 .collect()
167}
168
169fn damage_profile_summary(t: &proto::ChunkTelemetry) -> Option<DamageProfileSummary> {
170 let profile = t.damage_profile.as_ref()?;
171 let total = profile.direct_damage_x10 + profile.periodic_damage_x10 + profile.pet_damage_x10;
172
173 if total == 0 {
174 return None;
175 }
176
177 let tf = counter_as_f64(total);
178
179 Some(DamageProfileSummary {
180 direct: counter_as_f64(profile.direct_damage_x10) / tf,
181 periodic: counter_as_f64(profile.periodic_damage_x10) / tf,
182 pet: counter_as_f64(profile.pet_damage_x10) / tf,
183 })
184}
185
186pub(super) fn print_text(summary: &SimSummary) {
187 print_summary(summary);
188 print_spell_breakdown(summary);
189 print_aura_uptime(summary);
190 print_resources(summary);
191 print_damage_profile(summary);
192 print_throughput(summary);
193}
194
195pub(super) fn print_json(summary: &SimSummary) {
196 output::json(summary);
197}
198
199fn print_summary(s: &SimSummary) {
200 output::blank();
201 output::header(&format!("{} Simulation Results", s.spec));
202 output::separator();
203 output::info(&format!(
204 "Duration: {:.0}s | Iterations: {} | Threads: {} | Elapsed: {:.2}s",
205 s.duration_s,
206 fmt_integer(u64::from(s.iterations)),
207 s.threads,
208 s.elapsed_s,
209 ));
210 output::blank();
211
212 output::kv(
213 "DPS",
214 &format!(
215 "{} \u{00b1} {}",
216 fmt_number(s.mean_dps),
217 fmt_number(s.std_dps)
218 ),
219 );
220 output::kv(
221 "Min / Max",
222 &format!("{} / {}", fmt_number(s.min_dps), fmt_number(s.max_dps)),
223 );
224}
225
226fn print_spell_breakdown(s: &SimSummary) {
227 if s.spells.is_empty() {
228 return;
229 }
230
231 output::blank();
232 output::header("Spell Breakdown");
233 let rows: Vec<SpellRow> = s
234 .spells
235 .iter()
236 .map(|sp| SpellRow {
237 spell_id: fmt_integer(u64::from(sp.spell_id)),
238 dps: fmt_number(sp.dps),
239 pct: fmt_pct(sp.damage_pct),
240 casts: fmt_integer(sp.casts),
241 crit: fmt_pct(sp.crit_pct),
242 })
243 .collect();
244
245 output::table(rows);
246}
247
248fn print_aura_uptime(s: &SimSummary) {
249 if s.auras.is_empty() {
250 return;
251 }
252
253 output::blank();
254 output::header("Aura Uptime");
255 let rows: Vec<AuraRow> = s
256 .auras
257 .iter()
258 .map(|aura| AuraRow {
259 aura_id: fmt_integer(u64::from(aura.aura_id)),
260 uptime: format!("{}ms", aura.uptime_ms),
261 })
262 .collect();
263
264 output::table(rows);
265}
266
267fn print_resources(s: &SimSummary) {
268 if s.resources.is_empty() {
269 return;
270 }
271
272 output::blank();
273 output::header("Resources");
274 let rows: Vec<ResourceRow> = s
275 .resources
276 .iter()
277 .map(|res| ResourceRow {
278 resource_type: fmt_integer(u64::from(res.resource_type)),
279 gained: fmt_number(res.gained),
280 spent: fmt_number(res.spent),
281 wasted: fmt_number(res.wasted),
282 waste_pct: fmt_pct(res.waste_pct),
283 })
284 .collect();
285
286 output::table(rows);
287}
288
289fn print_damage_profile(s: &SimSummary) {
290 let Some(profile) = s.damage_profile.as_ref() else {
291 return;
292 };
293
294 output::blank();
295 output::header("Damage Profile");
296 output::kv("Direct", &fmt_pct(profile.direct));
297 output::kv("Periodic", &fmt_pct(profile.periodic));
298 output::kv("Pet", &fmt_pct(profile.pet));
299}
300
301fn print_throughput(s: &SimSummary) {
302 output::blank();
303 output::kv(
304 "Throughput",
305 &format!(
306 "{} iterations in {:.2}s ({} iter/sec)",
307 fmt_integer(u64::from(s.iterations)),
308 s.elapsed_s,
309 fmt_number(s.throughput_per_sec),
310 ),
311 );
312}
313
314#[derive(tabled::Tabled)]
315struct SpellRow {
316 #[tabled(rename = "Spell")]
317 spell_id: String,
318 #[tabled(rename = "DPS")]
319 dps: String,
320 #[tabled(rename = "%")]
321 pct: String,
322 #[tabled(rename = "Casts")]
323 casts: String,
324 #[tabled(rename = "Crit%")]
325 crit: String,
326}
327
328#[derive(tabled::Tabled)]
329struct AuraRow {
330 #[tabled(rename = "Aura")]
331 aura_id: String,
332 #[tabled(rename = "Uptime")]
333 uptime: String,
334}
335
336#[derive(tabled::Tabled)]
337struct ResourceRow {
338 #[tabled(rename = "Type")]
339 resource_type: String,
340 #[tabled(rename = "Gained")]
341 gained: String,
342 #[tabled(rename = "Spent")]
343 spent: String,
344 #[tabled(rename = "Wasted")]
345 wasted: String,
346 #[tabled(rename = "Waste%")]
347 waste_pct: String,
348}