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