Skip to main content

wowlab_engine_content/hooks/balance_druid/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::ResourceOps as _;
3use wowlab_engine_combat::AuraOps as _;
4use wowlab_engine_combat::{
5    DamageFlags, HookCtx, ImpactEvent, LandedImpactEvent, MasteryCtx, ResourceGainSource,
6};
7use wowlab_engine_rng::proc_chance;
8
9use crate::generated::specs::balance_druid::{
10    AURA_ATMOSPHERIC_EXPOSURE, AURA_BOAT_ARCANE, AURA_BOAT_NATURE, AURA_CELESTIAL_ALIGNMENT,
11    AURA_ECLIPSE_LUNAR, AURA_ECLIPSE_SOLAR, AURA_FUNGAL_GROWTH,
12    AURA_INCARNATION_CHOSEN_OF_ELUNE, AURA_SOLSTICE, AURA_STARFALL, AURA_STARLORD,
13    AURA_SUNDERED_FIRMAMENT_BEAM, AURA_SUNDERED_FIRMAMENT_ENERGIZE,
14    AURA_TOUCH_THE_COSMOS, EFFECT, REPORTED_SPELL,
15};
16
17use super::config::{balance_config, BalanceConfig, BalanceRuntime};
18
19/// Label 2391 identifies spells receiving both mastery effects.
20const MASTERY_BOTH_SCHOOLS_LABEL: i32 = 2391;
21/// Sundered Firmament fires on every other Eclipse (394094 "Every other Eclipse").
22const SUNDERED_ECLIPSE_PERIOD: u32 = 2;
23/// `SimC`'s server-side Wild Mushroom resource gain per target step.
24const WILD_MUSHROOM_AP_PER_TARGET_STEP: f64 = 5.0;
25
26/// Returns Astral Invocation mastery damage for spell 393014.
27#[must_use]
28pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
29    let (arcane_eff, nature_eff) = if ctx.is_periodic {
30        (
31            EFFECT::ASTRAL_INVOCATION_ARCANE_PERIODIC.1,
32            EFFECT::ASTRAL_INVOCATION_NATURE_PERIODIC.1,
33        )
34    } else {
35        (
36            EFFECT::ASTRAL_INVOCATION_ARCANE_DIRECT.1,
37            EFFECT::ASTRAL_INVOCATION_NATURE_DIRECT.1,
38        )
39    };
40    let both = ctx.spell_has_label(MASTERY_BOTH_SCHOOLS_LABEL);
41    let mut mult = 1.0;
42
43    if both || ctx.affects_spell(arcane_eff) {
44        mult *= ctx.bonus(arcane_eff);
45    }
46
47    if both || ctx.affects_spell(nature_eff) {
48        mult *= ctx.bonus(nature_eff);
49    }
50
51    mult
52}
53
54fn eclipse_active(ctx: &HookCtx<'_>) -> bool {
55    ctx.is_aura_active(AURA_ECLIPSE_SOLAR.raw()) || ctx.is_aura_active(AURA_ECLIPSE_LUNAR.raw())
56}
57
58fn grant_solstice(ctx: &mut HookCtx<'_>, dur_ms: u32) {
59    if ctx.is_aura_active(AURA_SOLSTICE.raw()) {
60        ctx.extend_aura(AURA_SOLSTICE.raw(), dur_ms);
61
62        return;
63    }
64
65    ctx.apply_aura_with_duration(AURA_SOLSTICE.raw(), dur_ms);
66}
67
68fn star_cascade(ctx: &mut HookCtx<'_>, cfg: &BalanceConfig) {
69    if cfg.cascade_chance > 0.0 && proc_chance(ctx.rng(), cfg.cascade_chance) {
70        ctx.deal_damage_sp(
71            EFFECT::STARSURGE_CASCADE_DMG.0,
72            cfg.cascade_coef,
73            DamageFlags::empty(),
74        );
75
76        if cfg.hail_free_ms > 0 {
77            grant_solstice(ctx, cfg.hail_free_ms);
78        }
79    }
80}
81
82pub(crate) fn starfall_tick_hook(ctx: &mut HookCtx<'_>) {
83    let stacks = f64::from(ctx.aura_stacks(AURA_STARFALL.raw()).max(1));
84
85    ctx.deal_effect_damage_sp_with_geometry(
86        EFFECT::STARFALL_DAMAGE,
87        EFFECT::STARFALL_GEOMETRY,
88        stacks,
89        DamageFlags::PERIODIC,
90    );
91}
92
93fn touch_the_cosmos(ctx: &mut HookCtx<'_>, chance: f64) {
94    if chance > 0.0 && proc_chance(ctx.rng(), chance) {
95        ctx.apply_aura(AURA_TOUCH_THE_COSMOS.raw());
96    }
97}
98
99fn shooting_star_hit(
100    ctx: &mut HookCtx<'_>,
101    cfg: &BalanceConfig,
102    spell_id: u32,
103    coef: f64,
104    base_ap: f64,
105) {
106    ctx.deal_damage_sp(spell_id, coef, DamageFlags::empty());
107    let mut gain = base_ap;
108
109    if cfg.mid1_2pc_ap > 0.0 && !eclipse_active(ctx) {
110        gain += cfg.mid1_2pc_ap;
111    }
112
113    ctx.gain_resource(gain);
114}
115
116fn enter_eclipse(ctx: &mut HookCtx<'_>) {
117    ctx.expire_aura(AURA_STARLORD.raw());
118    let cfg = balance_config(ctx);
119
120    if cfg.has_solstice {
121        ctx.expire_aura(AURA_SOLSTICE.raw());
122        ctx.apply_aura(AURA_SOLSTICE.raw());
123    }
124
125    if cfg.has_sundered {
126        let fire = ctx.spec_runtime_mut::<BalanceRuntime>().is_some_and(|runtime| {
127            runtime.eclipse_entries += 1;
128
129            runtime.eclipse_entries % SUNDERED_ECLIPSE_PERIOD == 0
130        });
131
132        if fire {
133            ctx.apply_aura(AURA_SUNDERED_FIRMAMENT_ENERGIZE.raw());
134            ctx.apply_aura(AURA_SUNDERED_FIRMAMENT_BEAM.raw());
135        }
136    }
137}
138
139pub(crate) fn wrath_hook(ctx: &mut HookCtx<'_>) {
140    let cfg = balance_config(ctx);
141
142    star_cascade(ctx, &cfg);
143    touch_the_cosmos(ctx, cfg.ttc_chance_wrath);
144}
145
146pub(crate) fn starfire_hook(ctx: &mut HookCtx<'_>) {
147    let cfg = balance_config(ctx);
148
149    star_cascade(ctx, &cfg);
150    touch_the_cosmos(ctx, cfg.ttc_chance_starfire);
151}
152
153pub(crate) fn starsurge_hook(ctx: &mut HookCtx<'_>) {
154    let cfg = balance_config(ctx);
155
156    if ctx.consume_aura(AURA_TOUCH_THE_COSMOS.raw()) && cfg.hail_paid_ms > 0 {
157        grant_solstice(ctx, cfg.hail_paid_ms);
158    }
159
160    if cfg.goldrinn_chance > 0.0 && proc_chance(ctx.rng(), cfg.goldrinn_chance) {
161        ctx.deal_damage_sp(
162            EFFECT::GOLDRINN_DMG.0,
163            cfg.goldrinn_coef,
164            DamageFlags::empty(),
165        );
166    }
167
168    if cfg.exploding_chance > 0.0 && proc_chance(ctx.rng(), cfg.exploding_chance) {
169        shooting_star_hit(
170            ctx,
171            &cfg,
172            EFFECT::EXPLODING_STAR_DMG.0,
173            cfg.exploding_coef,
174            cfg.exploding_ap,
175        );
176    }
177}
178
179pub(crate) fn eclipse_hook(ctx: &mut HookCtx<'_>) {
180    enter_eclipse(ctx);
181
182    if balance_config(ctx).has_boat {
183        ctx.apply_aura(AURA_BOAT_NATURE.raw());
184    }
185}
186
187pub(crate) fn celestial_alignment_hook(ctx: &mut HookCtx<'_>) {
188    let cfg = balance_config(ctx);
189
190    if cfg.incarnation {
191        ctx.apply_aura(AURA_INCARNATION_CHOSEN_OF_ELUNE.raw());
192    } else {
193        ctx.apply_aura(AURA_CELESTIAL_ALIGNMENT.raw());
194    }
195
196    enter_eclipse(ctx);
197
198    if cfg.has_boat {
199        ctx.apply_aura(AURA_BOAT_ARCANE.raw());
200        ctx.apply_aura(AURA_BOAT_NATURE.raw());
201    }
202}
203
204pub(super) fn shooting_stars_fire(
205    ctx: &mut HookCtx<'_>,
206    _impact: ImpactEvent,
207) {
208    let cfg = balance_config(ctx);
209
210    if cfg.shooting_stars_chance <= 0.0 {
211        return;
212    }
213
214    let mut chance = cfg.shooting_stars_chance;
215
216    if ctx.is_aura_active(AURA_SOLSTICE.raw()) {
217        chance *= 1.0 + cfg.solstice_rate;
218    }
219
220    if proc_chance(ctx.rng(), chance) {
221        shooting_star_hit(
222            ctx,
223            &cfg,
224            EFFECT::SHOOTING_STARS_DMG.0,
225            cfg.shooting_stars_coef,
226            cfg.shooting_stars_ap,
227        );
228    }
229}
230
231pub(super) fn atmospheric_exposure_fire(
232    ctx: &mut HookCtx<'_>,
233    _impact: ImpactEvent,
234) {
235    ctx.apply_aura(AURA_ATMOSPHERIC_EXPOSURE.raw());
236}
237
238pub(super) fn sunseeker_fungal_growth_fire(
239    ctx: &mut HookCtx<'_>,
240    impact: LandedImpactEvent,
241) {
242    let cfg = balance_config(ctx);
243
244    mushroom_impact_transaction(
245        ctx,
246        impact,
247        EFFECT::SUNSEEKER_MUSHROOM_AP_CAP,
248        cfg.sunseeker_mushroom_ap_cap,
249    );
250}
251
252pub(super) fn wild_mushroom_fungal_growth_fire(
253    ctx: &mut HookCtx<'_>,
254    impact: LandedImpactEvent,
255) {
256    let cfg = balance_config(ctx);
257
258    mushroom_impact_transaction(
259        ctx,
260        impact,
261        EFFECT::WILD_MUSHROOM_AP_CAP,
262        cfg.wild_mushroom_ap_cap,
263    );
264}
265
266fn mushroom_impact_transaction(
267    ctx: &mut HookCtx<'_>,
268    impact: LandedImpactEvent,
269    resource_effect: (u32, u8),
270    resource_cap: f64,
271) {
272    ctx.apply_aura(AURA_FUNGAL_GROWTH.raw());
273
274    // The batch runs after every damage impact; grant the shared resource only
275    // after the final landed target has received Fungal Growth as well.
276
277    if impact.target_ordinal != impact.targets_hit.saturating_sub(1) {
278        return;
279    }
280
281    let target_count = f64::from(impact.targets_hit);
282    let amount = mushroom_resource_gain(target_count, resource_cap);
283
284    ctx.gain_resource_from(
285        amount,
286        ResourceGainSource::SpellEffect {
287            spell_id: resource_effect.0,
288            effect_index: resource_effect.1,
289        },
290    );
291}
292
293fn mushroom_resource_gain(target_count: f64, cap: f64) -> f64 {
294    ((target_count + 1.0) * WILD_MUSHROOM_AP_PER_TARGET_STEP).min(cap)
295}
296
297/// Applies Astral Smolder 1261565 e1 as a value-equivalent flat hit.
298pub(super) fn astral_smolder_fire(
299    ctx: &mut HookCtx<'_>,
300    impact: ImpactEvent,
301) {
302    let cfg = balance_config(ctx);
303
304    if cfg.smolder_pct <= 0.0 || !eclipse_active(ctx) {
305        return;
306    }
307
308    ctx.deal_damage_flat(
309        REPORTED_SPELL::ASTRAL_SMOLDER,
310        impact.amount * cfg.smolder_pct,
311    );
312}
313
314#[cfg(test)]
315mod mushroom_tests {
316    use googletest::prelude::*;
317
318    use super::mushroom_resource_gain;
319
320    #[gtest]
321    fn resource_gain_counts_targets_plus_one_and_respects_data_cap() -> Result<()> {
322        verify_that!(mushroom_resource_gain(1.0, 20.0), eq(10.0))?;
323        verify_that!(mushroom_resource_gain(3.0, 20.0), eq(20.0))?;
324
325        verify_that!(mushroom_resource_gain(8.0, 17.0), eq(17.0))
326    }
327}