1use std::fmt::Write;
2
3use anyhow::{Context, Result};
4use serde::Serialize;
5use wowlab_common::output;
6use wowlab_loadout::{DecodedTraitLoadout, EnrichedLoadout, decode_trait_loadout};
7use wowlab_types::data::TraitTreeFlat;
8
9use super::DecodeLoadoutArgs;
10
11#[derive(Serialize)]
12struct RawTalent {
13 node_index: i32,
14 ranks: i32,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 choice_index: Option<u8>,
17}
18
19#[derive(Serialize)]
20struct RawOutput {
21 spec_id: u16,
22 version: u8,
23 tree_hash: String,
24 talents: Vec<RawTalent>,
25 total_points: i32,
26}
27
28#[derive(Serialize)]
29struct EnrichedOutput {
30 #[serde(flatten)]
31 enriched: EnrichedLoadout,
32 version: u8,
33 tree_hash: String,
34 bitstream_position_count: usize,
35 tree_position_count: usize,
36 mapped_position_count: usize,
37 selected_node_count: usize,
38 selected_entry_count: usize,
39 trait_entries: Vec<DecodedTalentEntry>,
40}
41
42#[derive(Serialize)]
43#[expect(
44 clippy::struct_excessive_bools,
45 reason = "these flags mirror the decoded loadout wire representation"
46)]
47struct DecodedTalentEntry {
48 position: usize,
49 node_id: i32,
50 trait_node_entry_id: i32,
51 spell_id: i32,
52 name: String,
53 tree_index: i32,
54 tree_kind: &'static str,
55 sub_tree_id: i32,
56 rank: i32,
57 selected: bool,
58 granted: bool,
59 purchased: bool,
60 partially_ranked: bool,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 choice_index: Option<u8>,
63}
64
65pub(super) fn run_decode_loadout(args: &DecodeLoadoutArgs) -> Result<()> {
66 let decoded = decode_trait_loadout(&args.loadout).context("Failed to decode loadout")?;
67
68 let mut tree_hash = String::with_capacity(decoded.tree_hash.len() * 2);
69
70 for byte in &decoded.tree_hash {
71 write!(&mut tree_hash, "{byte:02x}").context("failed to format trait-tree hash")?;
72 }
73
74 if let Some(data_dir) = &args.data_dir {
75 use wowlab_parsers::{DbcData, transform_trait_tree};
76
77 let dbc = DbcData::load_all(data_dir)?;
78 let tree = transform_trait_tree(&dbc, i32::from(decoded.spec_id))?;
79 let output = enriched_output(tree, &decoded, tree_hash);
80
81 output::json(&output);
82 } else {
83 let mut talents = Vec::new();
84 let mut total = 0i32;
85
86 for (i, node) in decoded.nodes.iter().enumerate() {
87 if !node.selected || !node.purchased {
88 continue;
89 }
90
91 let ranks = node.ranks_purchased.map_or(1, i32::from);
92
93 total += ranks;
94
95 talents.push(RawTalent {
96 node_index: i32::try_from(i).context("loadout contains too many talent nodes")?,
97 ranks,
98 choice_index: node.choice_index,
99 });
100 }
101
102 let output = RawOutput {
103 spec_id: decoded.spec_id,
104 version: decoded.version,
105 tree_hash,
106 talents,
107 total_points: total,
108 };
109
110 output::json(&output);
111 }
112
113 Ok(())
114}
115
116fn enriched_output(
117 tree: TraitTreeFlat,
118 decoded: &DecodedTraitLoadout,
119 tree_hash: String,
120) -> EnrichedOutput {
121 use wowlab_loadout::{apply_decoded_traits, enrich_loadout};
122
123 let tree_position_count = tree.all_node_ids.len();
124 let mapped_position_count = decoded.nodes.len().min(tree_position_count);
125 let positions: wowlab_types::sim::IntMap<i32, (usize, i32)> = tree
126 .nodes
127 .iter()
128 .filter_map(|node| {
129 tree.all_node_ids
130 .iter()
131 .position(|&node_id| node_id == node.id)
132 .map(|position| (node.id, (position, node.granted_ranks)))
133 })
134 .collect();
135 let with_selections = apply_decoded_traits(tree, decoded);
136 let enriched = enrich_loadout(&with_selections);
137
138 let trait_entries = enriched
139 .talents
140 .iter()
141 .filter_map(|talent| {
142 let &(position, granted_ranks) = positions.get(&talent.node_id)?;
143 let decoded_node = decoded.nodes.get(position);
144 let purchased = decoded_node.is_some_and(|node| node.purchased);
145 let selected = decoded_node.is_some_and(|node| node.selected);
146
147 Some(DecodedTalentEntry {
148 position,
149 node_id: talent.node_id,
150 trait_node_entry_id: talent.trait_node_entry_id,
151 spell_id: talent.spell_id,
152 name: talent.name.clone(),
153 tree_index: talent.tree_index,
154 tree_kind: tree_kind(talent.tree_index),
155 sub_tree_id: talent.sub_tree_id,
156 rank: talent.ranks,
157 selected,
158 granted: !purchased && (selected || granted_ranks > 0),
159 purchased,
160 partially_ranked: decoded_node.is_some_and(|node| node.partially_ranked),
161 choice_index: decoded_node.and_then(|node| node.choice_index),
162 })
163 })
164 .collect::<Vec<_>>();
165 let selected_node_count = decoded
166 .nodes
167 .iter()
168 .take(mapped_position_count)
169 .filter(|node| node.selected)
170 .count();
171 let selected_entry_count = trait_entries.len();
172
173 EnrichedOutput {
174 enriched,
175 version: decoded.version,
176 tree_hash,
177 bitstream_position_count: decoded.nodes.len(),
178 tree_position_count,
179 mapped_position_count,
180 selected_node_count,
181 selected_entry_count,
182 trait_entries,
183 }
184}
185
186const fn tree_kind(tree_index: i32) -> &'static str {
187 match wowlab_types::data::TraitTreeIndex::from_dbc(tree_index) {
188 Some(wowlab_types::data::TraitTreeIndex::Class) => "class",
189 Some(wowlab_types::data::TraitTreeIndex::Specialization) => "specialization",
190 Some(wowlab_types::data::TraitTreeIndex::Hero) => "hero",
191 Some(wowlab_types::data::TraitTreeIndex::Selection) => "selection",
192 _ => "unknown",
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use googletest::{Result as GtestResult, prelude::*};
199 use serde_json::Value;
200 use wowlab_loadout::DecodedTraitNode;
201 use wowlab_types::data::{TraitNode, TraitNodeEntry, TraitSubTree};
202
203 use super::*;
204
205 fn entry(id: i32, spell_id: i32, sub_tree_id: i32, max_ranks: i32) -> TraitNodeEntry {
206 TraitNodeEntry {
207 id,
208 spell_id,
209 sub_tree_id,
210 max_ranks,
211 name: format!("Entry {id}"),
212 ..Default::default()
213 }
214 }
215
216 fn decoded_node(
217 purchased: bool,
218 ranks: Option<u8>,
219 choice_index: Option<u8>,
220 ) -> DecodedTraitNode {
221 DecodedTraitNode {
222 selected: true,
223 purchased,
224 partially_ranked: ranks.is_some(),
225 ranks_purchased: ranks,
226 choice_node: choice_index.is_some(),
227 choice_index,
228 }
229 }
230
231 fn comparison_fixture() -> (TraitTreeFlat, DecodedTraitLoadout) {
232 let tree = TraitTreeFlat {
233 spec_id: 255,
234 all_node_ids: vec![10, 20, 30, 40],
235 nodes: vec![
236 TraitNode {
237 id: 10,
238 max_ranks: 1,
239 tree_index: 1,
240 entries: vec![entry(100, 1_000, 0, 1)],
241 ..Default::default()
242 },
243 TraitNode {
244 id: 20,
245 max_ranks: 1,
246 node_type: 3,
247 tree_index: 4,
248 entries: vec![entry(200, 0, 60, 1)],
249 ..Default::default()
250 },
251 TraitNode {
252 id: 30,
253 max_ranks: 1,
254 tree_index: 3,
255 sub_tree_id: 60,
256 entries: vec![entry(300, 3_000, 0, 1)],
257 ..Default::default()
258 },
259 TraitNode {
260 id: 40,
261 max_ranks: 3,
262 node_type: 1,
263 tree_index: 2,
264 entries: vec![entry(400, 4_000, 0, 1), entry(401, 4_001, 0, 2)],
265 ..Default::default()
266 },
267 ],
268 sub_trees: vec![TraitSubTree {
269 id: 60,
270 name: "Pack Leader".to_string(),
271 ..Default::default()
272 }],
273 ..Default::default()
274 };
275 let decoded = DecodedTraitLoadout {
276 version: 2,
277 spec_id: 255,
278 tree_hash: [7; 16],
279 nodes: vec![
280 decoded_node(false, None, None),
281 decoded_node(true, None, Some(0)),
282 decoded_node(true, None, None),
283 decoded_node(true, Some(2), None),
284 ],
285 };
286
287 (tree, decoded)
288 }
289
290 #[gtest]
291 fn enriched_output_emits_stable_entry_comparison_rows() -> GtestResult<()> {
292 let (tree, decoded) = comparison_fixture();
293 let output = enriched_output(tree, &decoded, "07".repeat(16));
294 let json = serde_json::to_value(output).expect("serialize comparison output");
295 let entries = json["trait_entries"]
296 .as_array()
297 .expect("trait_entries array");
298
299 verify_that!(json["version"].as_u64(), some(eq(2)))?;
300 verify_that!(json["spec_id"].as_i64(), some(eq(255)))?;
301 verify_that!(json["bitstream_position_count"].as_u64(), some(eq(4)))?;
302 verify_that!(json["tree_position_count"].as_u64(), some(eq(4)))?;
303 verify_that!(json["mapped_position_count"].as_u64(), some(eq(4)))?;
304 verify_that!(json["selected_node_count"].as_u64(), some(eq(4)))?;
305 verify_that!(json["selected_entry_count"].as_u64(), some(eq(5)))?;
306 verify_that!(entries.len(), eq(5))?;
307
308 verify_that!(entries[0]["position"].as_u64(), some(eq(0)))?;
309 verify_that!(entries[0]["node_id"].as_i64(), some(eq(10)))?;
310 verify_that!(entries[0]["trait_node_entry_id"].as_i64(), some(eq(100)))?;
311 verify_that!(entries[0]["tree_kind"].as_str(), some(eq("class")))?;
312 verify_that!(entries[0]["selected"].as_bool(), some(eq(true)))?;
313 verify_that!(entries[0]["granted"].as_bool(), some(eq(true)))?;
314 verify_that!(entries[0]["purchased"].as_bool(), some(eq(false)))?;
315
316 verify_that!(entries[1]["tree_kind"].as_str(), some(eq("selection")))?;
317 verify_that!(entries[1]["sub_tree_id"].as_i64(), some(eq(60)))?;
318 verify_that!(entries[1]["choice_index"].as_u64(), some(eq(0)))?;
319 verify_that!(entries[2]["tree_kind"].as_str(), some(eq("hero")))?;
320 verify_that!(entries[2]["sub_tree_id"].as_i64(), some(eq(60)))?;
321
322 verify_that!(entries[3]["rank"].as_i64(), some(eq(1)))?;
323 verify_that!(entries[4]["rank"].as_i64(), some(eq(1)))?;
324
325 verify_that!(entries[4]["partially_ranked"].as_bool(), some(eq(true)))
326 }
327
328 #[gtest]
329 fn tree_kind_has_stable_unknown_fallback() -> GtestResult<()> {
330 verify_that!(tree_kind(1), eq("class"))?;
331 verify_that!(tree_kind(2), eq("specialization"))?;
332 verify_that!(tree_kind(3), eq("hero"))?;
333 verify_that!(tree_kind(4), eq("selection"))?;
334
335 verify_that!(tree_kind(99), eq("unknown"))
336 }
337
338 #[gtest]
339 fn output_schema_keeps_choice_index_optional() -> GtestResult<()> {
340 let (tree, decoded) = comparison_fixture();
341 let output = enriched_output(tree, &decoded, "07".repeat(16));
342 let json = serde_json::to_value(output).or_fail()?;
343 let entries = json["trait_entries"].as_array().or_fail()?;
344 let first = entries.first().or_fail()?;
345 let second = entries.get(1).or_fail()?;
346
347 verify_that!(first.get("choice_index"), none())?;
348
349 verify_that!(second.get("choice_index"), some(eq(&Value::from(0))))
350 }
351}