Skip to main content

wowlab_engine_combat/systems/
channel.rs

1//! Channeled-spell per-tick cost, damage, and early-end handling.
2
3use wowlab_engine_domain::rotation::DenseBuffer;
4use wowlab_engine_ports::Event;
5use wowlab_engine_telemetry::ResourceEventKind;
6#[cfg(test)]
7use wowlab_engine_telemetry::TelemetrySink;
8use wowlab_types::sim::SimTime;
9#[cfg(test)]
10use wowlab_types::sim::{ActorId, EnemyIdx};
11
12use super::{
13    EffectExecution, execute_effect_range,
14    procs::{ResourceTelemetry, emit_resource},
15    resources::spend_resource,
16};
17use crate::{
18    DamageFlags,
19    context::{CombatCtx, HookCtx},
20    state::CombatState,
21};
22
23// #t(fn: rust_cyclomatic_complexity) channel tick branches over cost/affordability/early-end/tick-hook paths
24pub fn process_channel_tick_for(ctx: &mut CombatCtx<'_>, spell_id: u32, generation: u64) {
25    let source = ctx.source;
26    let target = ctx.target;
27    let now = ctx.now;
28    let state = &mut *ctx.state;
29    let buf = &mut *ctx.buf;
30    let sink = &mut *ctx.sink;
31    let rng = &mut *ctx.rng;
32
33    // A channel may end and be recast while its old tick events remain in the queue. Reject the
34    // stale generation before consulting target validity or shared channel flags, since either
35    // branch could otherwise damage or terminate the newer cast.
36
37    if state.runtime.casting.channel_generation != generation
38        || state.runtime.casting.active_channel_target != Some(target)
39    {
40        return;
41    }
42
43    let Some((local, spell_ref)) = state.spell_data(spell_id) else {
44        return;
45    };
46    let spell = *spell_ref;
47
48    if !spell.channel.behavior.is_channel {
49        return;
50    }
51
52    let idx = wowlab_types::sim::SpellIdx(spell_id);
53
54    if !state.is_valid_target(target) {
55        if let Some(spell) = buf.spell_mut(idx) {
56            spell.is_channeling = 0;
57            spell.channel_end = now.as_secs_f64();
58        }
59
60        buf.player_mut().channel_end = now.as_secs_f64();
61        state.runtime.casting.active_channel_target = None;
62        state.schedule(Event::PlayerReady { t: now });
63
64        return;
65    }
66
67    if buf.spell(idx).is_some_and(|s| s.is_channeling == 0) {
68        return;
69    }
70
71    if buf
72        .spell(idx)
73        .is_some_and(|spell| now.as_secs_f64() > spell.channel_end)
74    {
75        super::end_channel(state, buf, now);
76
77        return;
78    }
79
80    if !spend_channel_tick_cost(
81        &mut CombatCtx {
82            state,
83            buf,
84            sink,
85            now,
86            rng,
87            source,
88            target,
89        },
90        &spell,
91        idx,
92    ) {
93        return;
94    }
95
96    // SimC splits `tick()` from `last_tick()` on the dot's own remaining-tick count, so the engine
97    // publishes the same signal instead of leaving each hook to re-derive it from timestamps.
98
99    let remaining = state
100        .runtime
101        .casting
102        .active_channel_ticks_remaining
103        .saturating_sub(1);
104
105    state.runtime.casting.active_channel_ticks_remaining = remaining;
106    state.runtime.casting.active_channel_on_last_tick = remaining == 0;
107
108    let previous_damage_mult = apply_channel_tick_damage_factor(state, buf, idx, now);
109
110    execute_channel_tick_payload(
111        &mut CombatCtx {
112            state,
113            buf,
114            sink,
115            now,
116            rng,
117            source,
118            target,
119        },
120        &spell,
121    );
122
123    state.runtime.casting.active_channel_last_tick = now;
124
125    if let Some(hook_fn) = state
126        .defs
127        .tick_hooks
128        .get(local.as_usize())
129        .copied()
130        .flatten()
131    {
132        let mut hook_ctx = HookCtx::new(
133            crate::context::HookCtxServices {
134                state,
135                buf,
136                sink,
137                rng,
138            },
139            crate::context::HookCtxRequest::for_target(now, target).with_source(source),
140        )
141        .with_driver_spell(idx.0);
142
143        hook_fn(&mut hook_ctx);
144    }
145
146    execute_channel_completion_effects(
147        &mut CombatCtx {
148            state,
149            buf,
150            sink,
151            now,
152            rng,
153            source,
154            target,
155        },
156        &spell,
157    );
158
159    state.runtime.casting.active_channel_damage_mult = previous_damage_mult;
160
161    if let Some(s) = buf.spell_mut(idx) {
162        if now.as_secs_f64() >= s.channel_end {
163            s.is_channeling = 0;
164            state.runtime.casting.active_channel_target = None;
165            state.runtime.casting.active_channel_spell_id = None;
166        }
167    }
168}
169
170fn spend_channel_tick_cost(
171    ctx: &mut CombatCtx<'_>,
172    spell: &crate::state::SpellData,
173    spell_idx: wowlab_types::sim::SpellIdx,
174) -> bool {
175    let tick_cost = if spell.channel.tick_cost_aura != 0
176        && super::auras::aura_stacks_by_id(
177            ctx.state,
178            ctx.buf,
179            spell.channel.tick_cost_aura,
180            ctx.source,
181            Some(ctx.target),
182        ) > 0
183    {
184        spell.channel.tick_cost_alt
185    } else {
186        spell.channel.tick_cost
187    };
188
189    if tick_cost <= 0.0 {
190        return true;
191    }
192
193    if spend_resource(ctx.state, ctx.buf, tick_cost) {
194        emit_resource(
195            ctx.state,
196            ctx.buf,
197            ctx.sink,
198            ResourceTelemetry {
199                kind: ResourceEventKind::Spend,
200                amount: tick_cost,
201                secondary: false,
202                now: ctx.now,
203                source_spell_id: spell.spell_id,
204            },
205        );
206
207        return true;
208    }
209
210    tracing::trace!(
211        spell_id = spell.spell_id,
212        amount = tick_cost,
213        "channel ended early: insufficient primary resource for tick cost"
214    );
215
216    if let Some(slot) = ctx.buf.spell_mut(spell_idx) {
217        slot.is_channeling = 0;
218        slot.channel_end = ctx.now.as_secs_f64();
219    }
220
221    ctx.buf.player_mut().channel_end = ctx.now.as_secs_f64();
222    ctx.state.runtime.casting.active_channel_target = None;
223    ctx.state.schedule(Event::PlayerReady { t: ctx.now });
224
225    false
226}
227
228fn apply_channel_tick_damage_factor(
229    state: &mut CombatState,
230    buf: &DenseBuffer,
231    spell: wowlab_types::sim::SpellIdx,
232    now: SimTime,
233) -> f64 {
234    let previous = state.runtime.casting.active_channel_damage_mult;
235
236    state.runtime.casting.active_channel_damage_mult *=
237        channel_tick_damage_factor(state, buf, spell, now);
238
239    previous
240}
241
242fn channel_tick_damage_factor(
243    state: &CombatState,
244    buf: &DenseBuffer,
245    spell: wowlab_types::sim::SpellIdx,
246    now: SimTime,
247) -> f64 {
248    let Some(slot) = buf.spell(spell) else {
249        return 1.0;
250    };
251
252    if now.as_secs_f64() < slot.channel_end {
253        return 1.0;
254    }
255
256    let elapsed_ms = now
257        .saturating_sub(state.runtime.casting.active_channel_last_tick)
258        .as_millis();
259    let interval_ms = state.runtime.casting.active_channel_tick_interval_ms;
260
261    if interval_ms == 0 {
262        1.0
263    } else {
264        (f64::from(elapsed_ms) / f64::from(interval_ms)).clamp(0.0, 1.0)
265    }
266}
267
268fn execute_channel_tick_payload(ctx: &mut CombatCtx<'_>, spell: &crate::state::SpellData) {
269    let tick_flags = channel_tick_flags(spell);
270    let hostile = !matches!(
271        spell.channel.tick_damage,
272        crate::state::RuntimeDamageDef::None
273    ) || !matches!(
274        spell.channel.tick_damage_alt,
275        crate::state::RuntimeDamageDef::None
276    ) || super::effect_range_is_hostile(ctx.state, spell.channel.tick_effects);
277    let resolved_flags = if hostile {
278        super::resolve_spell_impact_flags(ctx, spell.spell_id, tick_flags)
279    } else {
280        Some(tick_flags)
281    };
282    let Some(resolved_flags) = resolved_flags else {
283        return;
284    };
285
286    apply_channel_tick_damage(ctx, spell, resolved_flags);
287    execute_effect_range(
288        ctx,
289        EffectExecution {
290            range: spell.channel.tick_effects,
291            profile_spell_id: spell.spell_id,
292            flags: resolved_flags,
293        },
294    );
295}
296
297fn execute_channel_completion_effects(ctx: &mut CombatCtx<'_>, spell: &crate::state::SpellData) {
298    if !ctx.state.runtime.casting.active_channel_on_last_tick
299        || spell.channel.complete_effects.len == 0
300    {
301        return;
302    }
303
304    execute_effect_range(
305        ctx,
306        EffectExecution {
307            range: spell.channel.complete_effects,
308            profile_spell_id: spell.spell_id,
309            flags: DamageFlags::empty(),
310        },
311    );
312}
313
314/// Legally clips the active player channel, granting its elapsed partial tick.
315///
316/// `chain` permits an immediate recast of the same channel without starting another GCD.
317#[must_use]
318pub fn clip_active_channel(ctx: &mut CombatCtx<'_>, chain: bool) -> bool {
319    let Some(spell_id) = ctx.state.runtime.casting.active_channel_spell_id else {
320        return false;
321    };
322    let Some((_, spell)) = ctx.state.spell_data(spell_id) else {
323        return false;
324    };
325    let spell = *spell;
326    let interval_ms = ctx.state.runtime.casting.active_channel_tick_interval_ms;
327
328    if interval_ms > 0 {
329        let elapsed_ms = ctx
330            .now
331            .saturating_sub(ctx.state.runtime.casting.active_channel_last_tick)
332            .as_millis();
333        let factor = (f64::from(elapsed_ms) / f64::from(interval_ms)).clamp(0.0, 1.0);
334
335        if factor > f64::EPSILON {
336            let previous = ctx.state.runtime.casting.active_channel_damage_mult;
337
338            ctx.state.runtime.casting.active_channel_damage_mult *= factor;
339            execute_channel_tick_payload(ctx, &spell);
340            ctx.state.runtime.casting.active_channel_damage_mult = previous;
341        }
342    }
343
344    ctx.state.runtime.casting.channel_chain_spell_id = chain.then_some(spell_id);
345    super::end_channel(ctx.state, ctx.buf, ctx.now);
346    ctx.state.schedule(Event::PlayerReady {
347        t: if chain {
348            ctx.now
349        } else {
350            ctx.now.saturating_add(SimTime::from_millis(
351                u32::from(spell.channel.completion.apply_lag)
352                    * ctx.state.config.cast_latency.channel_ms,
353            ))
354        },
355    });
356
357    true
358}
359
360const fn channel_tick_flags(spell: &crate::state::SpellData) -> DamageFlags {
361    let mut bits = spell.damage_attribute_flags.bits();
362
363    if spell.channel.timing.tick_is_periodic {
364        bits |= DamageFlags::PERIODIC.bits();
365    }
366
367    if spell.behavior.is_pet {
368        bits |= DamageFlags::PET.bits();
369    }
370
371    DamageFlags::from_bits_retain(bits)
372}
373
374#[cfg(test)]
375fn process_channel_tick_fixture(
376    state: &mut CombatState,
377    buf: &mut DenseBuffer,
378    spell_id: u32,
379    now: SimTime,
380    rng: &mut dyn FnMut() -> f64,
381    sink: &mut TelemetrySink,
382) {
383    let Some(target) = state.current_target() else {
384        return;
385    };
386
387    state.runtime.casting.active_channel_target = Some(target);
388    let generation = state.runtime.casting.channel_generation;
389    let mut ctx = CombatCtx {
390        state,
391        buf,
392        sink,
393        now,
394        rng,
395        source: ActorId::Player,
396        target,
397    };
398
399    process_channel_tick_for(&mut ctx, spell_id, generation);
400}
401
402fn apply_channel_tick_damage(
403    ctx: &mut CombatCtx<'_>,
404    spell: &crate::state::SpellData,
405    flags: DamageFlags,
406) {
407    let use_alt = spell.channel.tick_damage_aura != 0
408        && super::auras::aura_stacks_by_id(
409            ctx.state,
410            ctx.buf,
411            spell.channel.tick_damage_aura,
412            ctx.source,
413            Some(ctx.target),
414        ) > 0;
415    let (damage, damage_spell_id, may_crit) = if use_alt {
416        (
417            spell.channel.tick_damage_alt,
418            spell.channel.tick_damage_alt_spell_id,
419            spell.channel.completion.tick_damage_alt_may_crit,
420        )
421    } else {
422        (
423            spell.channel.tick_damage,
424            spell.channel.tick_spell_id,
425            spell.channel.behavior.tick_may_crit,
426        )
427    };
428    let channel_damage_mult = ctx.state.runtime.casting.active_channel_damage_mult;
429
430    crate::systems::deal_profiled_damage_def(
431        ctx,
432        crate::state::DamageEffectRef::new(damage_spell_id, 1),
433        spell.spell_id,
434        Some(spell.base_points),
435        damage.scaled(channel_damage_mult),
436        flags
437            | if may_crit {
438                DamageFlags::empty()
439            } else {
440                DamageFlags::NO_CRIT
441            },
442    );
443}
444
445#[cfg(test)]
446#[path = "channel/tests.rs"]
447mod tests;