1use std::collections::BTreeMap;
7
8#[cfg(test)]
9use googletest::{Result as GtestResult, prelude::*};
10use tabled::Tabled;
11use wowlab_common::output;
12use wowlab_types::constants::HUNDRED;
13
14use crate::provider::{CastEntry, SimOutput, SpellResult};
15
16const MAX_SPELL_NAME_LEN: usize = 24;
17const TIMELINE_MATCH_TOLERANCE: f64 = 0.05;
18const TIMELINE_ORDER_SLACK: f64 = 0.001;
19
20#[derive(Tabled)]
21struct DpsRow {
22 #[tabled(rename = "Source")]
23 source: String,
24 #[tabled(rename = "Mean DPS")]
25 mean_dps: String,
26}
27
28#[derive(Tabled)]
29struct SpellRow {
30 #[tabled(rename = "Spell")]
31 name: String,
32 #[tabled(rename = "wl DPS")]
33 wl_dps: String,
34 #[tabled(rename = "wl casts")]
35 wl_casts: String,
36 #[tabled(rename = "wl hits")]
37 wl_hits: String,
38 #[tabled(rename = "wl %")]
39 wl_pct: String,
40 #[tabled(rename = "sc DPS")]
41 sc_dps: String,
42 #[tabled(rename = "sc casts")]
43 sc_casts: String,
44 #[tabled(rename = "sc hits")]
45 sc_hits: String,
46 #[tabled(rename = "sc %")]
47 sc_pct: String,
48 #[tabled(rename = "Delta")]
49 delta: String,
50 #[tabled(rename = "Δ%")]
51 delta_pct: String,
52 #[tabled(rename = "dpc Δ%")]
53 dpc_delta_pct: String,
54 #[tabled(rename = "dph Δ%")]
55 dph_delta_pct: String,
56}
57
58#[derive(Tabled)]
59struct TimelineRow {
60 #[tabled(rename = "Time")]
61 time: String,
62 #[tabled(rename = "wowlab")]
63 wl_spell: String,
64 #[tabled(rename = "SimC")]
65 sc_spell: String,
66}
67
68pub(crate) fn print_banner(slug: &str, parameters: crate::run::RunParameters) {
69 print_encounter_banner(slug, parameters, "Patchwerk");
70}
71
72pub(crate) fn print_encounter_banner(
73 slug: &str,
74 parameters: crate::run::RunParameters,
75 encounter: &str,
76) {
77 let iterations = parameters.iterations();
78 let duration = parameters.fight_duration_secs();
79
80 output::blank();
81 output::header(&format!(
82 "{slug} -- {iterations} iter, {duration}s {encounter}"
83 ));
84}
85
86pub(crate) fn print_dps_comparison(wl: &SimOutput, sc: &SimOutput, wl_name: &str, sc_name: &str) {
87 let diff = wl.dps - sc.dps;
88 let pct = if sc.dps > 0.0 {
89 diff / sc.dps * HUNDRED
90 } else {
91 0.0
92 };
93
94 output::blank();
95 output::subheader("DPS Comparison");
96
97 let rows = vec![
98 DpsRow {
99 source: wl_name.to_string(),
100 mean_dps: format!("{:.1}", wl.dps),
101 },
102 DpsRow {
103 source: sc_name.to_string(),
104 mean_dps: format!("{:.1}", sc.dps),
105 },
106 DpsRow {
107 source: "Delta".to_string(),
108 mean_dps: format!("{diff:+.1} ({pct:+.1}%)"),
109 },
110 ];
111
112 output::table(rows);
113 output::blank();
114}
115
116pub(crate) fn print_spell_comparison(wl: &SimOutput, sc: &SimOutput) {
117 let wl_spells = attribution_normalized_spells(wl);
118 let sc_spells = attribution_normalized_spells(sc);
119 let mut all_names: Vec<String> = wl_spells.keys().chain(sc_spells.keys()).cloned().collect();
120
121 all_names.sort();
122 all_names.dedup();
123
124 all_names.sort_by(|a, b| {
125 let a_dps = wl_spells
126 .get(a)
127 .map_or(0.0, |s| s.dps)
128 .max(sc_spells.get(a).map_or(0.0, |s| s.dps));
129 let b_dps = wl_spells
130 .get(b)
131 .map_or(0.0, |s| s.dps)
132 .max(sc_spells.get(b).map_or(0.0, |s| s.dps));
133
134 b_dps
135 .partial_cmp(&a_dps)
136 .unwrap_or(std::cmp::Ordering::Equal)
137 });
138
139 output::subheader("Spell Breakdown");
140
141 let rows: Vec<SpellRow> = all_names
142 .iter()
143 .map(|name| {
144 let wl_s = wl_spells.get(name);
145 let sc_s = sc_spells.get(name);
146 let wl_dps = wl_s.map_or(0.0, |s| s.dps);
147 let sc_dps = sc_s.map_or(0.0, |s| s.dps);
148 let wl_casts = wl_s.map_or(0, |s| s.casts);
149 let sc_casts = sc_s.map_or(0, |s| s.casts);
150 let wl_hits = wl_s.map_or(0, |s| s.hits);
151 let sc_hits = sc_s.map_or(0, |s| s.hits);
152 let wl_pct = wl_s.map_or(0.0, |s| s.pct);
153 let sc_pct = sc_s.map_or(0.0, |s| s.pct);
154 let diff = wl_dps - sc_dps;
155 let diff_pct = if sc_dps > 0.0 {
156 diff / sc_dps * HUNDRED
157 } else if wl_dps > 0.0 {
158 f64::INFINITY
159 } else {
160 0.0
161 };
162
163 let delta_pct = if diff_pct.is_finite() {
164 format!("{diff_pct:+.1}%")
165 } else {
166 " -- ".to_string()
167 };
168
169 let per_cast_delta_pct = per_event_delta_pct(wl_dps, wl_casts, sc_dps, sc_casts);
171 let per_hit_delta_pct = per_event_delta_pct(wl_dps, wl_hits, sc_dps, sc_hits);
173
174 let display_name: String = name.chars().take(MAX_SPELL_NAME_LEN).collect();
175
176 SpellRow {
177 name: display_name,
178 wl_dps: format!("{wl_dps:.1}"),
179 wl_casts: wl_casts.to_string(),
180 wl_hits: wl_hits.to_string(),
181 wl_pct: format!("{:.1}%", wl_pct * HUNDRED),
182 sc_dps: format!("{sc_dps:.1}"),
183 sc_casts: sc_casts.to_string(),
184 sc_hits: sc_hits.to_string(),
185 sc_pct: format!("{:.1}%", sc_pct * HUNDRED),
186 delta: format!("{diff:+.1}"),
187 delta_pct,
188 dpc_delta_pct: per_cast_delta_pct,
189 dph_delta_pct: per_hit_delta_pct,
190 }
191 })
192 .collect();
193
194 output::table(rows);
195
196 let wl_only: Vec<&String> = wl_spells
197 .keys()
198 .filter(|k| !sc_spells.contains_key(*k))
199 .collect();
200 let sc_only: Vec<&String> = sc_spells
201 .keys()
202 .filter(|k| !wl_spells.contains_key(*k))
203 .collect();
204
205 if !wl_only.is_empty() {
206 let names: Vec<&str> = wl_only.iter().map(|s| s.as_str()).collect();
207
208 output::detail(&format!("wowlab only: {}", names.join(", ")));
209 }
210
211 if !sc_only.is_empty() {
212 let names: Vec<&str> = sc_only.iter().map(|s| s.as_str()).collect();
213
214 output::detail(&format!("SimC only: {}", names.join(", ")));
215 }
216
217 output::blank();
218}
219
220fn per_event_delta_pct(wl_dps: f64, wl_events: u32, sc_dps: f64, sc_events: u32) -> String {
222 if wl_events == 0 || sc_events == 0 || sc_dps <= 0.0 {
223 return " -- ".to_string();
224 }
225
226 let wowlab_per_event = wl_dps / f64::from(wl_events);
227 let simc_per_event = sc_dps / f64::from(sc_events);
228
229 format!(
230 "{:+.1}%",
231 (wowlab_per_event / simc_per_event - 1.0) * HUNDRED
232 )
233}
234
235fn attribution_normalized_spells(output: &SimOutput) -> BTreeMap<String, SpellResult> {
236 let mut normalized = BTreeMap::new();
237
238 for (name, result) in &output.spells {
239 let entry = normalized
240 .entry(canonical_spell_name(name).to_string())
241 .or_insert_with(SpellResult::default);
242
243 entry.dps += result.dps;
244 entry.casts = entry.casts.max(result.casts);
245 entry.hits += result.hits;
246 }
247
248 let mut timeline_casts = BTreeMap::new();
249
250 for cast in &output.timeline {
251 *timeline_casts
252 .entry(canonical_spell_name(&cast.spell_name))
253 .or_insert(0) += 1;
254 }
255
256 for (name, casts) in timeline_casts {
257 normalized.entry(name.to_string()).or_default().casts = casts;
258 }
259
260 for result in normalized.values_mut() {
261 result.pct = if output.dps > 0.0 {
262 result.dps / output.dps
263 } else {
264 0.0
265 };
266 }
267
268 normalized
269}
270
271fn canonical_spell_name(name: &str) -> &str {
274 match name {
275 "amplifying_poison_debuff" => "amplifying_poison",
276 "auto_attack_mh" | "auto_attack_oh" => "auto_attack",
277 "barbed_shot_dot" => "barbed_shot",
278 "bladestorm_mh" | "bladestorm_offhand" | "bladestorm_oh" => "bladestorm",
279 "bloodbath_bladestorm_unhinged" => "bloodbath",
280 "bloodshed_dot" => "bloodshed",
281 "bloodthirst_bladestorm_unhinged" => "bloodthirst",
282 "chain_lightning_ll_rtl" | "chain_lightning_ss_rtl" => "chain_lightning",
283 "execute_mainhand" | "execute_offhand" => "execute",
284 "instant_poison_damage" => "instant_poison",
285 "kill_command_pet" | "wildspeaker_kill_command" => "kill_command",
286 "lightning_bolt_ps" | "lightning_bolt_ti" => "lightning_bolt",
287 "lightning_rod_damage" => "lightning_rod",
288 "mutilate_mh" | "mutilate_oh" => "mutilate",
289 "pet_attack" | "pet_auto_attack_mh" | "pet_auto_attack_oh" | "infernal_melee"
290 | "dreadstalker_melee" => "melee",
291 "pet_bestial_wrath" => "bestial_wrath",
292 "phoenix_pyroblast" | "pyroblast_pyromaniac" => "pyroblast",
293 "odyns_fury_dot" | "odyns_fury_mh" | "odyns_fury_offhand" | "odyns_fury_oh" => "odyns_fury",
294 "raging_blow_mh" | "raging_blow_oh" => "raging_blow",
295 "rampage1" | "rampage2" | "rampage3" | "rampage4" => "rampage",
296 "rend_dot" => "rend",
297 "rune_of_unleashed_fire_lingering" => "rune_of_lingering",
298 "stormblast_stormstrike_mh" | "stormblast_stormstrike_offhand" => "stormblast",
299 "thundering_hooves" => "stomp",
300 "tremor_es" => "tremor",
301 "voltaic_blaze_damage" => "voltaic_blaze",
302 "whirlwind_mh_first"
303 | "whirlwind_mh_others"
304 | "whirlwind_oh_first"
305 | "whirlwind_oh_others" => "whirlwind",
306 _ => name,
307 }
308}
309
310pub(crate) fn print_resource_comparison(wl: &SimOutput, sc: &SimOutput) {
311 #[derive(Tabled)]
312 struct ResourceRow {
313 #[tabled(rename = "Source")]
314 name: String,
315 #[tabled(rename = "wl gain")]
316 wl_gain: String,
317 #[tabled(rename = "wl waste")]
318 wl_waste: String,
319 #[tabled(rename = "sc gain")]
320 sc_gain: String,
321 #[tabled(rename = "sc waste")]
322 sc_waste: String,
323 #[tabled(rename = "Δ gain")]
324 delta: String,
325 }
326
327 let mut names: Vec<&String> = wl
328 .resource_gains
329 .keys()
330 .chain(sc.resource_gains.keys())
331 .collect();
332
333 names.sort();
334 names.dedup();
335 let mut rows: Vec<(f64, ResourceRow)> = names
336 .into_iter()
337 .map(|name| {
338 let w = wl.resource_gains.get(name).copied().unwrap_or_default();
339 let c = sc.resource_gains.get(name).copied().unwrap_or_default();
340 let key = w.gained.max(c.gained);
341
342 (
343 key,
344 ResourceRow {
345 name: name.clone(),
346 wl_gain: format!("{:.0}", w.gained),
347 wl_waste: format!("{:.0}", w.wasted),
348 sc_gain: format!("{:.0}", c.gained),
349 sc_waste: format!("{:.0}", c.wasted),
350 delta: format!("{:+.0}", w.gained - c.gained),
351 },
352 )
353 })
354 .collect();
355
356 rows.sort_by(|(a, _), (b, _)| b.total_cmp(a));
357
358 let wl_total: f64 = wl.resource_gains.values().map(|g| g.gained).sum();
359 let sc_total: f64 = sc.resource_gains.values().map(|g| g.gained).sum();
360 let wl_waste: f64 = wl.resource_gains.values().map(|g| g.wasted).sum();
361 let sc_waste: f64 = sc.resource_gains.values().map(|g| g.wasted).sum();
362
363 rows.push((
364 -1.0,
365 ResourceRow {
366 name: "TOTAL".to_string(),
367 wl_gain: format!("{wl_total:.0}"),
368 wl_waste: format!("{wl_waste:.0}"),
369 sc_gain: format!("{sc_total:.0}"),
370 sc_waste: format!("{sc_waste:.0}"),
371 delta: format!("{:+.0}", wl_total - sc_total),
372 },
373 ));
374
375 output::blank();
376 output::subheader("Resource Income (per iteration)");
377 output::table(rows.into_iter().map(|(_, row)| row).collect::<Vec<_>>());
378}
379
380pub(crate) fn print_timeline(wl: &SimOutput, sc: &SimOutput, max_s: f64) {
381 let merged = merge_timelines(&wl.timeline, &sc.timeline, max_s);
382
383 output::subheader(&format!("Side-by-Side Cast Timeline (first {max_s:.0}s)"));
384
385 let rows: Vec<TimelineRow> = merged
386 .iter()
387 .map(|(time, wl_name, sc_name)| TimelineRow {
388 time: format!("{time:.3}"),
389 wl_spell: wl_name.clone(),
390 sc_spell: sc_name.clone(),
391 })
392 .collect();
393
394 output::table(rows);
395
396 let wl_count = wl.timeline.iter().filter(|e| e.time_secs <= max_s).count();
397 let sc_count = sc.timeline.iter().filter(|e| e.time_secs <= max_s).count();
398
399 output::detail(&format!(
400 "wowlab: {wl_count} casts, SimC: {sc_count} casts in first {max_s:.0}s",
401 ));
402 output::blank();
403}
404
405fn merge_timelines(wl: &[CastEntry], sc: &[CastEntry], max_s: f64) -> Vec<(f64, String, String)> {
406 let wl: Vec<_> = wl.iter().filter(|e| e.time_secs <= max_s).collect();
407 let sc: Vec<_> = sc.iter().filter(|e| e.time_secs <= max_s).collect();
408
409 let mut rows = Vec::new();
410 let mut wi = 0;
411 let mut si = 0;
412
413 while wi < wl.len() || si < sc.len() {
414 let wt = if wi < wl.len() {
415 wl[wi].time_secs
417 } else {
418 f64::INFINITY
419 };
420 let st = if si < sc.len() {
421 sc[si].time_secs
423 } else {
424 f64::INFINITY
425 };
426
427 if wt <= st + TIMELINE_ORDER_SLACK && wi < wl.len() {
428 if si < sc.len() && (st - wt).abs() < TIMELINE_MATCH_TOLERANCE {
429 rows.push((wt, wl[wi].spell_name.clone(), sc[si].spell_name.clone()));
431 wi += 1;
432 si += 1;
433 } else {
434 rows.push((wt, wl[wi].spell_name.clone(), String::new()));
436 wi += 1;
437 }
438 } else if si < sc.len() {
439 rows.push((st, String::new(), sc[si].spell_name.clone()));
441 si += 1;
442 } else {
443 break;
444 }
445 }
446
447 rows
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[gtest]
455 fn attribution_normalization_folds_child_damage_into_parent_spell() -> GtestResult<()> {
456 let output = SimOutput::new(
457 100.0,
458 vec![
459 (
460 "auto_attack_mh".to_string(),
461 SpellResult {
462 dps: 20.0,
463 casts: 10,
464 hits: 10,
465 pct: 0.2,
466 },
467 ),
468 (
469 "auto_attack_oh".to_string(),
470 SpellResult {
471 dps: 10.0,
472 casts: 8,
473 hits: 8,
474 pct: 0.1,
475 },
476 ),
477 (
478 "rampage4".to_string(),
479 SpellResult {
480 dps: 15.0,
481 casts: 0,
482 hits: 12,
483 pct: 0.15,
484 },
485 ),
486 ],
487 Vec::new(),
488 );
489
490 let spells = attribution_normalized_spells(&output);
491
492 verify_that!(spells.len(), eq(2))?;
493 let auto_attack = spells.get("auto_attack").or_fail()?;
494
495 verify_that!(
496 auto_attack,
497 matches_pattern!(SpellResult {
498 casts: eq(&10),
499 hits: eq(&18),
501 dps: near(30.0, f64::EPSILON),
502 pct: near(0.3, f64::EPSILON),
503 })
504 )?;
505 verify_true!(spells.contains_key("rampage"))?;
506
507 Ok(())
508 }
509
510 #[gtest]
511 fn attribution_normalization_uses_canonical_foreground_timeline_casts() -> GtestResult<()> {
512 let output = SimOutput::new(
513 100.0,
514 vec![
515 (
516 "odyns_fury_mh".to_string(),
517 SpellResult {
518 dps: 20.0,
519 casts: 12,
520 hits: 24,
521 pct: 0.2,
522 },
523 ),
524 (
525 "odyns_fury_mh".to_string(),
526 SpellResult {
527 dps: 10.0,
528 casts: 12,
529 hits: 24,
530 pct: 0.1,
531 },
532 ),
533 (
534 "odyns_fury_dot".to_string(),
535 SpellResult {
536 dps: 30.0,
537 casts: 0,
538 hits: 48,
539 pct: 0.3,
540 },
541 ),
542 ],
543 vec![
544 CastEntry {
545 time_secs: 0.0,
546 spell_name: "odyns_fury".to_string(),
547 },
548 CastEntry {
549 time_secs: 45.0,
550 spell_name: "odyns_fury".to_string(),
551 },
552 ],
553 );
554
555 let spells = attribution_normalized_spells(&output);
556 let odyns_fury = spells.get("odyns_fury").or_fail()?;
557
558 verify_that!(
559 odyns_fury,
560 matches_pattern!(SpellResult {
561 casts: eq(&2),
562 hits: eq(&96),
563 dps: near(60.0, f64::EPSILON),
564 pct: near(0.6, f64::EPSILON),
565 })
566 )?;
567
568 Ok(())
569 }
570
571 #[gtest]
572 fn simc_omnium_lingering_name_matches_engine_attribution() -> GtestResult<()> {
573 verify_that!(
574 canonical_spell_name("rune_of_unleashed_fire_lingering"),
575 eq("rune_of_lingering")
576 )?;
577
578 Ok(())
579 }
580
581 #[gtest]
582 fn enhancement_trigger_variants_match_engine_attribution() -> GtestResult<()> {
583 verify_that!(
584 canonical_spell_name("chain_lightning_ss_rtl"),
585 eq("chain_lightning")
586 )?;
587 verify_that!(
588 canonical_spell_name("chain_lightning_ll_rtl"),
589 eq("chain_lightning")
590 )?;
591 verify_that!(
592 canonical_spell_name("lightning_bolt_ps"),
593 eq("lightning_bolt")
594 )?;
595 verify_that!(
596 canonical_spell_name("lightning_bolt_ti"),
597 eq("lightning_bolt")
598 )?;
599 verify_that!(
600 canonical_spell_name("stormblast_stormstrike_mh"),
601 eq("stormblast")
602 )?;
603 verify_that!(
604 canonical_spell_name("stormblast_stormstrike_offhand"),
605 eq("stormblast")
606 )?;
607 verify_that!(canonical_spell_name("tremor_es"), eq("tremor"))?;
608
609 verify_that!(
610 canonical_spell_name("voltaic_blaze_damage"),
611 eq("voltaic_blaze")
612 )
613 }
614
615 #[gtest]
616 fn beast_mastery_pet_payloads_match_simc_parent_attribution() -> GtestResult<()> {
617 verify_that!(canonical_spell_name("barbed_shot_dot"), eq("barbed_shot"))?;
618 verify_that!(canonical_spell_name("bloodshed_dot"), eq("bloodshed"))?;
619 verify_that!(canonical_spell_name("kill_command_pet"), eq("kill_command"))?;
620 verify_that!(
621 canonical_spell_name("wildspeaker_kill_command"),
622 eq("kill_command")
623 )?;
624 verify_that!(
625 canonical_spell_name("pet_bestial_wrath"),
626 eq("bestial_wrath")
627 )?;
628
629 verify_that!(canonical_spell_name("thundering_hooves"), eq("stomp"))
630 }
631
632 #[gtest]
633 fn pet_and_owner_auto_attacks_keep_distinct_attribution() -> GtestResult<()> {
634 verify_that!(canonical_spell_name("auto_attack_mh"), eq("auto_attack"))?;
635 verify_that!(canonical_spell_name("auto_attack_oh"), eq("auto_attack"))?;
636 verify_that!(canonical_spell_name("pet_auto_attack_mh"), eq("melee"))?;
637
638 verify_that!(canonical_spell_name("pet_auto_attack_oh"), eq("melee"))
639 }
640}