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
16const AP_DISPLAY: f64 = 10.0;
18const GOLDRINN_PROC_CHANCE: f64 = 0.33;
20const CA_BASE_RECHARGE_MS: i64 = 180_000;
22const WHIRLING_STARS_CHARGES: u8 = 2;
24const HAIL_OF_STARS_FREE_MS: u32 = 1_000;
26const INCARNATION_ECLIPSE_MS: u32 = 20_000;
28
29const 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
37const 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 built.patch_spell(SPELL_WRATH, |s| {
230 s.cost.resource_gain += points(params, EFFECT::ASTRAL_POWER_WRATH_AP) / AP_DISPLAY;
231 });
232
233 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}