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
18#[cfg(any(test, feature = "test-support"))]
19pub(crate) use cast::process_cast;
20pub(crate) use cast::process_selected_cast;
21use channel::process_channel_cast;
22use empower::{
23    adjust_empower_payload, empower_rank_for_cast, empower_release_adjustment,
24    finisher_scaled_damage,
25};
26use hooks::{
27    apply_cast_aura, consume_cast_auras, fire_cast_hook, fire_cast_hook_with_source,
28    fire_expire_hooks, fire_player_cast_hooks, process_followup_effects,
29};
30use impact::schedule_spell_impact;
31pub(crate) use impact::{process_spell_impact, process_spell_launch};
32#[cfg(test)]
33use resources::optional_cost_spend;
34use resources::{process_resource_threshold_triggers, process_resources, process_single_resource};
35
36use crate::{
37    DamageFlags,
38    context::{CombatCtx, FreeActionSource, HookCtx},
39    state::{
40        EmpowerReleaseAdjustment, LocalAuraIdx, LocalSpellIdx, PendingSpellImpact, ResourceGain,
41        ResourceGainSource, RuntimeDamageDef, SpellData, with_scratch,
42    },
43    systems::{
44        EffectExecution, ProfiledDamageDef, ResourceTelemetry, apply_aura,
45        apply_aura_with_duration, aura_stacks, break_stealth_if_active, consume_aura_stack,
46        current_haste_mult, deal_bound_profiled_damage_def, deal_profiled_damage_def_at,
47        drain_deferred_work, effect_range_is_hostile, effective_gcd_ms,
48        effective_optional_spell_cost, effective_spell_cost, emit_resource, execute_effect_range,
49        fire_spell_phase_procs, gain_resource_with_procs, secondary_spends_all_points,
50        spend_all_secondary_resource, spend_health_cost, spend_resource, spend_secondary_resource,
51        start_cooldown_in_slot,
52    },
53};
54
55mod cdr;
56mod channel_start;
57mod free_action;
58use cdr::process_cdr_effects;
59use channel_start::process_channel_start_payload;
60#[cfg(test)]
61use free_action::MAX_FREE_ACTION_DISPATCH_DEPTH;
62pub(crate) use free_action::process_free_action;
63
64/// Execute a server-triggered spell without player action costs while retaining its exact actor target.
65pub(crate) fn process_triggered_spell_local(
66    ctx: &mut CombatCtx<'_>,
67    spell_local: LocalSpellIdx,
68    affected: ActorId,
69) {
70    process_triggered_spell_local_with_proc_driver(ctx, spell_local, affected, None);
71}
72
73pub(crate) fn process_trigger_only_action(
74    ctx: &mut CombatCtx<'_>,
75    action: crate::state::program::LocalDamageActionIdx,
76) {
77    let Some(binding) = ctx.program.damage.action_request(action) else {
78        return;
79    };
80    let Some((base_points, def)) = ctx.program.damage.resolved_action_damage(action) else {
81        return;
82    };
83    let spell_id = ctx.program.damage.action_at(action).spell.as_u32();
84    let effect = crate::state::DamageEffectRef::new(spell_id, 1);
85
86    deal_bound_profiled_damage_def(
87        ctx,
88        ProfiledDamageDef {
89            binding,
90            effect,
91            geometry_effect: effect,
92            profile_spell_id: spell_id,
93            base_points: Some(base_points),
94            def,
95            flags: DamageFlags::empty(),
96            source_position: None,
97            destination: None,
98        },
99    );
100}
101
102pub(crate) fn process_proc_triggered_spell(
103    ctx: &mut CombatCtx<'_>,
104    spell_local: LocalSpellIdx,
105    affected: ActorId,
106    proc_driver_spell_id: u32,
107) {
108    process_triggered_spell_local_with_proc_driver(
109        ctx,
110        spell_local,
111        affected,
112        Some(proc_driver_spell_id),
113    );
114}
115
116fn process_triggered_spell_local_with_proc_driver(
117    ctx: &mut CombatCtx<'_>,
118    spell_local: LocalSpellIdx,
119    affected: ActorId,
120    proc_driver_spell_id: Option<u32>,
121) {
122    process_triggered_spell_payload(ctx, spell_local, affected, proc_driver_spell_id);
123}
124
125fn process_triggered_spell_payload(
126    ctx: &mut CombatCtx<'_>,
127    spell_local: LocalSpellIdx,
128    affected: ActorId,
129    proc_driver_spell_id: Option<u32>,
130) {
131    let spell = *ctx.program.spell(spell_local);
132    let base_flags = spell.damage_attribute_flags
133        | if spell.behavior.is_pet {
134            DamageFlags::PET
135        } else {
136            DamageFlags::empty()
137        }
138        | if spell.damage_policy.may_crit {
139            DamageFlags::empty()
140        } else {
141            DamageFlags::NO_CRIT
142        }
143        | proc_driver_spell_id.map_or_else(DamageFlags::empty, |_| DamageFlags::PROC);
144    let flags = if matches!(affected, ActorId::Enemy(_)) {
145        let Some(flags) = resolve_payload_flags(
146            ctx,
147            ctx.program.damage.spell_action(spell_local),
148            spell.damage,
149            spell.applies_aura,
150            spell.followup_effects,
151            base_flags,
152        ) else {
153            return;
154        };
155
156        flags
157    } else {
158        base_flags
159    };
160
161    if let Some(aura_local) = spell.applies_aura {
162        let hook = ctx.hook_ctx().with_effect_target(affected);
163        let mut hook = if let Some(driver_spell_id) = proc_driver_spell_id {
164            hook.with_driver_spell(driver_spell_id)
165                .with_source_damage_flags(base_flags)
166        } else {
167            hook
168        };
169
170        apply_aura(&mut hook, aura_local);
171    }
172
173    if matches!(affected, ActorId::Enemy(_))
174        && let Some(binding) = ctx.program.damage.spell_request(spell_local)
175    {
176        deal_bound_profiled_damage_def(
177            ctx,
178            ProfiledDamageDef {
179                binding: binding.request,
180                effect: spell.damage_effect,
181                geometry_effect: spell.damage_effect,
182                profile_spell_id: spell.spell_id,
183                base_points: Some(spell.base_points),
184                def: spell.damage,
185                flags,
186                source_position: None,
187                destination: None,
188            },
189        );
190    }
191
192    let execution = EffectExecution {
193        range: spell.followup_effects,
194        profile_spell_id: spell.spell_id,
195        profile_action: ctx.program.damage.spell_action(spell_local),
196        flags,
197    };
198
199    if let Some(driver_spell_id) = proc_driver_spell_id {
200        crate::systems::execute_proc_effect_range_for_actor(
201            ctx,
202            execution,
203            Some(affected),
204            driver_spell_id,
205        );
206    } else {
207        crate::systems::execute_effect_range_for_actor(ctx, execution, Some(affected));
208    }
209}
210
211fn emit_cast_telemetry(ctx: &mut CombatCtx<'_>, spell: &SpellData, now: SimTime, gcd_ms: u32) {
212    if let Some(scope) = ctx.program.telemetry_scope(ctx.source, ctx.target) {
213        ctx.sink
214            .emit_cast(spell.spell_id, now.as_millis(), gcd_ms, scope);
215    }
216}
217
218fn player_aura_for_triggered_payload(
219    program: &crate::state::CombatProgram,
220    _state: &crate::state::CombatInstance,
221    spell: &SpellData,
222) -> Option<LocalAuraIdx> {
223    let aura_local = spell.applies_aura?;
224
225    (spell.followup_effects.len > 0
226        && program
227            .auras
228            .get(aura_local.as_usize())
229            .is_some_and(|aura| aura.on == wowlab_types::sim::AuraOn::Player))
230    .then_some(aura_local)
231}
232
233fn aura_is_hostile(program: &crate::state::CombatProgram, aura: LocalAuraIdx) -> bool {
234    program
235        .auras
236        .get(aura.as_usize())
237        .is_some_and(|aura| aura.on == wowlab_types::sim::AuraOn::Target)
238}
239
240fn resolve_payload_flags(
241    ctx: &mut CombatCtx<'_>,
242    profile_action: crate::state::program::LocalDamageActionIdx,
243    damage: RuntimeDamageDef,
244    aura: Option<LocalAuraIdx>,
245    effects: crate::state::SpellEffectRange,
246    flags: DamageFlags,
247) -> Option<DamageFlags> {
248    let hostile = !matches!(damage, RuntimeDamageDef::None)
249        || aura.is_some_and(|aura| aura_is_hostile(ctx.program, aura))
250        || effect_range_is_hostile(ctx.program, ctx.instance, effects);
251
252    if hostile {
253        crate::systems::resolve_spell_impact_flags(ctx, profile_action, flags)
254    } else {
255        Some(flags)
256    }
257}
258
259#[derive(Clone, Copy)]
260struct SpellCastPayload {
261    damage: RuntimeDamageDef,
262    base_points: f64,
263    aura_duration_ms: Option<u32>,
264}
265
266fn schedule_ready_after_cast(ctx: &mut CombatCtx<'_>, spell: &SpellData, now: SimTime) {
267    if spell.behavior.off_gcd {
268        ctx.instance.schedule(Event::PlayerReady { t: now });
269
270        return;
271    }
272
273    let gcd_end = SimTime::from_secs_f64(ctx.instance.buffer.player().gcd_end);
274    let ready_at = if gcd_end > now {
275        #[cfg(feature = "rotation-optimizations")]
276        if !ctx.program.rotation.may_cast_off_gcd() {
277            ctx.instance.schedule(Event::PlayerReady { t: gcd_end });
278
279            return;
280        }
281
282        gcd_end
283            .saturating_sub(SimTime::from_millis(
284                ctx.program.config.cast_latency.queue_window_ms,
285            ))
286            .max(now)
287    } else {
288        now.saturating_add(SimTime::from_millis(
289            ctx.program.config.cast_latency.queue_ms,
290        ))
291    };
292
293    ctx.instance.schedule(Event::PlayerReady { t: ready_at });
294}
295
296fn ready_after_gcd(gcd_end_secs: f64, fallback: SimTime) -> SimTime {
297    let gcd_end = SimTime::from_secs_f64(gcd_end_secs);
298
299    if gcd_end > fallback {
300        gcd_end
301    } else {
302        fallback
303    }
304}
305
306fn update_history(
307    instance: &mut crate::state::CombatInstance,
308    handle: wowlab_engine_domain::rotation::HistorySlotHandle,
309    off_gcd: bool,
310    _now: SimTime,
311) {
312    instance.buffer.clear_history();
313
314    let history = instance.buffer.history_mut_at(handle);
315
316    if off_gcd {
317        history.prev_off_gcd = 1;
318    } else {
319        history.prev_gcd = 1;
320    }
321
322    #[cfg(feature = "rotation-optimizations")]
323    {
324        instance.runtime.rotation_mutations.record_semantic(
325            crate::state::runtime::RotationSemanticMutation::HistoryState,
326            crate::state::runtime::RotationMutationDirection::Rise,
327            _now,
328        );
329        instance.runtime.rotation_mutations.record_semantic(
330            crate::state::runtime::RotationSemanticMutation::HistoryState,
331            crate::state::runtime::RotationMutationDirection::Fall,
332            _now,
333        );
334    }
335}
336
337// `mem::take` + restore over player_cast_hooks/scratch.hooks avoids per-cast `Vec::clone`: 50% faster (14.0 -> 7.0us at 4 hooks).
338
339#[cfg(test)]
340#[path = "pipeline/tests.rs"]
341mod tests;