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
18pub use cast::process_cast;
19use channel::process_channel_cast;
20use empower::{
21 adjust_empower_payload, empower_rank_for_cast, empower_release_adjustment,
22 finisher_scaled_damage,
23};
24use hooks::{
25 apply_cast_aura, consume_cast_auras, fire_cast_hook, fire_cast_hook_with_source,
26 fire_expire_hooks, fire_player_cast_hooks, process_followup_effects,
27};
28use impact::schedule_spell_impact;
29pub(crate) use impact::{process_spell_impact, process_spell_launch};
30#[cfg(test)]
31use resources::optional_cost_spend;
32use resources::{process_resource_threshold_triggers, process_resources, process_single_resource};
33
34use crate::{
35 DamageFlags,
36 context::{CombatCtx, FreeActionSource, HookCtx},
37 state::{
38 EmpowerReleaseAdjustment, LocalAuraIdx, LocalSpellIdx, PendingSpellImpact, ResourceGain,
39 ResourceGainSource, RuntimeDamageDef, ScratchDisposition, SpellData, with_scratch,
40 },
41 systems::{
42 EffectExecution, ProfiledDamageDef, ResourceTelemetry, apply_aura,
43 apply_aura_with_duration, aura_stacks_by_id, break_stealth_if_active, bypass_aura_active,
44 consume_aura_stack_by_id, current_haste_mult, deal_profiled_damage_def,
45 deal_profiled_damage_def_at, drain_deferred_work, effect_range_is_hostile,
46 effective_gcd_ms, effective_optional_spell_cost, effective_spell_cost, emit_resource,
47 execute_effect_range, fire_spell_phase_procs, gain_resource_with_procs,
48 secondary_spends_all_points, spend_all_secondary_resource, spend_health_cost,
49 spend_resource, spend_secondary_resource, start_cooldown_in_slot,
50 },
51};
52
53mod cdr;
54mod channel_start;
55mod free_action;
56use cdr::process_cdr_effects;
57use channel_start::process_channel_start_payload;
58#[cfg(test)]
59use free_action::MAX_FREE_ACTION_DISPATCH_DEPTH;
60pub(crate) use free_action::process_free_action;
61
62pub(crate) fn process_triggered_spell(ctx: &mut CombatCtx<'_>, spell_id: u32, affected: ActorId) {
64 process_triggered_spell_with_proc_driver(ctx, spell_id, affected, None);
65}
66
67pub(crate) fn process_proc_triggered_spell(
68 ctx: &mut CombatCtx<'_>,
69 spell_id: u32,
70 affected: ActorId,
71 proc_driver_spell_id: u32,
72) {
73 process_triggered_spell_with_proc_driver(ctx, spell_id, affected, Some(proc_driver_spell_id));
74}
75
76fn process_triggered_spell_with_proc_driver(
77 ctx: &mut CombatCtx<'_>,
78 spell_id: u32,
79 affected: ActorId,
80 proc_driver_spell_id: Option<u32>,
81) {
82 let Some((_, spell)) = ctx.state.spell_data(spell_id) else {
83 tracing::warn!(spell_id, "UNKNOWN_TRIGGERED_SPELL");
84
85 return;
86 };
87 let spell = *spell;
88 let base_flags = spell.damage_attribute_flags
89 | if spell.behavior.is_pet {
90 DamageFlags::PET
91 } else {
92 DamageFlags::empty()
93 }
94 | if spell.damage_policy.may_crit {
95 DamageFlags::empty()
96 } else {
97 DamageFlags::NO_CRIT
98 }
99 | proc_driver_spell_id.map_or_else(DamageFlags::empty, |_| DamageFlags::PROC);
100 let flags = if matches!(affected, ActorId::Enemy(_)) {
101 let Some(flags) = resolve_payload_flags(
102 ctx,
103 spell.spell_id,
104 spell.damage,
105 spell.applies_aura,
106 spell.followup_effects,
107 base_flags,
108 ) else {
109 return;
110 };
111
112 flags
113 } else {
114 base_flags
115 };
116
117 if let Some(aura_local) = spell.applies_aura {
118 let hook = ctx.hook_ctx().with_effect_target(affected);
119 let mut hook = if let Some(driver_spell_id) = proc_driver_spell_id {
120 hook.with_driver_spell(driver_spell_id)
121 .with_source_damage_flags(base_flags)
122 } else {
123 hook
124 };
125
126 apply_aura(&mut hook, aura_local);
127 }
128
129 if matches!(affected, ActorId::Enemy(_)) {
130 deal_profiled_damage_def(
131 ctx,
132 spell.damage_effect,
133 spell.spell_id,
134 Some(spell.base_points),
135 spell.damage,
136 flags,
137 );
138 }
139
140 let execution = EffectExecution {
141 range: spell.followup_effects,
142 profile_spell_id: spell.spell_id,
143 flags,
144 };
145
146 if let Some(driver_spell_id) = proc_driver_spell_id {
147 crate::systems::execute_proc_effect_range_for_actor(
148 ctx,
149 execution,
150 Some(affected),
151 driver_spell_id,
152 );
153 } else {
154 crate::systems::execute_effect_range_for_actor(ctx, execution, Some(affected));
155 }
156}
157
158fn emit_cast_telemetry(ctx: &mut CombatCtx<'_>, spell: &SpellData, now: SimTime, gcd_ms: u32) {
159 if let Some(scope) = ctx.state.telemetry_scope(ctx.source, ctx.target) {
160 ctx.sink
161 .emit_cast(spell.spell_id, now.as_millis(), gcd_ms, scope);
162 }
163}
164
165fn player_aura_for_triggered_payload(
166 state: &crate::state::CombatState,
167 spell: &SpellData,
168) -> Option<LocalAuraIdx> {
169 let aura_local = spell.applies_aura?;
170
171 (spell.followup_effects.len > 0
172 && state
173 .defs
174 .auras
175 .get(aura_local.as_usize())
176 .is_some_and(|aura| aura.on == wowlab_types::sim::AuraOn::Player))
177 .then_some(aura_local)
178}
179
180fn aura_is_hostile(state: &crate::state::CombatState, aura: LocalAuraIdx) -> bool {
181 state
182 .defs
183 .auras
184 .get(aura.as_usize())
185 .is_some_and(|aura| aura.on == wowlab_types::sim::AuraOn::Target)
186}
187
188fn resolve_payload_flags(
189 ctx: &mut CombatCtx<'_>,
190 spell_id: u32,
191 damage: RuntimeDamageDef,
192 aura: Option<LocalAuraIdx>,
193 effects: crate::state::SpellEffectRange,
194 flags: DamageFlags,
195) -> Option<DamageFlags> {
196 let hostile = !matches!(damage, RuntimeDamageDef::None)
197 || aura.is_some_and(|aura| aura_is_hostile(ctx.state, aura))
198 || effect_range_is_hostile(ctx.state, effects);
199
200 if hostile {
201 crate::systems::resolve_spell_impact_flags(ctx, spell_id, flags)
202 } else {
203 Some(flags)
204 }
205}
206
207#[derive(Clone, Copy)]
208struct SpellCastPayload {
209 damage: RuntimeDamageDef,
210 base_points: f64,
211 aura_duration_ms: Option<u32>,
212}
213
214fn schedule_ready_after_cast(ctx: &mut CombatCtx<'_>, spell: &SpellData, now: SimTime) {
215 if spell.behavior.off_gcd {
216 ctx.state.schedule(Event::PlayerReady { t: now });
217
218 return;
219 }
220
221 let gcd_end = SimTime::from_secs_f64(ctx.buf.player().gcd_end);
222 let ready_at = if gcd_end > now {
223 gcd_end
224 .saturating_sub(SimTime::from_millis(
225 ctx.state.config.cast_latency.queue_window_ms,
226 ))
227 .max(now)
228 } else {
229 now.saturating_add(SimTime::from_millis(ctx.state.config.cast_latency.queue_ms))
230 };
231
232 ctx.state.schedule(Event::PlayerReady { t: ready_at });
233}
234
235fn ready_after_gcd(gcd_end_secs: f64, fallback: SimTime) -> SimTime {
236 let gcd_end = SimTime::from_secs_f64(gcd_end_secs);
237
238 if gcd_end > fallback {
239 gcd_end
240 } else {
241 fallback
242 }
243}
244
245fn update_history(
246 buf: &mut wowlab_engine_domain::rotation::DenseBuffer,
247 cast_idx: SpellIdx,
248 off_gcd: bool,
249) {
250 buf.clear_history();
251
252 if let Some(h) = buf.history_mut(cast_idx) {
253 if off_gcd {
254 h.prev_off_gcd = 1;
255 } else {
256 h.prev_gcd = 1;
257 }
258 }
259}
260
261#[cfg(test)]
264#[path = "pipeline/tests.rs"]
265mod tests;