Skip to main content

wowlab_engine_combat/builder/spell_builder/
from_data.rs

1use wowlab_engine_domain::{constants::OFF_HAND_DAMAGE_MULT, dbc::EffectLookup};
2use wowlab_engine_gamedata::ResolvedGameData;
3use wowlab_types::{constants::HUNDRED, sim::EffectRef};
4
5use super::SpellDefinitionDraft;
6use crate::{
7    DamageFlags,
8    builder::{
9        BuilderError,
10        def::{BuilderDamageDef, BuilderSpellEffect},
11    },
12    systems::damage_flags_from_data,
13};
14
15fn damage_geometry_effect(lookup: EffectLookup<'_>) -> EffectRef {
16    let effect_type = lookup.effect_type();
17
18    if wowlab_engine_domain::dbc::spell_effect_is_any(
19        effect_type,
20        &[
21            wowlab_engine_domain::dbc::SpellEffectKind::TriggerMissile,
22            wowlab_engine_domain::dbc::SpellEffectKind::TriggerSpell,
23            wowlab_engine_domain::dbc::SpellEffectKind::TriggerSpellWithValue,
24            wowlab_engine_domain::dbc::SpellEffectKind::TriggerSpell2,
25        ],
26    ) && let Ok(triggered) = wowlab_engine_domain::dbc::resolve_triggered_damage(lookup)
27    {
28        return EffectRef::new(triggered.spell_id, triggered.damage.effect_index);
29    }
30
31    lookup.effect
32}
33
34fn append_interrupt_effect(
35    spell: &mut SpellDefinitionDraft,
36    data: &ResolvedGameData,
37    spell_id: wowlab_types::sim::SpellIdx,
38    effect_type: i32,
39) {
40    if wowlab_engine_domain::dbc::spell_effect_is(
41        effect_type,
42        wowlab_engine_domain::dbc::SpellEffectKind::InterruptCast,
43    ) {
44        spell
45            .followup_effects
46            .push(BuilderSpellEffect::InterruptCast {
47                lockout_ms: data.aura_duration_ms(spell_id).unwrap_or_default(),
48            });
49    }
50}
51
52fn append_dispel_effect(
53    spell: &mut SpellDefinitionDraft,
54    lookup: EffectLookup<'_>,
55    effect_type: i32,
56) {
57    let steal = wowlab_engine_domain::dbc::spell_effect_is(
58        effect_type,
59        wowlab_engine_domain::dbc::SpellEffectKind::StealBeneficialBuff,
60    );
61
62    if steal
63        || wowlab_engine_domain::dbc::spell_effect_is(
64            effect_type,
65            wowlab_engine_domain::dbc::SpellEffectKind::Dispel,
66        )
67    {
68        spell.followup_effects.push(BuilderSpellEffect::Dispel {
69            dispel_type: lookup.effect_misc_value_0(),
70            steal,
71        });
72    }
73}
74
75#[derive(Clone, Copy, Debug, Default)]
76struct DamageEquipmentRequirements {
77    main_hand: bool,
78    off_hand: bool,
79    equipped_item: Option<wowlab_types::data::EquippedItemRequirement>,
80}
81
82#[expect(
83    clippy::struct_excessive_bools,
84    reason = "resolved spell behavior mirrors independent DBC flags"
85)]
86struct ResolvedSpellBehavior {
87    cooldown_hasted: bool,
88    gcd_haste_type: wowlab_types::combat::GcdHasteType,
89    hasted_ticks: bool,
90    duration_hasted: bool,
91    cannot_crit: bool,
92    requires_unshifted: bool,
93    allow_while_unshifted: bool,
94    requires_stealth: bool,
95    requires_behind_target: bool,
96    treat_as_area_effect: bool,
97    shapeshift_required_mask: u64,
98    shapeshift_excluded_mask: u64,
99    requires_main_hand: bool,
100    requires_off_hand: bool,
101    equipped_item_requirement: Option<wowlab_types::data::EquippedItemRequirement>,
102    breaks_stealth: bool,
103    usable_while_casting: bool,
104    usable_while_moving: bool,
105    explicit_target_mask: u32,
106    required_explicit_target_mask: u32,
107}
108
109#[expect(
110    clippy::struct_excessive_bools,
111    reason = "resolved timing behavior mirrors independent DBC flags"
112)]
113struct ResolvedTimingBehavior {
114    cooldown_hasted: bool,
115    gcd_haste_type: wowlab_types::combat::GcdHasteType,
116    hasted_ticks: bool,
117    duration_hasted: bool,
118    cannot_crit: bool,
119    breaks_stealth: bool,
120    usable_while_casting: bool,
121    usable_while_moving: bool,
122}
123
124#[expect(
125    clippy::struct_excessive_bools,
126    reason = "resolved targeting behavior mirrors independent DBC flags"
127)]
128struct ResolvedTargetBehavior {
129    requires_unshifted: bool,
130    allow_while_unshifted: bool,
131    requires_stealth: bool,
132    requires_behind_target: bool,
133    treat_as_area_effect: bool,
134    shapeshift_required_mask: u64,
135    shapeshift_excluded_mask: u64,
136    requires_main_hand: bool,
137    requires_off_hand: bool,
138    equipped_item_requirement: Option<wowlab_types::data::EquippedItemRequirement>,
139    explicit_target_mask: u32,
140    required_explicit_target_mask: u32,
141}
142
143fn resolve_timing_behavior(
144    data: &ResolvedGameData,
145    spell_id: u32,
146) -> Result<ResolvedTimingBehavior, BuilderError> {
147    let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
148    let missing = |field| crate::builder::BuilderErrorKind::MissingSpellData { spell_id, field };
149
150    Ok(ResolvedTimingBehavior {
151        cooldown_hasted: data
152            .cooldown_hasted(idx)
153            .ok_or_else(|| missing("cooldown_hasted"))?,
154        gcd_haste_type: data
155            .gcd_haste_type(idx)
156            .ok_or_else(|| missing("gcd_haste_type"))?,
157        hasted_ticks: data
158            .hasted_ticks(idx)
159            .ok_or_else(|| missing("hasted_ticks"))?,
160        duration_hasted: data
161            .duration_hasted(idx)
162            .ok_or_else(|| missing("duration_hasted"))?,
163        cannot_crit: data
164            .cannot_crit(idx)
165            .ok_or_else(|| missing("cannot_crit"))?,
166        breaks_stealth: data
167            .breaks_stealth(idx)
168            .ok_or_else(|| missing("breaks_stealth"))?,
169        usable_while_casting: data
170            .usable_while_casting(idx)
171            .ok_or_else(|| missing("usable_while_casting"))?,
172        usable_while_moving: data
173            .usable_while_moving(idx)
174            .ok_or_else(|| missing("usable_while_moving"))?,
175    })
176}
177
178fn resolve_target_behavior(
179    data: &ResolvedGameData,
180    spell_id: u32,
181) -> Result<ResolvedTargetBehavior, BuilderError> {
182    let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
183    let missing = |field| crate::builder::BuilderErrorKind::MissingSpellData { spell_id, field };
184
185    Ok(ResolvedTargetBehavior {
186        requires_unshifted: data
187            .requires_unshifted(idx)
188            .ok_or_else(|| missing("requires_unshifted"))?,
189        allow_while_unshifted: data
190            .allow_while_unshifted(idx)
191            .ok_or_else(|| missing("allow_while_unshifted"))?,
192        requires_stealth: data
193            .requires_stealth(idx)
194            .ok_or_else(|| missing("requires_stealth"))?,
195        requires_behind_target: data
196            .requires_behind_target(idx)
197            .ok_or_else(|| missing("requires_behind_target"))?,
198        treat_as_area_effect: data
199            .treat_as_area_effect(idx)
200            .ok_or_else(|| missing("treat_as_area_effect"))?,
201        shapeshift_required_mask: data
202            .shapeshift_required_mask(idx)
203            .ok_or_else(|| missing("shapeshift_required_mask"))?,
204        shapeshift_excluded_mask: data
205            .shapeshift_excluded_mask(idx)
206            .ok_or_else(|| missing("shapeshift_excluded_mask"))?,
207        requires_main_hand: data
208            .requires_main_hand(idx)
209            .ok_or_else(|| missing("requires_main_hand"))?,
210        requires_off_hand: data
211            .requires_off_hand(idx)
212            .ok_or_else(|| missing("requires_off_hand"))?,
213        equipped_item_requirement: data.equipped_item_requirement(idx),
214        explicit_target_mask: data
215            .explicit_target_mask(idx)
216            .ok_or_else(|| missing("explicit_target_mask"))?,
217        required_explicit_target_mask: data
218            .required_explicit_target_mask(idx)
219            .ok_or_else(|| missing("required_explicit_target_mask"))?,
220    })
221}
222
223fn resolve_spell_behavior(
224    data: &ResolvedGameData,
225    spell_id: u32,
226) -> Result<ResolvedSpellBehavior, BuilderError> {
227    let timing = resolve_timing_behavior(data, spell_id)?;
228    let target = resolve_target_behavior(data, spell_id)?;
229
230    Ok(ResolvedSpellBehavior {
231        cooldown_hasted: timing.cooldown_hasted,
232        gcd_haste_type: timing.gcd_haste_type,
233        hasted_ticks: timing.hasted_ticks,
234        duration_hasted: timing.duration_hasted,
235        cannot_crit: timing.cannot_crit,
236        requires_unshifted: target.requires_unshifted,
237        allow_while_unshifted: target.allow_while_unshifted,
238        requires_stealth: target.requires_stealth,
239        requires_behind_target: target.requires_behind_target,
240        treat_as_area_effect: target.treat_as_area_effect,
241        shapeshift_required_mask: target.shapeshift_required_mask,
242        shapeshift_excluded_mask: target.shapeshift_excluded_mask,
243        requires_main_hand: target.requires_main_hand,
244        requires_off_hand: target.requires_off_hand,
245        equipped_item_requirement: target.equipped_item_requirement,
246        breaks_stealth: timing.breaks_stealth,
247        usable_while_casting: timing.usable_while_casting,
248        usable_while_moving: timing.usable_while_moving,
249        explicit_target_mask: target.explicit_target_mask,
250        required_explicit_target_mask: target.required_explicit_target_mask,
251    })
252}
253
254#[expect(
255    clippy::missing_errors_doc,
256    reason = "DBC-backed fluent methods uniformly return BuilderError when required spell data is absent or invalid"
257)]
258impl SpellDefinitionDraft {
259    pub fn additional_damage_ap_from_data(
260        self,
261        lookup: EffectLookup<'_>,
262        multiplier: f64,
263    ) -> Result<Self, BuilderError> {
264        self.additional_damage_ap_from_data_typed(
265            lookup,
266            multiplier,
267            crate::state::WeaponApType::MainHand,
268        )
269    }
270
271    pub fn additional_damage_ap_from_data_typed(
272        self,
273        lookup: EffectLookup<'_>,
274        multiplier: f64,
275        ap_type: crate::state::WeaponApType,
276    ) -> Result<Self, BuilderError> {
277        let data = lookup.data;
278        let idx = lookup.effect.spell;
279        let spell_id = idx.as_u32();
280        let effect_index = lookup.effect.effect_index;
281        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
282            crate::builder::BuilderErrorKind::MissingSpellData {
283                spell_id,
284                field: "is_physical",
285            },
286        )?;
287
288        Ok(self.additional_damage(
289            crate::state::DamageEffectRef::new(spell_id, effect_index),
290            BuilderDamageDef::ApCoefficient {
291                coef: data.ap_coef(idx, effect_index) * multiplier,
292                is_physical,
293                ap_type,
294            },
295            data.base_points(idx, effect_index),
296            !data.cannot_crit(idx).unwrap_or(false),
297            damage_flags_from_data(data, idx, is_physical),
298            DamageEquipmentRequirements::default(),
299        ))
300    }
301
302    pub fn additional_damage_sp_from_data(
303        self,
304        lookup: EffectLookup<'_>,
305        multiplier: f64,
306    ) -> Result<Self, BuilderError> {
307        let data = lookup.data;
308        let idx = lookup.effect.spell;
309        let spell_id = idx.as_u32();
310        let effect_index = lookup.effect.effect_index;
311        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
312            crate::builder::BuilderErrorKind::MissingSpellData {
313                spell_id,
314                field: "is_physical",
315            },
316        )?;
317
318        Ok(self.additional_damage(
319            crate::state::DamageEffectRef::new(spell_id, effect_index),
320            BuilderDamageDef::SpCoefficient {
321                coef: data.sp_coef(idx, effect_index) * multiplier,
322                is_physical,
323            },
324            data.base_points(idx, effect_index),
325            !data.cannot_crit(idx).unwrap_or(false),
326            damage_flags_from_data(data, idx, is_physical),
327            DamageEquipmentRequirements::default(),
328        ))
329    }
330
331    /// Append the terminal child damage reached through an exact DBC trigger edge.
332    pub fn triggered_damage_from_data(
333        self,
334        lookup: EffectLookup<'_>,
335        multiplier: f64,
336    ) -> Result<Self, BuilderError> {
337        let data = lookup.data;
338        let triggered = wowlab_engine_domain::dbc::resolve_triggered_damage(lookup)?;
339        let may_crit = !data.cannot_crit(triggered.spell_id).unwrap_or(false);
340        let attribute_flags =
341            damage_flags_from_data(data, triggered.spell_id, triggered.damage.is_physical);
342
343        self.additional_triggered_damage(triggered, multiplier, may_crit, attribute_flags)
344    }
345
346    /// Applies every DBC cooldown component without collapsing their independent pools.
347    pub fn apply_cooldown_pools_from_data(
348        self,
349        data: &ResolvedGameData,
350        spell_id: u32,
351    ) -> Result<Self, BuilderError> {
352        let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
353        let missing =
354            |field| crate::builder::BuilderErrorKind::MissingSpellData { spell_id, field };
355        let cooldown_s = data.cooldown_s(idx).ok_or_else(|| missing("cooldown_s"))?;
356        let charges = data.charges(idx).ok_or_else(|| missing("charges"))?;
357        let charge_cd_s = data
358            .charge_cd_s(idx)
359            .ok_or_else(|| missing("charge_cd_s"))?;
360        let category_cooldown_s = data
361            .category_cooldown_s(idx)
362            .ok_or_else(|| missing("category_cooldown_s"))?;
363
364        let mut spell = self
365            .cooldown(cooldown_s)
366            .charges(charges, charge_cd_s)
367            .cooldown_categories(
368                data.cooldown_category(idx),
369                category_cooldown_s,
370                data.charge_category(idx),
371            )
372            .start_recovery_category(data.start_recovery_category(idx));
373
374        if data.is_empower_spell(idx) {
375            spell = spell.apply_empower_from_data(data, spell_id)?;
376        }
377
378        Ok(spell)
379    }
380
381    /// Apply the standard data-driven field set for `spell_id`, erroring on any missing required field.
382    // #t(fn: rust_cyclomatic_complexity) base construction composes independent DBC timing, targeting, resource, damage, and healing semantics
383    // #t(fn: rust_max_fn_lines) the ordered base-property lowering remains auditable as one DBC-to-runtime transaction
384    pub fn apply_base_from_data(
385        self,
386        data: &ResolvedGameData,
387        spell_id: u32,
388    ) -> Result<Self, BuilderError> {
389        let idx = wowlab_types::sim::SpellIdx::from_raw(spell_id);
390        let missing =
391            |field| crate::builder::BuilderErrorKind::MissingSpellData { spell_id, field };
392
393        let cast_time_ms = data
394            .cast_time_ms(idx)
395            .ok_or_else(|| missing("cast_time_ms"))?;
396        let behavior = resolve_spell_behavior(data, spell_id)?;
397        let gcd_ms = data.gcd_ms(idx).ok_or_else(|| missing("gcd_ms"))?;
398        let resources = data
399            .spell_resources(idx)
400            .ok_or_else(|| missing("spell_resources"))?;
401        let damage =
402            wowlab_engine_domain::dbc::damage_def(data, idx).ok_or_else(|| missing("damage"))?;
403        let geometry = damage_geometry_effect(EffectLookup::new(
404            data,
405            EffectRef::new(idx, damage.effect_index),
406        ));
407        let target_plan = wowlab_engine_domain::targeting::compile_target_plan(
408            wowlab_engine_domain::targeting::TargetPlanInput {
409                spell_id: geometry.spell.as_u32(),
410                effect_index: geometry.effect_index,
411                effect_type: data.effect_type(geometry.spell, geometry.effect_index),
412                target_a: data.implicit_target_a(geometry.spell, geometry.effect_index),
413                target_b: data.implicit_target_b(geometry.spell, geometry.effect_index),
414            },
415        )?;
416
417        let mut spell = self
418            .apply_cooldown_pools_from_data(data, spell_id)?
419            .cost(resources.primary_cost)
420            .optional_cost(resources.primary_optional_cost)
421            .gcd(gcd_ms)
422            .gain(resources.primary_gain)
423            .secondary_cost(resources.secondary_cost)
424            .secondary_optional_cost(resources.secondary_optional_cost)
425            .secondary_gain(resources.secondary_gain)
426            .damage_auto(damage)?
427            .damage_effect(geometry.spell.as_u32(), geometry.effect_index);
428
429        if !data.is_empower_spell(idx) {
430            spell = spell.cast_time(cast_time_ms);
431        }
432
433        spell.cooldown_hasted = behavior.cooldown_hasted;
434        spell.gcd_haste_type = behavior.gcd_haste_type;
435        spell.damage_may_crit = !behavior.cannot_crit;
436        spell.channel_tick_may_crit = !behavior.cannot_crit;
437        spell.damage_attribute_flags = damage_flags_from_data(data, idx, damage.is_physical);
438        spell.requires_unshifted = behavior.requires_unshifted;
439        spell.allow_while_unshifted = behavior.allow_while_unshifted;
440        spell.requires_stealth = behavior.requires_stealth;
441        spell.requires_behind_target = behavior.requires_behind_target;
442        spell.is_aoe |= behavior.treat_as_area_effect
443            || wowlab_engine_domain::targeting::target_plan_is_multi_target(&target_plan);
444        spell.shapeshift_required_mask = behavior.shapeshift_required_mask;
445        spell.shapeshift_excluded_mask = behavior.shapeshift_excluded_mask;
446        spell.caster_aura_spell = data.caster_aura_spell(idx).unwrap_or_default();
447        spell.caster_aura_state = data
448            .caster_aura_state(idx)
449            .and_then(wowlab_engine_domain::dbc::AuraState::from_dbc);
450        spell.exclude_caster_aura_spell = data.exclude_caster_aura_spell(idx).unwrap_or_default();
451        spell.exclude_caster_aura_state = data
452            .exclude_caster_aura_state(idx)
453            .and_then(wowlab_engine_domain::dbc::AuraState::from_dbc);
454        spell.target_aura_spell = data.target_aura_spell(idx).unwrap_or_default();
455        spell.target_aura_state = data
456            .target_aura_state(idx)
457            .and_then(wowlab_engine_domain::dbc::AuraState::from_dbc);
458        spell.exclude_target_aura_spell = data.exclude_target_aura_spell(idx).unwrap_or_default();
459        spell.exclude_target_aura_state = data
460            .exclude_target_aura_state(idx)
461            .and_then(wowlab_engine_domain::dbc::AuraState::from_dbc);
462        spell.requires_main_hand = behavior.requires_main_hand;
463        spell.requires_off_hand = behavior.requires_off_hand;
464        spell.equipped_item_requirement = behavior.equipped_item_requirement;
465        spell.breaks_stealth = behavior.breaks_stealth;
466        spell.usable_while_casting = behavior.usable_while_casting;
467        spell.usable_while_moving = behavior.usable_while_moving;
468        spell.explicit_target_mask = behavior.explicit_target_mask;
469        spell.required_explicit_target_mask = behavior.required_explicit_target_mask;
470        spell.resource_cost_pct = resources.primary_cost_pct;
471        spell.maximum_resource_cost_pct = resources.primary_max_cost_pct;
472        spell.optional_resource_cost_pct = resources.primary_optional_cost_pct;
473        spell.health_cost = resources.health_cost;
474        spell.health_cost_pct = resources.health_cost_pct;
475        spell.health_max_cost_pct = resources.health_max_cost_pct;
476        spell.health_optional_cost = resources.health_optional_cost;
477        spell.health_optional_cost_pct = resources.health_optional_cost_pct;
478        spell.channel_hasted_ticks = behavior.hasted_ticks;
479        spell.channel_duration_hasted = behavior.duration_hasted;
480        spell.channel_tick_interval_ms = data.aura_tick_ms(idx).unwrap_or_default();
481        spell.channel_tick_zero = data.tick_on_application(idx).unwrap_or_default();
482        spell.projectile_speed = data
483            .projectile_speed(idx)
484            .ok_or_else(|| missing("projectile_speed"))?;
485        spell.launch_delay_s = data
486            .launch_delay_s(idx)
487            .ok_or_else(|| missing("launch_delay_s"))?;
488        spell.dbc_fixed_travel_time = data
489            .fixed_travel_time(idx)
490            .ok_or_else(|| missing("fixed_travel_time"))?;
491
492        let mut has_heal = false;
493        let mut has_damage = false;
494
495        for effect_index in 1..=data.max_effect_index(idx) {
496            let effect_type = data.effect_type(idx, effect_index);
497
498            has_damage |= wowlab_engine_domain::dbc::spell_effect_is_any(
499                effect_type,
500                &[
501                    wowlab_engine_domain::dbc::SpellEffectKind::SchoolDamage,
502                    wowlab_engine_domain::dbc::SpellEffectKind::HealthLeech,
503                    wowlab_engine_domain::dbc::SpellEffectKind::WeaponDamageNoSchool,
504                    wowlab_engine_domain::dbc::SpellEffectKind::WeaponPercentDamage,
505                    wowlab_engine_domain::dbc::SpellEffectKind::WeaponDamage,
506                    wowlab_engine_domain::dbc::SpellEffectKind::NormalizedWeaponDamage,
507                ],
508            );
509
510            append_interrupt_effect(&mut spell, data, idx, effect_type);
511            append_dispel_effect(
512                &mut spell,
513                EffectLookup::new(data, EffectRef::new(idx, effect_index)),
514                effect_type,
515            );
516
517            if !wowlab_engine_domain::dbc::spell_effect_is_any(
518                effect_type,
519                &[
520                    wowlab_engine_domain::dbc::SpellEffectKind::Heal,
521                    wowlab_engine_domain::dbc::SpellEffectKind::HealMaxHealthPercent,
522                    wowlab_engine_domain::dbc::SpellEffectKind::DirectHealPercent,
523                ],
524            ) {
525                continue;
526            }
527
528            has_heal = true;
529            let is_percent = wowlab_engine_domain::dbc::spell_effect_is_any(
530                effect_type,
531                &[
532                    wowlab_engine_domain::dbc::SpellEffectKind::HealMaxHealthPercent,
533                    wowlab_engine_domain::dbc::SpellEffectKind::DirectHealPercent,
534                ],
535            );
536
537            spell.followup_effects.push(BuilderSpellEffect::Heal {
538                effect: Some(crate::state::DamageEffectRef::new(spell_id, effect_index)),
539                base: if is_percent {
540                    0.0
541                } else {
542                    data.base_points(idx, effect_index).max(0.0)
543                },
544                ap_coef: if is_percent {
545                    0.0
546                } else {
547                    data.ap_coef(idx, effect_index)
548                },
549                sp_coef: if is_percent {
550                    0.0
551                } else {
552                    data.sp_coef(idx, effect_index)
553                },
554                percent_of_max: if is_percent {
555                    data.base_points(idx, effect_index).max(0.0) / HUNDRED
556                } else {
557                    0.0
558                },
559                may_crit: !behavior.cannot_crit,
560            });
561        }
562
563        if has_heal && !has_damage {
564            spell = spell.without_data_damage();
565        }
566
567        Ok(spell)
568    }
569
570    /// Apply DBC chain targeting and its per-hop multiplier from the primary damage effect.
571    pub fn apply_aoe_from_data(mut self, data: &ResolvedGameData) -> Self {
572        let Some(effect) = self.damage_effect else {
573            return self;
574        };
575        let idx = wowlab_types::sim::SpellIdx::from_raw(effect.spell_id);
576        let geometry = damage_geometry_effect(EffectLookup::new(
577            data,
578            EffectRef::new(idx, effect.effect_index),
579        ));
580        let target_plan = wowlab_engine_domain::targeting::compile_target_plan(
581            wowlab_engine_domain::targeting::TargetPlanInput {
582                spell_id: geometry.spell.as_u32(),
583                effect_index: geometry.effect_index,
584                effect_type: data.effect_type(geometry.spell, geometry.effect_index),
585                target_a: data.implicit_target_a(geometry.spell, geometry.effect_index),
586                target_b: data.implicit_target_b(geometry.spell, geometry.effect_index),
587            },
588        );
589
590        self.is_aoe |= data.treat_as_area_effect(idx).unwrap_or(false)
591            || data.treat_as_area_effect(geometry.spell).unwrap_or(false)
592            || target_plan
593                .as_ref()
594                .is_ok_and(wowlab_engine_domain::targeting::target_plan_is_multi_target);
595        let max_targets = wowlab_types::numeric::i32_to_u8_saturating(
596            data.chain_targets(geometry.spell, geometry.effect_index),
597        );
598        let chain_multiplier = data.chain_multiplier(geometry.spell, geometry.effect_index);
599
600        self.chain_targets(max_targets)
601            .chain_multiplier(chain_multiplier)
602    }
603
604    /// Set AP-coefficient damage, resolving coef + physical flag from data; errors if the school mask is missing on populated data.
605    pub fn damage_ap_from_data(self, lookup: EffectLookup<'_>) -> Result<Self, BuilderError> {
606        let data = lookup.data;
607        let idx = lookup.effect.spell;
608        let spell_id = idx.as_u32();
609        let effect_index = lookup.effect.effect_index;
610        let coef = lookup.ap_coef();
611        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
612            crate::builder::BuilderErrorKind::MissingSpellData {
613                spell_id,
614                field: "is_physical",
615            },
616        )?;
617        let flags = damage_flags_from_data(data, idx, is_physical);
618
619        Ok(self
620            .damage_ap(coef, flags)
621            .damage_effect(spell_id, effect_index))
622    }
623
624    /// Like [`Self::damage_ap_from_data`] with an explicit weapon-AP composite.
625    pub fn damage_ap_from_data_typed(
626        self,
627        lookup: EffectLookup<'_>,
628        ap_type: crate::state::WeaponApType,
629    ) -> Result<Self, BuilderError> {
630        let data = lookup.data;
631        let idx = lookup.effect.spell;
632        let spell_id = idx.as_u32();
633        let effect_index = lookup.effect.effect_index;
634        let coef = lookup.ap_coef();
635        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
636            crate::builder::BuilderErrorKind::MissingSpellData {
637                spell_id,
638                field: "is_physical",
639            },
640        )?;
641        let flags = damage_flags_from_data(data, idx, is_physical);
642
643        Ok(self
644            .damage_ap_typed(coef, flags, ap_type)
645            .damage_effect(spell_id, effect_index))
646    }
647
648    /// Set SP-coefficient damage, resolving coef + physical flag from data; errors if the school mask is missing on populated data.
649    pub fn damage_sp_from_data(self, lookup: EffectLookup<'_>) -> Result<Self, BuilderError> {
650        let data = lookup.data;
651        let idx = lookup.effect.spell;
652        let spell_id = idx.as_u32();
653        let effect_index = lookup.effect.effect_index;
654        let coef = lookup.sp_coef();
655        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
656            crate::builder::BuilderErrorKind::MissingSpellData {
657                spell_id,
658                field: "is_physical",
659            },
660        )?;
661        let flags = damage_flags_from_data(data, idx, is_physical);
662
663        Ok(self
664            .damage_sp(coef, flags)
665            .damage_effect(spell_id, effect_index))
666    }
667
668    /// Set AP-coefficient channel-tick damage, resolving coef + physical flag from game data.
669    pub fn channel_tick_damage_ap_from_data(
670        self,
671        lookup: EffectLookup<'_>,
672    ) -> Result<Self, BuilderError> {
673        let data = lookup.data;
674        let idx = lookup.effect.spell;
675        let spell_id = idx.as_u32();
676        let coef = lookup.ap_coef();
677        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
678            crate::builder::BuilderErrorKind::MissingSpellData {
679                spell_id,
680                field: "is_physical",
681            },
682        )?;
683        let flags = damage_flags_from_data(data, idx, is_physical);
684
685        Ok(self
686            .channel_tick_damage_ap(coef, flags)
687            .channel_tick_source(spell_id))
688    }
689
690    /// Set SP-coefficient channel-tick damage, resolving coef + physical flag from game data.
691    pub fn channel_tick_damage_sp_from_data(
692        self,
693        lookup: EffectLookup<'_>,
694    ) -> Result<Self, BuilderError> {
695        let data = lookup.data;
696        let idx = lookup.effect.spell;
697        let spell_id = idx.as_u32();
698        let coef = lookup.sp_coef();
699        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
700            crate::builder::BuilderErrorKind::MissingSpellData {
701                spell_id,
702                field: "is_physical",
703            },
704        )?;
705        let flags = damage_flags_from_data(data, idx, is_physical);
706
707        Ok(self
708            .channel_tick_damage_sp(coef, flags)
709            .channel_tick_source(spell_id))
710    }
711
712    /// Set an aura-conditional AP channel-tick payload from game data.
713    pub fn channel_tick_damage_alt_ap_from_data(
714        self,
715        lookup: EffectLookup<'_>,
716        aura_id: u32,
717    ) -> Result<Self, BuilderError> {
718        let data = lookup.data;
719        let idx = lookup.effect.spell;
720        let spell_id = idx.as_u32();
721        let coef = lookup.ap_coef();
722        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
723            crate::builder::BuilderErrorKind::MissingSpellData {
724                spell_id,
725                field: "is_physical",
726            },
727        )?;
728
729        Ok(self.channel_tick_damage_alt_ap(
730            coef,
731            damage_flags_from_data(data, idx, is_physical),
732            spell_id,
733            aura_id,
734        ))
735    }
736
737    /// Set an aura-conditional SP channel-tick payload from game data.
738    pub fn channel_tick_damage_alt_sp_from_data(
739        self,
740        lookup: EffectLookup<'_>,
741        aura_id: u32,
742    ) -> Result<Self, BuilderError> {
743        let data = lookup.data;
744        let idx = lookup.effect.spell;
745        let spell_id = idx.as_u32();
746        let coef = lookup.sp_coef();
747        let is_physical = wowlab_engine_domain::dbc::is_physical(data, idx).ok_or(
748            crate::builder::BuilderErrorKind::MissingSpellData {
749                spell_id,
750                field: "is_physical",
751            },
752        )?;
753
754        Ok(self.channel_tick_damage_alt_sp(
755            coef,
756            damage_flags_from_data(data, idx, is_physical),
757            spell_id,
758            aura_id,
759        ))
760    }
761
762    fn apply_empower_from_data(
763        mut self,
764        data: &ResolvedGameData,
765        spell_id: u32,
766    ) -> Result<Self, BuilderError> {
767        let cast_times_ms =
768            data.require_empower_cast_times_ms(wowlab_types::sim::SpellIdx::from_raw(spell_id))?;
769
770        u8::try_from(cast_times_ms.len()).map_err(|source| {
771            crate::builder::BuilderErrorKind::InvalidEmpowerRankCount {
772                spell_id,
773                rank_count: cast_times_ms.len(),
774                source,
775            }
776        })?;
777
778        self.cast_time_ms = cast_times_ms.first().copied().unwrap_or(0);
779        self.empower_cast_times_ms = cast_times_ms;
780
781        Ok(self)
782    }
783
784    fn additional_damage(
785        mut self,
786        effect: crate::state::DamageEffectRef,
787        damage: BuilderDamageDef,
788        base_points: f64,
789        may_crit: bool,
790        attribute_flags: DamageFlags,
791        equipment: DamageEquipmentRequirements,
792    ) -> Self {
793        self.followup_effects.push(BuilderSpellEffect::Damage {
794            effect,
795            damage,
796            base_points,
797            may_crit,
798            attribute_flags,
799            requires_main_hand: equipment.main_hand,
800            requires_off_hand: equipment.off_hand,
801            equipped_item_requirement: equipment.equipped_item,
802        });
803
804        self
805    }
806
807    fn additional_triggered_damage(
808        self,
809        triggered: wowlab_engine_domain::dbc::TriggeredDamage,
810        multiplier: f64,
811        may_crit: bool,
812        attribute_flags: DamageFlags,
813    ) -> Result<Self, BuilderError> {
814        if self.followup_effects.iter().any(|effect| {
815            matches!(
816                effect,
817                BuilderSpellEffect::Damage { effect, .. }
818                    if effect.spell_id == triggered.spell_id.as_u32()
819                        && effect.effect_index == triggered.damage.effect_index
820            )
821        }) {
822            return Ok(self);
823        }
824
825        let damage = BuilderDamageDef::from_resolved(triggered.damage, OFF_HAND_DAMAGE_MULT)
826            .ok_or(crate::builder::BuilderErrorKind::UnsupportedDamageKind)?
827            .scaled(multiplier);
828        let base_points = crate::state::resolved_damage_base_points(triggered.damage, multiplier);
829
830        Ok(self.additional_damage(
831            crate::state::DamageEffectRef::new(
832                triggered.spell_id.as_u32(),
833                triggered.damage.effect_index,
834            ),
835            damage,
836            base_points,
837            may_crit,
838            attribute_flags,
839            DamageEquipmentRequirements {
840                main_hand: triggered.requires_main_hand,
841                off_hand: triggered.requires_off_hand,
842                equipped_item: triggered.equipped_item_requirement,
843            },
844        ))
845    }
846}
847
848#[cfg(test)]
849#[path = "from_data/tests.rs"]
850mod tests;