Skip to main content

wowlab_engine_combat/pipeline/
free_action.rs

1//! Cost-free registered child actions dispatched from content hooks.
2
3use 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
20/// Execute one registered cost-free child action without entering the player-cast pipeline.
21pub(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}