Skip to main content

forge/
paperdoll.rs

1// #t(file: rust_alloc_in_loop) CLI display formatting, bounded by spell/aura count
2
3//! Paperdoll subcommand: bootstrap a handler and dump the character sheet.
4
5use 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    /// Spec slug (e.g. "`outlaw_rogue`", "`fire_mage`").
27    pub spec: String,
28
29    /// Output raw JSON instead of formatted text.
30    #[arg(long)]
31    pub json: bool,
32
33    /// Inspect one of the maintained encounter fixtures.
34    #[arg(long, value_enum)]
35    pub encounter_fixture: Option<EncounterFixture>,
36
37    /// Generate a tracked spatial debugger under docs/multi-enemy-encounters.
38    #[arg(long, requires = "encounter_fixture")]
39    pub encounter_html: bool,
40
41    /// Write a TRACE log of game-data resolution to a temporary file.
42    #[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
115/// Uses comparison profile inputs when available so paperdoll and compare agree.
116async 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}