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