wowlab_engine_application/talents/
loadout.rs1use std::collections::BTreeMap;
4
5use wowlab_engine_domain::dbc::{SpellEffectKind, SpellEffectSemanticExt as _};
6use wowlab_engine_ports::{
7 DataResolver as _, DynDataResolver, EngineError, SpellId, TalentEffectOverride, TalentSelection,
8};
9use wowlab_types::{
10 data::{ExpansionTraitTreeFlat, TraitNode, TraitNodeEntry},
11 game::SpecId,
12 sim::FastSet,
13};
14
15pub(crate) async fn decode_loadout_talents(
16 spec: SpecId,
17 loadout: &str,
18 resolver: &DynDataResolver<'_>,
19) -> Result<Vec<TalentSelection>, EngineError> {
20 use wowlab_loadout::{apply_decoded_traits, decode_trait_loadout, enrich_loadout};
21
22 let decoded = decode_trait_loadout(loadout).map_err(|e| {
23 EngineError::intent_validation(format!("failed to decode loadout string: {e}"))
24 })?;
25
26 let tree = resolver
27 .get_trait_tree(wowlab_types::numeric::u32_to_i32_saturating(
28 spec.wow_spec_id(),
29 ))
30 .await
31 .map_err(|e| {
32 EngineError::spec_construction(format!(
33 "failed to resolve trait tree for {spec:?}: {e}"
34 ))
35 })?;
36
37 let with_selections = apply_decoded_traits(tree, &decoded);
38 let enriched = enrich_loadout(&with_selections);
39 let mut talents = Vec::with_capacity(enriched.talents.len());
40
41 for talent in enriched.talents {
42 if talent.spell_id <= 0 || talent.ranks <= 0 {
43 continue;
44 }
45
46 let spell_id = wowlab_types::numeric::i32_to_u32_nonnegative(talent.spell_id);
47 let precombat_aura = resolver
48 .get_spell(SpellId::new(talent.spell_id))
49 .await
50 .is_ok_and(|spell| {
51 spell.is_passive
52 && spell
53 .effects
54 .iter()
55 .any(|effect| effect.effect_is(SpellEffectKind::ApplyAura))
56 });
57
58 talents.push(TalentSelection {
59 spell_id,
60 override_spell_id: wowlab_types::numeric::i32_to_u32_nonnegative(
61 talent.override_spell_id,
62 ),
63 replaces_spell_id: wowlab_types::numeric::i32_to_u32_nonnegative(
64 talent.replaces_spell_id,
65 ),
66 ranks: wowlab_types::numeric::i32_to_u8_saturating(talent.ranks),
67 precombat_aura,
68 hero_tree: (!matches!(talent.tree_section.as_str(), "class" | "spec" | "selection"))
69 .then_some(talent.tree_section),
70 effect_overrides: talent
71 .effect_overrides
72 .into_iter()
73 .map(|effect| TalentEffectOverride {
74 effect_index: effect.effect_index,
75 operation: effect.operation,
76 value: effect.value,
77 })
78 .collect(),
79 });
80 }
81
82 Ok(talents)
83}
84
85pub(crate) async fn decode_expansion_talents(
88 expansion_id: u32,
89 selections: &BTreeMap<String, Vec<String>>,
90 resolver: &DynDataResolver<'_>,
91) -> Result<Vec<TalentSelection>, EngineError> {
92 let mut talents = Vec::new();
93 let mut selected_nodes = FastSet::default();
94
95 for (system, selected_entries) in selections {
96 let expansion_id = i32::try_from(expansion_id).map_err(|error| {
97 EngineError::intent_validation(format!(
98 "expansion id is outside the supported range: {error}"
99 ))
100 })?;
101 let tree = resolver
102 .get_expansion_trait_tree(expansion_id, system)
103 .await
104 .map_err(|error| {
105 EngineError::spec_construction(format!(
106 "failed to resolve expansion trait tree {system:?} for expansion {expansion_id}: {error}"
107 ))
108 })?;
109
110 selected_nodes.clear();
111 let mut spent_points = 0i32;
112
113 for selection in selected_entries {
114 let (token, ranks) = parse_expansion_selection(selection);
115 let Some((node, entry)) = find_expansion_entry(&tree, token) else {
116 return Err(EngineError::intent_validation(format!(
117 "unknown {system} expansion talent {token:?} for expansion {expansion_id}"
118 )));
119 };
120 let ranks = validate_expansion_rank(system, entry, ranks)?;
121
122 if !selected_nodes.insert(node.id) {
123 return Err(EngineError::intent_validation(format!(
124 "multiple {system} expansion talents selected from node {}",
125 node.id
126 )));
127 }
128
129 spent_points += i32::from(ranks);
130
131 let spell = resolver
132 .get_spell(SpellId::new(entry.spell_id))
133 .await
134 .map_err(|error| {
135 EngineError::spec_construction(format!(
136 "failed to resolve {system} expansion talent spell {}: {error}",
137 entry.spell_id
138 ))
139 })?;
140 let spell_id = u32::try_from(entry.spell_id).map_err(|error| {
141 EngineError::spec_construction(format!(
142 "{system} expansion talent has invalid spell id {}: {error}",
143 entry.spell_id
144 ))
145 })?;
146
147 talents.push(TalentSelection {
148 spell_id,
149 override_spell_id: wowlab_types::numeric::i32_to_u32_nonnegative(
150 entry.override_spell_id,
151 ),
152 replaces_spell_id: wowlab_types::numeric::i32_to_u32_nonnegative(
153 entry.replaces_spell_id,
154 ),
155 ranks,
156 precombat_aura: spell.is_passive
157 && spell
158 .effects
159 .iter()
160 .any(|effect| effect.effect_is(SpellEffectKind::ApplyAura)),
161 hero_tree: None,
162 effect_overrides: entry
163 .effect_points
164 .iter()
165 .filter_map(|effect| {
166 effect
167 .rank_values
168 .iter()
169 .find(|(rank, _)| (*rank - f64::from(ranks)).abs() < f64::EPSILON)
170 .map(|(_, value)| TalentEffectOverride {
171 effect_index: effect.effect_index,
172 operation: effect.operation,
173 value: *value,
174 })
175 })
176 .collect(),
177 });
178 }
179
180 if tree.max_points > 0 && spent_points > tree.max_points {
181 return Err(EngineError::intent_validation(format!(
182 "{system} expansion talents spend {spent_points} points but the tree allows {}",
183 tree.max_points
184 )));
185 }
186 }
187
188 Ok(talents)
189}
190
191fn parse_expansion_selection(selection: &str) -> (&str, i32) {
192 selection
193 .rsplit_once(':')
194 .and_then(|(token, ranks)| ranks.parse::<i32>().ok().map(|ranks| (token, ranks)))
195 .unwrap_or((selection, 1))
196}
197
198fn find_expansion_entry<'a>(
199 tree: &'a ExpansionTraitTreeFlat,
200 token: &str,
201) -> Option<(&'a TraitNode, &'a TraitNodeEntry)> {
202 let tokenized = wowlab_engine_ports::tokenize_name(token);
203 let by_id = token.parse::<i32>().ok();
204
205 tree.nodes.iter().find_map(|node| {
206 node.entries
207 .iter()
208 .find(|entry| {
209 by_id == Some(entry.id)
210 || wowlab_engine_ports::tokenize_name(&entry.name) == tokenized
211 })
212 .map(|entry| (node, entry))
213 })
214}
215
216fn validate_expansion_rank(
217 system: &str,
218 entry: &TraitNodeEntry,
219 ranks: i32,
220) -> Result<u8, EngineError> {
221 if ranks <= 0 || ranks > entry.max_ranks {
222 return Err(EngineError::intent_validation(format!(
223 "invalid rank {ranks} for {system} expansion talent {:?}; expected 1..={}",
224 entry.name, entry.max_ranks
225 )));
226 }
227
228 u8::try_from(ranks).map_err(|error| {
229 EngineError::intent_validation(format!(
230 "{system} expansion talent rank is unsupported: {error}"
231 ))
232 })
233}
234
235#[cfg(test)]
236mod tests {
237 use googletest::prelude::*;
238 use wowlab_engine_adapter_data::InMemoryResolver;
239 use wowlab_types::data::SpellDataFlat;
240
241 use super::*;
242
243 fn expansion_talent_fixture() -> InMemoryResolver {
244 let tree = ExpansionTraitTreeFlat {
245 expansion_id: 11,
246 system: "omnium".into(),
247 tree_id: 1186,
248 all_node_ids: vec![110_275],
249 nodes: vec![TraitNode {
250 id: 110_275,
251 max_ranks: 1,
252 entries: vec![
253 TraitNodeEntry {
254 id: 136_822,
255 spell_id: 1_279_599,
256 max_ranks: 1,
257 name: "Rune of Unleashed Fire".into(),
258 ..TraitNodeEntry::default()
259 },
260 TraitNodeEntry {
261 id: 136_814,
262 spell_id: 1_279_596,
263 max_ranks: 1,
264 name: "Rune of Void-Touched Orbs".into(),
265 ..TraitNodeEntry::default()
266 },
267 ],
268 ..TraitNode::default()
269 }],
270 max_points: 5,
271 ..ExpansionTraitTreeFlat::default()
272 };
273
274 InMemoryResolver::new()
275 .with_expansion_trait_tree(tree)
276 .with_spell(SpellDataFlat {
277 id: 1_279_599,
278 ..SpellDataFlat::default()
279 })
280 .with_spell(SpellDataFlat {
281 id: 1_279_596,
282 ..SpellDataFlat::default()
283 })
284 }
285
286 #[gtest]
287 #[tokio::test]
288 async fn expansion_talents_resolve_tokenized_names_from_the_expansion_tree() -> Result<()> {
289 let resolver = expansion_talent_fixture();
290 let selections = BTreeMap::from([("omnium".into(), vec!["rune_of_unleashed_fire".into()])]);
291
292 let talents =
293 decode_expansion_talents(11, &selections, DynDataResolver::from_ref(&resolver))
294 .await
295 .or_fail()?;
296
297 verify_that!(
298 (
299 talents.len(),
300 talents
301 .first()
302 .map(|talent| (talent.spell_id, talent.ranks))
303 ),
304 (eq(1), some(eq((1_279_599, 1))))
305 )?;
306
307 Ok(())
308 }
309
310 #[gtest]
311 #[tokio::test]
312 async fn expansion_talents_reject_two_entries_from_the_same_choice_node() -> Result<()> {
313 let resolver = expansion_talent_fixture();
314 let selections = BTreeMap::from([(
315 "omnium".into(),
316 vec![
317 "rune_of_unleashed_fire".into(),
318 "rune_of_void_touched_orbs".into(),
319 ],
320 )]);
321
322 let error = decode_expansion_talents(11, &selections, DynDataResolver::from_ref(&resolver))
323 .await
324 .err()
325 .or_fail()?;
326
327 verify_true!(
328 error
329 .to_string()
330 .contains("multiple omnium expansion talents")
331 )?;
332
333 Ok(())
334 }
335}