wowlab_engine_combat/pipeline/
free_action.rs1use super::{
4 CombatCtx, DamageFlags, FreeActionSource, LocalSpellIdx, ProfiledDamageDef, ResourceGain,
5 ResourceGainSource, RuntimeDamageDef, SpellCastPayload, SpellData, apply_cast_aura,
6 deal_bound_profiled_damage_def, emit_cast_telemetry, fire_cast_hook_with_source,
7 fire_expire_hooks, player_aura_for_triggered_payload, process_followup_effects,
8 process_single_resource, resolve_payload_flags, schedule_spell_impact,
9};
10
11pub(super) const MAX_FREE_ACTION_DISPATCH_DEPTH: u8 = 8;
12
13#[derive(Clone, Copy)]
14struct SavedSpend {
15 primary: f64,
16 optional_primary: f64,
17 secondary: f64,
18}
19
20pub(crate) fn process_free_action(
22 ctx: &mut CombatCtx<'_>,
23 spell_local: LocalSpellIdx,
24 source: FreeActionSource,
25) -> bool {
26 let Some(spell) = eligible_free_action(ctx, spell_local) else {
27 return false;
28 };
29 let saved_spend = begin_free_action(ctx);
30
31 emit_cast_telemetry(ctx, &spell, ctx.now, 0);
32 process_free_action_gains(ctx, &spell);
33 process_free_action_payload(ctx, spell_local, &spell);
34 fire_cast_hook_with_source(ctx, spell_local, 0, Some(source));
35 fire_expire_hooks(ctx);
36 finish_free_action(ctx, saved_spend);
37
38 true
39}
40
41fn eligible_free_action(ctx: &CombatCtx<'_>, spell_local: LocalSpellIdx) -> Option<SpellData> {
42 let spell_id = ctx.program.spell(spell_local).spell_id;
43
44 if ctx.instance.runtime.casting.free_action_dispatch_depth >= MAX_FREE_ACTION_DISPATCH_DEPTH {
45 tracing::warn!(
46 spell_id,
47 depth = ctx.instance.runtime.casting.free_action_dispatch_depth,
48 "FREE_ACTION_RECURSION_LIMIT"
49 );
50
51 return None;
52 }
53
54 let spell = *ctx.program.spell(spell_local);
55
56 if spell.channel.behavior.is_channel {
57 tracing::warn!(spell_id, "NESTED_FREE_CHANNEL_UNSUPPORTED");
58
59 return None;
60 }
61
62 Some(spell)
63}
64
65const fn begin_free_action(ctx: &mut CombatCtx<'_>) -> SavedSpend {
66 let saved = SavedSpend {
67 primary: ctx.instance.runtime.resources.last_primary_spent,
68 optional_primary: ctx.instance.runtime.resources.last_optional_primary_spent,
69 secondary: ctx.instance.runtime.resources.last_secondary_spent,
70 };
71
72 ctx.instance.runtime.casting.free_action_dispatch_depth += 1;
73 ctx.instance.runtime.resources.last_primary_spent = 0.0;
74 ctx.instance.runtime.resources.last_optional_primary_spent = 0.0;
75 ctx.instance.runtime.resources.last_secondary_spent = 0.0;
76
77 saved
78}
79
80fn finish_free_action(ctx: &mut CombatCtx<'_>, saved: SavedSpend) {
81 ctx.instance.runtime.resources.last_primary_spent = saved.primary;
82 ctx.instance.runtime.resources.last_optional_primary_spent = saved.optional_primary;
83 ctx.instance.runtime.resources.last_secondary_spent = saved.secondary;
84 ctx.instance.runtime.casting.free_action_dispatch_depth -= 1;
85}
86
87fn process_free_action_gains(ctx: &mut CombatCtx<'_>, spell: &SpellData) {
88 for (gain, secondary) in [
89 (spell.cost.resource_gain, false),
90 (spell.cost.secondary_resource_gain, true),
91 ] {
92 process_single_resource(
93 ctx,
94 0.0,
95 ResourceGain::modified(gain, ResourceGainSource::Spell(spell.spell_id)),
96 secondary,
97 ctx.now,
98 spell.spell_id,
99 );
100 }
101}
102
103fn process_free_action_payload(
104 ctx: &mut CombatCtx<'_>,
105 spell_local: LocalSpellIdx,
106 spell: &SpellData,
107) {
108 let payload = SpellCastPayload {
109 damage: spell.damage,
110 base_points: spell.base_points,
111 aura_duration_ms: None,
112 };
113 let has_impact_payload = !matches!(payload.damage, RuntimeDamageDef::None)
114 || spell.applies_aura.is_some()
115 || spell.followup_effects.len > 0;
116
117 if spell.travel.has_projectile_timing() && has_impact_payload {
118 schedule_spell_impact(ctx, spell_local, spell, payload, ctx.now);
119 } else {
120 apply_free_action_impact(ctx, spell_local, spell, payload);
121 }
122}
123
124fn apply_free_action_impact(
125 ctx: &mut CombatCtx<'_>,
126 spell_local: LocalSpellIdx,
127 spell: &SpellData,
128 payload: SpellCastPayload,
129) {
130 let flags = spell.damage_attribute_flags
131 | if spell.behavior.is_pet {
132 DamageFlags::PET
133 } else {
134 DamageFlags::empty()
135 }
136 | if spell.damage_policy.may_crit {
137 DamageFlags::empty()
138 } else {
139 DamageFlags::NO_CRIT
140 };
141 let Some(flags) = resolve_payload_flags(
142 ctx,
143 ctx.program.damage.spell_action(spell_local),
144 payload.damage,
145 spell.applies_aura,
146 spell.followup_effects,
147 flags,
148 ) else {
149 return;
150 };
151 let aura_before_payload = player_aura_for_triggered_payload(ctx.program, ctx.instance, spell);
152
153 if let Some(aura_local) = aura_before_payload {
154 apply_cast_aura(ctx, aura_local, None, flags);
155 }
156
157 if let Some(binding) = ctx.program.damage.spell_request(spell_local) {
158 deal_bound_profiled_damage_def(
159 ctx,
160 ProfiledDamageDef {
161 binding: binding.request,
162 effect: spell.damage_effect,
163 geometry_effect: spell.damage_effect,
164 profile_spell_id: spell.spell_id,
165 base_points: Some(payload.base_points),
166 def: payload.damage,
167 flags,
168 source_position: None,
169 destination: None,
170 },
171 );
172 }
173
174 if let Some(aura_local) = spell
175 .applies_aura
176 .filter(|aura_local| Some(*aura_local) != aura_before_payload)
177 {
178 apply_cast_aura(ctx, aura_local, None, flags);
179 }
180
181 process_followup_effects(ctx, spell_local, spell, flags);
182}