wowlab_engine/cli/inspect/
mod.rs1use wowlab_common::output;
4use wowlab_engine_application::introspect_spec_resolved;
5use wowlab_fs::path::Path;
6use wowlab_types::game::{DamageKind, PeriodicEffect, SpecId, SpecIntrospection, SpellInfo};
7
8use super::{CliError, args::OutputFormat, presentation};
9use crate::composition::EngineComposition;
10
11pub(crate) async fn run(
12 spec: SpecId,
13 format: OutputFormat,
14 id_filter: Option<u32>,
15 composition: EngineComposition,
16 workspace_root: &Path,
17) -> Result<(), CliError> {
18 let handle = super::resolver::create(workspace_root)?;
19 let introspection =
20 introspect_spec_resolved(composition.catalog(), spec, &handle.resolver).await?;
21
22 match format {
23 OutputFormat::Json => print_json(&introspection, id_filter),
24 OutputFormat::Text => print_text(&introspection, spec, id_filter),
25 }
26
27 Ok(())
28}
29
30fn print_json(intro: &SpecIntrospection, id_filter: Option<u32>) {
31 if let Some(id) = id_filter {
32 let spell = intro.spells.iter().find(|s| s.spell_id == id);
33 let aura = intro.auras.iter().find(|a| a.aura_id == id);
34 let auto = intro.auto_attacks.iter().find(|a| a.spell_id == id);
35
36 output::json(&serde_json::json!({ "spell": spell, "aura": aura, "auto_attack": auto }));
37 } else {
38 output::json(intro);
39 }
40}
41
42fn print_text(intro: &SpecIntrospection, spec: SpecId, id_filter: Option<u32>) {
43 output::banner(
44 &format!("{} Introspection", presentation::spec_display_name(spec)),
45 "",
46 );
47 print_resources(intro);
48 print_spells(intro, id_filter);
49 print_auras(intro, id_filter);
50 print_auto_attacks(intro, id_filter);
51 print_hero_talents(intro);
52}
53
54fn print_resources(intro: &SpecIntrospection) {
55 output::header("Primary Resource");
56 let r = &intro.resource_primary;
57
58 output::kv("Name", &r.name);
59 output::kv_fmt("Max", r.max);
60 output::kv_fmt("Regen", r.regen);
61 output::kv_fmt("Start", r.starts_at);
62 output::kv_fmt("Type ID", r.type_id);
63
64 if let Some(ref sec) = intro.resource_secondary {
65 output::blank();
66 output::header("Secondary Resource");
67 output::kv("Name", &sec.name);
68 output::kv_fmt("Max", sec.max);
69 output::kv_fmt("Regen", sec.regen);
70 output::kv_fmt("Start", sec.starts_at);
71 output::kv_fmt("Type ID", sec.type_id);
72 }
73
74 output::blank();
75}
76
77fn print_spells(intro: &SpecIntrospection, id_filter: Option<u32>) {
78 let spells: Vec<&SpellInfo> = match id_filter {
79 Some(id) => intro.spells.iter().filter(|s| s.spell_id == id).collect(),
80 None => intro.spells.iter().collect(),
81 };
82
83 output::header(&format!("Spells ({})", spells.len()));
84
85 for s in &spells {
86 output::detail(&format!(
87 "[{:>6}] {:<35} cast={:>5}ms gcd={:>5}ms cost={:<6.1} gain={:<6.1} off_gcd={} pet={}",
88 s.spell_id,
89 s.name,
90 s.cast_time_ms,
91 s.gcd_ms,
92 s.resource_cost,
93 s.resource_gain,
94 s.off_gcd,
95 s.is_pet,
96 ));
97
98 if let Some(ref cd) = s.cooldown {
99 output::detail(&format!(
100 " cd={:.1}s charges={} recharge={:.1}s",
101 cd.duration_secs, cd.max_charges, cd.recharge_secs,
102 ));
103 }
104
105 print_damage_kind(&s.damage.kind);
106
107 if let Some(aura_id) = s.applies_aura_id {
108 output::detail(&format!(" applies_aura={aura_id}"));
109 }
110 }
111
112 output::blank();
113}
114
115fn print_damage_kind(kind: &DamageKind) {
116 if !matches!(kind, DamageKind::None) {
117 output::detail(&format!(" {kind}"));
118 }
119}
120
121fn print_auras(intro: &SpecIntrospection, id_filter: Option<u32>) {
122 let auras: Vec<_> = match id_filter {
123 Some(id) => intro.auras.iter().filter(|a| a.aura_id == id).collect(),
124 None => intro.auras.iter().collect(),
125 };
126
127 output::header(&format!("Auras ({})", auras.len()));
128
129 for a in &auras {
130 output::detail(&format!(
131 "[{:>6}] {:<35} dur={:>6}ms stacks={:<2} on={:<7} pandemic={}",
132 a.aura_id, a.name, a.base_duration_ms, a.max_stacks, a.on, a.pandemic,
133 ));
134
135 if let Some(ref p) = a.periodic {
136 print_periodic_effect(p.tick_ms, &p.effect);
137 }
138 }
139
140 output::blank();
141}
142
143fn print_periodic_effect(tick_ms: u32, effect: &PeriodicEffect) {
144 output::detail(&format!(" tick={tick_ms}ms {effect}"));
145}
146
147fn print_auto_attacks(intro: &SpecIntrospection, id_filter: Option<u32>) {
148 if intro.auto_attacks.is_empty() {
149 return;
150 }
151
152 output::header(&format!("Auto Attacks ({})", intro.auto_attacks.len()));
153
154 for aa in &intro.auto_attacks {
155 if id_filter.is_some_and(|id| id != aa.spell_id) {
156 continue;
157 }
158
159 output::detail(&format!(
160 "[{:>6}] swing={}ms ap_coef={:.4} pet={}",
161 aa.spell_id, aa.swing_ms, aa.ap_coef, aa.is_pet,
162 ));
163 }
164
165 output::blank();
166}
167
168fn print_hero_talents(intro: &SpecIntrospection) {
169 if intro.hero_talents.is_empty() {
170 return;
171 }
172
173 output::header(&format!("Hero Talent Trees ({})", intro.hero_talents.len()));
174
175 for tree in &intro.hero_talents {
176 output::subheader(&tree.name);
177
178 for (name, id) in &tree.spells {
179 output::detail(&format!("spell: {name} ({id})"));
180 }
181
182 for (name, id) in &tree.auras {
183 output::detail(&format!("aura: {name} ({id})"));
184 }
185 }
186
187 output::blank();
188}