Skip to main content

wowlab_engine_combat/
pipeline.rs

1//! Cast processing pipeline: resource cost, cooldowns, damage, aura application, CDR, and post-cast hooks.
2
3use wowlab_engine_domain::dbc::SpellCastTargetFlags;
4use wowlab_engine_ports::Event;
5use wowlab_engine_telemetry::ResourceEventKind;
6use wowlab_types::{
7    constants::HUNDRED,
8    sim::{ActorId, SimTime, SpellIdx},
9};
10
11mod cast;
12mod channel;
13mod empower;
14mod hooks;
15mod impact;
16mod resources;
17
18pub use cast::process_cast;
19use channel::process_channel_cast;
20use empower::{
21    adjust_empower_payload, empower_rank_for_cast, empower_release_adjustment,
22    finisher_scaled_damage,
23};
24use hooks::{
25    apply_cast_aura, consume_cast_auras, fire_cast_hook, fire_cast_hook_with_source,
26    fire_expire_hooks, fire_player_cast_hooks, process_followup_effects,
27};
28use impact::schedule_spell_impact;
29pub(crate) use impact::{process_spell_impact, process_spell_launch};
30#[cfg(test)]
31use resources::optional_cost_spend;
32use resources::{process_resource_threshold_triggers, process_resources, process_single_resource};
33
34use crate::{
35    DamageFlags,
36    context::{CombatCtx, FreeActionSource, HookCtx},
37    state::{
38        EmpowerReleaseAdjustment, LocalAuraIdx, LocalSpellIdx, PendingSpellImpact, ResourceGain,
39        ResourceGainSource, RuntimeDamageDef, ScratchDisposition, SpellData, with_scratch,
40    },
41    systems::{
42        EffectExecution, ProfiledDamageDef, ResourceTelemetry, apply_aura,
43        apply_aura_with_duration, aura_stacks_by_id, break_stealth_if_active, bypass_aura_active,
44        consume_aura_stack_by_id, current_haste_mult, deal_profiled_damage_def,
45        deal_profiled_damage_def_at, drain_deferred_work, effect_range_is_hostile,
46        effective_gcd_ms, effective_optional_spell_cost, effective_spell_cost, emit_resource,
47        execute_effect_range, fire_spell_phase_procs, gain_resource_with_procs,
48        secondary_spends_all_points, spend_all_secondary_resource, spend_health_cost,
49        spend_resource, spend_secondary_resource, start_cooldown_in_slot,
50    },
51};
52
53mod cdr;
54mod channel_start;
55mod free_action;
56use cdr::process_cdr_effects;
57use channel_start::process_channel_start_payload;
58#[cfg(test)]
59use free_action::MAX_FREE_ACTION_DISPATCH_DEPTH;
60pub(crate) use free_action::process_free_action;
61
62/// Execute a server-triggered spell without player action costs while retaining its exact actor target.
63pub(crate) fn process_triggered_spell(ctx: &mut CombatCtx<'_>, spell_id: u32, affected: ActorId) {
64    process_triggered_spell_with_proc_driver(ctx, spell_id, affected, None);
65}
66
67pub(crate) fn process_proc_triggered_spell(
68    ctx: &mut CombatCtx<'_>,
69    spell_id: u32,
70    affected: ActorId,
71    proc_driver_spell_id: u32,
72) {
73    process_triggered_spell_with_proc_driver(ctx, spell_id, affected, Some(proc_driver_spell_id));
74}
75
76fn process_triggered_spell_with_proc_driver(
77    ctx: &mut CombatCtx<'_>,
78    spell_id: u32,
79    affected: ActorId,
80    proc_driver_spell_id: Option<u32>,
81) {
82    let Some((_, spell)) = ctx.state.spell_data(spell_id) else {
83        tracing::warn!(spell_id, "UNKNOWN_TRIGGERED_SPELL");
84
85        return;
86    };
87    let spell = *spell;
88    let base_flags = spell.damage_attribute_flags
89        | if spell.behavior.is_pet {
90            DamageFlags::PET
91        } else {
92            DamageFlags::empty()
93        }
94        | if spell.damage_policy.may_crit {
95            DamageFlags::empty()
96        } else {
97            DamageFlags::NO_CRIT
98        }
99        | proc_driver_spell_id.map_or_else(DamageFlags::empty, |_| DamageFlags::PROC);
100    let flags = if matches!(affected, ActorId::Enemy(_)) {
101        let Some(flags) = resolve_payload_flags(
102            ctx,
103            spell.spell_id,
104            spell.damage,
105            spell.applies_aura,
106            spell.followup_effects,
107            base_flags,
108        ) else {
109            return;
110        };
111
112        flags
113    } else {
114        base_flags
115    };
116
117    if let Some(aura_local) = spell.applies_aura {
118        let hook = ctx.hook_ctx().with_effect_target(affected);
119        let mut hook = if let Some(driver_spell_id) = proc_driver_spell_id {
120            hook.with_driver_spell(driver_spell_id)
121                .with_source_damage_flags(base_flags)
122        } else {
123            hook
124        };
125
126        apply_aura(&mut hook, aura_local);
127    }
128
129    if matches!(affected, ActorId::Enemy(_)) {
130        deal_profiled_damage_def(
131            ctx,
132            spell.damage_effect,
133            spell.spell_id,
134            Some(spell.base_points),
135            spell.damage,
136            flags,
137        );
138    }
139
140    let execution = EffectExecution {
141        range: spell.followup_effects,
142        profile_spell_id: spell.spell_id,
143        flags,
144    };
145
146    if let Some(driver_spell_id) = proc_driver_spell_id {
147        crate::systems::execute_proc_effect_range_for_actor(
148            ctx,
149            execution,
150            Some(affected),
151            driver_spell_id,
152        );
153    } else {
154        crate::systems::execute_effect_range_for_actor(ctx, execution, Some(affected));
155    }
156}
157
158fn emit_cast_telemetry(ctx: &mut CombatCtx<'_>, spell: &SpellData, now: SimTime, gcd_ms: u32) {
159    if let Some(scope) = ctx.state.telemetry_scope(ctx.source, ctx.target) {
160        ctx.sink
161            .emit_cast(spell.spell_id, now.as_millis(), gcd_ms, scope);
162    }
163}
164
165fn player_aura_for_triggered_payload(
166    state: &crate::state::CombatState,
167    spell: &SpellData,
168) -> Option<LocalAuraIdx> {
169    let aura_local = spell.applies_aura?;
170
171    (spell.followup_effects.len > 0
172        && state
173            .defs
174            .auras
175            .get(aura_local.as_usize())
176            .is_some_and(|aura| aura.on == wowlab_types::sim::AuraOn::Player))
177    .then_some(aura_local)
178}
179
180fn aura_is_hostile(state: &crate::state::CombatState, aura: LocalAuraIdx) -> bool {
181    state
182        .defs
183        .auras
184        .get(aura.as_usize())
185        .is_some_and(|aura| aura.on == wowlab_types::sim::AuraOn::Target)
186}
187
188fn resolve_payload_flags(
189    ctx: &mut CombatCtx<'_>,
190    spell_id: u32,
191    damage: RuntimeDamageDef,
192    aura: Option<LocalAuraIdx>,
193    effects: crate::state::SpellEffectRange,
194    flags: DamageFlags,
195) -> Option<DamageFlags> {
196    let hostile = !matches!(damage, RuntimeDamageDef::None)
197        || aura.is_some_and(|aura| aura_is_hostile(ctx.state, aura))
198        || effect_range_is_hostile(ctx.state, effects);
199
200    if hostile {
201        crate::systems::resolve_spell_impact_flags(ctx, spell_id, flags)
202    } else {
203        Some(flags)
204    }
205}
206
207#[derive(Clone, Copy)]
208struct SpellCastPayload {
209    damage: RuntimeDamageDef,
210    base_points: f64,
211    aura_duration_ms: Option<u32>,
212}
213
214fn schedule_ready_after_cast(ctx: &mut CombatCtx<'_>, spell: &SpellData, now: SimTime) {
215    if spell.behavior.off_gcd {
216        ctx.state.schedule(Event::PlayerReady { t: now });
217
218        return;
219    }
220
221    let gcd_end = SimTime::from_secs_f64(ctx.buf.player().gcd_end);
222    let ready_at = if gcd_end > now {
223        gcd_end
224            .saturating_sub(SimTime::from_millis(
225                ctx.state.config.cast_latency.queue_window_ms,
226            ))
227            .max(now)
228    } else {
229        now.saturating_add(SimTime::from_millis(ctx.state.config.cast_latency.queue_ms))
230    };
231
232    ctx.state.schedule(Event::PlayerReady { t: ready_at });
233}
234
235fn ready_after_gcd(gcd_end_secs: f64, fallback: SimTime) -> SimTime {
236    let gcd_end = SimTime::from_secs_f64(gcd_end_secs);
237
238    if gcd_end > fallback {
239        gcd_end
240    } else {
241        fallback
242    }
243}
244
245fn update_history(
246    buf: &mut wowlab_engine_domain::rotation::DenseBuffer,
247    cast_idx: SpellIdx,
248    off_gcd: bool,
249) {
250    buf.clear_history();
251
252    if let Some(h) = buf.history_mut(cast_idx) {
253        if off_gcd {
254            h.prev_off_gcd = 1;
255        } else {
256            h.prev_gcd = 1;
257        }
258    }
259}
260
261// `mem::take` + restore over player_cast_hooks/scratch.hooks avoids per-cast `Vec::clone`: 50% faster (14.0 -> 7.0us at 4 hooks).
262
263#[cfg(test)]
264#[path = "pipeline/tests.rs"]
265mod tests;