Skip to main content

wowlab_engine_application/game_data/spells/
descriptions.rs

1use 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            // #t(rust_alloc_in_loop) allocation occurs only on the terminal invalid-cycle error path.
96            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                // #t(rust_log_in_loop) logged at most once — the chain ends here.
105                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}