1use anyhow::{Context, Result, anyhow, ensure};
6use tabled::Tabled;
7use wowlab_common::output;
8use wowlab_engine_adapter_data::{LocalCsvResolver, OverlayResolver};
9use wowlab_engine_application::{build_handler, simulate_intent};
10use wowlab_engine_ports::{ContentCatalog, NoopProgress, Paperdoll, content_catalog};
11use wowlab_fs::file;
12use wowlab_types::{constants::MS_PER_SECOND, game::SpecId};
13
14use crate::{
15 constants::{default_data_dir, engine_dir},
16 encounter_fixture::EncounterFixture,
17 intent,
18 run::{FightDurationSeconds, IterationCount, RunParameters},
19 sim,
20};
21
22const PAPERDOLL_DURATION_S: u32 = 300;
23
24#[derive(Debug, clap::Args)]
25pub(crate) struct PaperdollArgs {
26 pub spec: String,
28
29 #[arg(long)]
31 pub json: bool,
32
33 #[arg(long, value_enum)]
35 pub encounter_fixture: Option<EncounterFixture>,
36
37 #[arg(long, requires = "encounter_fixture")]
39 pub encounter_html: bool,
40
41 #[arg(long)]
43 pub trace: bool,
44}
45
46pub(crate) fn run(args: &PaperdollArgs) -> Result<()> {
47 ensure!(
48 !(args.json && args.encounter_fixture.is_some()),
49 "--json cannot be combined with --encounter-fixture; fixture diagnostics are tabular"
50 );
51 let spec = SpecId::from_manifest_slug(&args.spec)
52 .ok_or_else(|| anyhow!("Unknown spec slug: {}", args.spec))?;
53 let trace_path = crate::trace::init_trace_log(args.trace, &args.spec)?;
54
55 let slug = spec.slug();
56 let rotation_id = format!("{slug}_assisted");
57 let parameters = RunParameters::new(
58 IterationCount::ONE,
59 FightDurationSeconds::from_nonzero_const(PAPERDOLL_DURATION_S),
60 );
61
62 let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
63 let rotation_path = engine_dir()
64 .join("examples/rotations")
65 .join(format!("{rotation_id}.json"));
66 let rotation_script = file::read_text(&rotation_path)
67 .with_context(|| format!("failed to read rotation {}", rotation_path.display()))?;
68
69 let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir.as_str()))
70 .with_rotation_script(&rotation_id, rotation_script);
71 let resolver_dyn = wowlab_engine_ports::DynDataResolver::from_ref(&resolver);
72
73 let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
74 let catalog = content_catalog().context("engine content catalog is unavailable")?;
75 let sim_config = rt.block_on(paperdoll_sim_config(
76 args,
77 spec,
78 &rotation_id,
79 parameters,
80 resolver_dyn,
81 ))?;
82 let handler = rt
83 .block_on(build_handler(catalog, &sim_config, resolver_dyn))
84 .with_context(|| format!("failed to build handler for {slug}"))?;
85
86 let encounter_debug = collect_encounter_debug(
87 catalog,
88 args.encounter_fixture,
89 &sim_config,
90 resolver_dyn,
91 &rt,
92 slug,
93 )?;
94
95 let pd = handler
96 .paperdoll()
97 .ok_or_else(|| anyhow::anyhow!("paperdoll not supported for {slug}"))?;
98
99 if args.json {
100 output::json(&pd);
101 } else {
102 print_paperdoll(&pd);
103 }
104
105 render_encounter_debug(args, encounter_debug.as_ref())?;
106
107 if let Some(path) = trace_path {
108 output::blank();
109 output::detail(&format!("Trace log written to: {}", path.display()));
110 }
111
112 Ok(())
113}
114
115async fn paperdoll_sim_config(
117 args: &PaperdollArgs,
118 spec: SpecId,
119 rotation_id: &str,
120 parameters: RunParameters,
121 resolver: &wowlab_engine_ports::DynDataResolver<'_>,
122) -> Result<String> {
123 if crate::simc::simc_profile_path(spec).is_some() {
124 let config = crate::provider::ComparisonConfig {
125 spec,
126 parameters,
127 race: None,
128 bugs: true,
129 simc_debug: false,
130 };
131
132 return crate::wowlab::wowlab_profile_config_with_fixture(
133 &config,
134 rotation_id,
135 args.encounter_fixture,
136 resolver,
137 )
138 .await;
139 }
140
141 let mut intent =
142 intent::canonical_patchwerk_intent(spec, rotation_id, f64::from(PAPERDOLL_DURATION_S))
143 .map_err(|message| anyhow!("failed to build paperdoll intent: {message}"))?;
144
145 if let Some(fixture) = args.encounter_fixture {
146 intent.encounter = fixture.definition()?;
147 }
148
149 wowlab_common::sim::intent::serialize_sim_config(&intent)
150 .context("failed to serialize paperdoll intent")
151}
152
153fn render_encounter_debug(
154 args: &PaperdollArgs,
155 report: Option<&crate::encounter_debug::EncounterDebugReport>,
156) -> Result<()> {
157 let Some(report) = report else {
158 return Ok(());
159 };
160
161 crate::encounter_debug::print_report(report);
162
163 if args.encounter_html {
164 let path = crate::encounter_html::write(report)?;
165
166 output::blank();
167 output::detail(&format!("Spatial debugger: {}", path.display()));
168 }
169
170 Ok(())
171}
172
173fn collect_encounter_debug(
174 catalog: &ContentCatalog,
175 fixture: Option<EncounterFixture>,
176 sim_config: &str,
177 resolver: &wowlab_engine_ports::DynDataResolver<'_>,
178 runtime: &tokio::runtime::Runtime,
179 spec_slug: &str,
180) -> Result<Option<crate::encounter_debug::EncounterDebugReport>> {
181 let Some(fixture) = fixture else {
182 return Ok(None);
183 };
184 let chunk = sim::make_chunk("paperdoll-encounter-fixture", 1, None);
185 let report = runtime
186 .block_on(simulate_intent(
187 catalog,
188 sim_config,
189 &chunk,
190 crate::constants::DEFAULT_SEED,
191 resolver,
192 &NoopProgress,
193 ))
194 .with_context(|| format!("encounter fixture simulation failed for {spec_slug}"))?;
195 let telemetry = sim::decode_telemetry(&report)?;
196
197 runtime
198 .block_on(crate::encounter_debug::build_report(
199 fixture, &telemetry, resolver,
200 ))
201 .map(Some)
202}
203
204const MILLIS_PER_HOUR: u32 = 3_600_000;
205const SECS_PER_MIN: f64 = 60.0;
206
207fn fmt_duration_ms(ms: u32) -> String {
208 if ms == 0 {
209 return "instant".to_string();
210 }
211
212 if ms >= MILLIS_PER_HOUR {
213 return "permanent".to_string();
214 }
215
216 let secs = f64::from(ms) / MS_PER_SECOND;
217
218 if secs >= SECS_PER_MIN {
219 format!("{:.0}m", secs / SECS_PER_MIN)
220 } else if secs.fract().abs() < f64::EPSILON {
221 format!("{secs:.0}s")
222 } else {
223 format!("{secs:.1}s")
224 }
225}
226
227fn fmt_damage(info: &wowlab_types::game::DamageInfo) -> String {
228 use wowlab_types::game::DamageKind;
229
230 match &info.kind {
231 DamageKind::None => "-".to_string(),
232 DamageKind::Flat { amount } => format!("{amount:.0} flat"),
233 DamageKind::ApCoefficient { coef, is_physical } => {
234 let school = if *is_physical { "phys" } else { "magic" };
235
236 format!("{coef:.3} AP ({school})")
237 }
238 DamageKind::SpCoefficient { coef, is_physical } => {
239 let school = if *is_physical { "phys" } else { "magic" };
240
241 format!("{coef:.3} SP ({school})")
242 }
243 _ => "?".to_string(),
244 }
245}
246
247#[derive(Tabled)]
248struct SpellRow {
249 #[tabled(rename = "Spell")]
250 name: String,
251 #[tabled(rename = "ID")]
252 id: u32,
253 #[tabled(rename = "Cast")]
254 cast: String,
255 #[tabled(rename = "GCD")]
256 gcd: String,
257 #[tabled(rename = "Cost")]
258 cost: String,
259 #[tabled(rename = "Cooldown")]
260 cooldown: String,
261 #[tabled(rename = "Damage")]
262 damage: String,
263}
264
265#[derive(Tabled)]
266struct AuraRow {
267 #[tabled(rename = "Aura")]
268 name: String,
269 #[tabled(rename = "ID")]
270 id: u32,
271 #[tabled(rename = "Duration")]
272 duration: String,
273 #[tabled(rename = "Stacks")]
274 max_stacks: u8,
275 #[tabled(rename = "Haste")]
276 haste: String,
277 #[tabled(rename = "Dmg Mult")]
278 damage_mult: String,
279 #[tabled(rename = "Periodic")]
280 periodic: String,
281}
282
283fn aura_name(intro: &wowlab_types::game::SpecIntrospection, aura_id: u32) -> &str {
284 intro
285 .auras
286 .iter()
287 .find(|a| a.aura_id == aura_id)
288 .map_or("?", |a| a.name.as_str())
289}
290
291fn fmt_resource(r: &wowlab_types::game::ResourceInfo) -> String {
292 if r.regen > 0.0 {
293 format!("max {:.0}, regen {:.1}/s", r.max, r.regen)
294 } else {
295 format!("max {:.0}", r.max)
296 }
297}
298
299fn fmt_spell_cost(sp: &wowlab_types::game::SpellInfo) -> String {
300 match (sp.resource_cost > 0.0, sp.secondary_resource_cost > 0.0) {
301 (true, true) => format!(
302 "{:.0} + {:.0} sec",
303 sp.resource_cost, sp.secondary_resource_cost
304 ),
305 (true, false) => format!("{:.0}", sp.resource_cost),
306 (false, true) => format!("{:.0} sec", sp.secondary_resource_cost),
307 (false, false) => "-".into(),
308 }
309}
310
311fn fmt_cooldown(sp: &wowlab_types::game::SpellInfo) -> String {
312 let cooldown = sp.cooldown.as_ref().filter(|cd| cd.duration_secs > 0.0);
313
314 cooldown.map_or_else(
315 || "-".into(),
316 |cd| {
317 if cd.max_charges > 1 {
318 format!("{:.0}s ({}ch)", cd.duration_secs, cd.max_charges)
319 } else {
320 format!("{:.0}s", cd.duration_secs)
321 }
322 },
323 )
324}
325
326fn fmt_pct_or_dash(v: f64) -> String {
327 if v > 0.0 {
328 format!("+{v:.0}%")
329 } else {
330 "-".into()
331 }
332}
333
334fn print_paperdoll(pd: &Paperdoll) {
335 let s = &pd.stats;
336 let intro = &pd.introspection;
337
338 output::header("Character");
339 output::kv("Attack Power", &format!("{:.0}", s.attack_power));
340 output::kv("Spell Power", &format!("{:.0}", s.spell_power));
341 output::kv("Crit", &format!("{:.2}%", s.crit_chance));
342 output::kv("Haste", &format!("{:.2}%", s.haste));
343 output::kv("Mastery", &format!("{:.2}%", s.mastery));
344 output::kv("Versatility", &format!("{:.2}%", s.versatility));
345 output::kv("Stamina", &format!("{:.0}", s.stamina));
346 output::kv("Armor", &format!("{:.0}", s.armor));
347 output::kv(
348 &intro.resource_primary.name,
349 &fmt_resource(&intro.resource_primary),
350 );
351
352 if let Some(ref sec) = intro.resource_secondary {
353 output::kv(&sec.name, &fmt_resource(sec));
354 }
355
356 if !pd.precombat_aura_ids.is_empty() {
357 output::blank();
358 output::header("Precombat Auras");
359
360 for &id in &pd.precombat_aura_ids {
361 output::detail(&format!("{} ({id})", aura_name(intro, id)));
362 }
363 }
364
365 if !pd.talent_ranks.is_empty() {
366 output::blank();
367 output::header("Talent Ranks");
368
369 for &(id, rank) in &pd.talent_ranks {
370 output::detail(&format!("{} ({id}) rank {rank}", aura_name(intro, id)));
371 }
372 }
373
374 output::blank();
375 output::header(&format!("Spells ({})", intro.spells.len()));
376 output::table(intro.spells.iter().map(|sp| SpellRow {
377 name: sp.name.clone(),
378 id: sp.spell_id,
379 cast: if sp.cast_time_ms == 0 {
380 "instant".into()
381 } else {
382 fmt_duration_ms(sp.cast_time_ms)
383 },
384 gcd: if sp.off_gcd {
385 "off-GCD".into()
386 } else {
387 fmt_duration_ms(sp.gcd_ms)
388 },
389 cost: fmt_spell_cost(sp),
390 cooldown: fmt_cooldown(sp),
391 damage: fmt_damage(&sp.damage),
392 }));
393
394 output::blank();
395 output::header(&format!("Auras ({})", intro.auras.len()));
396 output::table(intro.auras.iter().map(|a| AuraRow {
397 name: a.name.clone(),
398 id: a.aura_id,
399 duration: fmt_duration_ms(a.base_duration_ms),
400 max_stacks: a.max_stacks,
401 haste: fmt_pct_or_dash(a.haste_buff_pct),
402 damage_mult: if a.damage_mult_pct > 0.0 {
403 format!("+{:.0}%", (a.damage_mult_pct - 1.0) * 100.0)
404 } else {
405 "-".into()
406 },
407 periodic: a.periodic.as_ref().map_or_else(
408 || "-".into(),
409 |p| format!("every {}", fmt_duration_ms(p.tick_ms)),
410 ),
411 }));
412
413 if !intro.auto_attacks.is_empty() {
414 output::blank();
415 output::header("Auto Attacks");
416
417 for aa in &intro.auto_attacks {
418 let kind = if aa.is_pet { "pet" } else { "melee" };
419
420 output::detail(&format!(
421 "{kind} ({}) swing={} ap={:.2}",
422 aa.spell_id,
423 fmt_duration_ms(aa.swing_ms),
424 aa.ap_coef
425 ));
426 }
427 }
428
429 for &(slot, spell_id) in &pd.item_use_spells {
430 output::detail(&format!("on-use: {slot:?} -> spell {spell_id}"));
431 }
432}