Skip to main content

wowlab_loadout/
enrich.rs

1// #t(file: rust_alloc_in_loop) enrichment builds per-node output strings during tree walk
2
3use serde::Serialize;
4use wowlab_types::{
5    data::{
6        TraitNode, TraitNodeEntry, TraitNodeType, TraitSelection, TraitTreeIndex,
7        TraitTreeWithSelections,
8    },
9    sim::{FastBuildHasher, FastMap, FastSet},
10};
11
12/// Enriched talent loadout with resolved spell info and point summary.
13#[derive(Clone, Debug, Serialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15pub struct EnrichedLoadout {
16    pub spec_id: i32,
17    pub spec_name: String,
18    pub class_name: String,
19    pub talents: Vec<EnrichedTalent>,
20    pub summary: TalentPointSummary,
21}
22
23/// A single resolved talent with spell info and tree classification.
24#[derive(Clone, Debug, Serialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct EnrichedTalent {
27    pub node_id: i32,
28    pub trait_node_entry_id: i32,
29    pub spell_id: i32,
30    /// `TraitDefinition.VisibleSpellID`: the spell `spell_id` is displayed and activated as in-game.
31    pub override_spell_id: i32,
32    /// `TraitDefinition.OverridesSpellID`: the learned talent replaces this spell on the action bar.
33    pub replaces_spell_id: i32,
34    pub name: String,
35    pub ranks: i32,
36    pub max_ranks: i32,
37    pub tree_index: i32,
38    pub sub_tree_id: i32,
39    pub tree_section: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub choice_index: Option<u8>,
42    pub effect_overrides: Vec<EnrichedTalentEffectOverride>,
43}
44
45/// Rank-specific adjustment for one effect on a selected talent spell.
46#[derive(Clone, Debug, Serialize)]
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48// #t(rust_similar_structs) enriched serialized output is separate from the engine-port selection contract
49pub struct EnrichedTalentEffectOverride {
50    pub effect_index: i32,
51    pub operation: wowlab_types::data::TraitEffectOperation,
52    pub value: f64,
53}
54
55/// Point totals broken down by tree section.
56#[derive(Clone, Debug, Serialize)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct TalentPointSummary {
59    pub total_points: i32,
60    pub class_points: i32,
61    pub spec_points: i32,
62    pub hero_points: i32,
63}
64
65/// Enriches a decoded loadout with spell metadata and point totals.
66#[must_use]
67pub fn enrich_loadout(data: &TraitTreeWithSelections) -> EnrichedLoadout {
68    let mut node_by_id =
69        FastMap::with_capacity_and_hasher(data.tree.nodes.len(), FastBuildHasher::default());
70
71    for node in &data.tree.nodes {
72        node_by_id.entry(node.id).or_insert(node);
73    }
74
75    let sub_tree_names: FastMap<i32, &str> = data
76        .tree
77        .sub_trees
78        .iter()
79        .map(|st| (st.id, st.name.as_str()))
80        .collect();
81
82    let active_sub_trees = active_sub_tree_ids(data, &node_by_id);
83
84    let mut talents = Vec::with_capacity(data.selections.len());
85    let mut class_points = 0i32;
86    let mut spec_points = 0i32;
87    let mut hero_points = 0i32;
88
89    for sel in &data.selections {
90        if !sel.selected || sel.ranks_purchased <= 0 {
91            continue;
92        }
93
94        let Some(node) = node_by_id.get(&sel.node_id) else {
95            continue;
96        };
97
98        if node.sub_tree_id > 0
99            && active_sub_trees
100                .as_ref()
101                .is_some_and(|ids| !ids.contains(&node.sub_tree_id))
102        {
103            continue;
104        }
105
106        let allocations = allocate_entry_ranks(node, sel);
107
108        if allocations.is_empty() {
109            continue;
110        }
111
112        let tree_section = match TraitTreeIndex::from_dbc(node.tree_index) {
113            Some(TraitTreeIndex::Class) => {
114                class_points += sel.ranks_purchased;
115
116                "class".to_string()
117            }
118            Some(TraitTreeIndex::Specialization) => {
119                spec_points += sel.ranks_purchased;
120
121                "spec".to_string()
122            }
123            Some(TraitTreeIndex::Selection) => "selection".to_string(),
124            _ => {
125                hero_points += sel.ranks_purchased;
126
127                sub_tree_names
128                    .get(&node.sub_tree_id)
129                    .map_or_else(|| "hero".to_string(), |s| (*s).to_string())
130            }
131        };
132
133        for allocation in allocations {
134            let EntryAllocation {
135                entry,
136                ranks,
137                max_ranks,
138            } = allocation;
139
140            talents.push(EnrichedTalent {
141                node_id: sel.node_id,
142                trait_node_entry_id: entry.id,
143                spell_id: entry.spell_id,
144                override_spell_id: entry.override_spell_id,
145                replaces_spell_id: entry.replaces_spell_id,
146                // #t(block: rust_clone_in_loop) building owned structs from borrowed tree entries requires cloning
147                name: entry.name.clone(),
148                ranks,
149                max_ranks,
150                tree_index: node.tree_index,
151                sub_tree_id: if TraitTreeIndex::from_dbc(node.tree_index)
152                    == Some(TraitTreeIndex::Selection)
153                {
154                    entry.sub_tree_id
155                } else {
156                    node.sub_tree_id
157                },
158                tree_section: tree_section.clone(),
159                choice_index: sel.choice_index,
160                effect_overrides: effect_overrides(entry, ranks),
161            });
162        }
163    }
164
165    EnrichedLoadout {
166        spec_id: data.tree.spec_id,
167        spec_name: data.tree.spec_name.clone(),
168        class_name: data.tree.class_name.clone(),
169        talents,
170        summary: TalentPointSummary {
171            total_points: class_points + spec_points + hero_points,
172            class_points,
173            spec_points,
174            hero_points,
175        },
176    }
177}
178
179fn effect_overrides(entry: &TraitNodeEntry, ranks: i32) -> Vec<EnrichedTalentEffectOverride> {
180    let rank = f64::from(ranks);
181
182    entry
183        .effect_points
184        .iter()
185        .filter_map(|effect| {
186            effect
187                .rank_values
188                .iter()
189                .find(|(point_rank, _)| (*point_rank - rank).abs() < f64::EPSILON)
190                .map(|&(_, value)| EnrichedTalentEffectOverride {
191                    effect_index: effect.effect_index,
192                    operation: effect.operation,
193                    value,
194                })
195        })
196        .collect()
197}
198
199fn active_sub_tree_ids(
200    data: &TraitTreeWithSelections,
201    node_by_id: &FastMap<i32, &TraitNode>,
202) -> Option<FastSet<i32>> {
203    let mut active =
204        FastSet::with_capacity_and_hasher(data.tree.sub_trees.len(), FastBuildHasher::default());
205    let mut has_selected_picker = false;
206
207    for sel in &data.selections {
208        if !sel.selected {
209            continue;
210        }
211
212        let Some(node) = node_by_id.get(&sel.node_id) else {
213            continue;
214        };
215
216        if TraitTreeIndex::from_dbc(node.tree_index) != Some(TraitTreeIndex::Selection) {
217            continue;
218        }
219
220        has_selected_picker = true;
221
222        let entry_idx = sel.choice_index.unwrap_or(0) as usize;
223        let Some(entry) = node.entries.get(entry_idx) else {
224            continue;
225        };
226
227        if entry.sub_tree_id > 0
228            && data
229                .tree
230                .sub_trees
231                .iter()
232                .any(|sub_tree| sub_tree.id == entry.sub_tree_id)
233        {
234            active.insert(entry.sub_tree_id);
235        }
236    }
237
238    has_selected_picker.then_some(active)
239}
240
241struct EntryAllocation<'a> {
242    entry: &'a TraitNodeEntry,
243    ranks: i32,
244    max_ranks: i32,
245}
246
247fn allocate_entry_ranks<'a>(node: &'a TraitNode, sel: &TraitSelection) -> Vec<EntryAllocation<'a>> {
248    if TraitNodeType::from_dbc(node.node_type) == Some(TraitNodeType::Tiered)
249        && node.entries.iter().any(|e| e.max_ranks > 0)
250    {
251        let mut allocations = Vec::with_capacity(node.entries.len());
252        let mut remaining = sel.ranks_purchased;
253
254        for entry in &node.entries {
255            if remaining <= 0 {
256                break;
257            }
258
259            let allocated = remaining.min(entry.max_ranks);
260
261            if allocated > 0 {
262                allocations.push(EntryAllocation {
263                    entry,
264                    ranks: allocated,
265                    max_ranks: entry.max_ranks,
266                });
267                remaining -= allocated;
268            }
269        }
270
271        return allocations;
272    }
273
274    let entry_idx = sel.choice_index.unwrap_or(0) as usize;
275    let entry = node.entries.get(entry_idx).or_else(|| node.entries.first());
276
277    entry
278        .map(|entry| EntryAllocation {
279            entry,
280            ranks: sel.ranks_purchased,
281            max_ranks: node.max_ranks,
282        })
283        .into_iter()
284        .collect()
285}
286
287impl EnrichedLoadout {
288    /// Returns selected spell IDs and ranks.
289    // docref:start talent-trees-get-spell-ids
290    #[must_use]
291    pub fn talent_selections(&self) -> Vec<(u32, u8)> {
292        self.talents
293            .iter()
294            .filter(|t| t.spell_id > 0)
295            .filter_map(|t| Some((u32::try_from(t.spell_id).ok()?, u8::try_from(t.ranks).ok()?)))
296            .collect()
297    }
298    // docref:end talent-trees-get-spell-ids
299}
300
301#[cfg(test)]
302#[path = "enrich_tests.rs"]
303mod tests;