Skip to main content

wowlab_types/types/data/
spell.rs

1use compact_str::CompactString;
2use serde::{Deserialize, Serialize};
3use wowlab_engine_macros::CopyInsert;
4
5use super::{KnowledgeSource, PeriodicType, RefreshBehavior, RppmMod, SpellLabel};
6
7/// Identity of a row in DBC `SpellCategory`.
8///
9/// This is deliberately an open newtype: newly-added category IDs remain valid.
10/// pool keys without requiring an engine release to extend a closed enum.
11#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
12#[serde(transparent)]
13pub struct CooldownCategoryId(i32);
14
15impl CooldownCategoryId {
16    /// Ordinary player global-cooldown pool.
17    pub const GLOBAL: Self = Self(133);
18
19    #[must_use]
20    pub const fn from_raw(raw: i32) -> Option<Self> {
21        if raw > 0 { Some(Self(raw)) } else { None }
22    }
23
24    #[must_use]
25    pub const fn raw(self) -> i32 {
26        self.0
27    }
28}
29
30#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32pub struct EmpowerStage {
33    pub stage: i32,
34    pub duration_ms: i32,
35}
36
37#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39pub struct LearnSpell {
40    pub learn_spell_id: i32,
41    pub overrides_spell_id: i32,
42}
43
44/// Denormalized spell effect data for description-variable resolution (`$s1`, `$t1`, `$x1`, `$a1`, etc.).
45#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
48// docref:start spell-data-spell-effect
49pub struct SpellEffect {
50    pub index: i32,
51    pub effect: i32,
52    /// Raw `SpellEffect.EffectMechanic`; zero inherits the spell-level mechanic.
53    #[serde(default)]
54    pub mechanic: i32,
55    /// Raw `SpellEffect.EffectAttributes` bitmask.
56    #[serde(default)]
57    pub effect_attributes: i32,
58    pub aura: i32,
59    pub base_points: f64,
60    pub period: i32,
61    pub chain_targets: i32,
62    #[serde(default)]
63    pub chain_multiplier: f64,
64    pub trigger_spell: i32,
65    pub misc_value_0: i32,
66    pub misc_value_1: i32,
67    /// Raw `SpellShapeshiftForm.Flags` for the form referenced by a shapeshift aura.
68    #[serde(default)]
69    pub shapeshift_form_flags: i32,
70    /// Raw `SpellShapeshiftForm.CombatRoundTime` in milliseconds; non-zero when the form imposes its own swing period.
71    #[serde(default)]
72    pub shapeshift_combat_round_time_ms: i32,
73    pub radius_min: f32,
74    pub radius_max: f32,
75    pub coefficient: f32,
76    #[serde(default)]
77    pub scaling_class: i32,
78    pub points_per_resource: f32,
79    pub variance: f32,
80    pub bonus_coefficient: f64,
81    pub bonus_coefficient_from_ap: f64,
82    pub amplitude: f32,
83    pub pvp_multiplier: f32,
84    #[serde(default)]
85    pub effect_class_mask_1: i32,
86    #[serde(default)]
87    pub effect_class_mask_2: i32,
88    #[serde(default)]
89    pub effect_class_mask_3: i32,
90    #[serde(default)]
91    pub effect_class_mask_4: i32,
92    /// Raw `SpellEffect.ImplicitTarget[0]` selector (`TargetA`).
93    #[serde(default)]
94    pub implicit_target_a: i32,
95    /// Raw `SpellEffect.ImplicitTarget[1]` selector (`TargetB`).
96    #[serde(default)]
97    pub implicit_target_b: i32,
98}
99// docref:end spell-data-spell-effect
100
101/// A single power cost entry from SpellPower.csv.
102#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub struct PowerCostEntry {
105    pub power_type: i32,
106    pub cost: i32,
107    pub cost_pct: f64,
108    #[serde(default)]
109    pub max_cost_pct: f64,
110    pub optional_cost: i32,
111    #[serde(default)]
112    pub optional_cost_pct: f64,
113    #[serde(default)]
114    pub required_aura_spell_id: i32,
115}
116
117/// Resource amount against which a DBC percentage power cost is resolved.
118#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120#[non_exhaustive]
121pub enum ResourceCostPercentBasis {
122    /// The actor's configured base resource maximum.
123    #[default]
124    Base,
125    /// The actor's current maximum after runtime modifiers.
126    Maximum,
127}
128
129/// A percentage power cost paired with its authoritative resource basis.
130#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct ResourceCostPercent {
133    pub percent: f64,
134    pub basis: ResourceCostPercentBasis,
135}
136
137impl ResourceCostPercent {
138    #[must_use]
139    pub const fn base(percent: f64) -> Self {
140        Self {
141            percent,
142            basis: ResourceCostPercentBasis::Base,
143        }
144    }
145
146    #[must_use]
147    pub const fn maximum(percent: f64) -> Self {
148        Self {
149            percent,
150            basis: ResourceCostPercentBasis::Maximum,
151        }
152    }
153
154    #[must_use]
155    pub fn resolve(self, base: f64, maximum: f64) -> f64 {
156        let basis = match self.basis {
157            ResourceCostPercentBasis::Base => base,
158            ResourceCostPercentBasis::Maximum => maximum,
159        };
160
161        self.percent * basis / crate::constants::HUNDRED
162    }
163}
164
165/// Scaled costs and resource gains resolved for one spell.
166#[derive(Clone, Copy, Debug, Default)]
167pub struct SpellResources {
168    pub primary_cost: f64,
169    pub primary_optional_cost: f64,
170    pub primary_cost_pct: ResourceCostPercent,
171    pub primary_max_cost_pct: ResourceCostPercent,
172    pub primary_optional_cost_pct: ResourceCostPercent,
173    pub primary_gain: f64,
174    pub secondary_cost: f64,
175    pub secondary_optional_cost: f64,
176    pub secondary_gain: f64,
177    pub health_cost: f64,
178    pub health_cost_pct: f64,
179    pub health_max_cost_pct: f64,
180    pub health_optional_cost: f64,
181    pub health_optional_cost_pct: ResourceCostPercent,
182    /// 1-based DBC power-cost entry backing `primary_cost` (`0` = none).
183    pub primary_cost_entry: u8,
184    /// 1-based DBC power-cost entry backing `secondary_cost` (`0` = none).
185    pub secondary_cost_entry: u8,
186}
187
188/// DBC `SpellEquippedItems` restriction for a spell payload.
189#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191pub struct EquippedItemRequirement {
192    pub item_class: i32,
193    pub inventory_type_mask: i32,
194    pub subclass_mask: i32,
195}
196
197fn signed_mask_contains(mask: i32, value: i32) -> bool {
198    mask == 0
199        || u32::try_from(value)
200            .ok()
201            .filter(|value| *value < u32::BITS)
202            .is_some_and(|value| u32::from_ne_bytes(mask.to_ne_bytes()) & (1 << value) != 0)
203}
204
205impl EquippedItemRequirement {
206    /// Whether an item's class and subclass satisfy this restriction.
207    #[must_use]
208    pub fn matches_class_and_subclass(self, item_class: i32, subclass: i32) -> bool {
209        (self.item_class < 0 || self.item_class == item_class)
210            && signed_mask_contains(self.subclass_mask, subclass)
211    }
212
213    #[must_use]
214    pub fn matches(self, item_class: i32, subclass: i32, inventory_type: i32) -> bool {
215        self.matches_class_and_subclass(item_class, subclass)
216            && signed_mask_contains(self.inventory_type_mask, inventory_type)
217    }
218}
219
220#[derive(Clone, CopyInsert, Debug, Default, Deserialize, Serialize)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222#[expect(
223    clippy::struct_excessive_bools,
224    reason = "flat DBC row preserves independent boolean columns from the source schema"
225)]
226pub struct SpellDataFlat {
227    pub id: i32,
228    #[cfg_attr(feature = "schema", schemars(with = "String"))]
229    pub name: CompactString,
230    pub description: String,
231    pub aura_description: String,
232    pub description_variables: String,
233    #[cfg_attr(feature = "schema", schemars(with = "String"))]
234    pub file_name: CompactString,
235    pub is_passive: bool,
236    /// `SpellAuraOptions.ProcChance` in percent points; 0 when the spell has no proc data.
237    #[serde(default)]
238    pub proc_chance: i32,
239    /// Combined `SpellAuraOptions.ProcTypeMask_0/1` event mask.
240    #[serde(default)]
241    pub proc_type_mask: i64,
242    /// `SpellAuraOptions.ProcCategoryRecovery` internal cooldown in milliseconds.
243    #[serde(default)]
244    pub proc_category_recovery_ms: i32,
245    #[copy(json)]
246    pub knowledge_source: KnowledgeSource,
247
248    pub cast_time: i32,
249    pub recovery_time: i32,
250    /// Some CDs record their real cooldown here with `recovery_time` at 0, so engines must use `max(recovery_time, category_recovery_time)`.
251    #[serde(default)]
252    #[copy(skip)]
253    pub category_recovery_time: i32,
254    /// DBC `SpellCategories.Category`; category-cooldown modifiers target this identity.
255    #[serde(default)]
256    #[copy(skip)]
257    pub category_id: i32,
258    /// DBC `SpellCategories.ChargeCategory`; category-targeted cooldown passives use this instead of class masks.
259    #[serde(default)]
260    #[copy(skip)]
261    pub charge_category_id: i32,
262    /// DBC `SpellCategories.StartRecoveryCategory`; recovery locks only conflict within this category.
263    #[serde(default)]
264    #[copy(skip)]
265    pub start_recovery_category: i32,
266    pub start_recovery_time: i32,
267
268    pub mana_cost: i32,
269    #[copy(json)]
270    pub power_costs: Vec<PowerCostEntry>,
271
272    pub charge_recovery_time: i32,
273    pub max_charges: i32,
274
275    // Range (enemy index 0, ally index 1)
276    pub range_max_0: f32,
277    pub range_max_1: f32,
278    pub range_min_0: f32,
279    pub range_min_1: f32,
280
281    pub cone_degrees: f32,
282    /// Raw `SpellTargetRestrictions.Targets` cast-target bitmask.
283    #[serde(default)]
284    pub target_flags: i32,
285    /// DBC `SpellTargetRestrictions.MaxTargets`; zero means no spell-level cap.
286    #[serde(default)]
287    pub max_targets: i32,
288    pub radius_max: f32,
289    pub radius_min: f32,
290
291    pub defense_type: i32,
292    pub school_mask: i32,
293
294    pub bonus_coefficient_from_ap: f64,
295    pub effect_bonus_coefficient: f64,
296    pub min_scaling_level: i32,
297    pub max_scaling_level: i32,
298
299    pub interrupt_aura_0: i32,
300    pub interrupt_aura_1: i32,
301    pub interrupt_channel_0: i32,
302    pub interrupt_channel_1: i32,
303    pub interrupt_flags: i32,
304
305    pub duration: i32,
306    pub max_duration: i32,
307
308    pub can_empower: bool,
309    #[copy(json)]
310    pub empower_stages: Vec<EmpowerStage>,
311
312    pub dispel_type: i32,
313    /// Raw `SpellCategories.Mechanic`; zero means the spell carries no mechanic tag.
314    #[serde(default)]
315    #[copy(skip)]
316    pub mechanic: i32,
317    pub facing_caster_flags: i32,
318    pub speed: f32,
319    /// `SpellMisc.LaunchDelay`, in seconds.
320    #[serde(default)]
321    #[copy(skip)]
322    pub launch_delay: f32,
323    pub spell_class_mask_1: i32,
324    pub spell_class_mask_2: i32,
325    pub spell_class_mask_3: i32,
326    pub spell_class_mask_4: i32,
327    pub spell_class_set: i32,
328
329    pub base_level: i32,
330    pub max_level: i32,
331    pub max_passive_aura_level: i32,
332    pub spell_level: i32,
333
334    pub caster_aura_spell: i32,
335    pub caster_aura_state: i32,
336    pub exclude_caster_aura_spell: i32,
337    pub exclude_caster_aura_state: i32,
338    pub exclude_target_aura_spell: i32,
339    pub exclude_target_aura_state: i32,
340    pub target_aura_spell: i32,
341    pub target_aura_state: i32,
342
343    pub replacement_spell_id: i32,
344
345    pub shapeshift_exclude_0: i32,
346    pub shapeshift_exclude_1: i32,
347    pub shapeshift_mask_0: i32,
348    pub shapeshift_mask_1: i32,
349    pub stance_bar_order: i32,
350
351    pub required_totem_category_0: i32,
352    pub required_totem_category_1: i32,
353    pub totem_0: i32,
354    pub totem_1: i32,
355
356    pub attributes: Vec<i32>,
357    #[serde(default)]
358    #[copy(json)]
359    pub equipped_item_requirement: Option<EquippedItemRequirement>,
360    pub effect_trigger_spell: Vec<i32>,
361    pub implicit_target: Vec<i32>,
362    #[copy(json)]
363    pub learn_spells: Vec<LearnSpell>,
364
365    #[copy(json)]
366    pub effects: Vec<SpellEffect>,
367
368    /// `SpellAuraOptions.ProcCharges`; positive values are stacks applied per cast.
369    #[serde(default)]
370    pub proc_charges: i32,
371    pub max_stacks: i32,
372    #[copy(with = "periodic_type_col")]
373    pub periodic_type: Option<PeriodicType>,
374    pub tick_period_ms: i32,
375    #[copy(with = "refresh_behavior_col")]
376    pub refresh_behavior: RefreshBehavior,
377    pub duration_hasted: bool,
378    pub hasted_ticks: bool,
379    pub pandemic_refresh: bool,
380    pub rolling_periodic: bool,
381    pub tick_may_crit: bool,
382    pub tick_on_application: bool,
383    pub rppm_base_rate: f32,
384    pub rppm_flags: i32,
385    #[copy(json)]
386    pub rppm_mods: Vec<RppmMod>,
387    #[copy(json)]
388    pub labels: Vec<SpellLabel>,
389}
390
391fn periodic_type_col(s: &SpellDataFlat) -> Option<String> {
392    s.periodic_type
393        .as_ref()
394        .and_then(|t| serde_json::to_value(t).ok())
395        .and_then(|v| v.as_str().map(str::to_string))
396}
397
398fn refresh_behavior_col(s: &SpellDataFlat) -> String {
399    serde_json::to_value(s.refresh_behavior)
400        .ok()
401        .and_then(|v| v.as_str().map(str::to_string))
402        .unwrap_or_else(|| "duration".to_string())
403}
404
405#[cfg(test)]
406mod tests {
407    use googletest::prelude::*;
408    use rstest::rstest;
409
410    use super::{CooldownCategoryId, EquippedItemRequirement};
411
412    #[gtest]
413    #[rstest]
414    #[case::two_hand_axe(2, 5, 17, true)]
415    #[case::one_hand_sword(2, 7, 13, false)]
416    #[case::wrong_class(4, 5, 17, false)]
417    #[case::unrestricted_inventory(2, 5, 21, true)]
418    fn equipped_item_requirement_matches_masks(
419        #[case] item_class: i32,
420        #[case] subclass: i32,
421        #[case] inventory_type: i32,
422        #[case] expected: bool,
423    ) -> Result<()> {
424        let requirement = EquippedItemRequirement {
425            item_class: 2,
426            inventory_type_mask: 0,
427            subclass_mask: 132_450,
428        };
429
430        verify_that!(
431            requirement.matches(item_class, subclass, inventory_type),
432            eq(expected)
433        )
434    }
435
436    #[gtest]
437    #[rstest]
438    #[case(1)]
439    #[case(133)]
440    #[case(99_999)]
441    fn cooldown_category_accepts_any_positive_data_identity(#[case] raw: i32) -> Result<()> {
442        verify_that!(
443            CooldownCategoryId::from_raw(raw).map(CooldownCategoryId::raw),
444            some(eq(raw))
445        )
446    }
447
448    #[gtest]
449    fn zero_means_no_cooldown_category_membership() -> Result<()> {
450        verify_that!(CooldownCategoryId::from_raw(0), none())
451    }
452}