wowlab_engine_combat/systems/
channel.rs1use 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
23pub 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 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 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#[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;