1use compact_str::CompactString;
2use serde::{Deserialize, Serialize};
3use wowlab_engine_macros::CopyInsert;
4
5use super::{KnowledgeSource, PeriodicType, RefreshBehavior, RppmMod, SpellLabel};
6
7#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
12#[serde(transparent)]
13pub struct CooldownCategoryId(i32);
14
15impl CooldownCategoryId {
16 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#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
48pub struct SpellEffect {
50 pub index: i32,
51 pub effect: i32,
52 #[serde(default)]
54 pub mechanic: i32,
55 #[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 #[serde(default)]
69 pub shapeshift_form_flags: i32,
70 #[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 #[serde(default)]
94 pub implicit_target_a: i32,
95 #[serde(default)]
97 pub implicit_target_b: i32,
98}
99#[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#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120#[non_exhaustive]
121pub enum ResourceCostPercentBasis {
122 #[default]
124 Base,
125 Maximum,
127}
128
129#[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#[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 pub primary_cost_entry: u8,
184 pub secondary_cost_entry: u8,
186}
187
188#[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 #[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 #[serde(default)]
238 pub proc_chance: i32,
239 #[serde(default)]
241 pub proc_type_mask: i64,
242 #[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 #[serde(default)]
252 #[copy(skip)]
253 pub category_recovery_time: i32,
254 #[serde(default)]
256 #[copy(skip)]
257 pub category_id: i32,
258 #[serde(default)]
260 #[copy(skip)]
261 pub charge_category_id: i32,
262 #[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 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 #[serde(default)]
284 pub target_flags: i32,
285 #[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 #[serde(default)]
315 #[copy(skip)]
316 pub mechanic: i32,
317 pub facing_caster_flags: i32,
318 pub speed: f32,
319 #[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 #[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}