Skip to main content

wowlab_engine_domain/dbc/game_data/
trigger_program.rs

1//! Interpretation policy over resolved game data.
2
3use wowlab_engine_gamedata::{ResolvedDamageDef, ResolvedGameData};
4use wowlab_types::{
5    combat::ResourceType,
6    sim::{EffectRef, SpellIdx},
7};
8
9use super::{super::EffectLookup, damage_def::damage_def_at, error::TriggerResolveError};
10
11pub(super) const MAX_TRIGGER_DEPTH: usize = 8;
12
13pub(super) struct TriggerPath {
14    source_spell_id: u32,
15    ancestors: [u32; MAX_TRIGGER_DEPTH + 1],
16}
17
18impl TriggerPath {
19    pub(super) fn new(source: SpellIdx) -> Self {
20        let mut ancestors = [0; MAX_TRIGGER_DEPTH + 1];
21
22        ancestors[0] = source.as_u32();
23
24        Self {
25            source_spell_id: source.as_u32(),
26            ancestors,
27        }
28    }
29
30    pub(super) fn enter(
31        &mut self,
32        spell_id: SpellIdx,
33        depth: usize,
34    ) -> Result<(), TriggerResolveError> {
35        if depth > MAX_TRIGGER_DEPTH {
36            return Err(TriggerResolveError::depth_exceeded(self.source_spell_id));
37        }
38
39        let raw_spell_id = spell_id.as_u32();
40
41        let Some(ancestors) = self.ancestors.get(..depth) else {
42            return Err(TriggerResolveError::depth_exceeded(self.source_spell_id));
43        };
44
45        if ancestors.contains(&raw_spell_id) {
46            return Err(TriggerResolveError::cycle(raw_spell_id));
47        }
48
49        let Some(slot) = self.ancestors.get_mut(depth) else {
50            return Err(TriggerResolveError::depth_exceeded(self.source_spell_id));
51        };
52
53        *slot = raw_spell_id;
54
55        Ok(())
56    }
57}
58
59/// One typed combat operation resolved from a DBC trigger child.
60#[derive(Clone, Debug)]
61#[non_exhaustive]
62pub enum TriggerOperation {
63    Damage(TriggeredDamage),
64    Delayed {
65        delay_ms: u32,
66        profile_spell_id: SpellIdx,
67        operations: Vec<TriggerOperation>,
68    },
69    ApplyAura {
70        aura_id: SpellIdx,
71    },
72    RemoveAura {
73        aura_id: SpellIdx,
74    },
75    EnergizePrimary {
76        amount: f64,
77    },
78    EnergizeSecondary {
79        amount: f64,
80    },
81    EnergizePercent {
82        resource_type: ResourceType,
83        percent: f64,
84        effect_index: u8,
85    },
86    MutateCooldown {
87        target: TriggerCooldownTarget,
88        operation: CooldownMutation,
89    },
90}
91
92/// Cooldown coordinate selected by a DBC effect-program operation.
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94// #t(rust_non_exhaustive_on_public) the lowered effect-program algebra is intentionally exhaustive
95pub enum TriggerCooldownTarget {
96    Spell(SpellIdx),
97    ChargeCategory(wowlab_types::data::CooldownCategoryId),
98}
99
100/// Typed cooldown mutation shared by direct and triggered effect programs.
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102// #t(rust_non_exhaustive_on_public) the lowered effect-program algebra is intentionally exhaustive
103pub enum CooldownMutation {
104    ReduceRemaining { amount_ms: u32 },
105    Recharge { charges: u8 },
106}
107
108/// Ordered, bounded child operations reached through one DBC trigger edge.
109#[derive(Clone, Debug)]
110pub struct TriggerProgram {
111    pub child_spell_id: SpellIdx,
112    pub operations: Vec<TriggerOperation>,
113}
114
115/// Terminal damage payload reached by following one DBC trigger edge.
116#[derive(Clone, Copy, Debug)]
117#[non_exhaustive]
118pub struct TriggeredDamage {
119    pub spell_id: SpellIdx,
120    pub damage: ResolvedDamageDef,
121    pub requires_main_hand: bool,
122    pub requires_off_hand: bool,
123    pub equipped_item_requirement: Option<wowlab_types::data::EquippedItemRequirement>,
124}
125
126fn immediate_operation_at(
127    lookup: EffectLookup<'_>,
128    forwarded_value: Option<f64>,
129) -> Result<Option<TriggerOperation>, TriggerResolveError> {
130    let data = lookup.data;
131    let EffectRef {
132        spell: spell_id,
133        effect_index,
134    } = lookup.effect;
135    let raw_spell_id = spell_id.as_u32();
136
137    match super::super::spell_effect_kind(lookup.effect_type()) {
138        Some(super::super::SpellEffectKind::ApplyAura) => {
139            Ok(Some(TriggerOperation::ApplyAura { aura_id: spell_id }))
140        }
141        Some(
142            super::super::SpellEffectKind::CancelAura | super::super::SpellEffectKind::RemoveAura,
143        ) => {
144            let aura_id = lookup.trigger_spell().ok_or_else(|| {
145                TriggerResolveError::missing_removed_aura(raw_spell_id, effect_index)
146            })?;
147
148            Ok(Some(TriggerOperation::RemoveAura { aura_id }))
149        }
150        Some(super::super::SpellEffectKind::ReduceRemainingCooldown) => {
151            let target = data
152                .trigger_spell(spell_id, effect_index)
153                .ok_or_else(|| TriggerResolveError::missing_edge(raw_spell_id, effect_index))?;
154            let amount = forwarded_value
155                .unwrap_or_else(|| lookup.base_points())
156                .abs()
157                .round();
158
159            if !amount.is_finite() || amount <= 0.0 || amount > f64::from(u32::MAX) {
160                return Err(TriggerResolveError::invalid_cooldown_amount(
161                    raw_spell_id,
162                    effect_index,
163                    wowlab_types::numeric::f64_to_i64_saturating_trunc(amount),
164                ));
165            }
166
167            let amount_ms = wowlab_types::numeric::f64_to_u32_saturating_trunc(amount);
168
169            Ok(Some(TriggerOperation::MutateCooldown {
170                target: TriggerCooldownTarget::Spell(target),
171                operation: CooldownMutation::ReduceRemaining { amount_ms },
172            }))
173        }
174        Some(super::super::SpellEffectKind::ImmediateCooldownRechargeCategory) => {
175            let category = lookup.effect_misc_value_0();
176            let category =
177                wowlab_types::data::CooldownCategoryId::from_raw(category).ok_or_else(|| {
178                    TriggerResolveError::invalid_cooldown_category(
179                        raw_spell_id,
180                        effect_index,
181                        category,
182                    )
183                })?;
184            let charges = forwarded_value
185                .unwrap_or_else(|| lookup.base_points())
186                .round()
187                .clamp(1.0, f64::from(u8::MAX));
188            let charges = wowlab_types::numeric::f64_to_u8_saturating_trunc(charges);
189
190            Ok(Some(TriggerOperation::MutateCooldown {
191                target: TriggerCooldownTarget::ChargeCategory(category),
192                operation: CooldownMutation::Recharge { charges },
193            }))
194        }
195        Some(super::super::SpellEffectKind::EnergizePowerPercent) => {
196            let resource_type = lookup.effect_misc_value_0();
197            let resource_type = u8::try_from(resource_type)
198                .ok()
199                .and_then(|value| ResourceType::try_from(value).ok())
200                .ok_or_else(|| {
201                    TriggerResolveError::invalid_resource_type(
202                        raw_spell_id,
203                        effect_index,
204                        resource_type,
205                    )
206                })?;
207
208            Ok(Some(TriggerOperation::EnergizePercent {
209                resource_type,
210                percent: forwarded_value.unwrap_or_else(|| lookup.base_points()),
211                effect_index,
212            }))
213        }
214        _ => Ok(None),
215    }
216}
217
218/// Resolves one immediate DBC effect into the shared typed effect program.
219///
220/// # Errors
221///
222/// Returns an error when an encoded operation references invalid or missing game data.
223pub fn resolve_effect_program(
224    lookup: EffectLookup<'_>,
225    forwarded_value: Option<f64>,
226) -> Result<Option<TriggerProgram>, TriggerResolveError> {
227    let source = lookup.effect.spell;
228    let Some(operation) = immediate_operation_at(lookup, forwarded_value)? else {
229        return Ok(None);
230    };
231    let child_spell_id = match &operation {
232        TriggerOperation::MutateCooldown {
233            target: TriggerCooldownTarget::Spell(target),
234            ..
235        } => *target,
236        TriggerOperation::RemoveAura { aura_id } => *aura_id,
237        _ => source,
238    };
239
240    Ok(Some(TriggerProgram {
241        child_spell_id,
242        operations: vec![operation],
243    }))
244}
245
246/// Resolve one trigger edge into an ordered program of supported child operations.
247///
248/// # Errors
249///
250/// Returns an error when the trigger graph is missing, cyclic, too deep, or unsupported.
251pub fn resolve_trigger_program(
252    lookup: EffectLookup<'_>,
253    forwarded_value: Option<f64>,
254) -> Result<TriggerProgram, TriggerResolveError> {
255    let data = lookup.data;
256    let EffectRef {
257        spell: source,
258        effect_index,
259    } = lookup.effect;
260    let child = lookup
261        .trigger_spell()
262        .ok_or_else(|| TriggerResolveError::missing_edge(source.as_u32(), effect_index))?;
263    let mut path = TriggerPath::new(source);
264    let mut operations = Vec::new();
265
266    resolve_trigger_spell(
267        data,
268        source.as_u32(),
269        child,
270        forwarded_value,
271        1,
272        &mut path,
273        &mut operations,
274    )?;
275
276    Ok(TriggerProgram {
277        child_spell_id: child,
278        operations,
279    })
280}
281
282// #t(fn: rust_cyclomatic_complexity) the branches are the canonical child-operation classifier.
283// #t(fn: rust_large_fn_params) bounded recursion carries one explicit path and output arena.
284// #t(fn: rust_recursive_fn) recursion is hard-bounded by MAX_TRIGGER_DEPTH before every descent.
285fn resolve_trigger_spell(
286    data: &ResolvedGameData,
287    source_spell_id: u32,
288    spell_id: SpellIdx,
289    forwarded_value: Option<f64>,
290    depth: usize,
291    path: &mut TriggerPath,
292    operations: &mut Vec<TriggerOperation>,
293) -> Result<(), TriggerResolveError> {
294    let raw_spell_id = spell_id.as_u32();
295
296    path.enter(spell_id, depth)?;
297    let max_effect = data.max_effect_index(spell_id);
298
299    if max_effect == 0 {
300        return Err(TriggerResolveError::invalid_child_id(
301            source_spell_id,
302            raw_spell_id,
303        ));
304    }
305
306    let mut applied_aura = false;
307    let mut emitted_primary_gain = false;
308    let mut emitted_secondary_gain = false;
309    let operation_start = operations.len();
310    let mut first_inert_dummy = None;
311
312    for child_effect_index in 1..=max_effect {
313        let effect_type = data.effect_type(spell_id, child_effect_index);
314        let aura_subtype = data.effect_aura(spell_id, child_effect_index);
315
316        if let Some(damage) = damage_def_at(
317            EffectLookup::new(data, EffectRef::new(spell_id, child_effect_index)),
318            forwarded_value,
319        ) {
320            operations.push(TriggerOperation::Damage(TriggeredDamage {
321                spell_id,
322                damage,
323                requires_main_hand: data.requires_main_hand(spell_id).unwrap_or(false),
324                requires_off_hand: data.requires_off_hand(spell_id).unwrap_or(false),
325                equipped_item_requirement: data.equipped_item_requirement(spell_id),
326            }));
327            continue;
328        }
329
330        if let Some(operation) = immediate_operation_at(
331            EffectLookup::new(data, EffectRef::new(spell_id, child_effect_index)),
332            forwarded_value,
333        )? {
334            if matches!(operation, TriggerOperation::ApplyAura { .. }) {
335                if applied_aura {
336                    continue;
337                }
338
339                applied_aura = true;
340            }
341
342            operations.push(operation);
343            continue;
344        }
345
346        match super::super::spell_effect_kind(effect_type) {
347            Some(super::super::SpellEffectKind::None) => {}
348            Some(super::super::SpellEffectKind::Dummy) if aura_subtype == 0 => {
349                first_inert_dummy.get_or_insert((child_effect_index, effect_type, aura_subtype));
350            }
351            Some(super::super::SpellEffectKind::Energize) => {
352                if !emitted_primary_gain {
353                    // #t(rust_floating_point_eq) resolved resource zero is the DBC absent sentinel.
354                    if let Some(amount) = data.gain(spell_id).filter(|amount| *amount != 0.0) {
355                        operations.push(TriggerOperation::EnergizePrimary {
356                            amount: forwarded_value.unwrap_or(amount),
357                        });
358                        emitted_primary_gain = true;
359                    }
360                }
361
362                if !emitted_secondary_gain {
363                    if let Some(amount) = data
364                        .secondary_gain(spell_id)
365                        // #t(rust_floating_point_eq) resolved resource zero is the DBC absent sentinel.
366                        .filter(|amount| *amount != 0.0)
367                    {
368                        operations.push(TriggerOperation::EnergizeSecondary {
369                            amount: forwarded_value.unwrap_or(amount),
370                        });
371                        emitted_secondary_gain = true;
372                    }
373                }
374            }
375            Some(
376                super::super::SpellEffectKind::TriggerMissile
377                | super::super::SpellEffectKind::TriggerSpell
378                | super::super::SpellEffectKind::TriggerSpellWithValue
379                | super::super::SpellEffectKind::TriggerSpell2,
380            ) => {
381                let next = data
382                    .trigger_spell(spell_id, child_effect_index)
383                    .ok_or_else(|| {
384                        TriggerResolveError::missing_edge(raw_spell_id, child_effect_index)
385                    })?;
386                let next_forwarded = nested_forwarded_value(
387                    EffectLookup::new(data, EffectRef::new(spell_id, child_effect_index)),
388                    effect_type,
389                    forwarded_value,
390                );
391
392                let mut nested_operations = Vec::new();
393
394                resolve_trigger_spell(
395                    data,
396                    source_spell_id,
397                    next,
398                    next_forwarded,
399                    depth + 1,
400                    path,
401                    &mut nested_operations,
402                )?;
403
404                if !nested_operations.is_empty() {
405                    let delay_ms =
406                        u32::try_from(data.effect_misc_value_0(spell_id, child_effect_index))
407                            .unwrap_or(0);
408
409                    operations.push(TriggerOperation::Delayed {
410                        delay_ms,
411                        profile_spell_id: next,
412                        operations: nested_operations,
413                    });
414                }
415            }
416            _ => {
417                return Err(TriggerResolveError::unsupported_child_effect(
418                    raw_spell_id,
419                    child_effect_index,
420                    effect_type,
421                    aura_subtype,
422                ));
423            }
424        }
425    }
426
427    if operations.len() == operation_start
428        && let Some((effect_index, effect_type, aura_subtype)) = first_inert_dummy
429    {
430        return Err(TriggerResolveError::unsupported_child_effect(
431            raw_spell_id,
432            effect_index,
433            effect_type,
434            aura_subtype,
435        ));
436    }
437
438    Ok(())
439}
440
441fn nested_forwarded_value(
442    lookup: EffectLookup<'_>,
443    effect_type: i32,
444    inherited: Option<f64>,
445) -> Option<f64> {
446    if matches!(
447        super::super::spell_effect_kind(effect_type),
448        Some(super::super::SpellEffectKind::TriggerSpellWithValue)
449    ) {
450        Some(lookup.base_points())
451    } else {
452        inherited
453    }
454}