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#[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#[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#[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 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}