Skip to main content

wowlab_engine_content/hooks/balance_druid/
config.rs

1use wowlab_engine_combat::{
2    BuffEffect, DamageEffectRef, ImpactActorFilter, ImpactProc, LandedImpactProc, PeriodicKind,
3    ProcDriver, ProcPolicy, ProcTriggerSide,
4};
5use wowlab_engine_ports::HandlerParams;
6use wowlab_types::{constants::HUNDRED, sim::SpellIdx};
7
8use crate::generated::specs::balance_druid::{
9    AURA, AURA_AKILZONS_CLARITY, AURA_ECLIPSE_LUNAR, AURA_ECLIPSE_SOLAR, EFFECT,
10    REPORTED_SPELL, SET_BONUS, SPELL, SPELL_CELESTIAL_ALIGNMENT, SPELL_MOONFIRE,
11    SPELL_SOLAR_ECLIPSE, SPELL_STARFIRE, SPELL_STARSURGE, SPELL_WRATH, TALENT,
12};
13
14use super::hooks;
15
16/// Astral-power display modifier (DBC `ManaCostPerLevel`-style display scaling: /10).
17const AP_DISPLAY: f64 = 10.0;
18/// Power of Goldrinn proc chance (394046 `Proc Chance: 33%`, `SpellAuraOptions`).
19const GOLDRINN_PROC_CHANCE: f64 = 0.33;
20/// Celestial Alignment / Incarnation base recharge (194223/102560: 180s charge cooldown).
21const CA_BASE_RECHARGE_MS: i64 = 180_000;
22/// Whirling Stars gives CA/Incarnation a second charge (468743 e2).
23const WHIRLING_STARS_CHARGES: u8 = 2;
24/// Hail of Stars grants one second of Solstice for inherently free spenders.
25const HAIL_OF_STARS_FREE_MS: u32 = 1_000;
26/// Incarnation-sourced Eclipses use spell 102560's 20-second duration.
27const INCARNATION_ECLIPSE_MS: u32 = 20_000;
28
29/// Fury of Elune pulses (incl. Sundered Firmament's) apply Atmospheric Exposure.
30const fn foe_tick_filter(spell_id: u32) -> bool {
31    matches!(
32        spell_id,
33        AURA::FURY_OF_ELUNE_BEAM | AURA::SUNDERED_FIRMAMENT_BEAM
34    )
35}
36
37/// Astral Smolder drivers: balance class-ability damage (1261565 attr 415 "Only Proc From Class Abilities").
38const fn smolder_filter(spell_id: u32) -> bool {
39    matches!(
40        spell_id,
41        SPELL::WRATH
42            | SPELL::STARFIRE
43            | SPELL::STARSURGE
44            | AURA::MOONFIRE
45            | AURA::SUNFIRE
46            | REPORTED_SPELL::SHOOTING_STARS
47            | AURA::FURY_OF_ELUNE_BEAM
48            | REPORTED_SPELL::STARSURGE_CASCADE
49            | REPORTED_SPELL::GOLDRINN
50    )
51}
52
53const fn dot_filter(spell_id: u32) -> bool {
54    matches!(spell_id, AURA::MOONFIRE | AURA::SUNFIRE)
55}
56
57const fn sunseeker_mushroom_filter(spell_id: u32) -> bool {
58    spell_id == REPORTED_SPELL::SUNSEEKER_MUSHROOM
59}
60
61const fn wild_mushroom_filter(spell_id: u32) -> bool {
62    spell_id == REPORTED_SPELL::WILD_MUSHROOM
63}
64
65fn deterministic_payload_driver() -> ProcDriver {
66    ProcDriver {
67        policy: ProcPolicy {
68            can_proc_from_procs: true,
69            ..ProcPolicy::default()
70        },
71        trigger_side: ProcTriggerSide::Caster,
72    }
73}
74
75fn points(params: &HandlerParams<'_>, eff: (u32, u8)) -> f64 {
76    params
77        .game_data
78        .base_points(SpellIdx::from_raw(eff.0), eff.1)
79}
80
81fn coef(params: &HandlerParams<'_>, eff: (u32, u8)) -> f64 {
82    params.game_data.sp_coef(SpellIdx::from_raw(eff.0), eff.1)
83}
84
85#[derive(Clone, Copy, Debug, Default)]
86pub(super) struct BalanceRuntime {
87    pub(super) eclipse_entries: u32,
88    pub(super) akilzons_duration_ms: u32,
89}
90
91crate::hooks::define_spec_config! {
92pub(super) struct BalanceConfig {
93    lunar_calling: bool { provenance: "Lunar Calling talent selection.", gate: params.talent_picked(TALENT::LUNAR_CALLING), source: true, transform: |value| value },
94    sunseeker: bool { provenance: "Sunseeker Mushroom selects its Fungal Growth action profile.", gate: params.talent_picked(TALENT::SUNSEEKER_MUSHROOM), source: true, transform: |value| value },
95    sunseeker_fungal_coef: f64 { provenance: "Sunseeker Fungal Growth 1280213 e1 SP coefficient.", gate: params.talent_picked(TALENT::SUNSEEKER_MUSHROOM), source: coef(params, EFFECT::FUNGAL_GROWTH_SUNSEEKER), transform: |value| value },
96    incarnation: bool { provenance: "Incarnation: Chosen of Elune talent selection.", gate: params.talent_picked(TALENT::INCARNATION_CHOSEN_OF_ELUNE), source: true, transform: |value| value },
97    has_boat: bool { provenance: "Balance of All Things 394048 selection.", gate: params.talent_picked(TALENT::BALANCE_OF_ALL_THINGS), source: true, transform: |value| value },
98    shooting_stars_chance: f64 { provenance: "Shooting Stars 202342 e1 chance.", gate: shooting_stars, source: points(params, EFFECT::SHOOTING_STARS_CHANCE), transform: |value| value / HUNDRED },
99    shooting_stars_coef: f64 { provenance: "Shooting Stars 202497 e1 SP coefficient.", gate: shooting_stars, source: coef(params, EFFECT::SHOOTING_STARS_DMG), transform: |value| value },
100    shooting_stars_ap: f64 { provenance: "Shooting Stars 202497 e2 energize.", gate: shooting_stars, source: points(params, EFFECT::SHOOTING_STARS_AP), transform: |value| value / AP_DISPLAY },
101    solstice_rate: f64 { provenance: "Solstice 343647 e1 rate bonus.", gate: shooting_stars && has_solstice, source: points(params, EFFECT::SOLSTICE_RATE), transform: |value| value / HUNDRED },
102    has_solstice: bool { provenance: "Solstice talent selection.", gate: has_solstice, source: true, transform: |value| value },
103    mid2_2pc_instant_coef: f64 { provenance: "MID2 Balance 2pc extra Starfall hit 1301742 e1 SP coefficient.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_BALANCE_2PC), source: coef(params, EFFECT::STARFALL_INSTANT_DAMAGE), transform: |value| value },
104    mid2_4pc: bool { provenance: "MID2 Balance four-piece enables Akil'zon's Clarity during Eclipse.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_BALANCE_4PC), source: true, transform: |value| value },
105    akilzons_max_pct: f64 { provenance: "Akil'zon's Clarity 1301768 e3 endpoint damage bonus.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_BALANCE_4PC), source: points(params, EFFECT::AKILZONS_CLARITY_MAX), transform: |value| value },
106    akilzons_min_pct: f64 { provenance: "Akil'zon's Clarity 1301768 e4 midpoint damage bonus.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_BALANCE_4PC), source: points(params, EFFECT::AKILZONS_CLARITY_MIN), transform: |value| value },
107    smolder_pct: f64 { provenance: "Astral Smolder 1261565 e1 crit-damage share.", gate: smolder, source: points(params, EFFECT::ASTRAL_SMOLDER_PCT), transform: |value| value / HUNDRED },
108    cascade_chance: f64 { provenance: "Star Cascade 1271206 e1 proc chance.", gate: cascade, source: points(params, EFFECT::STAR_CASCADE_CHANCE), transform: |value| value / HUNDRED },
109    cascade_coef: f64 { provenance: "Starsurge Cascade 1271222 e1 scaled by 1271206 e3.", gate: cascade, source: coef(params, EFFECT::STARSURGE_CASCADE_DMG) * points(params, EFFECT::STAR_CASCADE_EFFECTIVENESS), transform: |value| value / HUNDRED },
110    goldrinn_chance: f64 { provenance: "Power of Goldrinn `SpellAuraOptions` proc chance.", gate: goldrinn, source: GOLDRINN_PROC_CHANCE, transform: |value| value },
111    goldrinn_coef: f64 { provenance: "Goldrinn 394047 e1 SP coefficient.", gate: goldrinn, source: coef(params, EFFECT::GOLDRINN_DMG), transform: |value| value },
112    ttc_chance_wrath: f64 { provenance: "Touch the Cosmos 450356 e1 Wrath chance.", gate: touch_cosmos, source: points(params, EFFECT::TTC_CHANCE_WRATH), transform: |value| value / HUNDRED },
113    ttc_chance_starfire: f64 { provenance: "Touch the Cosmos 450356 e2 Starfire chance.", gate: touch_cosmos, source: points(params, EFFECT::TTC_CHANCE_STARFIRE), transform: |value| value / HUNDRED },
114    stellar_amp_cap_ms: u32 { provenance: "Stellar Amplification 450212 e1 duration cap.", gate: stellar_amp, source: points(params, EFFECT::STELLAR_AMP_CAP), transform: wowlab_types::numeric::f64_to_u32_saturating_trunc },
115    hail_paid_ms: u32 { provenance: "Hail of Stars 469004 e1 paid-action duration.", gate: hail, source: points(params, EFFECT::HAIL_OF_STARS_MS), transform: wowlab_types::numeric::f64_to_u32_saturating_trunc },
116    hail_free_ms: u32 { provenance: "Hail of Stars free-spender duration.", gate: hail, source: HAIL_OF_STARS_FREE_MS, transform: |value| value },
117    has_sundered: bool { provenance: "Sundered Firmament talent selection.", gate: params.talent_picked(TALENT::SUNDERED_FIRMAMENT), source: true, transform: |value| value },
118    sunseeker_mushroom_ap_cap: f64 { provenance: "Sunseeker Mushroom 1280212 e2 impact resource cap.", gate: params.talent_picked(TALENT::SUNSEEKER_MUSHROOM), source: points(params, EFFECT::SUNSEEKER_MUSHROOM_AP_CAP), transform: |value| value },
119    wild_mushroom_ap_cap: f64 { provenance: "Wild Mushroom 88751 e2 impact resource cap.", gate: params.talent_picked(TALENT::WILD_MUSHROOM), source: points(params, EFFECT::WILD_MUSHROOM_AP_CAP), transform: |value| value },
120}
121accessor pub(super) fn balance_config(ctx);
122register pub(super) fn register_balance(built, params) -> BalanceConfig;
123prepare {
124    let shooting_stars = params.talent_picked(TALENT::SHOOTING_STARS);
125    let has_solstice = params.talent_picked(TALENT::SOLSTICE);
126    let smolder = params.talent_picked(TALENT::ASCENDANT_ECLIPSES_2);
127    let hail = params.talent_picked(TALENT::HAIL_OF_STARS);
128    let cascade = params.talent_picked(TALENT::STAR_CASCADE);
129    let goldrinn = params.talent_picked(TALENT::POWER_OF_GOLDRINN);
130    let touch_cosmos = params.talent_picked(TALENT::TOUCH_THE_COSMOS);
131    let stellar_amp = params.talent_picked(TALENT::STELLAR_AMPLIFICATION);
132}
133auras {}
134wiring {
135    { provenance: "Sunseeker Mushroom 1280212 impact applies the shared Fungal Growth transaction.", gate: params.talent_picked(TALENT::SUNSEEKER_MUSHROOM), apply: {
136    built.register_landed_impact_proc(LandedImpactProc { driver: deterministic_payload_driver(),
137        actor_filter: ImpactActorFilter::Player,
138            chance: 1.0,
139            fire: hooks::sunseeker_fungal_growth_fire,
140            spell_filter: Some(sunseeker_mushroom_filter),
141            periodic_only: false,
142            skip_periodic: true,
143            crit_only: false,
144        });
145    } },
146    { provenance: "Wild Mushroom 88751 impact applies the shared Fungal Growth transaction.", gate: params.talent_picked(TALENT::WILD_MUSHROOM), apply: {
147    built.register_landed_impact_proc(LandedImpactProc { driver: deterministic_payload_driver(),
148        actor_filter: ImpactActorFilter::Player,
149            chance: 1.0,
150            fire: hooks::wild_mushroom_fungal_growth_fire,
151            spell_filter: Some(wild_mushroom_filter),
152            periodic_only: false,
153            skip_periodic: true,
154            crit_only: false,
155        });
156    } },
157    { provenance: "Shooting Stars periodic proc registration.", gate: shooting_stars, apply: {
158    built.register_impact_proc(
159        ImpactProc::new(hooks::shooting_stars_fire)
160            .with_spell_filter(dot_filter)
161            .periodic_only(),
162    );
163    } },
164    { provenance: "Astral Smolder critical proc registration.", gate: smolder, apply: {
165    built.register_impact_proc(
166        ImpactProc::new(hooks::astral_smolder_fire)
167            .with_spell_filter(smolder_filter)
168            .crit_only(),
169    );
170    } },
171    { provenance: "Nature's Balance starting Astral Power.", gate: params.talent_picked(TALENT::NATURE_S_BALANCE), apply: {
172        built.patch_base_stats(|stats| {
173            stats.resource_start = points(params, EFFECT::NATURES_BALANCE_START);
174        });
175    } },
176    { provenance: "Wild Surges flat Starfire and Wrath energize modifiers.", gate: params.talent_picked(TALENT::WILD_SURGES), apply: {
177        let gain = points(params, EFFECT::WILD_SURGES_AP) / AP_DISPLAY;
178        for spell in [SPELL_STARFIRE, SPELL_WRATH] {
179            built.patch_spell(spell, |def| {
180                def.cost.resource_gain += gain;
181            });
182        }
183    } },
184    { provenance: "Moon Guardian flat Starfire and Moonfire energize modifiers.", gate: params.talent_picked(TALENT::MOON_GUARDIAN), apply: {
185        let gain = points(params, EFFECT::MOON_GUARDIAN_AP) / AP_DISPLAY;
186        for spell in [SPELL_STARFIRE, SPELL_MOONFIRE] {
187            built.patch_spell(spell, |def| {
188                def.cost.resource_gain += gain;
189            });
190        }
191    } },
192    { provenance: "Rattle the Stars resource-cost modifier.", gate: params.talent_picked(TALENT::RATTLE_THE_STARS), apply: {
193        let cost_mult = 1.0 + points(params, EFFECT::RATTLE_COST_PCT) / HUNDRED;
194        for idx in [SPELL_STARSURGE, crate::generated::specs::balance_druid::SPELL_STARFALL] {
195            built.patch_spell(idx, |spell| {
196                spell.cost.resource_cost = (spell.cost.resource_cost * cost_mult).round();
197            });
198        }
199    } },
200    { provenance: "Whirling Stars Celestial Alignment charge and recharge modifier.", gate: params.talent_picked(TALENT::WHIRLING_STARS), apply: {
201        built.patch_spell(SPELL_CELESTIAL_ALIGNMENT, |spell| {
202            spell.cooldown.max_charges = WHIRLING_STARS_CHARGES;
203            let recharge_ms = CA_BASE_RECHARGE_MS
204                + wowlab_types::numeric::f64_to_i64_saturating_trunc(points(params, EFFECT::WHIRLING_STARS_CDR));
205            spell.cooldown.recharge_ms = u32::try_from(recharge_ms)
206                .expect("Whirling Stars recharge remains a non-negative u32 duration");
207        });
208    } },
209    { provenance: "Improved Eclipse extra Solar Eclipse charge.", gate: params.talent_picked(TALENT::IMPROVED_ECLIPSE), apply: {
210        built.patch_spell(SPELL_SOLAR_ECLIPSE, |spell| {
211            spell.cooldown.max_charges = spell.cooldown.max_charges.saturating_add(1);
212        });
213    } },
214    { provenance: "Atmospheric Exposure impact proc registration.", gate: params.talent_picked(TALENT::ATMOSPHERIC_EXPOSURE), apply: {
215    built.register_impact_proc(
216        ImpactProc::new(hooks::atmospheric_exposure_fire).with_spell_filter(foe_tick_filter),
217    );
218    } },
219}
220finish |cfg| {
221    if cfg.mid2_4pc {
222        built.patch_aura(AURA_AKILZONS_CLARITY, |aura| aura.effects.fill(None));
223        built.set_aura_effect(
224            AURA_AKILZONS_CLARITY,
225            BuffEffect::DynamicDamageMult(hooks::akilzons_clarity_mult),
226        );
227    }
228    // Wrath's baseline Astral Power override is not talent-gated.
229    built.patch_spell(SPELL_WRATH, |s| {
230        s.cost.resource_gain += points(params, EFFECT::ASTRAL_POWER_WRATH_AP) / AP_DISPLAY;
231    });
232
233    // With Lunar Calling, every Eclipse comes from Incarnation and inherits its duration.
234    if cfg.incarnation && cfg.lunar_calling {
235        for idx in [AURA_ECLIPSE_SOLAR, AURA_ECLIPSE_LUNAR] {
236            built.patch_aura(idx, |a| {
237                a.base_duration_ms = INCARNATION_ECLIPSE_MS;
238            });
239        }
240    }
241    if cfg.lunar_calling {
242        built.disable_spell(SPELL::SOLAR_ECLIPSE);
243    }
244    if cfg.sunseeker {
245        built.patch_aura(crate::generated::specs::balance_druid::AURA_FUNGAL_GROWTH, |aura| {
246            let periodic = aura
247                .periodic
248                .as_mut()
249                .expect("Fungal Growth remains a periodic damage aura");
250
251            periodic.damage_spell_id = EFFECT::FUNGAL_GROWTH_SUNSEEKER.0;
252            periodic.effect_ref = Some(DamageEffectRef::new(
253                EFFECT::FUNGAL_GROWTH_SUNSEEKER.0,
254                EFFECT::FUNGAL_GROWTH_SUNSEEKER.1,
255            ));
256            let PeriodicKind::RollingDamageSp { coef, .. } = &mut periodic.effect else {
257                panic!("Fungal Growth remains rolling spell-power damage");
258            };
259
260            *coef = cfg.sunseeker_fungal_coef;
261        });
262    }
263
264    built.set_spec_config(cfg);
265    built.set_spec_runtime(BalanceRuntime::default());
266    cfg
267}
268}
269
270#[cfg(test)]
271mod tests {
272    use googletest::prelude::*;
273
274    use super::*;
275
276    #[gtest]
277    fn mushroom_payload_riders_accept_only_their_exact_child_identities() -> Result<()> {
278        verify_true!(sunseeker_mushroom_filter(
279            REPORTED_SPELL::SUNSEEKER_MUSHROOM
280        ))?;
281        verify_false!(sunseeker_mushroom_filter(REPORTED_SPELL::WILD_MUSHROOM))?;
282        verify_true!(wild_mushroom_filter(REPORTED_SPELL::WILD_MUSHROOM))?;
283
284        verify_false!(wild_mushroom_filter(
285            REPORTED_SPELL::SUNSEEKER_MUSHROOM
286        ))
287    }
288
289    #[gtest]
290    fn deterministic_payload_rider_accepts_proc_origin_damage() -> Result<()> {
291        let driver = deterministic_payload_driver();
292
293        verify_true!(driver.policy.can_proc_from_procs)?;
294
295        verify_that!(driver.trigger_side, eq(ProcTriggerSide::Caster))
296    }
297}