Skip to main content

wowlab_engine_combat/builder/combat_builder/
trigger_program.rs

1//! DBC trigger-program lowering and automatic registration.
2
3use wowlab_engine_domain::{
4    constants::OFF_HAND_DAMAGE_MULT,
5    dbc::{AuraSubtypeKind, SpellEffectKind, spell_effect_is},
6};
7
8use super::{TriggerProgramLowering, TriggerProgramRegistration};
9use crate::builder::def::{BuilderDamageDef, BuilderSpellEffect, PeriodicEffectDef};
10
11#[derive(Debug, thiserror::Error)]
12pub(super) enum TriggerProgramLowerError {
13    #[error("trigger program references unregistered aura {aura_id}")]
14    UnregisteredAura { aura_id: u32 },
15    #[error("trigger program references unregistered cooldown category {category}")]
16    UnregisteredCooldownCategory { category: i32 },
17    #[error("trigger program references unregistered cooldown spell {spell_id}")]
18    UnregisteredCooldownSpell { spell_id: u32 },
19    #[error("trigger program damage uses an unsupported damage kind")]
20    UnsupportedDamageKind,
21}
22
23wowlab_engine_macros::define_error! {
24#[derive(Debug)]
25struct TriggerProgramError {
26    #[source]
27    kind: TriggerProgramErrorKind,
28}
29
30#[derive(Debug, thiserror::Error)]
31enum TriggerProgramErrorKind {
32    #[error("{0}")]
33    Resolve(#[source] wowlab_engine_domain::dbc::TriggerResolveError),
34    #[error("{0}")]
35    Lower(#[source] TriggerProgramLowerError),
36}
37}
38
39#[cfg(test)]
40impl TriggerProgramError {
41    const fn is_resolve(&self) -> bool {
42        matches!(self.kind, TriggerProgramErrorKind::Resolve(_))
43    }
44
45    const fn is_lower(&self) -> bool {
46        matches!(self.kind, TriggerProgramErrorKind::Lower(_))
47    }
48}
49
50impl From<TriggerProgramLowerError> for TriggerProgramError {
51    fn from(source: TriggerProgramLowerError) -> Self {
52        Self {
53            kind: TriggerProgramErrorKind::Lower(source),
54        }
55    }
56}
57
58impl From<wowlab_engine_domain::dbc::TriggerResolveError> for TriggerProgramError {
59    fn from(source: wowlab_engine_domain::dbc::TriggerResolveError) -> Self {
60        Self {
61            kind: TriggerProgramErrorKind::Resolve(source),
62        }
63    }
64}
65
66fn lower_trigger_damage(
67    triggered: wowlab_engine_domain::dbc::TriggeredDamage,
68) -> Result<(BuilderDamageDef, f64), TriggerProgramLowerError> {
69    let damage = BuilderDamageDef::from_resolved(triggered.damage, OFF_HAND_DAMAGE_MULT)
70        .ok_or(TriggerProgramLowerError::UnsupportedDamageKind)?;
71    let base_points = crate::state::resolved_damage_base_points(triggered.damage, 1.0);
72
73    Ok((damage, base_points))
74}
75
76impl TriggerProgramLowering<'_> {
77    pub(super) fn lower_trigger_program(
78        &self,
79        program: &wowlab_engine_domain::dbc::TriggerProgram,
80    ) -> Result<Vec<BuilderSpellEffect>, TriggerProgramLowerError> {
81        self.lower_trigger_operations(&program.operations)
82    }
83
84    // #t(fn: rust_cyclomatic_complexity) exhaustive lowering over the typed trigger-operation vocabulary
85    fn lower_trigger_operations(
86        &self,
87        operations: &[wowlab_engine_domain::dbc::TriggerOperation],
88    ) -> Result<Vec<BuilderSpellEffect>, TriggerProgramLowerError> {
89        let mut effects = Vec::with_capacity(operations.len());
90
91        for operation in operations {
92            match operation {
93                wowlab_engine_domain::dbc::TriggerOperation::Damage(triggered) => {
94                    let (damage, base_points) = lower_trigger_damage(*triggered)?;
95
96                    effects.push(BuilderSpellEffect::Damage {
97                        effect: crate::state::DamageEffectRef::new(
98                            triggered.spell_id.as_u32(),
99                            triggered.damage.effect_index,
100                        ),
101                        damage,
102                        base_points,
103                        may_crit: !self
104                            .definition
105                            .game_data
106                            .cannot_crit(triggered.spell_id)
107                            .unwrap_or(false),
108                        attribute_flags: crate::systems::damage_flags_from_data(
109                            &self.definition.game_data,
110                            triggered.spell_id,
111                            triggered.damage.is_physical,
112                        ),
113                        requires_main_hand: triggered.requires_main_hand,
114                        requires_off_hand: triggered.requires_off_hand,
115                        equipped_item_requirement: triggered.equipped_item_requirement,
116                    });
117                }
118                wowlab_engine_domain::dbc::TriggerOperation::Delayed {
119                    delay_ms,
120                    profile_spell_id,
121                    operations,
122                } => effects.push(BuilderSpellEffect::Delayed {
123                    delay_ms: *delay_ms,
124                    profile_spell_id: profile_spell_id.as_u32(),
125                    effects: self.lower_trigger_operations(operations)?,
126                }),
127                wowlab_engine_domain::dbc::TriggerOperation::ApplyAura { aura_id } => {
128                    let index = self
129                        .definition
130                        .aura_defs
131                        .iter()
132                        .position(|aura| aura.aura_id == aura_id.as_u32())
133                        .ok_or(TriggerProgramLowerError::UnregisteredAura {
134                            aura_id: aura_id.as_u32(),
135                        })?;
136                    let local = u8::try_from(index)
137                        .map(crate::LocalAuraIdx::new)
138                        .ok()
139                        .ok_or(TriggerProgramLowerError::UnregisteredAura {
140                            aura_id: aura_id.as_u32(),
141                        })?;
142
143                    effects.push(BuilderSpellEffect::ApplyAura { aura_local: local });
144                }
145                wowlab_engine_domain::dbc::TriggerOperation::RemoveAura { aura_id } => {
146                    let index = self
147                        .definition
148                        .aura_defs
149                        .iter()
150                        .position(|aura| aura.aura_id == aura_id.as_u32())
151                        .ok_or(TriggerProgramLowerError::UnregisteredAura {
152                            aura_id: aura_id.as_u32(),
153                        })?;
154                    let local = u8::try_from(index)
155                        .map(crate::LocalAuraIdx::new)
156                        .ok()
157                        .ok_or(TriggerProgramLowerError::UnregisteredAura {
158                            aura_id: aura_id.as_u32(),
159                        })?;
160
161                    effects.push(BuilderSpellEffect::RemoveAura { aura_local: local });
162                }
163                wowlab_engine_domain::dbc::TriggerOperation::EnergizePrimary { amount } => {
164                    effects.push(BuilderSpellEffect::Energize {
165                        effect: None,
166                        pool: crate::state::ResourcePool::Primary,
167                        amount: *amount,
168                    });
169                }
170                wowlab_engine_domain::dbc::TriggerOperation::EnergizeSecondary { amount } => {
171                    effects.push(BuilderSpellEffect::Energize {
172                        effect: None,
173                        pool: crate::state::ResourcePool::Secondary,
174                        amount: *amount,
175                    });
176                }
177                wowlab_engine_domain::dbc::TriggerOperation::EnergizePercent {
178                    resource_type,
179                    percent,
180                    effect_index,
181                } => effects.push(BuilderSpellEffect::EnergizePercent {
182                    resource_type: *resource_type,
183                    percent: *percent,
184                    effect_index: *effect_index,
185                }),
186                wowlab_engine_domain::dbc::TriggerOperation::MutateCooldown {
187                    target,
188                    operation,
189                } => {
190                    let (index, error) = match target {
191                        wowlab_engine_domain::dbc::TriggerCooldownTarget::Spell(spell_id) => (
192                            self.definition
193                                .spells
194                                .iter()
195                                .position(|spell| spell.spell_id == spell_id.as_u32()),
196                            TriggerProgramLowerError::UnregisteredCooldownSpell {
197                                spell_id: spell_id.as_u32(),
198                            },
199                        ),
200                        wowlab_engine_domain::dbc::TriggerCooldownTarget::ChargeCategory(
201                            category,
202                        ) => (
203                            self.definition.spells.iter().position(|spell| {
204                                spell
205                                    .cooldown
206                                    .as_ref()
207                                    .and_then(|cooldown| cooldown.charge_category)
208                                    == Some(*category)
209                            }),
210                            TriggerProgramLowerError::UnregisteredCooldownCategory {
211                                category: category.raw(),
212                            },
213                        ),
214                    };
215                    let local = index
216                        .and_then(|index| u8::try_from(index).ok())
217                        .map(crate::state::LocalSpellIdx::new)
218                        .ok_or(error)?;
219
220                    effects.push(BuilderSpellEffect::MutateCooldown {
221                        spell_local: local,
222                        operation: *operation,
223                    });
224                }
225                _ => return Err(TriggerProgramLowerError::UnsupportedDamageKind),
226            }
227        }
228
229        Ok(effects)
230    }
231    fn lower_dbc_effect_program(
232        &self,
233        source: wowlab_types::sim::SpellIdx,
234        effect_index: u8,
235    ) -> Result<Option<(u32, Vec<BuilderSpellEffect>)>, TriggerProgramError> {
236        let Some(program) = wowlab_engine_domain::dbc::resolve_effect_program(
237            wowlab_engine_domain::dbc::EffectLookup::new(
238                &self.definition.game_data,
239                wowlab_types::sim::EffectRef::new(source, effect_index),
240            ),
241            None,
242        )?
243        else {
244            return Ok(None);
245        };
246        let child_spell_id = program.child_spell_id.as_u32();
247        let effects = self.lower_trigger_program(&program)?;
248
249        Ok(Some((child_spell_id, effects)))
250    }
251}
252
253// #t(fn: rust_recursive_fn) delayed effects come from the cycle-checked, depth-bounded DBC trigger resolver
254fn effect_matches(left: &BuilderSpellEffect, right: &BuilderSpellEffect) -> bool {
255    match (left, right) {
256        (
257            BuilderSpellEffect::Conditional {
258                predicate: left_predicate,
259                effects: left_effects,
260            },
261            BuilderSpellEffect::Conditional {
262                predicate: right_predicate,
263                effects: right_effects,
264            },
265        ) => {
266            std::ptr::fn_addr_eq(*left_predicate, *right_predicate)
267                && left_effects.len() == right_effects.len()
268                && left_effects
269                    .iter()
270                    .zip(right_effects)
271                    .all(|(left, right)| effect_matches(left, right))
272        }
273        (
274            BuilderSpellEffect::Damage { effect: left, .. },
275            BuilderSpellEffect::Damage { effect: right, .. },
276        ) => left == right,
277        (
278            BuilderSpellEffect::ApplyAura { aura_local: left },
279            BuilderSpellEffect::ApplyAura { aura_local: right },
280        )
281        | (
282            BuilderSpellEffect::RemoveAura { aura_local: left },
283            BuilderSpellEffect::RemoveAura { aura_local: right },
284        ) => left == right,
285        (
286            BuilderSpellEffect::MutateCooldown {
287                spell_local: left_spell,
288                operation: left_operation,
289            },
290            BuilderSpellEffect::MutateCooldown {
291                spell_local: right_spell,
292                operation: right_operation,
293            },
294        ) => left_spell == right_spell && left_operation == right_operation,
295        (
296            BuilderSpellEffect::Delayed {
297                delay_ms: left_delay,
298                profile_spell_id: left_profile,
299                effects: left_effects,
300            },
301            BuilderSpellEffect::Delayed {
302                delay_ms: right_delay,
303                profile_spell_id: right_profile,
304                effects: right_effects,
305            },
306        ) => {
307            left_delay == right_delay
308                && left_profile == right_profile
309                && left_effects.len() == right_effects.len()
310                && left_effects
311                    .iter()
312                    .zip(right_effects)
313                    .all(|(left, right)| effect_matches(left, right))
314        }
315        (BuilderSpellEffect::Delayed { effects, .. }, right) if effects.len() == 1 => {
316            effect_matches(
317                effects
318                    .first()
319                    .expect("single-effect delayed program contains one effect"),
320                right,
321            )
322        }
323        _ => false,
324    }
325}
326
327// #t(fn: rust_recursive_fn) nested delays come from the cycle-checked, depth-bounded DBC trigger resolver
328fn retain_inferred_delay(authored: &mut BuilderSpellEffect, inferred: &BuilderSpellEffect) -> bool {
329    let BuilderSpellEffect::Delayed {
330        delay_ms,
331        profile_spell_id,
332        effects,
333    } = inferred
334    else {
335        return false;
336    };
337    let [inferred_child] = effects.as_slice() else {
338        return false;
339    };
340    let mut authored_child = authored.clone();
341
342    if !effect_matches(&authored_child, inferred_child)
343        && !retain_inferred_delay(&mut authored_child, inferred_child)
344    {
345        return false;
346    }
347
348    *authored = BuilderSpellEffect::Delayed {
349        delay_ms: *delay_ms,
350        profile_spell_id: *profile_spell_id,
351        effects: vec![authored_child],
352    };
353
354    true
355}
356
357fn extend_missing_occurrences(
358    destination: &mut Vec<BuilderSpellEffect>,
359    source: Vec<BuilderSpellEffect>,
360) {
361    let existing_len = destination.len();
362    let mut matched = vec![false; existing_len];
363
364    for effect in source {
365        let mut existing = destination
366            .iter()
367            .take(existing_len)
368            .enumerate()
369            .find(|(index, candidate)| {
370                !matched.get(*index).copied().unwrap_or(false) && effect_matches(candidate, &effect)
371            })
372            .map(|(index, _)| index);
373
374        if existing.is_none() {
375            existing = destination
376                .iter_mut()
377                .take(existing_len)
378                .enumerate()
379                .find_map(|(index, candidate)| {
380                    (!matched.get(index).copied().unwrap_or(false)
381                        && retain_inferred_delay(candidate, &effect))
382                    .then_some(index)
383                });
384        }
385
386        if let Some(index) = existing {
387            let slot = matched
388                .get_mut(index)
389                .expect("matched indices originate from destination iteration");
390
391            *slot = true;
392        } else {
393            destination.push(effect);
394        }
395    }
396}
397
398/// The parent `damage_def` absorbed this trigger child; dealing it again would double it.
399fn should_strip_program_damage(
400    spell: &crate::builder::def::BuilderSpellDef,
401    lookup: wowlab_engine_domain::dbc::EffectLookup<'_>,
402) -> bool {
403    let spell_id = lookup.effect.spell.as_u32();
404    let effect_index = lookup.effect.effect_index;
405
406    if !spell.infer_data_damage {
407        return true;
408    }
409
410    if matches!(spell.damage, BuilderDamageDef::None) {
411        return false;
412    }
413
414    if spell.damage_effect == crate::state::DamageEffectRef::new(spell_id, effect_index) {
415        return true;
416    }
417
418    wowlab_engine_domain::dbc::resolve_triggered_damage(lookup).is_ok_and(|triggered| {
419        spell.damage_effect
420            == crate::state::DamageEffectRef::new(
421                triggered.spell_id.as_u32(),
422                triggered.damage.effect_index,
423            )
424    })
425}
426
427// #t(fn: rust_recursive_fn) delayed effects come from the cycle-checked, depth-bounded DBC trigger resolver
428fn retain_non_damage_operations(effects: &mut Vec<BuilderSpellEffect>) {
429    effects.retain_mut(|effect| match effect {
430        BuilderSpellEffect::Damage { .. } => false,
431        BuilderSpellEffect::Conditional { effects, .. }
432        | BuilderSpellEffect::Delayed { effects, .. } => {
433            retain_non_damage_operations(effects);
434
435            !effects.is_empty()
436        }
437        _ => true,
438    });
439}
440
441impl TriggerProgramRegistration<'_> {
442    pub(super) fn auto_register_dbc_trigger_programs(&mut self) {
443        self.auto_register_dbc_spell_trigger_programs();
444        let aura_ids: Vec<_> = self
445            .definition
446            .aura_defs
447            .iter()
448            .map(|aura| aura.aura_id)
449            .collect();
450        let mut tick_programs = Vec::new();
451        let mut apply_programs = vec![Vec::new(); aura_ids.len()];
452        let mut apply_children = vec![Vec::new(); aura_ids.len()];
453
454        for (aura_index, aura_id) in aura_ids.into_iter().enumerate() {
455            let has_periodic_hook = matches!(
456                self.definition
457                    .aura_defs
458                    .get(aura_index)
459                    .expect("aura index originates from aura_ids")
460                    .periodic
461                    .as_ref()
462                    .map(|periodic| &periodic.effect),
463                Some(PeriodicEffectDef::Hook)
464            );
465            let source = wowlab_types::sim::SpellIdx::from_raw(aura_id);
466
467            for effect_index in 1..=self.definition.game_data.max_effect_index(source) {
468                let effect_type = self.definition.game_data.effect_type(source, effect_index);
469
470                if !spell_effect_is(effect_type, SpellEffectKind::ApplyAura) {
471                    match self
472                        .lowering()
473                        .lower_dbc_effect_program(source, effect_index)
474                    {
475                        Ok(Some((child_spell_id, effects))) => {
476                            apply_programs
477                                .get_mut(aura_index)
478                                .expect("program slots mirror aura_ids")
479                                .extend(effects);
480                            apply_children
481                                .get_mut(aura_index)
482                                .expect("child slots mirror aura_ids")
483                                .push(child_spell_id);
484                            continue;
485                        }
486                        Ok(None) => {}
487                        // #t(rust_log_in_loop) construction-time gap diagnostics retain the exact aura/effect edge
488                        Err(error) => tracing::debug!(
489                            aura_id,
490                            effect_index,
491                            %error,
492                            "DBC_AURA_EFFECT_PROGRAM_GAP"
493                        ),
494                    }
495                }
496
497                let aura_subtype = self.definition.game_data.effect_aura(source, effect_index);
498                let is_periodic = has_periodic_hook
499                    && aura_subtype == AuraSubtypeKind::PeriodicTriggerSpell as i32;
500                let is_apply = aura_subtype == AuraSubtypeKind::CastLinkedSpellWithValue as i32;
501
502                if !is_periodic && !is_apply {
503                    continue;
504                }
505
506                let forwarded_value =
507                    is_apply.then(|| self.definition.game_data.base_points(source, effect_index));
508                let result = self.lower_dbc_trigger_program(source, effect_index, forwarded_value);
509
510                match result {
511                    Ok((child_spell_id, effects)) if !effects.is_empty() => {
512                        if is_apply {
513                            apply_programs
514                                .get_mut(aura_index)
515                                .expect("program slots mirror aura_ids")
516                                .extend(effects);
517                            apply_children
518                                .get_mut(aura_index)
519                                .expect("child slots mirror aura_ids")
520                                .push(child_spell_id);
521                        } else {
522                            tick_programs.push((aura_index, child_spell_id, effects));
523                        }
524                    }
525                    Ok(_) => {}
526                    // #t(rust_log_in_loop) construction-time gap diagnostics retain the exact aura/effect edge
527                    Err(error) => tracing::debug!(
528                        aura_id,
529                        effect_index,
530                        %error,
531                        "DBC_AURA_TRIGGER_PROGRAM_GAP"
532                    ),
533                }
534            }
535        }
536
537        for (aura_index, child_spell_id, effects) in tick_programs {
538            let aura = self
539                .definition
540                .aura_defs
541                .get_mut(aura_index)
542                .expect("tick programs retain an index from aura_ids");
543
544            extend_missing_occurrences(&mut aura.tick_effects, effects);
545            aura.tick_profile_spell_id = child_spell_id;
546            // #t(rust_log_in_loop) one construction-time summary is emitted per registered periodic program
547            tracing::debug!(
548                aura_id = aura.aura_id,
549                child_spell_id,
550                "registered periodic DBC trigger program"
551            );
552        }
553
554        for (aura_index, effects) in apply_programs.into_iter().enumerate() {
555            if effects.is_empty() {
556                continue;
557            }
558
559            let aura = self
560                .definition
561                .aura_defs
562                .get_mut(aura_index)
563                .expect("application programs mirror registered auras");
564
565            extend_missing_occurrences(&mut aura.apply_effects, effects);
566            let child_spell_ids = apply_children
567                .get(aura_index)
568                .expect("child slots mirror registered auras");
569
570            aura.apply_profile_spell_id = *child_spell_ids
571                .first()
572                .expect("non-empty application effects retain a child spell");
573            // #t(rust_log_in_loop) one construction-time summary is emitted per registered application program
574            tracing::debug!(
575                aura_id = aura.aura_id,
576                ?child_spell_ids,
577                "registered aura-application DBC trigger program"
578            );
579        }
580    }
581
582    fn lowering(&self) -> TriggerProgramLowering<'_> {
583        TriggerProgramLowering {
584            definition: &*self.definition,
585        }
586    }
587
588    fn lower_dbc_trigger_program(
589        &self,
590        source: wowlab_types::sim::SpellIdx,
591        effect_index: u8,
592        forwarded_value: Option<f64>,
593    ) -> Result<(u32, Vec<BuilderSpellEffect>), TriggerProgramError> {
594        let program = wowlab_engine_domain::dbc::resolve_trigger_program(
595            wowlab_engine_domain::dbc::EffectLookup::new(
596                &self.definition.game_data,
597                wowlab_types::sim::EffectRef::new(source, effect_index),
598            ),
599            forwarded_value,
600        )?;
601        let child_spell_id = program.child_spell_id.as_u32();
602        let effects = self.lowering().lower_trigger_program(&program)?;
603
604        Ok((child_spell_id, effects))
605    }
606    // #t(fn: rust_max_fn_lines) two ordered passes preserve per-effect registration and final per-spell merge semantics
607    // #t(fn: rust_cyclomatic_complexity) one DBC trigger walk keeps duplicate suppression, conditional grouping, and diagnostics on the same effect coordinate
608    fn auto_register_dbc_spell_trigger_programs(&mut self) {
609        let spell_ids: Vec<_> = self
610            .definition
611            .spells
612            .iter()
613            .map(|spell| spell.spell_id)
614            .collect();
615        let mut spell_programs = vec![Vec::new(); spell_ids.len()];
616        let mut spell_children = vec![Vec::new(); spell_ids.len()];
617
618        for (spell_index, spell_id) in spell_ids.into_iter().enumerate() {
619            let source = wowlab_types::sim::SpellIdx::from_raw(spell_id);
620
621            for effect_index in 1..=self.definition.game_data.max_effect_index(source) {
622                let predicate = self
623                    .definition
624                    .spells
625                    .get(spell_index)
626                    .expect("spell index originates from spell_ids")
627                    .trigger_program_predicates
628                    .iter()
629                    .rev()
630                    .find(|entry| entry.effect_index == effect_index)
631                    .map(|entry| entry.predicate);
632
633                match self
634                    .lowering()
635                    .lower_dbc_effect_program(source, effect_index)
636                {
637                    Ok(Some((child_spell_id, effects))) => {
638                        let mut accepted = Vec::new();
639
640                        for effect in effects {
641                            let duplicate_primary_aura = matches!(
642                                effect,
643                                BuilderSpellEffect::ApplyAura { aura_local }
644                                    if self
645                                        .definition
646                                        .spells
647                                        .get(spell_index)
648                                        .expect("spell index originates from spell_ids")
649                                        .applies_aura
650                                        == Some(aura_local)
651                            );
652                            let duplicate_program_aura = matches!(
653                                effect,
654                                BuilderSpellEffect::ApplyAura { aura_local }
655                                    if spell_programs
656                                        .get(spell_index)
657                                        .expect("program slots mirror spell_ids")
658                                        .iter()
659                                        .any(|existing| matches!(
660                                            existing,
661                                            BuilderSpellEffect::ApplyAura { aura_local: existing_local }
662                                                if *existing_local == aura_local
663                                        ))
664                                        || accepted.iter().any(|existing| matches!(
665                                            existing,
666                                            BuilderSpellEffect::ApplyAura { aura_local: existing_local }
667                                                if *existing_local == aura_local
668                                        ))
669                            );
670
671                            if !duplicate_primary_aura && !duplicate_program_aura {
672                                accepted.push(effect);
673                            }
674                        }
675
676                        if !accepted.is_empty() {
677                            let programs = spell_programs
678                                .get_mut(spell_index)
679                                .expect("program slots mirror spell_ids");
680
681                            if let Some(predicate) = predicate {
682                                programs.push(BuilderSpellEffect::Conditional {
683                                    predicate,
684                                    effects: accepted,
685                                });
686                            } else {
687                                programs.extend(accepted);
688                            }
689                        }
690
691                        spell_children
692                            .get_mut(spell_index)
693                            .expect("child slots mirror spell_ids")
694                            .push(child_spell_id);
695                        continue;
696                    }
697                    Ok(None) => {}
698                    // #t(rust_log_in_loop) construction-time gap diagnostics retain the exact spell/effect edge
699                    Err(error) => tracing::debug!(
700                        spell_id,
701                        effect_index,
702                        %error,
703                        "DBC_EFFECT_PROGRAM_GAP"
704                    ),
705                }
706
707                let effect_type = self.definition.game_data.effect_type(source, effect_index);
708                let is_trigger_effect = wowlab_engine_domain::dbc::spell_effect_is_any(
709                    effect_type,
710                    &[
711                        SpellEffectKind::TriggerMissile,
712                        SpellEffectKind::TriggerSpell,
713                        SpellEffectKind::TriggerSpellWithValue,
714                        SpellEffectKind::TriggerSpell2,
715                    ],
716                );
717
718                if !is_trigger_effect {
719                    continue;
720                }
721
722                let forwarded_value =
723                    spell_effect_is(effect_type, SpellEffectKind::TriggerSpellWithValue)
724                        .then(|| self.definition.game_data.base_points(source, effect_index));
725                let result = self.lower_dbc_trigger_program(source, effect_index, forwarded_value);
726
727                match result {
728                    Ok((child_spell_id, mut effects)) => {
729                        let spell = self
730                            .definition
731                            .spells
732                            .get(spell_index)
733                            .expect("spell index originates from spell_ids");
734
735                        if should_strip_program_damage(
736                            spell,
737                            wowlab_engine_domain::dbc::EffectLookup::new(
738                                &self.definition.game_data,
739                                wowlab_types::sim::EffectRef::new(source, effect_index),
740                            ),
741                        ) {
742                            retain_non_damage_operations(&mut effects);
743                        }
744
745                        if effects.is_empty() {
746                            continue;
747                        }
748
749                        let delay_ms = u32::try_from(
750                            self.definition
751                                .game_data
752                                .effect_misc_value_0(source, effect_index),
753                        )
754                        .unwrap_or(0);
755
756                        let effects = vec![BuilderSpellEffect::Delayed {
757                            delay_ms,
758                            profile_spell_id: child_spell_id,
759                            effects,
760                        }];
761                        let programs = spell_programs
762                            .get_mut(spell_index)
763                            .expect("program slots mirror spell_ids");
764
765                        if let Some(predicate) = predicate {
766                            programs.push(BuilderSpellEffect::Conditional { predicate, effects });
767                        } else {
768                            programs.extend(effects);
769                        }
770
771                        spell_children
772                            .get_mut(spell_index)
773                            .expect("child slots mirror spell_ids")
774                            .push(child_spell_id);
775                    }
776                    // #t(rust_log_in_loop) construction-time gap diagnostics retain the exact spell/effect edge
777                    Err(error) => tracing::debug!(
778                        spell_id,
779                        effect_index,
780                        %error,
781                        "DBC_TRIGGER_PROGRAM_GAP"
782                    ),
783                }
784            }
785        }
786
787        for (spell_index, effects) in spell_programs.into_iter().enumerate() {
788            if effects.is_empty() {
789                continue;
790            }
791
792            let spell = self
793                .definition
794                .spells
795                .get_mut(spell_index)
796                .expect("program slots mirror registered spells");
797
798            extend_missing_occurrences(&mut spell.followup_effects, effects);
799            let child_spell_ids = spell_children
800                .get(spell_index)
801                .expect("child slots mirror registered spells");
802
803            // #t(rust_log_in_loop) one construction-time summary is emitted per registered spell program
804            tracing::debug!(
805                spell_id = spell.spell_id,
806                ?child_spell_ids,
807                "registered immediate DBC trigger program"
808            );
809        }
810    }
811}
812
813#[cfg(test)]
814mod error_tests {
815    use std::error::Error as _;
816
817    use googletest::prelude::*;
818    use wowlab_engine_gamedata::ResolvedGameData;
819    use wowlab_engine_ports::CombatStats;
820    use wowlab_types::sim::SpellIdx;
821
822    use super::*;
823    use crate::{CombatSystemBuilder, DamageFlags, state::DamageEffectRef};
824
825    fn damage_effect(spell_id: u32) -> BuilderSpellEffect {
826        BuilderSpellEffect::Damage {
827            effect: DamageEffectRef::new(spell_id, 1),
828            damage: BuilderDamageDef::Flat(1.0),
829            base_points: 1.0,
830            may_crit: true,
831            attribute_flags: DamageFlags::empty(),
832            requires_main_hand: false,
833            requires_off_hand: false,
834            equipped_item_requirement: None,
835        }
836    }
837
838    fn lowering(data: ResolvedGameData) -> CombatSystemBuilder {
839        CombatSystemBuilder::new_for_test(CombatStats::default()).game_data(data)
840    }
841
842    #[gtest]
843    fn effect_program_resolution_failure_retains_trigger_source_and_edge_context() -> Result<()> {
844        const SOURCE: SpellIdx = SpellIdx(91_001);
845        let mut data = ResolvedGameData::builder();
846
847        data.insert_effect_type(
848            wowlab_types::sim::EffectRef::new(SOURCE, 1),
849            SpellEffectKind::RemoveAura as i32,
850        );
851        let definition = lowering(data.build());
852
853        let error = TriggerProgramLowering {
854            definition: &definition,
855        }
856        .lower_dbc_effect_program(SOURCE, 1)
857        .expect_err("missing trigger edge must fail resolution");
858
859        verify_true!(error.is_resolve())?;
860        verify_that!(
861            error.to_string(),
862            eq("trigger child spell 91001 effect 1 names no aura to remove")
863        )?;
864        verify_true!(
865            error
866                .source()
867                .and_then(std::error::Error::source)
868                .is_some_and(
869                    <dyn std::error::Error>::is::<wowlab_engine_domain::dbc::TriggerResolveError>
870                )
871        )?;
872
873        Ok(())
874    }
875
876    #[gtest]
877    fn authored_child_damage_retains_inferred_nested_delay_and_profile() -> Result<()> {
878        let mut authored = damage_effect(93_001);
879        let BuilderSpellEffect::Damage { base_points, .. } = &mut authored else {
880            unreachable!("fixture is damage");
881        };
882
883        *base_points = 7.0;
884        let mut effects = vec![authored];
885
886        extend_missing_occurrences(
887            &mut effects,
888            vec![BuilderSpellEffect::Delayed {
889                delay_ms: 300,
890                profile_spell_id: 93_001,
891                effects: vec![BuilderSpellEffect::Delayed {
892                    delay_ms: 700,
893                    profile_spell_id: 93_002,
894                    effects: vec![damage_effect(93_001)],
895                }],
896            }],
897        );
898
899        let [
900            BuilderSpellEffect::Delayed {
901                delay_ms: outer_delay,
902                profile_spell_id: outer_profile,
903                effects: outer_effects,
904            },
905        ] = effects.as_slice()
906        else {
907            unreachable!("expected one outer delayed effect");
908        };
909        let [
910            BuilderSpellEffect::Delayed {
911                delay_ms: inner_delay,
912                profile_spell_id: inner_profile,
913                effects: inner_effects,
914            },
915        ] = outer_effects.as_slice()
916        else {
917            unreachable!("expected one inner delayed effect");
918        };
919        let [BuilderSpellEffect::Damage { base_points, .. }] = inner_effects.as_slice() else {
920            unreachable!("expected authored damage payload");
921        };
922
923        verify_that!(
924            (
925                *outer_delay,
926                *outer_profile,
927                *inner_delay,
928                *inner_profile,
929                *base_points,
930            ),
931            eq((300, 93_001, 700, 93_002, 7.0))
932        )
933    }
934
935    #[gtest]
936    fn effect_program_lowering_failure_retains_typed_unregistered_aura_source() -> Result<()> {
937        const SOURCE: SpellIdx = SpellIdx(92_001);
938        let mut data = ResolvedGameData::builder();
939
940        data.insert_effect_type(
941            wowlab_types::sim::EffectRef::new(SOURCE, 1),
942            SpellEffectKind::ApplyAura as i32,
943        );
944        let definition = lowering(data.build());
945
946        let error = TriggerProgramLowering {
947            definition: &definition,
948        }
949        .lower_dbc_effect_program(SOURCE, 1)
950        .expect_err("unregistered child aura must fail lowering");
951
952        verify_true!(error.is_lower())?;
953        verify_that!(
954            error.to_string(),
955            eq("trigger program references unregistered aura 92001")
956        )?;
957        verify_true!(
958            error
959                .source()
960                .and_then(std::error::Error::source)
961                .is_some_and(<dyn std::error::Error>::is::<TriggerProgramLowerError>)
962        )?;
963
964        Ok(())
965    }
966}