1use serde::Serialize;
4use wowlab_types::{
5 data::{
6 TraitNode, TraitNodeEntry, TraitNodeType, TraitSelection, TraitTreeIndex,
7 TraitTreeWithSelections,
8 },
9 sim::{FastBuildHasher, FastMap, FastSet},
10};
11
12#[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#[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 pub override_spell_id: i32,
32 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#[derive(Clone, Debug, Serialize)]
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48pub struct EnrichedTalentEffectOverride {
50 pub effect_index: i32,
51 pub operation: wowlab_types::data::TraitEffectOperation,
52 pub value: f64,
53}
54
55#[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#[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 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 #[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 }
300
301#[cfg(test)]
302#[path = "enrich_tests.rs"]
303mod tests;