wowlab_engine_combat/pipeline/
free_action.rs1use super::{
4 CombatCtx, DamageFlags, FreeActionSource, LocalSpellIdx, ResourceGain, ResourceGainSource,
5 RuntimeDamageDef, SpellCastPayload, SpellData, apply_cast_aura, deal_profiled_damage_def,
6 emit_cast_telemetry, fire_cast_hook_with_source, fire_expire_hooks,
7 player_aura_for_triggered_payload, process_followup_effects, process_single_resource,
8 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_id: u32,
24 source: FreeActionSource,
25) -> bool {
26 let Some((spell_local, spell)) = eligible_free_action(ctx, spell_id) 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);
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_id: u32) -> Option<(LocalSpellIdx, SpellData)> {
42 if ctx.state.runtime.casting.free_action_dispatch_depth >= MAX_FREE_ACTION_DISPATCH_DEPTH {
43 tracing::warn!(
44 spell_id,
45 depth = ctx.state.runtime.casting.free_action_dispatch_depth,
46 "FREE_ACTION_RECURSION_LIMIT"
47 );
48
49 return None;
50 }
51
52 let Some((spell_local, spell)) = ctx.state.spell_data(spell_id) else {
53 tracing::warn!(spell_id, "UNKNOWN_FREE_ACTION");
54
55 return None;
56 };
57
58 if spell.channel.behavior.is_channel {
59 tracing::warn!(spell_id, "NESTED_FREE_CHANNEL_UNSUPPORTED");
60
61 return None;
62 }
63
64 Some((spell_local, *spell))
65}
66
67const fn begin_free_action(ctx: &mut CombatCtx<'_>) -> SavedSpend {
68 let saved = SavedSpend {
69 primary: ctx.state.runtime.resources.last_primary_spent,
70 optional_primary: ctx.state.runtime.resources.last_optional_primary_spent,
71 secondary: ctx.state.runtime.resources.last_secondary_spent,
72 };
73
74 ctx.state.runtime.casting.free_action_dispatch_depth += 1;
75 ctx.state.runtime.resources.last_primary_spent = 0.0;
76 ctx.state.runtime.resources.last_optional_primary_spent = 0.0;
77 ctx.state.runtime.resources.last_secondary_spent = 0.0;
78
79 saved
80}
81
82fn finish_free_action(ctx: &mut CombatCtx<'_>, saved: SavedSpend) {
83 ctx.state.runtime.resources.last_primary_spent = saved.primary;
84 ctx.state.runtime.resources.last_optional_primary_spent = saved.optional_primary;
85 ctx.state.runtime.resources.last_secondary_spent = saved.secondary;
86 ctx.state.runtime.casting.free_action_dispatch_depth -= 1;
87}
88
89fn process_free_action_gains(ctx: &mut CombatCtx<'_>, spell: &SpellData) {
90 for (gain, secondary) in [
91 (spell.cost.resource_gain, false),
92 (spell.cost.secondary_resource_gain, true),
93 ] {
94 process_single_resource(
95 ctx,
96 0.0,
97 ResourceGain::modified(gain, ResourceGainSource::Spell(spell.spell_id)),
98 secondary,
99 ctx.now,
100 spell.spell_id,
101 );
102 }
103}
104
105fn process_free_action_payload(ctx: &mut CombatCtx<'_>, spell: &SpellData) {
106 let payload = SpellCastPayload {
107 damage: spell.damage,
108 base_points: spell.base_points,
109 aura_duration_ms: None,
110 };
111 let has_impact_payload = !matches!(payload.damage, RuntimeDamageDef::None)
112 || spell.applies_aura.is_some()
113 || spell.followup_effects.len > 0;
114
115 if spell.travel.has_projectile_timing() && has_impact_payload {
116 schedule_spell_impact(ctx, spell, payload, ctx.now);
117 } else {
118 apply_free_action_impact(ctx, spell, payload);
119 }
120}
121
122fn apply_free_action_impact(ctx: &mut CombatCtx<'_>, spell: &SpellData, payload: SpellCastPayload) {
123 let flags = spell.damage_attribute_flags
124 | if spell.behavior.is_pet {
125 DamageFlags::PET
126 } else {
127 DamageFlags::empty()
128 }
129 | if spell.damage_policy.may_crit {
130 DamageFlags::empty()
131 } else {
132 DamageFlags::NO_CRIT
133 };
134 let Some(flags) = resolve_payload_flags(
135 ctx,
136 spell.spell_id,
137 payload.damage,
138 spell.applies_aura,
139 spell.followup_effects,
140 flags,
141 ) else {
142 return;
143 };
144 let aura_before_payload = player_aura_for_triggered_payload(ctx.state, spell);
145
146 if let Some(aura_local) = aura_before_payload {
147 apply_cast_aura(ctx, aura_local, None, flags);
148 }
149
150 deal_profiled_damage_def(
151 ctx,
152 spell.damage_effect,
153 spell.spell_id,
154 Some(payload.base_points),
155 payload.damage,
156 flags,
157 );
158
159 if let Some(aura_local) = spell
160 .applies_aura
161 .filter(|aura_local| Some(*aura_local) != aura_before_payload)
162 {
163 apply_cast_aura(ctx, aura_local, None, flags);
164 }
165
166 process_followup_effects(ctx, spell, flags);
167}