Skip to main content

wowlab_engine_content/hooks/arcane_mage/
hooks.rs

1use wowlab_engine_combat::{
2    AuraOps as _, CooldownOps as _, DamageFlags, DamageOps as _, GuardianEvent, HookCtx,
3    MasteryCtx, ResourceOps as _, SchedulingOps as _,
4};
5use wowlab_engine_domain::pool::SpellGate;
6use wowlab_engine_rng::proc_chance;
7use wowlab_types::constants::HUNDRED;
8#[cfg(test)]
9use wowlab_types::sim::SpellIdx;
10
11#[cfg(test)]
12use super::config::ArcaneConfig;
13use super::config::{ArcaneRuntime, arcane_config};
14#[cfg(test)]
15use crate::generated::specs::arcane_mage::SPELL;
16use crate::generated::specs::arcane_mage::{
17    AURA_ARCANE_CHARGE, AURA_ARCANE_SALVO, AURA_ARCANE_SURGE, AURA_CLEARCASTING,
18    AURA_CUMULATIVE_POWER, AURA_INTUITION, AURA_OVERPOWERED_MISSILES, EFFECT,
19    AURA_PRISMATIC_BOLT, SPELL_ARCANE_MISSILES, SPELL_ARCANE_ORB,
20};
21
22/// 5143: 2.5s channel, 0.625s period (4 ticks) + tick zero.
23const ARCANE_MISSILES_TICKS: u32 = 5;
24/// Orb Barrage 384858 e1: 4% chance per Arcane Salvo stack.
25const ORB_BARRAGE_CHANCE_PER_SALVO_STACK: f64 = 0.04;
26const ORB_MASTERY_STAGGER_MS: u32 = 150;
27/// Arcane Salvo cap; reaching it triggers Intuition.
28const SALVO_MAX_STACKS: i32 = 20;
29/// Arcane Surge 365350 e2 (2x), scaled by Mana Bomb 457521 e1 (+50%).
30const SURGE_MAX_MANA_MULT: f64 = 2.0;
31const MANA_BOMB_BONUS: f64 = 0.50;
32/// Touch of the Magi 210824 e1: 20% of damage dealt during the window.
33const TOUCH_OF_THE_MAGI_CAPTURE: f64 = 0.20;
34const TOUCH_OF_THE_ARCHMAGE_DAMAGE: f64 = 0.75;
35const TOUCH_OF_THE_ARCHMAGE_PRIMARY_BONUS: f64 = 0.50;
36/// Clearcasting has an undocumented two-percentage-point bonus.
37pub(super) const CLEARCASTING_UNDOCUMENTED_BONUS: f64 = 0.02;
38/// Clearcasting PRD uses the historical 13-attempt cap despite an uncapped tracker.
39pub(super) const CLEARCASTING_CONSTANT_CAP: u32 = 13;
40const CLEARCASTING_REACTION_MS: u32 = 50;
41const OVERPOWERED_MISSILES_CHANCE: f64 = 0.25;
42const OVERPOWERED_MISSILES_MULT: f64 = 2.0;
43/// High Voltage 461248 e1 (50%) grants 461524 e1 (1) Arcane Charge per tick.
44const HIGH_VOLTAGE_CHANCE: f64 = 0.50;
45/// Expanded Mind 1243557 e1 (Blast) / e2 (Arcane Orb): +2 Salvo stacks.
46const EXPANDED_MIND_BLAST_STACKS: i32 = 2;
47const EXPANDED_MIND_ORB_STACKS: i32 = 2;
48const PRISMATIC_BOLT_SALVO_MULTIPLIER: i32 = 2;
49const MISSILES_SALVO_STACKS_PER_TICK: i32 = 1;
50const OVERPOWERED_SALVO_STACKS_PER_TICK: i32 = 2;
51/// Infused Splinters 1261080 e1 (25%) / e3 (1): Salvo per splinter impact.
52const INFUSED_SPLINTERS_SALVO_CHANCE: f64 = 0.25;
53const INFUSED_SPLINTERS_SALVO_STACKS: i32 = 1;
54/// Spellfrost Teachings 444986 e1: -250ms Arcane Orb cooldown per splinter.
55const SPELLFROST_TEACHINGS_ORB_CDR_MS: u32 = 250;
56/// Splintering Orbs 444256 e4: 2 splinters per Arcane Orb impact.
57const SPLINTERING_ORBS_SPLINTERS: i32 = 2;
58/// Augury Abounds 1280165: 1.5% per attempt, 0.5-second ICD, eight splinters.
59const AUGURY_ABOUNDS_BASE_CHANCE: f64 = 0.015;
60const AUGURY_ABOUNDS_SPLINTERS: i32 = 8;
61const AUGURY_ABOUNDS_ICD_MS: u32 = 500;
62const SPLINTERS_PER_TRIGGER: i32 = 1;
63/// Splinterstorm 443783 e2 (100%): 2 splinters per trigger during Arcane Surge.
64const SPLINTERSTORM_SPLINTERS_PER_TRIGGER: i32 = 2;
65/// Splinterstorm 443783 e1: 8 splinters on Arcane Surge cast.
66const SPLINTERSTORM_ARCANE_SURGE_SPLINTERS: i32 = 8;
67/// Touch of the Magi 321507: grants 4 Arcane Charges.
68const TOUCH_OF_THE_MAGI_CHARGES: f64 = 4.0;
69
70pub(super) const ARCANE_FAMILIAR_PERIOD_MS: u32 = 3_000;
71const ENERGIZED_FAMILIAR_STAGGER_MS: u32 = 75;
72
73/// Savant 190740 e5 lists Missiles, Orb, Supernova, Touch of the Magi and Surge; e6 is label 3408.
74/// Arcane Blast and Barrage are in neither, taking Savant through the per-charge terms e2/e3.
75#[must_use]
76pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
77    ctx.affected_bonus_any(&[
78        EFFECT::SAVANT_MASTERY_DAMAGE.1,
79        EFFECT::SAVANT_SPLINTER_DAMAGE.1,
80    ])
81}
82
83fn arcane_assault(ctx: &mut HookCtx<'_>) {
84    let cfg = arcane_config(ctx);
85    let coef = ctx.effect_sp_coefficient(
86        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_ASSAULT_DAMAGE,
87    );
88
89    ctx.deal_damage_sp(
90        crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_ASSAULT,
91        coef,
92        DamageFlags::empty(),
93    );
94
95    if proc_chance(ctx.rng(), cfg.energized_familiar_chance) {
96        let max_mana = ctx.primary_resource_max();
97
98        ctx.gain_resource(max_mana * cfg.energized_familiar_gain_fraction);
99    }
100
101    if proc_chance(ctx.rng(), cfg.attuned_familiar_chance) {
102        trigger_splinter(ctx, 1);
103    }
104}
105
106fn delayed_arcane_assault(ctx: &mut HookCtx<'_>) {
107    arcane_assault(ctx);
108}
109
110pub(super) fn arcane_familiar_action(ctx: &mut HookCtx<'_>, _event: GuardianEvent) {
111    arcane_assault(ctx);
112    let cfg = arcane_config(ctx);
113
114    if ctx.is_aura_active(AURA_ARCANE_SURGE) {
115        for assault in 1..=cfg.arcane_familiar_extra_assaults {
116            ctx.schedule_hook(
117                ENERGIZED_FAMILIAR_STAGGER_MS.saturating_mul(assault),
118                delayed_arcane_assault,
119            );
120        }
121    }
122}
123
124fn charges(ctx: &HookCtx<'_>) -> f64 {
125    ctx.secondary_resource()
126        .max(f64::from(ctx.aura_stacks(AURA_ARCANE_CHARGE)))
127        .max(ctx.last_secondary_spent())
128}
129
130fn sync_charge_aura(ctx: &mut HookCtx<'_>) {
131    let wanted = wowlab_types::numeric::f64_to_i32_saturating_round(ctx.secondary_resource());
132
133    while ctx.aura_stacks(AURA_ARCANE_CHARGE) < wanted {
134        ctx.apply_aura(AURA_ARCANE_CHARGE);
135    }
136
137    while ctx.aura_stacks(AURA_ARCANE_CHARGE) > wanted {
138        ctx.consume_aura_stack(AURA_ARCANE_CHARGE);
139    }
140}
141
142fn trigger_clearcasting(ctx: &mut HookCtx<'_>) {
143    if roll_clearcasting(ctx) {
144        grant_clearcasting(ctx);
145    }
146}
147
148fn roll_clearcasting(ctx: &mut HookCtx<'_>) -> bool {
149    let cfg = arcane_config(ctx);
150    let attempts = ctx
151        .spec_runtime::<ArcaneRuntime>()
152        .map(|runtime| runtime.clearcasting_attempts)
153        .unwrap_or_default();
154    let attempt = attempts.saturating_add(1);
155    let prd_chance = cfg.clearcasting_prd_constant * f64::from(attempt);
156    let success = proc_chance(ctx.rng(), prd_chance);
157
158    tracing::trace!(attempt, prd_chance, success, "CLEARCASTING_ROLL");
159
160    if !success {
161        if let Some(runtime) = ctx.spec_runtime_mut::<ArcaneRuntime>() {
162            runtime.clearcasting_attempts = attempts.saturating_add(1);
163        }
164
165        return false;
166    }
167
168    if let Some(runtime) = ctx.spec_runtime_mut::<ArcaneRuntime>() {
169        runtime.clearcasting_attempts = 0;
170    }
171
172    true
173}
174
175fn grant_clearcasting(ctx: &mut HookCtx<'_>) {
176    let cfg = arcane_config(ctx);
177    let had_clearcasting = ctx.is_aura_active(AURA_CLEARCASTING);
178
179    ctx.apply_aura(AURA_CLEARCASTING);
180
181    if !had_clearcasting {
182        ctx.set_spell_gate(SPELL_ARCANE_MISSILES, SpellGate::ReactionDelay, false);
183        ctx.schedule_hook(CLEARCASTING_REACTION_MS, enable_arcane_missiles);
184    }
185
186    if cfg.overpowered_missiles && proc_chance(ctx.rng(), OVERPOWERED_MISSILES_CHANCE) {
187        ctx.apply_aura(AURA_OVERPOWERED_MISSILES);
188    }
189}
190
191fn enable_arcane_missiles(ctx: &mut HookCtx<'_>) {
192    ctx.set_spell_gate(SPELL_ARCANE_MISSILES, SpellGate::ReactionDelay, true);
193}
194
195fn trigger_salvo(ctx: &mut HookCtx<'_>, stacks: i32) {
196    if !arcane_config(ctx).arcane_salvo {
197        return;
198    }
199
200    let old_stacks = ctx.aura_stacks(AURA_ARCANE_SALVO);
201
202    ctx.apply_aura_n(AURA_ARCANE_SALVO, stacks);
203
204    if old_stacks < SALVO_MAX_STACKS && ctx.aura_stacks(AURA_ARCANE_SALVO) >= SALVO_MAX_STACKS {
205        ctx.apply_aura(AURA_INTUITION);
206    }
207}
208
209/// Arcane Charge uses 36032 e1/e2 and Savant 190740 e2/e3 per charge.
210fn arcane_charge_multiplier(ctx: &HookCtx<'_>, charge_count: f64, barrage: bool) -> f64 {
211    let cfg = arcane_config(ctx);
212    let base = if barrage {
213        cfg.arcane_charge_barrage_fraction
214    } else {
215        cfg.arcane_charge_blast_fraction
216    };
217    let savant_points = ctx.effect_points(if barrage {
218        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::SAVANT_BARRAGE
219    } else {
220        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::SAVANT_BLAST
221    });
222
223    resolved_arcane_charge_multiplier(charge_count, base, savant_points)
224}
225
226const fn resolved_arcane_charge_multiplier(
227    charge_count: f64,
228    base_fraction: f64,
229    savant_points: f64,
230) -> f64 {
231    let savant = savant_points / HUNDRED;
232
233    1.0 + charge_count * (base_fraction + savant)
234}
235
236pub(super) fn arcane_missiles_ticks(extra_waves: f64) -> u32 {
237    ARCANE_MISSILES_TICKS.saturating_add(wowlab_types::numeric::f64_to_u32_saturating_round(
238        extra_waves.max(0.0),
239    ))
240}
241
242const fn force_of_will_barrage_splinters(salvo_stacks: i32, stacks_per_splinter: i32) -> i32 {
243    if stacks_per_splinter > 0 {
244        salvo_stacks / stacks_per_splinter
245    } else {
246        0
247    }
248}
249
250fn roll_augury_abounds(ctx: &mut HookCtx<'_>) {
251    let cfg = arcane_config(ctx);
252
253    if !cfg.augury_abounds {
254        return;
255    }
256
257    let now = ctx.now().as_millis();
258    let runtime = ctx
259        .spec_runtime::<ArcaneRuntime>()
260        .copied()
261        .unwrap_or_default();
262
263    if now < runtime.augury_icd_until_ms {
264        return;
265    }
266
267    let attempts = runtime.augury_attempts.saturating_add(1);
268    let success = proc_chance(ctx.rng(), AUGURY_ABOUNDS_BASE_CHANCE * f64::from(attempts));
269
270    if let Some(runtime) = ctx.spec_runtime_mut::<ArcaneRuntime>() {
271        runtime.augury_icd_until_ms = now.saturating_add(AUGURY_ABOUNDS_ICD_MS);
272        runtime.augury_attempts = if success { 0 } else { attempts };
273    }
274
275    if success {
276        trigger_splinter_inner(ctx, AUGURY_ABOUNDS_SPLINTERS, false);
277    }
278}
279
280fn fire_splinter(ctx: &mut HookCtx<'_>, allow_augury: bool) {
281    let cfg = arcane_config(ctx);
282    let coef = ctx.effect_sp_coefficient(
283        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_SPLINTER_DAMAGE,
284    );
285
286    ctx.deal_damage_sp(
287        crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_SPLINTER,
288        coef,
289        DamageFlags::empty(),
290    );
291
292    if cfg.infused_splinters && proc_chance(ctx.rng(), INFUSED_SPLINTERS_SALVO_CHANCE) {
293        trigger_salvo(ctx, INFUSED_SPLINTERS_SALVO_STACKS);
294    }
295
296    if cfg.spellfrost_teachings {
297        ctx.reduce_cooldown(SPELL_ARCANE_ORB, SPELLFROST_TEACHINGS_ORB_CDR_MS);
298    }
299
300    if allow_augury {
301        roll_augury_abounds(ctx);
302    }
303}
304
305fn trigger_splinter_inner(ctx: &mut HookCtx<'_>, count: i32, allow_augury: bool) {
306    let cfg = arcane_config(ctx);
307
308    if !cfg.splintering_sorcery {
309        return;
310    }
311
312    let per_trigger = if cfg.splinterstorm && ctx.is_aura_active(AURA_ARCANE_SURGE) {
313        SPLINTERSTORM_SPLINTERS_PER_TRIGGER
314    } else {
315        SPLINTERS_PER_TRIGGER
316    };
317
318    for _ in 0..(count.max(0) * per_trigger) {
319        fire_splinter(ctx, allow_augury);
320    }
321}
322
323fn trigger_splinter(ctx: &mut HookCtx<'_>, count: i32) {
324    trigger_splinter_inner(ctx, count, true);
325}
326
327fn fire_arcane_orb(ctx: &mut HookCtx<'_>, damage_fraction: f64) {
328    let cfg = arcane_config(ctx);
329    let coef = ctx.effect_sp_coefficient(
330        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_ORB_BOLT_DAMAGE,
331    );
332
333    ctx.with_resolved_spell_effect_targets(
334        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_ORB_TARGETS,
335        crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_ORB_BOLT,
336        None,
337        |ctx, targets| {
338            ctx.gain_secondary_resource(1.0);
339            sync_charge_aura(ctx);
340
341            if cfg.expanded_mind {
342                trigger_salvo(ctx, EXPANDED_MIND_ORB_STACKS);
343            }
344
345            for target in targets.iter().copied() {
346                ctx.with_resolved_target(target, |target_ctx| {
347                    target_ctx.deal_damage_sp_to_target(
348                        target,
349                        crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_ORB_BOLT,
350                        coef * damage_fraction,
351                        DamageFlags::empty(),
352                    );
353                    target_ctx.gain_secondary_resource(1.0);
354                    sync_charge_aura(target_ctx);
355
356                    if cfg.splintering_orbs {
357                        trigger_splinter(target_ctx, SPLINTERING_ORBS_SPLINTERS);
358                    }
359                });
360            }
361        },
362    );
363}
364
365fn fire_orb_mastery(ctx: &mut HookCtx<'_>) {
366    let damage_fraction = arcane_config(ctx).orb_mastery_damage_fraction;
367
368    if damage_fraction <= 0.0 {
369        return;
370    }
371
372    fire_arcane_orb(ctx, damage_fraction);
373}
374
375pub(crate) fn arcane_blast_hook(ctx: &mut HookCtx<'_>) {
376    let charge_count = charges(ctx);
377    let mult = arcane_charge_multiplier(ctx, charge_count, false);
378
379    if mult > 1.0 {
380        let coef = ctx.effect_sp_coefficient(
381            crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_BLAST_DAMAGE,
382        );
383
384        ctx.deal_damage_sp(
385            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_BLAST,
386            coef * (mult - 1.0),
387            DamageFlags::empty(),
388        );
389    }
390
391    sync_charge_aura(ctx);
392
393    if arcane_config(ctx).expanded_mind {
394        trigger_salvo(ctx, EXPANDED_MIND_BLAST_STACKS);
395    }
396
397    trigger_splinter(ctx, 1);
398    trigger_clearcasting(ctx);
399    ctx.expire_aura(AURA_CUMULATIVE_POWER);
400}
401
402pub(crate) fn arcane_barrage_hook(ctx: &mut HookCtx<'_>) {
403    let cfg = arcane_config(ctx);
404    let salvo_stacks = ctx.aura_stacks(AURA_ARCANE_SALVO);
405    let orb_barrage_proc = cfg.orb_barrage
406        && salvo_stacks > 0
407        && proc_chance(
408            ctx.rng(),
409            f64::from(salvo_stacks) * ORB_BARRAGE_CHANCE_PER_SALVO_STACK,
410        );
411
412    if orb_barrage_proc {
413        fire_arcane_orb(ctx, 1.0);
414    }
415
416    let charge_count = charges(ctx);
417    let mut mult = arcane_charge_multiplier(ctx, charge_count, true);
418
419    mult *= 1.0 + f64::from(salvo_stacks) * cfg.arcane_salvo_fraction_per_stack;
420
421    if ctx.is_aura_active(AURA_INTUITION) {
422        mult *= cfg.intuition_damage_mult;
423    }
424
425    if mult > 1.0 {
426        let coef = ctx.effect_sp_coefficient(
427            crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_BARRAGE_DAMAGE,
428        );
429
430        ctx.deal_damage_sp(
431            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_BARRAGE,
432            coef * (mult - 1.0),
433            DamageFlags::empty(),
434        );
435    }
436
437    if cfg.mana_adept_refund_per_charge > 0.0 {
438        let max_mana = ctx.primary_resource_max();
439
440        ctx.gain_resource(max_mana * charge_count * cfg.mana_adept_refund_per_charge);
441    }
442
443    ctx.spend_all_secondary_resource(0.0);
444    ctx.expire_aura(AURA_ARCANE_CHARGE);
445    ctx.expire_aura(AURA_ARCANE_SALVO);
446    ctx.expire_aura(AURA_INTUITION);
447    trigger_splinter(
448        ctx,
449        force_of_will_barrage_splinters(salvo_stacks, cfg.force_of_will_salvo_per_splinter),
450    );
451    // A triggered Orb Barrage suppresses this Barrage's Clearcasting roll and BLP advance.
452
453    if !orb_barrage_proc {
454        trigger_clearcasting(ctx);
455    }
456
457    if cfg.prismatic_bolt
458        && salvo_stacks > 0
459        && proc_chance(
460            ctx.rng(),
461            f64::from(salvo_stacks) * cfg.prismatic_bolt_proc_per_salvo,
462        )
463    {
464        ctx.apply_aura(AURA_PRISMATIC_BOLT);
465    }
466}
467
468pub(crate) fn prismatic_bolt_hook(ctx: &mut HookCtx<'_>) {
469    let cfg = arcane_config(ctx);
470    let primary_effect =
471        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::PRISMATIC_BOLT_DAMAGE;
472
473    ctx.deal_damage_sp(
474        crate::generated::specs::arcane_mage::DAMAGE_REPORTED::PRISMATIC_BOLT,
475        ctx.effect_sp_coefficient(primary_effect),
476        DamageFlags::empty(),
477    );
478    let effect = crate::generated::specs::arcane_mage::DAMAGE_EFFECT::PRISMATIC_BOLT_AOE_DAMAGE;
479    let coefficient = ctx.effect_sp_coefficient(effect);
480    let primary = ctx.target();
481
482    ctx.with_resolved_spell_effect_targets(
483        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::PRISMATIC_BOLT_AOE_TARGETS,
484        crate::generated::specs::arcane_mage::DAMAGE_REPORTED::PRISMATIC_BOLT_AOE,
485        None,
486        |ctx, targets| {
487            let secondary_count = targets
488                .iter()
489                .filter(|target| Some(**target) != primary)
490                .count();
491            let reduced_aoe = if cfg.prismatic_bolt_reduced_targets > 0
492                && secondary_count > cfg.prismatic_bolt_reduced_targets
493            {
494                (wowlab_types::numeric::usize_to_f64(cfg.prismatic_bolt_reduced_targets)
495                    / wowlab_types::numeric::usize_to_f64(secondary_count))
496                .sqrt()
497            } else {
498                1.0
499            };
500
501            for target in targets
502                .iter()
503                .copied()
504                .filter(|target| Some(*target) != primary)
505            {
506                ctx.deal_damage_sp_to_target(
507                    target,
508                    crate::generated::specs::arcane_mage::DAMAGE_REPORTED::PRISMATIC_BOLT_AOE,
509                    coefficient * reduced_aoe,
510                    DamageFlags::AOE,
511                );
512            }
513        },
514    );
515    ctx.expire_aura(AURA_PRISMATIC_BOLT);
516    ctx.expire_aura(AURA_CUMULATIVE_POWER);
517    ctx.gain_secondary_resource(cfg.prismatic_bolt_charges);
518    sync_charge_aura(ctx);
519    trigger_salvo(
520        ctx,
521        EXPANDED_MIND_BLAST_STACKS * PRISMATIC_BOLT_SALVO_MULTIPLIER,
522    );
523
524    if cfg.prismatic_bolt_clearcasting_chance > 0.0
525        && proc_chance(ctx.rng(), cfg.prismatic_bolt_clearcasting_chance)
526    {
527        grant_clearcasting(ctx);
528    }
529}
530
531pub(crate) fn arcane_pulse_hook(ctx: &mut HookCtx<'_>) {
532    let cfg = arcane_config(ctx);
533    let coefficient = ctx.effect_sp_coefficient(
534        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_PULSE_DAMAGE,
535    );
536    let target_count = ctx.with_resolved_spell_effect_targets(
537        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_PULSE_DAMAGE,
538        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_PULSE_DAMAGE.action(),
539        None,
540        |ctx, targets| {
541            let reduced_aoe = if cfg.arcane_pulse_reduced_targets > 0
542                && targets.len() > cfg.arcane_pulse_reduced_targets
543            {
544                (wowlab_types::numeric::usize_to_f64(cfg.arcane_pulse_reduced_targets)
545                    / wowlab_types::numeric::usize_to_f64(targets.len()))
546                .sqrt()
547            } else {
548                1.0
549            };
550
551            for target in targets.iter().copied() {
552                ctx.deal_damage_sp_to_target(
553                    target,
554                    crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_PULSE_DAMAGE
555                        .action(),
556                    coefficient * reduced_aoe,
557                    DamageFlags::AOE,
558                );
559            }
560
561            targets.len()
562        },
563    );
564
565    ctx.gain_secondary_resource(
566        cfg.arcane_pulse_charges_per_target
567            * wowlab_types::numeric::usize_to_f64(target_count),
568    );
569    sync_charge_aura(ctx);
570    ctx.expire_aura(AURA_CUMULATIVE_POWER);
571
572    if cfg.expanded_mind {
573        trigger_salvo(ctx, EXPANDED_MIND_BLAST_STACKS);
574    }
575
576    trigger_splinter(ctx, 1);
577    trigger_clearcasting(ctx);
578}
579
580pub(crate) fn arcane_missiles_hook(ctx: &mut HookCtx<'_>) {
581    let cfg = arcane_config(ctx);
582    let overpowered = ctx.is_aura_active(AURA_OVERPOWERED_MISSILES);
583    let overpowered_mult = if overpowered {
584        OVERPOWERED_MISSILES_MULT
585    } else {
586        1.0
587    };
588    let ticks = cfg.arcane_missiles_ticks;
589    let coef = ctx.effect_sp_coefficient(
590        crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_MISSILES_DAMAGE,
591    );
592
593    for _ in 0..ticks {
594        let charged = ctx.secondary_resource() > 0.0;
595        let charged_mult = if charged {
596            1.0 + cfg.charged_missiles_fraction
597        } else {
598            1.0
599        };
600
601        ctx.deal_damage_sp(
602            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_MISSILES_DAMAGE,
603            coef * overpowered_mult * charged_mult,
604            DamageFlags::empty(),
605        );
606        trigger_salvo(ctx, MISSILES_SALVO_STACKS_PER_TICK);
607
608        if overpowered {
609            trigger_salvo(ctx, OVERPOWERED_SALVO_STACKS_PER_TICK);
610        }
611
612        if arcane_config(ctx).high_voltage && proc_chance(ctx.rng(), HIGH_VOLTAGE_CHANCE) {
613            ctx.gain_secondary_resource(1.0);
614            sync_charge_aura(ctx);
615        }
616
617        if cfg.charged_missiles_fraction > 0.0 {
618            ctx.spend_secondary_resource(1.0);
619            sync_charge_aura(ctx);
620        }
621
622        trigger_splinter(ctx, 1);
623
624        if cfg.mid2_cumulative_power {
625            ctx.apply_aura(AURA_CUMULATIVE_POWER);
626        }
627    }
628
629    ctx.consume_aura_stack(AURA_CLEARCASTING);
630
631    if overpowered {
632        ctx.expire_aura(AURA_OVERPOWERED_MISSILES);
633    }
634
635    trigger_clearcasting(ctx);
636}
637
638pub(crate) fn arcane_orb_hook(ctx: &mut HookCtx<'_>) {
639    sync_charge_aura(ctx);
640    let cfg = arcane_config(ctx);
641
642    fire_arcane_orb(ctx, 1.0);
643
644    if cfg.orb_mastery_damage_fraction > 0.0 {
645        for orb in 1..=cfg.orb_mastery_extra_orbs {
646            ctx.schedule_hook(orb.saturating_mul(ORB_MASTERY_STAGGER_MS), fire_orb_mastery);
647        }
648    }
649
650    trigger_clearcasting(ctx);
651}
652
653pub(crate) fn arcane_surge_hook(ctx: &mut HookCtx<'_>) {
654    let cfg = arcane_config(ctx);
655    let mana_pct = ctx.primary_resource_fraction();
656    let max_mana_mult = SURGE_MAX_MANA_MULT
657        * if cfg.mana_bomb {
658            1.0 + MANA_BOMB_BONUS
659        } else {
660            1.0
661        };
662    let extra = mana_pct * (max_mana_mult - 1.0);
663
664    if extra > 0.0 {
665        let coef = ctx.effect_sp_coefficient(
666            crate::generated::specs::arcane_mage::DAMAGE_EFFECT::ARCANE_SURGE_DAMAGE,
667        );
668
669        if coef > 0.0 {
670            ctx.deal_damage_sp(
671                crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_SURGE,
672                coef * extra,
673                DamageFlags::empty(),
674            );
675        }
676    }
677
678    if cfg.splinterstorm {
679        trigger_splinter(ctx, SPLINTERSTORM_ARCANE_SURGE_SPLINTERS);
680    }
681
682    grant_clearcasting(ctx);
683}
684
685pub(crate) fn touch_of_the_magi_hook(ctx: &mut HookCtx<'_>) {
686    let cfg = arcane_config(ctx);
687    let start_damage = ctx.total_damage();
688
689    if let Some(runtime) = ctx.spec_runtime_mut::<ArcaneRuntime>() {
690        runtime.touch_of_the_magi_start_damage = start_damage;
691    }
692
693    ctx.gain_secondary_resource(TOUCH_OF_THE_MAGI_CHARGES);
694    sync_charge_aura(ctx);
695    trigger_splinter(ctx, 1);
696    trigger_clearcasting(ctx);
697
698    if cfg.aegwynns_technique {
699        grant_clearcasting(ctx);
700    }
701}
702
703pub(crate) fn touch_of_the_magi_expire_hook(ctx: &mut HookCtx<'_>) {
704    let cfg = arcane_config(ctx);
705    let start_damage = ctx
706        .spec_runtime::<ArcaneRuntime>()
707        .map_or(0.0, |runtime| runtime.touch_of_the_magi_start_damage);
708    let captured = (ctx.total_damage() - start_damage).max(0.0);
709    let explosion = captured * TOUCH_OF_THE_MAGI_CAPTURE;
710
711    if explosion <= 0.0 {
712        return;
713    }
714
715    ctx.deal_damage_flat(
716        crate::generated::specs::arcane_mage::DAMAGE_SPELL::TOUCH_OF_THE_MAGI_EXPLOSION,
717        explosion,
718    );
719
720    if cfg.touch_of_the_archmage {
721        let archmage =
722            explosion * TOUCH_OF_THE_ARCHMAGE_DAMAGE * (1.0 + TOUCH_OF_THE_ARCHMAGE_PRIMARY_BONUS);
723
724        ctx.deal_damage_flat(
725            crate::generated::specs::arcane_mage::DAMAGE_SPELL::TOUCH_OF_THE_ARCHMAGE,
726            archmage,
727        );
728    }
729}
730
731#[cfg(test)]
732#[path = "hooks/charged_orb_tests.rs"]
733mod charged_orb_tests;
734
735#[cfg(test)]
736mod tests {
737    use googletest::prelude::*;
738    use wowlab_engine_gamedata::{ResolvedGameData, SpellProps};
739    use wowlab_engine_ports::CombatStats;
740    use wowlab_engine_telemetry::TelemetrySink;
741    use wowlab_types::sim::{EnemyIdx, SimTime};
742
743    use super::*;
744    use crate::generated::specs::arcane_mage::AURA;
745
746    #[gtest]
747    fn arcane_charge_multiplier_combines_resolved_base_and_mastery_values() -> Result<()> {
748        let multiplier = resolved_arcane_charge_multiplier(4.0, 1.20, 33.13 * 0.84);
749        let expected = 1.0 + 4.0 * (1.20 + 0.3313 * 0.84);
750
751        verify_that!(multiplier, near(expected, 1e-12))
752    }
753
754    #[gtest]
755    fn amplification_adds_its_resolved_extra_waves() -> Result<()> {
756        verify_that!(arcane_missiles_ticks(2.0), eq(7))
757    }
758
759    #[gtest]
760    fn force_of_will_converts_complete_salvo_groups_to_splinters() -> Result<()> {
761        verify_that!(force_of_will_barrage_splinters(19, 5), eq(3))
762    }
763
764    #[gtest]
765    fn arcane_orb_damages_each_spatially_eligible_identity_once() -> Result<()> {
766        let encounter = crate::hooks::shared::test_support::spatial_hook_encounter()?;
767        let mut data = ResolvedGameData::builder();
768
769        data.set_creature_armor(0.0);
770        data.set_armor_constant(1.0);
771        data.set_level(80);
772        data.insert_spell_props(
773            SpellIdx::from_raw(SPELL::ARCANE_ORB),
774            SpellProps {
775                hostile_max_range: 40.0,
776                radius: 40.0,
777                ..SpellProps::default()
778            },
779        );
780        data.insert_effect_type(
781            wowlab_types::sim::EffectRef::new(
782                SpellIdx::from_raw(EFFECT::ARCANE_ORB_TARGETS.0),
783                EFFECT::ARCANE_ORB_TARGETS.1,
784            ),
785            2,
786        );
787        data.insert_implicit_targets(
788            wowlab_types::sim::EffectRef::new(
789                SpellIdx::from_raw(EFFECT::ARCANE_ORB_TARGETS.0),
790                EFFECT::ARCANE_ORB_TARGETS.1,
791            ),
792            18,
793            16,
794        );
795        data.insert_spell_props(
796            SpellIdx::from_raw(SPELL::ARCANE_ORB_BOLT),
797            SpellProps {
798                ignores_line_of_sight: true,
799                treat_as_area_effect: true,
800                ..SpellProps::default()
801            },
802        );
803        data.insert_sp_coef(
804            SpellIdx::from_raw(EFFECT::ARCANE_ORB_BOLT_DAMAGE.0),
805            EFFECT::ARCANE_ORB_BOLT_DAMAGE.1,
806            1.0,
807        );
808        data.insert_school_mask(SpellIdx::from_raw(SPELL::ARCANE_ORB_BOLT), 0x40);
809        let mut draft = crate::test_combat_builder_for(
810            wowlab_engine_combat::generated_identities::specs::arcane_mage::PROOF,
811            CombatStats {
812                spell_power: 100.0,
813                ..CombatStats::default()
814            },
815        )
816        .secondary_resource("arcane_charges", 4.0)
817        .secondary_resource_type_id(16)
818        .game_data(data.build())
819        .encounter(encounter)
820        .aura("arcane_charge", AURA::ARCANE_CHARGE, |a| {
821            Ok(a.on_player().max_stacks(4))
822        })
823        .spell("arcane_orb", SPELL::ARCANE_ORB, Ok)
824        .spell("arcane_orb_bolt", SPELL::ARCANE_ORB_BOLT, Ok)
825        .into_draft()
826        .or_fail()?;
827
828        draft.set_spec_config(ArcaneConfig::default());
829        let mut built = draft
830            .finalize(&crate::test_support::wait_rotation())
831            .or_fail()?;
832
833        built
834            .instance
835            .apply_enemy_damage(
836                EnemyIdx(7),
837                crate::hooks::shared::test_support::max_health(),
838                SimTime::ZERO,
839            )
840            .or_fail()?;
841        let before: Vec<_> = (0..10)
842            .map(|raw| {
843                built
844                    .instance
845                    .enemy_health(&built.program, EnemyIdx(raw), SimTime::ZERO)
846                    .or_fail()
847            })
848            .collect::<Result<Vec<_>>>()?;
849        let mut sink = TelemetrySink::default();
850        let mut rng = || 1.0;
851        let mut ctx = HookCtx::new(
852            wowlab_engine_combat::HookCtxServices {
853                program: &built.program,
854                instance: &mut built.instance,
855                sink: &mut sink,
856                rng: &mut rng,
857            },
858            wowlab_engine_combat::HookCtxRequest::for_target(SimTime::ZERO, EnemyIdx::PRIMARY),
859        );
860
861        arcane_orb_hook(&mut ctx);
862        verify_that!(ctx.secondary_resource(), eq(4.0))?;
863        drop(ctx);
864
865        let hit_ids = [0_u16, 1, 2, 3, 4, 8];
866
867        for raw in 0..10 {
868            let after = built
869                .instance
870                .enemy_health(&built.program, EnemyIdx(raw), SimTime::ZERO)
871                .or_fail()?;
872
873            if hit_ids.contains(&raw) {
874                verify_that!(after, lt(before[usize::from(raw)]))?;
875            } else {
876                verify_that!(after, eq(before[usize::from(raw)]))?;
877            }
878        }
879
880        verify_that!(sink.damage, len(eq(hit_ids.len())))?;
881
882        verify_true!(
883            sink.damage
884                .iter()
885                .all(|event| event.spell_id == SPELL::ARCANE_ORB_BOLT)
886        )
887    }
888}
889
890#[cfg(test)]
891mod savant_mastery_tests {
892    use googletest::prelude::*;
893    use wowlab_engine_gamedata::SpellProps;
894    use wowlab_engine_ports::CombatStats;
895    use wowlab_types::sim::{EnemyIdx, SimTime};
896
897    use super::*;
898
899    /// 190740 e6 targets by `SpellLabel` 3408 (Frost Splinter 443722, Arcane Splinter 443763).
900    const SPLINTER_LABEL: i32 = 3408;
901    /// `Apply Percent Modifier w/ Label`, the aura subtype 190740 e6 carries.
902    const ADD_PERCENT_LABEL_MODIFIER: i32 = 218;
903    const MAGE_SPELL_FAMILY: i32 = 3;
904    const SAVANT_MASTERY_PCT: f64 = 33.0;
905    const SAVANT_TEST_ROTATION: &str = r#"{"version":1,"name":"savant","variables":{},"actions":[{"type":"wait","seconds":1.0}],"lists":{}}"#;
906
907    #[gtest]
908    fn savant_mastery_applies_only_to_its_dbc_affect_list() -> Result<()> {
909        let savant = SpellIdx::from_raw(EFFECT::SAVANT_MASTERY_DAMAGE.0);
910        let (list, label) = (
911            EFFECT::SAVANT_MASTERY_DAMAGE.1,
912            EFFECT::SAVANT_SPLINTER_DAMAGE.1,
913        );
914        let mut data = wowlab_engine_gamedata::ResolvedGameData::builder();
915        let props = SpellProps {
916            mastery_affects_points: true,
917            ..SpellProps::default()
918        };
919
920        data.insert_spell_props(savant, props);
921        data.insert_sp_coef(savant, list, 1.0);
922        data.insert_sp_coef(savant, label, 1.0);
923        data.insert_spell_class_flags(savant, MAGE_SPELL_FAMILY, [0, 0, 0, 0]);
924        data.insert_effect_class_mask(savant, list, [1, 0, 0, 0]);
925        data.insert_effect_aura(
926            wowlab_types::sim::EffectRef::new(savant, label),
927            ADD_PERCENT_LABEL_MODIFIER,
928        );
929        data.insert_effect_misc_value_1(
930            wowlab_types::sim::EffectRef::new(savant, label),
931            SPLINTER_LABEL,
932        );
933        data.insert_spell_labels(
934            SpellIdx::from_raw(SPELL::ARCANE_SPLINTER),
935            vec![SPLINTER_LABEL],
936        );
937
938        for (spells, mask) in [
939            (
940                [SPELL::ARCANE_ORB_BOLT, SPELL::ARCANE_MISSILES_DAMAGE],
941                [1, 0, 0, 0],
942            ),
943            ([SPELL::ARCANE_BLAST, SPELL::ARCANE_BARRAGE], [2, 0, 0, 0]),
944        ] {
945            for spell in spells {
946                data.insert_spell_class_flags(SpellIdx::from_raw(spell), MAGE_SPELL_FAMILY, mask);
947            }
948        }
949
950        let built = crate::test_combat_builder_for(
951            wowlab_engine_combat::generated_identities::specs::arcane_mage::PROOF,
952            CombatStats::default(),
953        )
954        .game_data(data.build())
955        .effect_identities(
956            wowlab_engine_combat::generated_identities::specs::arcane_mage::damage::ALL,
957        )
958        .mastery_spell(EFFECT::SAVANT_MASTERY_DAMAGE.0)
959        .mastery_hook(mastery)
960        .spell("arcane_blast", SPELL::ARCANE_BLAST, Ok)
961        .into_draft()
962        .expect("valid combat draft")
963        .finalize(&crate::test_support::rotation_from_json(
964            SAVANT_TEST_ROTATION,
965        ))
966        .or_fail()?;
967        let bonus = |action| {
968            mastery(
969                &MasteryCtx::new(
970                    &built.program,
971                    &built.instance,
972                    wowlab_engine_combat::MasteryEvent {
973                        action,
974                        school: wowlab_types::combat::DamageSchool::Arcane,
975                        mastery: SAVANT_MASTERY_PCT,
976                        player_crit_pct: 0.0,
977                        mastery_action: crate::generated::specs::arcane_mage::DAMAGE_EFFECT::SAVANT_MASTERY_DAMAGE.action(),
978                        is_periodic: false,
979                        is_pet: false,
980                        source: wowlab_types::sim::ActorId::Player,
981                        target: Some(EnemyIdx::PRIMARY),
982                        now: SimTime::ZERO,
983                    },
984                )
985                .expect("mastery fixture coordinates are finalized"),
986            )
987        };
988        let listed = 1.0 + SAVANT_MASTERY_PCT / HUNDRED;
989
990        for action in [
991            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_ORB_BOLT,
992            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_MISSILES_DAMAGE,
993            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_SPLINTER,
994        ] {
995            verify_that!(bonus(action), near(listed, 1e-12))?;
996        }
997
998        // Arcane Blast, Barrage and Assault are Arcane but in neither affect list.
999
1000        for action in [
1001            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_BLAST,
1002            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_BARRAGE,
1003            crate::generated::specs::arcane_mage::DAMAGE_SPELL::ARCANE_ASSAULT,
1004        ] {
1005            verify_that!(bonus(action), near(1.0, 1e-12))?;
1006        }
1007
1008        Ok(())
1009    }
1010}