1use 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 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
253fn 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
327fn 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
398fn 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
427fn 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 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 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 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 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 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 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 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 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}