Skip to main content

wowlab_parsers/parsers/transform/
assisted.rs

1use std::collections::BTreeMap;
2
3use wowlab_types::{
4    constants::{HUNDRED, MS_PER_SECOND},
5    data::{AssistedActionFlat, AssistedConditionFlat, AssistedRotationFlat, ConditionType},
6    sim::{FastMap, FastSet},
7};
8
9use super::super::dbc::{
10    DbcData,
11    rows::{AssistedCombatRuleRow, AssistedCombatStepRow},
12};
13use crate::parsers::formatting::{NearIntegerStrategy, format_decimal_trimmed};
14
15const ASSISTED_DECIMAL_PLACES: u8 = 3;
16
17/// Rebuild flat assisted rotations for every spec.
18#[must_use]
19pub fn transform_assisted_rotations(dbc: &DbcData) -> Vec<AssistedRotationFlat> {
20    let mut assisted_rows: Vec<_> = dbc.assisted_combat.values().collect();
21
22    assisted_rows.sort_by_key(|row| row.ID);
23
24    let mut actions_by_spec: BTreeMap<i32, Vec<AssistedActionFlat>> = BTreeMap::new();
25
26    for assisted in assisted_rows {
27        let mut steps = dbc
28            .assisted_combat_step
29            .get(&assisted.ID)
30            .cloned()
31            .unwrap_or_default();
32
33        steps.sort_by(|a, b| {
34            a.OrderIndex
35                .cmp(&b.OrderIndex)
36                .then_with(|| a.ID.cmp(&b.ID))
37        });
38
39        let actions = steps
40            .iter()
41            .map(|step| build_action(dbc, step))
42            .collect::<Vec<_>>();
43
44        actions_by_spec
45            .entry(assisted.ChrSpecializationID)
46            .or_default()
47            .extend(actions);
48    }
49
50    actions_by_spec
51        .into_iter()
52        .map(|(spec_id, actions)| AssistedRotationFlat { spec_id, actions })
53        .collect()
54}
55
56/// Build the flat assisted rotation for a single spec.
57#[must_use]
58pub fn transform_assisted_rotation(dbc: &DbcData, spec_id: i32) -> Option<AssistedRotationFlat> {
59    transform_assisted_rotations(dbc)
60        .into_iter()
61        .find(|rotation| rotation.spec_id == spec_id)
62}
63
64/// Resolve specialization placeholders used by a spec's assisted-combat rotation.
65#[must_use]
66pub fn assisted_spell_overrides(dbc: &DbcData, spec_id: i32) -> FastMap<i32, i32> {
67    let mut overrides = FastMap::default();
68    let specialization_placeholders: FastSet<_> = dbc
69        .specialization_spells
70        .values()
71        .flatten()
72        .filter_map(|row| (row.OverridesSpellID > 0).then_some(row.OverridesSpellID))
73        .collect();
74
75    if let Some(rows) = dbc.specialization_spells.get(&spec_id) {
76        for row in rows {
77            insert_spell_override(&mut overrides, row.OverridesSpellID, row.SpellID);
78        }
79    }
80
81    for loadout in dbc
82        .trait_tree_loadout_by_spec
83        .get(&spec_id)
84        .into_iter()
85        .flatten()
86    {
87        for selection in dbc
88            .trait_tree_loadout_entry
89            .get(&loadout.ID)
90            .into_iter()
91            .flatten()
92            .filter(|selection| selection.NumPoints > 0)
93        {
94            let entries = dbc
95                .trait_node_x_trait_node_entry
96                .get(&selection.SelectedTraitNodeID)
97                .into_iter()
98                .flatten()
99                .filter(|entry| {
100                    selection.SelectedTraitNodeEntryID == 0
101                        || entry.TraitNodeEntryID == selection.SelectedTraitNodeEntryID
102                });
103
104            for entry in entries {
105                let Some(definition) = dbc
106                    .trait_node_entry
107                    .get(&entry.TraitNodeEntryID)
108                    .and_then(|entry| dbc.trait_definition.get(&entry.TraitDefinitionID))
109                else {
110                    continue;
111                };
112
113                if !specialization_placeholders.contains(&definition.OverridesSpellID) {
114                    continue;
115                }
116
117                insert_spell_override(
118                    &mut overrides,
119                    definition.OverridesSpellID,
120                    definition.SpellID,
121                );
122            }
123        }
124    }
125
126    overrides
127}
128
129fn insert_spell_override(overrides: &mut FastMap<i32, i32>, replaced: i32, replacement: i32) {
130    if replaced > 0 && replacement > 0 {
131        overrides.insert(replaced, replacement);
132    }
133}
134
135fn build_action(dbc: &DbcData, step: &AssistedCombatStepRow) -> AssistedActionFlat {
136    let mut rules = dbc
137        .assisted_combat_rule
138        .get(&step.ID)
139        .cloned()
140        .unwrap_or_default();
141
142    rules.sort_by(|a, b| {
143        a.OrderIndex
144            .cmp(&b.OrderIndex)
145            .then_with(|| a.ID.cmp(&b.ID))
146    });
147
148    let mut allow_casting_success = false;
149    let mut predicates = Vec::new();
150    let mut conditions = Vec::with_capacity(rules.len());
151
152    for rule in rules {
153        if ConditionType::from_raw(rule.ConditionType)
154            == Some(ConditionType::CooldownAllowCastingSuccess)
155        {
156            allow_casting_success = true;
157        }
158
159        let predicate = rule_to_predicate(&rule);
160
161        // #t(block: rust_clone_in_loop) predicate string needed both for predicates vec and conditions vec
162        if let Some(ref text) = predicate {
163            predicates.push(text.clone());
164        }
165
166        conditions.push(AssistedConditionFlat {
167            r#type: rule.ConditionType,
168            name: condition_name(rule.ConditionType),
169            v1: rule.ConditionValue1,
170            v2: rule.ConditionValue2,
171            v3: rule.ConditionValue3,
172            flags: rule.Field_11_1_7_60520_002,
173            predicate,
174            condition_type: ConditionType::from_raw(rule.ConditionType),
175        });
176    }
177
178    AssistedActionFlat {
179        step_id: step.ID,
180        order_index: step.OrderIndex,
181        spell_id: step.SpellID,
182        allow_casting_success,
183        predicate: (!predicates.is_empty()).then(|| predicates.join(" AND ")),
184        conditions,
185    }
186}
187
188fn condition_name(condition_type: i32) -> String {
189    ConditionType::from_raw(condition_type).map_or_else(
190        || format!("UNKNOWN_{condition_type}"),
191        |t| t.name().to_string(),
192    )
193}
194
195fn rule_to_predicate(rule: &AssistedCombatRuleRow) -> Option<String> {
196    let condition = ConditionType::from_raw(rule.ConditionType);
197
198    if condition == Some(ConditionType::CooldownAllowCastingSuccess) {
199        return None;
200    }
201
202    let name = condition.map_or_else(
203        || condition_name(rule.ConditionType),
204        |t| t.name().to_string(),
205    );
206    let args = format_rule_args(rule);
207
208    Some(format!("{name}({args})"))
209}
210
211fn format_rule_args(rule: &AssistedCombatRuleRow) -> String {
212    let v1 = rule.ConditionValue1;
213    let v2 = rule.ConditionValue2;
214    let v3 = rule.ConditionValue3;
215
216    match ConditionType::from_raw(rule.ConditionType) {
217        Some(t) if t.uses_milliseconds() => format!(
218            "v1={}, seconds={}",
219            v1,
220            format_assisted_number(normalized_threshold(rule.ConditionType, v1, v2))
221        ),
222        Some(t) if t.uses_tenths() || t.uses_hundredths() => format!(
223            "threshold={}",
224            format_assisted_number(normalized_threshold(rule.ConditionType, v1, v2))
225        ),
226        _ => format_default_args(v1, v2, v3),
227    }
228}
229
230fn format_default_args(v1: i32, v2: i32, v3: i32) -> String {
231    let mut args = vec![format!("v1={}", v1)];
232
233    if v2 != 0 {
234        args.push(format!("v2={v2}"));
235    }
236
237    if v3 != 0 {
238        args.push(format!("v3={v3}"));
239    }
240
241    args.join(", ")
242}
243
244const TENTHS_DIVISOR: f64 = 10.0;
245
246fn normalized_threshold(condition_type: i32, value_1: i32, value_2: i32) -> f64 {
247    let Some(condition) = ConditionType::from_raw(condition_type) else {
248        return f64::from(value_1);
249    };
250
251    if condition.uses_milliseconds() {
252        return f64::from(value_2) / MS_PER_SECOND;
253    }
254
255    if condition.uses_tenths() {
256        return f64::from(value_1) / TENTHS_DIVISOR;
257    }
258
259    if condition.uses_hundredths() {
260        return f64::from(value_1) / HUNDRED;
261    }
262
263    f64::from(value_1)
264}
265
266fn format_assisted_number(value: f64) -> String {
267    format_decimal_trimmed(
268        value,
269        ASSISTED_DECIMAL_PLACES,
270        NearIntegerStrategy::FractionalFixed {
271            epsilon: f64::EPSILON,
272        },
273    )
274}
275
276#[cfg(test)]
277mod assisted_rotation_tests {
278    use googletest::prelude::*;
279
280    use super::*;
281    use crate::parsers::dbc::rows::AssistedCombatRow;
282
283    pub(super) fn empty_dbc() -> DbcData {
284        DbcData::default()
285    }
286
287    #[gtest]
288    fn reconstructs_sorted_actions_and_combines_predicates() -> Result<()> {
289        let mut dbc = empty_dbc();
290
291        dbc.assisted_combat.insert(
292            10,
293            AssistedCombatRow {
294                ID: 10,
295                ChrSpecializationID: 63,
296            },
297        );
298
299        dbc.assisted_combat_step.insert(
300            10,
301            vec![
302                AssistedCombatStepRow {
303                    ID: 2,
304                    SpellID: 200,
305                    AssistedCombatID: 10,
306                    OrderIndex: 1,
307                },
308                AssistedCombatStepRow {
309                    ID: 1,
310                    SpellID: 100,
311                    AssistedCombatID: 10,
312                    OrderIndex: 0,
313                },
314            ],
315        );
316
317        dbc.assisted_combat_rule.insert(
318            1,
319            vec![
320                AssistedCombatRuleRow {
321                    ID: 8,
322                    OrderIndex: 1,
323                    Field_11_1_7_60520_002: 0,
324                    ConditionType: 27,
325                    ConditionValue1: 5,
326                    ConditionValue2: 0,
327                    ConditionValue3: 0,
328                    IdParent: 1,
329                },
330                AssistedCombatRuleRow {
331                    ID: 7,
332                    OrderIndex: 0,
333                    Field_11_1_7_60520_002: 0,
334                    ConditionType: 3,
335                    ConditionValue1: 10,
336                    ConditionValue2: 0,
337                    ConditionValue3: 0,
338                    IdParent: 1,
339                },
340            ],
341        );
342
343        dbc.assisted_combat_rule.insert(
344            2,
345            vec![AssistedCombatRuleRow {
346                ID: 9,
347                OrderIndex: 0,
348                Field_11_1_7_60520_002: 0,
349                ConditionType: 67,
350                ConditionValue1: 0,
351                ConditionValue2: 0,
352                ConditionValue3: 0,
353                IdParent: 2,
354            }],
355        );
356
357        let rotations = transform_assisted_rotations(&dbc);
358
359        verify_that!(rotations.len(), eq(1))?;
360
361        let rotation = &rotations[0];
362
363        verify_that!(rotation.spec_id, eq(63))?;
364        verify_that!(rotation.actions.len(), eq(2))?;
365
366        verify_that!(rotation.actions[0].step_id, eq(1))?;
367        verify_that!(
368            rotation.actions[0].predicate.as_deref(),
369            eq(Some(
370                "TARGET_DISTANCE_LESS(v1=10) AND COMBO_POINTS_GREATER(v1=5)"
371            ))
372        )?;
373        verify_false!(rotation.actions[0].allow_casting_success)?;
374
375        verify_that!(rotation.actions[1].step_id, eq(2))?;
376        verify_true!(rotation.actions[1].allow_casting_success)?;
377        verify_that!(rotation.actions[1].predicate, none())?;
378
379        Ok(())
380    }
381
382    #[gtest]
383    fn applies_scaling_rules_to_predicates() -> Result<()> {
384        let mana = AssistedCombatRuleRow {
385            ID: 1,
386            OrderIndex: 0,
387            Field_11_1_7_60520_002: 0,
388            ConditionType: 19,
389            ConditionValue1: 135,
390            ConditionValue2: 0,
391            ConditionValue3: 0,
392            IdParent: 1,
393        };
394
395        verify_that!(
396            rule_to_predicate(&mana).as_deref(),
397            eq(Some("MANA_GREATER(threshold=13.5)"))
398        )?;
399
400        let insanity = AssistedCombatRuleRow {
401            ConditionType: 43,
402            ConditionValue1: 9000,
403            ..mana.clone()
404        };
405
406        verify_that!(
407            rule_to_predicate(&insanity).as_deref(),
408            eq(Some("INSANITY_GREATER(threshold=90)"))
409        )?;
410
411        let aura_duration = AssistedCombatRuleRow {
412            ConditionType: 17,
413            ConditionValue1: 123,
414            ConditionValue2: 2500,
415            ..mana.clone()
416        };
417
418        verify_that!(
419            rule_to_predicate(&aura_duration).as_deref(),
420            eq(Some("AURA_DURATION_PLAYER(v1=123, seconds=2.5)"))
421        )?;
422
423        let cooldown_remaining = AssistedCombatRuleRow {
424            ConditionType: 66,
425            ConditionValue1: 999,
426            ConditionValue2: 5000,
427            ..mana
428        };
429
430        verify_that!(
431            rule_to_predicate(&cooldown_remaining).as_deref(),
432            eq(Some("COOLDOWN_REMAINING_LESS(v1=999, seconds=5)"))
433        )?;
434
435        Ok(())
436    }
437}
438
439#[cfg(test)]
440mod assisted_override_tests {
441    use googletest::prelude::*;
442
443    use super::{assisted_rotation_tests::empty_dbc, *};
444    use crate::parsers::{
445        dbc::rows::{
446            SpecializationSpellsRow, TraitNodeEntryRow, TraitNodeXTraitNodeEntryRow,
447            TraitTreeLoadoutEntryRow, TraitTreeLoadoutRow,
448        },
449        transform::fixtures::trait_definition_row,
450    };
451
452    #[gtest]
453    fn assisted_overrides_include_selected_trait_replacements() -> Result<()> {
454        const SPEC_ID: i32 = 251;
455        const LOADOUT_ID: i32 = 909;
456        const NODE_ID: i32 = 76_116;
457        const ENTRY_ID: i32 = 96_246;
458        const DEFINITION_ID: i32 = 101_248;
459        const RUNE_STRIKE: i32 = 316_239;
460        const OBLITERATE: i32 = 49_020;
461        const OPTIONAL_ENTRY_ID: i32 = 126_017;
462        const OPTIONAL_DEFINITION_ID: i32 = 130_848;
463        const REMORSELESS_WINTER: i32 = 196_770;
464        const FROZEN_DOMINION: i32 = 377_226;
465
466        let mut dbc = empty_dbc();
467
468        dbc.specialization_spells.insert(
469            252,
470            vec![SpecializationSpellsRow {
471                Description_lang: None,
472                ID: 1,
473                SpecID: 252,
474                SpellID: 85_948,
475                OverridesSpellID: RUNE_STRIKE,
476                DisplayOrder: 13,
477            }],
478        );
479        dbc.trait_tree_loadout_by_spec.insert(
480            SPEC_ID,
481            vec![TraitTreeLoadoutRow {
482                ID: LOADOUT_ID,
483                TraitTreeID: 750,
484                ChrSpecializationID: SPEC_ID,
485            }],
486        );
487        dbc.trait_tree_loadout_entry.insert(
488            LOADOUT_ID,
489            vec![TraitTreeLoadoutEntryRow {
490                ID: 1,
491                TraitTreeLoadoutID: LOADOUT_ID,
492                SelectedTraitNodeID: NODE_ID,
493                SelectedTraitNodeEntryID: 0,
494                NumPoints: 1,
495                OrderIndex: 1,
496            }],
497        );
498        dbc.trait_node_x_trait_node_entry.insert(
499            NODE_ID,
500            vec![
501                TraitNodeXTraitNodeEntryRow {
502                    ID: 1,
503                    TraitNodeID: NODE_ID,
504                    TraitNodeEntryID: ENTRY_ID,
505                    Index: 100,
506                },
507                TraitNodeXTraitNodeEntryRow {
508                    ID: 2,
509                    TraitNodeID: NODE_ID,
510                    TraitNodeEntryID: OPTIONAL_ENTRY_ID,
511                    Index: 101,
512                },
513            ],
514        );
515        dbc.trait_node_entry.insert(
516            ENTRY_ID,
517            TraitNodeEntryRow {
518                ID: ENTRY_ID,
519                TraitDefinitionID: DEFINITION_ID,
520                MaxRanks: 1,
521                NodeEntryType: 1,
522                TraitSubTreeID: 0,
523            },
524        );
525        dbc.trait_definition.insert(
526            DEFINITION_ID,
527            crate::parsers::dbc::rows::TraitDefinitionRow {
528                SpellID: OBLITERATE,
529                OverridesSpellID: RUNE_STRIKE,
530                ..trait_definition_row(DEFINITION_ID)
531            },
532        );
533        dbc.trait_node_entry.insert(
534            OPTIONAL_ENTRY_ID,
535            TraitNodeEntryRow {
536                ID: OPTIONAL_ENTRY_ID,
537                TraitDefinitionID: OPTIONAL_DEFINITION_ID,
538                MaxRanks: 1,
539                NodeEntryType: 2,
540                TraitSubTreeID: 0,
541            },
542        );
543        dbc.trait_definition.insert(
544            OPTIONAL_DEFINITION_ID,
545            crate::parsers::dbc::rows::TraitDefinitionRow {
546                SpellID: FROZEN_DOMINION,
547                OverridesSpellID: REMORSELESS_WINTER,
548                ..trait_definition_row(OPTIONAL_DEFINITION_ID)
549            },
550        );
551
552        let overrides = assisted_spell_overrides(&dbc, SPEC_ID);
553
554        verify_that!(overrides.get(&RUNE_STRIKE), eq(Some(&OBLITERATE)))?;
555        verify_false!(overrides.contains_key(&REMORSELESS_WINTER))?;
556
557        Ok(())
558    }
559
560    #[gtest]
561    fn assisted_overrides_preserve_specialization_spell_replacements() -> Result<()> {
562        const SPEC_ID: i32 = 252;
563        let mut dbc = empty_dbc();
564
565        dbc.specialization_spells.insert(
566            SPEC_ID,
567            vec![SpecializationSpellsRow {
568                Description_lang: None,
569                ID: 1,
570                SpecID: SPEC_ID,
571                SpellID: 85_948,
572                OverridesSpellID: 316_239,
573                DisplayOrder: 13,
574            }],
575        );
576
577        verify_that!(
578            assisted_spell_overrides(&dbc, SPEC_ID).get(&316_239),
579            eq(Some(&85_948))
580        )?;
581
582        Ok(())
583    }
584}