wowlab_engine_application/game_data/spells/
descriptions.rs1use wowlab_engine_domain::dbc::{
2 TriggerPayload, effect_redirects_to_trigger_spell, extract_damage_payload,
3};
4use wowlab_engine_ports::{DataResolver, DynDataResolver, EngineError, SpellId};
5use wowlab_types::{
6 data::{SpellDataFlat, SpellEffect},
7 sim::FastSet,
8};
9
10use super::context::SpellResolutionContext;
11use crate::game_data::passives::apply_talent_effect_overrides;
12
13const MAX_TRIGGER_CHAIN_DEPTH: u8 = 4;
14const EXPRESSION_BLOCK_PREFIX: &str = "${$";
15
16pub(super) async fn described_effect_copy(
17 context: &SpellResolutionContext<'_, '_>,
18 spell: &SpellDataFlat,
19 effect: &SpellEffect,
20) -> Result<Option<f64>, EngineError> {
21 if !spell.description.contains(EXPRESSION_BLOCK_PREFIX) {
22 return Ok(None);
23 }
24
25 let copies = wowlab_parsers::spell_desc_cross_spell_precision_copies(&spell.description);
26
27 if copies.is_empty() {
28 return Ok(None);
29 }
30
31 let targets =
32 wowlab_parsers::spell_desc_precision_rendered_effect_indexes(&spell.aura_description);
33
34 if copies.len() != targets.len() {
35 return Ok(None);
36 }
37
38 let Some(copy) = copies
39 .iter()
40 .zip(targets)
41 .find(|(_, target)| i32::from(*target) - 1 == effect.index)
42 .map(|(copy, _)| copy)
43 else {
44 return Ok(None);
45 };
46 let source_id = SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(
47 copy.source_spell_id,
48 ));
49 let Ok(mut source) = context.resolver.get_spell(source_id).await else {
50 return Ok(None);
51 };
52
53 if let Some(talent) = context
54 .talents
55 .iter()
56 .find(|talent| talent.spell_id == copy.source_spell_id)
57 {
58 apply_talent_effect_overrides(&mut source, talent, false);
59 }
60
61 let source_index = i32::from(copy.source_effect) - 1;
62 let Some(base_points) = source
63 .effects
64 .iter()
65 .find(|source_effect| source_effect.index == source_index)
66 .map(|source_effect| source_effect.base_points)
67 else {
68 return Ok(None);
69 };
70 let value = base_points / copy.divisor;
71
72 tracing::debug!(
73 spell_id = spell.id,
74 spell_name = spell.name.as_str(),
75 effect_index = effect.index,
76 source_spell_id = copy.source_spell_id,
77 source_effect = copy.source_effect,
78 divisor = copy.divisor,
79 value,
80 "resolved an effect value the description copies from another spell"
81 );
82
83 Ok(Some(value))
84}
85
86pub(crate) async fn resolve_trigger_chain_payload(
87 resolver: &DynDataResolver<'_>,
88 seed_id: SpellId,
89) -> Result<Option<TriggerPayload>, EngineError> {
90 let mut current = seed_id;
91 let mut visited = FastSet::default();
92
93 for _ in 0..MAX_TRIGGER_CHAIN_DEPTH {
94 if !visited.insert(current.as_i32()) {
95 return Err(EngineError::spec_construction(format!(
97 "trigger chain contains a cycle at spell {current}"
98 )));
99 }
100
101 let spell = match resolver.get_spell(current).await {
102 Ok(spell) => spell,
103 Err(error) => {
104 tracing::warn!(
106 spell_id = current.as_i32(),
107 %error,
108 "trigger chain child not found; ending chain without payload"
109 );
110
111 return Ok(None);
112 }
113 };
114
115 if let Some(payload) = extract_damage_payload(&spell) {
116 return Ok(Some(payload));
117 }
118
119 let Some(next) = spell
120 .effects
121 .iter()
122 .find(|effect| effect_redirects_to_trigger_spell(effect))
123 .map(|effect| effect.trigger_spell)
124 else {
125 return Ok(None);
126 };
127
128 if next == 0 {
129 return Ok(None);
130 }
131
132 current = SpellId::new(next);
133 }
134
135 Err(EngineError::spec_construction(format!(
136 "trigger chain from spell {seed_id} exceeded {MAX_TRIGGER_CHAIN_DEPTH} edges"
137 )))
138}