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