Skip to main content

wowlab_engine_application/talents/
loadout.rs

1//! Decodes class/spec and expansion talent selections into the engine contract.
2
3use 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
85/// Resolve named selections from class-agnostic expansion trait trees.
86// #t(fn: rust_alloc_in_loop) expansion selection errors require owned messages containing the current system, token, and rank.
87pub(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}