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