Skip to main content

forge/talent_conformance/
snapshot.rs

1// #t(file: rust_alloc_in_loop) CLI conformance owns subprocess arguments and fixture records.
2
3use std::{collections::BTreeMap, process::Command};
4
5use anyhow::{Context, Result, bail};
6#[cfg(test)]
7use googletest::{Result as GtestResult, prelude::*};
8use wowlab_fs::{file, path::Path, temporary};
9use wowlab_loadout::{
10    DecodedTraitNode, apply_decoded_traits, decode_trait_loadout, enrich_loadout,
11};
12use wowlab_types::{
13    data::{
14        TraitNode, TraitNodeEntry, TraitNodeType, TraitTreeFlat, TraitTreeIndex,
15        TraitTreeWithSelections,
16    },
17    game::SpecId,
18};
19
20use super::{
21    SIMC_TALENT_OUTPUT_OPTION, SimcJsonReport, TalentRecord, TalentSnapshot,
22    golden::normalize_snapshot,
23};
24use crate::simc::extract_action_lists;
25
26pub(super) fn simc_snapshot(binary: &Path, profile: &Path, spec: SpecId) -> Result<TalentSnapshot> {
27    let output = temporary::Directory::with_prefix("wowlab-talent-conformance-")
28        .context("failed to create temporary SimC talent output directory")?;
29    let output_path = output.path().join("result.json");
30    let action_lists = extract_action_lists(profile);
31    let arguments = simc_arguments(profile, &output_path, &action_lists);
32    let result = Command::new(binary)
33        .args(&arguments)
34        .output()
35        .with_context(|| format!("failed to run patched SimC at {}", binary.display()))?;
36
37    if !result.status.success() {
38        bail!(
39            "SimC talent export failed for {}: {}",
40            spec.slug(),
41            String::from_utf8_lossy(&result.stderr)
42        );
43    }
44
45    let json = file::read_text(&output_path).with_context(|| {
46        format!(
47            "patched SimC did not write {SIMC_TALENT_OUTPUT_OPTION} output {}",
48            output_path.display()
49        )
50    })?;
51    let report: SimcJsonReport = serde_json::from_str(&json)
52        .with_context(|| format!("invalid SimC talent JSON for {}", spec.slug()))?;
53    let mut snapshot = report
54        .sim
55        .players
56        .into_iter()
57        .next()
58        .with_context(|| format!("SimC JSON has no player for {}", spec.slug()))?
59        .talent_decode;
60
61    normalize_snapshot(&mut snapshot);
62
63    Ok(snapshot)
64}
65
66fn simc_arguments(profile: &Path, output_path: &Path, action_lists: &[String]) -> Vec<String> {
67    let mut arguments = vec![profile.display().to_string()];
68
69    for name in action_lists {
70        if name == "default" {
71            arguments.push("actions=/".to_string());
72        } else {
73            arguments.push(format!("actions.{name}=/"));
74        }
75    }
76
77    arguments.extend([
78        "actions=wait,sec=1".to_string(),
79        "iterations=1".to_string(),
80        "max_time=1".to_string(),
81        "report_details=0".to_string(),
82        format!("{SIMC_TALENT_OUTPUT_OPTION}={}", output_path.display()),
83    ]);
84
85    arguments
86}
87
88pub(super) fn wowlab_snapshot(loadout: &str, tree: &TraitTreeFlat) -> Result<TalentSnapshot> {
89    let decoded = decode_trait_loadout(loadout).context("failed to decode MID1 loadout")?;
90
91    if i32::from(decoded.spec_id) != tree.spec_id {
92        bail!(
93            "loadout spec {} does not match transformed tree {}",
94            decoded.spec_id,
95            tree.spec_id
96        );
97    }
98
99    let raw_by_node: BTreeMap<i32, _> = decoded
100        .nodes
101        .iter()
102        .zip(&tree.all_node_ids)
103        .map(|(node, &node_id)| (node_id, node))
104        .collect();
105    let applied = apply_decoded_traits(tree.clone(), &decoded);
106    let records = raw_records(&applied, &raw_by_node)?;
107    let mut active_subtree_ids: Vec<i32> = records
108        .iter()
109        .filter(|record| record.tree == "selection")
110        .map(|record| record.subtree_id)
111        .collect();
112
113    active_subtree_ids.sort_unstable();
114    active_subtree_ids.dedup();
115    let enriched = enrich_loadout(&applied);
116    let effective_records = enriched
117        .talents
118        .into_iter()
119        .map(|talent| {
120            let raw = raw_by_node.get(&talent.node_id).copied();
121            let node = tree
122                .nodes
123                .iter()
124                .find(|node| node.id == talent.node_id)
125                .with_context(|| format!("enriched node {} is absent from tree", talent.node_id))?;
126
127            Ok(TalentRecord {
128                node_id: talent.node_id,
129                entry_id: talent.trait_node_entry_id,
130                spell_id: talent.spell_id,
131                tree: tree_name(talent.tree_index)?.to_string(),
132                subtree_id: talent.sub_tree_id,
133                rank: talent.ranks,
134                selected: raw.is_some_and(|decoded| decoded.selected),
135                granted: raw.is_none_or(|decoded| !decoded.purchased),
136                purchased: raw.is_some_and(|decoded| decoded.purchased),
137                choice_index: normalized_choice_index(node, raw),
138                choice_encoded: raw.is_some_and(|decoded| decoded.choice_node),
139                encoded_choice_index: raw.and_then(|decoded| decoded.choice_index),
140            })
141        })
142        .collect::<Result<Vec<_>>>()?;
143
144    let mut snapshot = TalentSnapshot {
145        version: u32::from(decoded.version),
146        spec_id: tree.spec_id,
147        node_count: tree.all_node_ids.len(),
148        node_ids: tree.all_node_ids.clone(),
149        records,
150        active_subtree_ids,
151        effective_records,
152    };
153
154    normalize_snapshot(&mut snapshot);
155
156    Ok(snapshot)
157}
158
159fn raw_records(
160    applied: &TraitTreeWithSelections,
161    raw_by_node: &BTreeMap<i32, &DecodedTraitNode>,
162) -> Result<Vec<TalentRecord>> {
163    let mut records = Vec::with_capacity(applied.selections.len());
164
165    for selection in &applied.selections {
166        if !selection.selected || selection.ranks_purchased <= 0 {
167            continue;
168        }
169
170        let Some(node) = applied
171            .tree
172            .nodes
173            .iter()
174            .find(|node| node.id == selection.node_id)
175        else {
176            continue;
177        };
178        let decoded = raw_by_node.get(&node.id).copied();
179        let rank = selection.ranks_purchased;
180
181        if TraitNodeType::from_dbc(node.node_type) == Some(TraitNodeType::Tiered) {
182            let mut remaining = rank;
183
184            for entry in &node.entries {
185                let allocated = remaining.min(entry.max_ranks);
186
187                if allocated > 0 {
188                    records.push(raw_record(node, entry, decoded, allocated)?);
189                    remaining -= allocated;
190                }
191            }
192
193            if remaining != 0 {
194                bail!("tiered node {} left {remaining} ranks unallocated", node.id);
195            }
196        } else {
197            let choice = usize::from(selection.choice_index.unwrap_or(0));
198            let entry = node
199                .entries
200                .get(choice)
201                .with_context(|| format!("node {} choice {choice} is out of bounds", node.id))?;
202
203            records.push(raw_record(node, entry, decoded, rank)?);
204        }
205    }
206
207    Ok(records)
208}
209
210fn raw_record(
211    node: &TraitNode,
212    entry: &TraitNodeEntry,
213    decoded: Option<&DecodedTraitNode>,
214    rank: i32,
215) -> Result<TalentRecord> {
216    Ok(TalentRecord {
217        node_id: node.id,
218        entry_id: entry.id,
219        spell_id: entry.spell_id,
220        tree: tree_name(node.tree_index)?.to_string(),
221        subtree_id: if TraitTreeIndex::from_dbc(node.tree_index) == Some(TraitTreeIndex::Selection)
222        {
223            entry.sub_tree_id
224        } else {
225            node.sub_tree_id
226        },
227        rank,
228        selected: decoded.is_some_and(|node| node.selected),
229        granted: decoded.is_none_or(|node| !node.purchased),
230        purchased: decoded.is_some_and(|node| node.purchased),
231        choice_index: normalized_choice_index(node, decoded),
232        choice_encoded: decoded.is_some_and(|node| node.choice_node),
233        encoded_choice_index: decoded.and_then(|node| node.choice_index),
234    })
235}
236
237fn normalized_choice_index(node: &TraitNode, decoded: Option<&DecodedTraitNode>) -> Option<u8> {
238    matches!(
239        TraitNodeType::from_dbc(node.node_type),
240        Some(TraitNodeType::Selection | TraitNodeType::SubtreeSelection)
241    )
242    .then(|| decoded.and_then(|raw| raw.choice_index).unwrap_or(0))
243}
244
245fn tree_name(tree_index: i32) -> Result<&'static str> {
246    match TraitTreeIndex::from_dbc(tree_index) {
247        Some(TraitTreeIndex::Class) => Ok("class"),
248        Some(TraitTreeIndex::Specialization) => Ok("spec"),
249        Some(TraitTreeIndex::Hero) => Ok("hero"),
250        Some(TraitTreeIndex::Selection) => Ok("selection"),
251        _ => bail!("unknown trait tree index {tree_index}"),
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[gtest]
260    fn simc_arguments_clear_profiles_and_pin_export_options() -> GtestResult<()> {
261        let arguments = simc_arguments(
262            Path::new("profiles/MID1.simc"),
263            Path::new("/tmp/talents.json"),
264            &["cooldowns".to_string(), "default".to_string()],
265        );
266
267        verify_that!(
268            arguments,
269            elements_are![
270                "profiles/MID1.simc",
271                "actions.cooldowns=/",
272                "actions=/",
273                "actions=wait,sec=1",
274                "iterations=1",
275                "max_time=1",
276                "report_details=0",
277                "json2=/tmp/talents.json",
278            ]
279        )?;
280
281        Ok(())
282    }
283}