1use 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
64pub(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#[cfg(test)]
340#[path = "pipeline/tests.rs"]
341mod tests;