wowlab_engine_content/hooks/balance_druid/
hooks.rs1use 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
20const MASTERY_BOTH_SCHOOLS_LABEL: i32 = 2391;
22const SUNDERED_ECLIPSE_PERIOD: u32 = 2;
24const WILD_MUSHROOM_AP_PER_TARGET_STEP: f64 = 5.0;
26const AKILZONS_MIDPOINT: f64 = 0.5;
27
28#[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 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
346pub(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}