1use 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
22pub 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 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 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#[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;