Skip to main content

wowlab_engine/cli/sim/
output.rs

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/// Sim summary derived once from telemetry so the text and JSON renderings can't drift.
8#[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}