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, 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
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_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}