1#[cfg(feature = "dbc")]
2use wowlab_engine_domain::dbc::{
3 AuraSubtypeKind, SpellAttributeKind, SpellEffectKind, aura_subtype_kind, spell_attribute_is,
4 spell_attribute_is_any, spell_effect_is, spell_effect_is_any,
5};
6#[cfg(feature = "dbc")]
7use wowlab_types::constants::DEFAULT_GCD_MS_I32;
8#[cfg(feature = "dbc")]
9use wowlab_types::data::{
10 EmpowerStage, KnowledgeSource, LearnSpell, PeriodicType, PowerCostEntry, RefreshBehavior,
11 RppmMod, SpellDataFlat, SpellEffect, SpellLabel,
12};
13
14#[cfg(feature = "dbc")]
15use super::super::dbc::DbcData;
16#[cfg(feature = "dbc")]
17use super::super::dbc::rows::SpellEffectRow;
18#[cfg(feature = "dbc")]
19use super::super::errors::TransformError;
20
21#[cfg(all(feature = "dbc", test))]
22const TEST_MAX_TARGETS: i32 = 7;
23
24#[cfg(feature = "dbc")]
25#[expect(
26 clippy::struct_excessive_bools,
27 reason = "these independent flags mirror orthogonal spell attributes and are consumed by name"
28)]
29struct AuraFlags {
30 duration_hasted: bool,
31 hasted_ticks: bool,
32 tick_may_crit: bool,
33 tick_on_application: bool,
34 pandemic_refresh: bool,
35 rolling_periodic: bool,
36}
37
38#[cfg(feature = "dbc")]
39struct PeriodicInfo {
40 periodic_type: Option<PeriodicType>,
41 tick_period_ms: i32,
42}
43
44#[cfg(feature = "dbc")]
45fn extract_aura_flags(attributes: &[i32]) -> AuraFlags {
46 AuraFlags {
47 duration_hasted: spell_attribute_is(attributes, SpellAttributeKind::HasteAffectsDuration),
48 hasted_ticks: spell_attribute_is_any(
49 attributes,
50 &[
51 SpellAttributeKind::SpellHasteAffectsPeriodic,
52 SpellAttributeKind::MeleeHasteAffectsPeriodic,
53 ],
54 ),
55 tick_may_crit: spell_attribute_is(attributes, SpellAttributeKind::PeriodicCanCrit),
56 tick_on_application: spell_attribute_is(attributes, SpellAttributeKind::TickOnApplication),
57 pandemic_refresh: spell_attribute_is(
58 attributes,
59 SpellAttributeKind::PeriodicRefreshExtendsDuration,
60 ),
61 rolling_periodic: spell_attribute_is(attributes, SpellAttributeKind::RollingPeriodic),
62 }
63}
64
65#[cfg(feature = "dbc")]
66fn extract_periodic_info(dbc: &DbcData, spell_id: i32) -> PeriodicInfo {
67 let effects = dbc
68 .spell_effect
69 .get(&spell_id)
70 .map_or(&[] as &[SpellEffectRow], Vec::as_slice);
71
72 for effect in effects {
73 if !spell_effect_is(effect.Effect, SpellEffectKind::ApplyAura) {
74 continue;
75 }
76
77 let periodic_type = match aura_subtype_kind(effect.EffectAura) {
78 Some(AuraSubtypeKind::PeriodicDamage) => Some(PeriodicType::Damage),
79 Some(AuraSubtypeKind::PeriodicHeal) => Some(PeriodicType::Heal),
80 Some(AuraSubtypeKind::PeriodicLeech) => Some(PeriodicType::Leech),
81 Some(AuraSubtypeKind::PeriodicEnergize) => Some(PeriodicType::Energize),
82 Some(AuraSubtypeKind::PeriodicTriggerSpell) => Some(PeriodicType::TriggerSpell),
83 _ => None,
84 };
85
86 if periodic_type.is_some() {
87 return PeriodicInfo {
88 periodic_type,
89 tick_period_ms: effect.EffectAuraPeriod,
90 };
91 }
92 }
93
94 PeriodicInfo {
95 periodic_type: None,
96 tick_period_ms: 0,
97 }
98}
99
100#[cfg(feature = "dbc")]
101fn effect_radius(dbc: &DbcData, effect: &SpellEffectRow) -> Option<(f32, f32)> {
102 let index = [effect.EffectRadiusIndex_0, effect.EffectRadiusIndex_1]
103 .into_iter()
104 .find(|index| *index > 0)?;
105
106 dbc.spell_radius
107 .get(&index)
108 .map(|radius| (radius.RadiusMin, radius.RadiusMax))
109}
110
111#[cfg(feature = "dbc")]
112fn knowledge_source(spell_id: i32, context: Option<&SpellKnowledgeContext>) -> KnowledgeSource {
113 let Some(context) = context else {
114 return KnowledgeSource::Unknown;
115 };
116
117 if let Some(definition_id) = context
118 .talent_spell_id_to_trait_definition_id
119 .as_ref()
120 .and_then(|talents| talents.get(&spell_id))
121 {
122 return KnowledgeSource::Talent {
123 trait_definition_id: *definition_id,
124 };
125 }
126
127 if let (Some(class_id), Some(class_spells)) = (context.class_id, &context.class_spell_ids) {
128 if class_spells.contains(&spell_id) {
129 return KnowledgeSource::Class { class_id };
130 }
131 }
132
133 context
134 .spec_id
135 .map_or(KnowledgeSource::Unknown, |spec_id| KnowledgeSource::Spec {
136 spec_id,
137 })
138}
139
140#[cfg(feature = "dbc")]
141const fn determine_refresh_behavior(flags: &AuraFlags, tick_period_ms: i32) -> RefreshBehavior {
142 if flags.pandemic_refresh {
143 RefreshBehavior::Pandemic
144 } else if tick_period_ms > 0 {
145 RefreshBehavior::Tick
146 } else {
147 RefreshBehavior::Duration
148 }
149}
150
151#[derive(Debug)]
153pub struct SpellKnowledgeContext {
154 pub class_id: Option<i32>,
155 pub class_spell_ids: Option<wowlab_types::sim::FastSet<i32>>,
156 pub spec_id: Option<i32>,
157 pub talent_spell_id_to_trait_definition_id: Option<wowlab_types::sim::IntMap<i32, i32>>,
158}
159
160#[cfg(feature = "dbc")]
166pub fn transform_spell(
168 dbc: &DbcData,
169 spell_id: i32,
170 context: Option<&SpellKnowledgeContext>,
171) -> Result<SpellDataFlat, TransformError> {
172 let name_row = dbc
173 .spell_name
174 .get(&spell_id)
175 .ok_or_else(|| TransformError::spell_not_found(spell_id))?;
176
177 let spell_row = dbc.spell.get(&spell_id);
178 let misc = dbc.spell_misc.get(&spell_id);
179 let effects = dbc
180 .spell_effect
181 .get(&spell_id)
182 .map_or(&[] as &[SpellEffectRow], Vec::as_slice);
183 let categories = dbc.spell_categories.get(&spell_id).and_then(|rows| {
184 rows.iter()
185 .find(|row| {
186 wowlab_engine_domain::dbc::DifficultyId::try_from(row.DifficultyID).ok()
187 == Some(wowlab_engine_domain::dbc::DifficultyId::Base)
188 })
189 .or_else(|| rows.first())
190 });
191
192 let attributes: Vec<i32> = misc
193 .map(super::super::dbc::rows::spell::SpellMiscRow::attributes_vec)
194 .unwrap_or_default();
195
196 let (range_min_0, range_min_1, range_max_0, range_max_1) = misc
197 .and_then(|m| dbc.spell_range.get(&m.RangeIndex))
198 .map_or((0.0, 0.0, 0.0, 0.0), |r| {
199 (r.RangeMin_0, r.RangeMin_1, r.RangeMax_0, r.RangeMax_1)
200 });
201
202 let (radius_min, radius_max) = effects
203 .iter()
204 .find_map(|effect| effect_radius(dbc, effect))
205 .unwrap_or((0.0, 0.0));
206
207 let cooldown = dbc.spell_cooldowns.get(&spell_id).and_then(|rows| {
208 rows.iter()
209 .find(|row| {
210 wowlab_engine_domain::dbc::DifficultyId::try_from(row.DifficultyID).ok()
211 == Some(wowlab_engine_domain::dbc::DifficultyId::Base)
212 })
213 .or_else(|| rows.first())
214 });
215 let recovery_time = cooldown.map_or(0, |c| c.RecoveryTime);
216 let category_recovery_time = cooldown.map_or(0, |c| c.CategoryRecoveryTime);
217 let start_recovery_time = cooldown.map_or(DEFAULT_GCD_MS_I32, |c| c.StartRecoveryTime);
218 let start_recovery_category = categories.map_or(0, |c| c.StartRecoveryCategory);
219
220 let cast_time = misc
221 .and_then(|m| dbc.spell_cast_times.get(&m.CastingTimeIndex))
222 .map_or(0, |ct| ct.Base);
223
224 let (duration, max_duration) = misc
225 .and_then(|m| dbc.spell_duration.get(&m.DurationIndex))
226 .map_or((0, 0), |d| (d.Duration, d.MaxDuration));
227
228 let charge_category = categories
229 .filter(|c| c.ChargeCategory > 0)
230 .and_then(|c| dbc.spell_category.get(&c.ChargeCategory));
231 let (max_charges, charge_recovery_time) =
232 charge_category.map_or((0, 0), |cat| (cat.MaxCharges, cat.ChargeRecoveryTime));
233
234 let power_costs: Vec<PowerCostEntry> = dbc
235 .spell_power
236 .get(&spell_id)
237 .map(|powers| {
238 powers
239 .iter()
240 .map(|p| PowerCostEntry {
241 power_type: p.PowerType,
242 cost: p.ManaCost,
243 cost_pct: p.PowerCostPct,
244 max_cost_pct: f64::from(p.PowerCostMaxPct),
245 optional_cost: p.OptionalCost,
246 optional_cost_pct: f64::from(p.OptionalCostPct),
247 required_aura_spell_id: p.RequiredAuraSpellID,
248 })
249 .collect()
250 })
251 .unwrap_or_default();
252
253 let mana_cost = effects
254 .iter()
255 .find(|e| {
256 spell_effect_is(e.Effect, SpellEffectKind::PowerDrain) && e.EffectMiscValue_0 == 0
257 })
258 .map_or(0, |e| {
259 wowlab_types::numeric::f64_to_i32_saturating_round(e.EffectBasePointsF.abs())
260 });
261
262 let class_options = dbc.spell_class_options.get(&spell_id);
263 let spell_class_set = class_options.map_or(0, |c| c.SpellClassSet);
264 let spell_class_mask_1 = class_options.map_or(0, |c| c.SpellClassMask_0);
265 let spell_class_mask_2 = class_options.map_or(0, |c| c.SpellClassMask_1);
266 let spell_class_mask_3 = class_options.map_or(0, |c| c.SpellClassMask_2);
267 let spell_class_mask_4 = class_options.map_or(0, |c| c.SpellClassMask_3);
268
269 let target_restrictions = dbc.spell_target_restrictions.get(&spell_id);
270 let cone_degrees = target_restrictions.map_or(0.0, |t| t.ConeDegrees);
271 let target_flags = target_restrictions.map_or(0, |t| t.Targets);
272 let max_targets = target_restrictions.map_or(0, |t| t.MaxTargets);
273
274 let aura = dbc.spell_aura_restrictions.get(&spell_id);
275 let caster_aura_spell = aura.map_or(0, |a| a.CasterAuraSpell);
276 let caster_aura_state = aura.map_or(0, |a| a.CasterAuraState);
277 let exclude_caster_aura_spell = aura.map_or(0, |a| a.ExcludeCasterAuraSpell);
278 let exclude_caster_aura_state = aura.map_or(0, |a| a.ExcludeCasterAuraState);
279 let exclude_target_aura_spell = aura.map_or(0, |a| a.ExcludeTargetAuraSpell);
280 let exclude_target_aura_state = aura.map_or(0, |a| a.ExcludeTargetAuraState);
281 let target_aura_spell = aura.map_or(0, |a| a.TargetAuraSpell);
282 let target_aura_state = aura.map_or(0, |a| a.TargetAuraState);
283
284 let levels = dbc
285 .spell_levels
286 .get(&spell_id)
287 .and_then(|lvls| lvls.first());
288 let base_level = levels.map_or(0, |l| l.BaseLevel);
289 let max_level = levels.map_or(0, |l| l.MaxLevel);
290 let spell_level = levels.map_or(0, |l| l.SpellLevel);
291 let max_passive_aura_level = levels.map_or(0, |l| l.MaxPassiveAuraLevel);
292
293 let learn_spells: Vec<LearnSpell> = dbc
294 .spell_learn_spell
295 .get(&spell_id)
296 .map(|v| {
297 v.iter()
298 .map(|ls| LearnSpell {
299 learn_spell_id: ls.LearnSpellID,
300 overrides_spell_id: ls.OverridesSpellID,
301 })
302 .collect()
303 })
304 .unwrap_or_default();
305
306 let replacement_spell_id = dbc
307 .spell_replacement
308 .get(&spell_id)
309 .map_or(0, |r| r.ReplacementSpellID);
310
311 let shapeshift = dbc.spell_shapeshift.get(&spell_id);
312 let shapeshift_exclude_0 = shapeshift.map_or(0, |s| s.ShapeshiftExclude_0);
313 let shapeshift_exclude_1 = shapeshift.map_or(0, |s| s.ShapeshiftExclude_1);
314 let shapeshift_mask_0 = shapeshift.map_or(0, |s| s.ShapeshiftMask_0);
315 let shapeshift_mask_1 = shapeshift.map_or(0, |s| s.ShapeshiftMask_1);
316 let stance_bar_order = shapeshift.map_or(0, |s| s.StanceBarOrder);
317
318 let totems = dbc.spell_totems.get(&spell_id).and_then(|t| t.first());
319 let totem_0 = totems.map_or(0, |t| t.Totem_0);
320 let totem_1 = totems.map_or(0, |t| t.Totem_1);
321 let required_totem_category_0 = totems.map_or(0, |t| t.RequiredTotemCategoryID_0);
322 let required_totem_category_1 = totems.map_or(0, |t| t.RequiredTotemCategoryID_1);
323
324 let (can_empower, empower_stages) =
325 dbc.spell_empower
326 .get(&spell_id)
327 .map_or((false, Vec::new()), |empower| {
328 let stages: Vec<EmpowerStage> = dbc
329 .spell_empower_stage
330 .get(&empower.ID)
331 .map(|stages| {
332 let mut sorted: Vec<_> = stages
333 .iter()
334 .map(|s| EmpowerStage {
335 stage: s.Stage,
336 duration_ms: s.DurationMs,
337 })
338 .collect();
339
340 sorted.sort_by_key(|s| s.stage);
341
342 sorted
343 })
344 .unwrap_or_default();
345
346 (true, stages)
347 });
348
349 let spell_scaling = dbc.spell_scaling.get(&spell_id);
350 let min_scaling_level = spell_scaling.map_or(0, |s| s.MinScalingLevel);
351 let max_scaling_level = spell_scaling.map_or(0, |s| s.MaxScalingLevel);
352
353 let (effect_bonus_coefficient, bonus_coefficient_from_ap) = effects
354 .iter()
355 .find(|e| {
356 spell_effect_is_any(
357 e.Effect,
358 &[SpellEffectKind::SchoolDamage, SpellEffectKind::Heal],
359 )
360 })
361 .map_or((0.0, 0.0), |e| {
362 (e.EffectBonusCoefficient, e.BonusCoefficientFromAP)
363 });
364
365 let effect_trigger_spell: Vec<i32> = effects
366 .iter()
367 .map(|e| e.EffectTriggerSpell)
368 .filter(|&t| t != 0)
369 .collect();
370
371 let implicit_target: Vec<i32> = effects
372 .iter()
373 .flat_map(|e| [e.ImplicitTarget_0, e.ImplicitTarget_1])
374 .filter(|&t| t != 0)
375 .collect();
376
377 let equipped_item_requirement = dbc.spell_equipped_items.get(&spell_id).map(|row| {
378 wowlab_types::data::EquippedItemRequirement {
379 item_class: row.EquippedItemClass,
380 inventory_type_mask: row.EquippedItemInvTypes,
381 subclass_mask: row.EquippedItemSubclass,
382 }
383 });
384
385 let interrupts = dbc.spell_interrupts.get(&spell_id);
386 let interrupt_flags = interrupts.map_or(0, |i| i.InterruptFlags);
387 let interrupt_aura_0 = interrupts.map_or(0, |i| i.AuraInterruptFlags_0);
388 let interrupt_aura_1 = interrupts.map_or(0, |i| i.AuraInterruptFlags_1);
389 let interrupt_channel_0 = interrupts.map_or(0, |i| i.ChannelInterruptFlags_0);
390 let interrupt_channel_1 = interrupts.map_or(0, |i| i.ChannelInterruptFlags_1);
391
392 let description_variables_row = dbc
393 .spell_x_description_variables
394 .get(&spell_id)
395 .and_then(|xvars| xvars.first())
396 .and_then(|xvar| {
397 dbc.spell_description_variables
398 .get(&xvar.SpellDescriptionVariablesID)
399 });
400 let description_variables = description_variables_row
401 .map(|dv| dv.Variables.clone())
402 .unwrap_or_default();
403
404 let icon_file_data_id = misc.map_or(0, |m| m.SpellIconFileDataID);
405 let file_name = super::resolve_icon_file_name(dbc, icon_file_data_id);
406
407 let is_passive = spell_attribute_is(&attributes, SpellAttributeKind::Passive);
409
410 let spell_effects: Vec<SpellEffect> = effects
411 .iter()
412 .map(|e| {
413 let (radius_min, radius_max) = effect_radius(dbc, e).unwrap_or((0.0, 0.0));
414
415 SpellEffect {
416 index: e.EffectIndex,
417 effect: e.Effect,
418 mechanic: e.EffectMechanic,
419 effect_attributes: e.EffectAttributes,
420 aura: e.EffectAura,
421 base_points: e.EffectBasePointsF,
422 period: e.EffectAuraPeriod,
423 chain_targets: e.EffectChainTargets,
424 chain_multiplier: f64::from(e.EffectChainAmplitude),
425 trigger_spell: e.EffectTriggerSpell,
426 misc_value_0: e.EffectMiscValue_0,
427 misc_value_1: e.EffectMiscValue_1,
428 shapeshift_form_flags: dbc
429 .spell_shapeshift_form
430 .get(&e.EffectMiscValue_0)
431 .map_or(0, |form| form.Flags),
432 shapeshift_combat_round_time_ms: dbc
433 .spell_shapeshift_form
434 .get(&e.EffectMiscValue_0)
435 .map_or(0, |form| form.CombatRoundTime),
436 radius_min,
437 radius_max,
438 coefficient: e.Coefficient,
439 scaling_class: e.ScalingClass,
440 points_per_resource: e.EffectPointsPerResource,
441 variance: e.Variance,
442 bonus_coefficient: e.EffectBonusCoefficient,
443 bonus_coefficient_from_ap: e.BonusCoefficientFromAP,
444 amplitude: e.EffectAmplitude,
445 pvp_multiplier: e.PvpMultiplier,
446 effect_class_mask_1: e.EffectSpellClassMask_0,
447 effect_class_mask_2: e.EffectSpellClassMask_1,
448 effect_class_mask_3: e.EffectSpellClassMask_2,
449 effect_class_mask_4: e.EffectSpellClassMask_3,
450 implicit_target_a: e.ImplicitTarget_0,
451 implicit_target_b: e.ImplicitTarget_1,
452 }
453 })
454 .collect();
455
456 let aura_options = dbc.spell_aura_options.get(&spell_id);
457 let aura_max_stacks = aura_options.map_or(1, |ao| ao.CumulativeAura).max(1);
458 let proc_charges = aura_options.map_or(0, |ao| ao.ProcCharges);
459 let proc_chance = aura_options.map_or(0, |ao| ao.ProcChance);
460 let proc_type_mask = aura_options.map_or(0, |ao| {
461 let mask = u64::from(u32::from_ne_bytes(ao.ProcTypeMask_0.to_ne_bytes()))
462 | (u64::from(u32::from_ne_bytes(ao.ProcTypeMask_1.to_ne_bytes())) << u32::BITS);
463
464 i64::try_from(mask).unwrap_or_default()
465 });
466 let proc_category_recovery_ms = aura_options.map_or(0, |ao| ao.ProcCategoryRecovery);
467 let periodic_info = extract_periodic_info(dbc, spell_id);
468 let aura_flags = extract_aura_flags(&attributes);
469 let refresh_behavior = determine_refresh_behavior(&aura_flags, periodic_info.tick_period_ms);
470
471 let (rppm_base_rate, rppm_flags, rppm_mods) = aura_options
472 .filter(|ao| ao.SpellProcsPerMinuteID > 0)
473 .and_then(|ao| {
474 dbc.spell_procs_per_minute
475 .get(&ao.SpellProcsPerMinuteID)
476 .map(|ppm| {
477 let mods: Vec<RppmMod> = dbc
478 .spell_procs_per_minute_mod
479 .get(&ao.SpellProcsPerMinuteID)
480 .map(|mods| {
481 mods.iter()
482 .map(|m| RppmMod {
483 mod_type: m.Type,
484 param: m.Param,
485 coeff: m.Coeff,
486 })
487 .collect()
488 })
489 .unwrap_or_default();
490
491 (ppm.BaseProcRate, ppm.Flags, mods)
492 })
493 })
494 .unwrap_or((0.0, 0, Vec::new()));
495
496 let knowledge_source = knowledge_source(spell_id, context);
497
498 Ok(SpellDataFlat {
499 id: spell_id,
500 name: name_row.Name_lang.clone().unwrap_or_default().into(),
501 description: spell_row
502 .and_then(|s| s.Description_lang.clone())
503 .unwrap_or_default(),
504 aura_description: spell_row
505 .and_then(|s| s.AuraDescription_lang.clone())
506 .unwrap_or_default(),
507 description_variables,
508 file_name: file_name.into(),
509 is_passive,
510 proc_chance,
511 proc_type_mask,
512 proc_category_recovery_ms,
513 knowledge_source,
514 cast_time,
515 recovery_time,
516 category_recovery_time,
517 category_id: categories.map_or(0, |c| c.Category),
518 charge_category_id: categories.map_or(0, |c| c.ChargeCategory),
519 start_recovery_category,
520 start_recovery_time,
521 mana_cost,
522 power_costs,
523 charge_recovery_time,
524 max_charges,
525 range_max_0,
526 range_max_1,
527 range_min_0,
528 range_min_1,
529 cone_degrees,
530 target_flags,
531 max_targets,
532 radius_max,
533 radius_min,
534 defense_type: categories.map_or(0, |c| c.DefenseType),
535 school_mask: misc.map_or(0, |m| m.SchoolMask),
536 bonus_coefficient_from_ap,
537 effect_bonus_coefficient,
538 min_scaling_level,
539 max_scaling_level,
540 interrupt_aura_0,
541 interrupt_aura_1,
542 interrupt_channel_0,
543 interrupt_channel_1,
544 interrupt_flags,
545 duration,
546 max_duration,
547 can_empower,
548 empower_stages,
549 dispel_type: categories.map_or(0, |c| c.DispelType),
550 mechanic: categories.map_or(0, |c| c.Mechanic),
551 facing_caster_flags: 0,
552 speed: misc.map_or(0.0, |m| m.Speed),
553 launch_delay: misc.map_or(0.0, |m| m.LaunchDelay),
554 spell_class_mask_1,
555 spell_class_mask_2,
556 spell_class_mask_3,
557 spell_class_mask_4,
558 spell_class_set,
559 base_level,
560 max_level,
561 max_passive_aura_level,
562 spell_level,
563 caster_aura_spell,
564 caster_aura_state,
565 exclude_caster_aura_spell,
566 exclude_caster_aura_state,
567 exclude_target_aura_spell,
568 exclude_target_aura_state,
569 target_aura_spell,
570 target_aura_state,
571 replacement_spell_id,
572 shapeshift_exclude_0,
573 shapeshift_exclude_1,
574 shapeshift_mask_0,
575 shapeshift_mask_1,
576 stance_bar_order,
577 required_totem_category_0,
578 required_totem_category_1,
579 totem_0,
580 totem_1,
581 attributes,
582 equipped_item_requirement,
583 effect_trigger_spell,
584 implicit_target,
585 learn_spells,
586 effects: spell_effects,
587 proc_charges,
588 max_stacks: aura_max_stacks,
589 periodic_type: periodic_info.periodic_type,
590 tick_period_ms: periodic_info.tick_period_ms,
591 refresh_behavior,
592 duration_hasted: aura_flags.duration_hasted,
593 hasted_ticks: aura_flags.hasted_ticks,
594 pandemic_refresh: aura_flags.pandemic_refresh,
595 rolling_periodic: aura_flags.rolling_periodic,
596 tick_may_crit: aura_flags.tick_may_crit,
597 tick_on_application: aura_flags.tick_on_application,
598 rppm_base_rate,
599 rppm_flags,
600 rppm_mods,
601 labels: dbc
602 .spell_label
603 .get(&spell_id)
604 .map(|rows| rows.iter().map(|r| SpellLabel::from(r.LabelID)).collect())
605 .unwrap_or_default(),
606 })
607}
608
609#[cfg(all(test, feature = "dbc"))]
610#[path = "spell/tests.rs"]
611mod tests;