Skip to main content

wowlab_engine_content/hooks/arcane_mage/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::CooldownOps as _;
3use wowlab_engine_combat::ResourceOps as _;
4use wowlab_engine_combat::SchedulingOps as _;
5use wowlab_engine_combat::AuraOps as _;
6use wowlab_types::constants::HUNDRED;
7#[cfg(test)]
8use wowlab_types::sim::SpellIdx;
9
10use wowlab_engine_combat::{DamageFlags, GuardianEvent, HookCtx, MasteryCtx};
11use wowlab_engine_domain::pool::SpellGate;
12use wowlab_engine_rng::proc_chance;
13
14use super::config::{arcane_config, ArcaneRuntime};
15#[cfg(test)]
16use super::config::ArcaneConfig;
17
18use crate::generated::specs::arcane_mage::{
19    AURA_ARCANE_CHARGE, AURA_ARCANE_SALVO, AURA_ARCANE_SURGE, AURA_CLEARCASTING, AURA_INTUITION,
20    AURA_OVERPOWERED_MISSILES, EFFECT, SPELL, SPELL_ARCANE_MISSILES, SPELL_ARCANE_ORB,
21};
22
23/// 5143: 2.5s channel, 0.625s period (4 ticks) + tick zero.
24const ARCANE_MISSILES_TICKS: u32 = 5;
25/// Orb Barrage 384858 e1: 4% chance per Arcane Salvo stack.
26const ORB_BARRAGE_CHANCE_PER_SALVO_STACK: f64 = 0.04;
27const ORB_MASTERY_STAGGER_MS: u32 = 150;
28/// Arcane Salvo cap; reaching it triggers Intuition.
29const SALVO_MAX_STACKS: i32 = 20;
30/// Arcane Surge 365350 e2 (2x), scaled by Mana Bomb 457521 e1 (+50%).
31const SURGE_MAX_MANA_MULT: f64 = 2.0;
32const MANA_BOMB_BONUS: f64 = 0.50;
33/// Touch of the Magi 210824 e1: 20% of damage dealt during the window.
34const TOUCH_OF_THE_MAGI_CAPTURE: f64 = 0.20;
35const TOUCH_OF_THE_ARCHMAGE_DAMAGE: f64 = 0.75;
36const TOUCH_OF_THE_ARCHMAGE_PRIMARY_BONUS: f64 = 0.50;
37/// Clearcasting has an undocumented two-percentage-point bonus.
38pub(super) const CLEARCASTING_UNDOCUMENTED_BONUS: f64 = 0.02;
39/// Clearcasting PRD uses the historical 13-attempt cap despite an uncapped tracker.
40pub(super) const CLEARCASTING_CONSTANT_CAP: u32 = 13;
41const CLEARCASTING_REACTION_MS: u32 = 50;
42const OVERPOWERED_MISSILES_CHANCE: f64 = 0.25;
43const OVERPOWERED_MISSILES_MULT: f64 = 2.0;
44/// High Voltage 461248 e1 (50%) grants 461524 e1 (1) Arcane Charge per tick.
45const HIGH_VOLTAGE_CHANCE: f64 = 0.50;
46/// Expanded Mind 1243557 e1 (Blast) / e2 (Arcane Orb): +2 Salvo stacks.
47const EXPANDED_MIND_BLAST_STACKS: i32 = 2;
48const EXPANDED_MIND_ORB_STACKS: 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/// See `sc_mage_live.inc:1591`, `:1701-1702` and `:2113-2118`.
76#[must_use]
77pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
78    ctx.affected_bonus_any(&[
79        EFFECT::SAVANT_MASTERY_DAMAGE.1,
80        EFFECT::SAVANT_SPLINTER_DAMAGE.1,
81    ])
82}
83
84fn arcane_assault(ctx: &mut HookCtx<'_>) {
85    let cfg = arcane_config(ctx);
86    let coef = ctx.effect_sp_coefficient(EFFECT::ARCANE_ASSAULT_DAMAGE);
87
88    ctx.deal_damage_sp(SPELL::ARCANE_ASSAULT, coef, DamageFlags::empty());
89
90    if proc_chance(ctx.rng(), cfg.energized_familiar_chance) {
91        let max_mana = ctx
92            .buf()
93            .resource(wowlab_types::combat::ResourceType::Mana)
94            .map_or(0.0, |resource| resource.max);
95
96        ctx.gain_resource(max_mana * cfg.energized_familiar_gain_fraction);
97    }
98
99    if proc_chance(ctx.rng(), cfg.attuned_familiar_chance) {
100        trigger_splinter(ctx, 1);
101    }
102}
103
104fn delayed_arcane_assault(ctx: &mut HookCtx<'_>) {
105    arcane_assault(ctx);
106}
107
108pub(super) fn arcane_familiar_action(
109    ctx: &mut HookCtx<'_>,
110    _event: GuardianEvent,
111) {
112    arcane_assault(ctx);
113    let cfg = arcane_config(ctx);
114
115    if ctx.is_aura_active(AURA_ARCANE_SURGE.raw()) {
116        for assault in 1..=cfg.arcane_familiar_extra_assaults {
117            ctx.schedule_hook(
118                ENERGIZED_FAMILIAR_STAGGER_MS.saturating_mul(assault),
119                delayed_arcane_assault,
120            );
121        }
122    }
123}
124
125fn charges(ctx: &HookCtx<'_>) -> f64 {
126    ctx.secondary_resource()
127        .max(f64::from(ctx.aura_stacks(AURA_ARCANE_CHARGE.raw())))
128        .max(ctx.last_secondary_spent())
129}
130
131fn sync_charge_aura(ctx: &mut HookCtx<'_>) {
132    let wanted = wowlab_types::numeric::f64_to_i32_saturating_round(ctx.secondary_resource());
133
134    while ctx.aura_stacks(AURA_ARCANE_CHARGE.raw()) < wanted {
135        ctx.apply_aura(AURA_ARCANE_CHARGE.raw());
136    }
137
138    while ctx.aura_stacks(AURA_ARCANE_CHARGE.raw()) > wanted {
139        ctx.consume_aura_stack(AURA_ARCANE_CHARGE.raw());
140    }
141}
142
143fn trigger_clearcasting(ctx: &mut HookCtx<'_>) {
144    if roll_clearcasting(ctx) {
145        grant_clearcasting(ctx);
146    }
147}
148
149fn roll_clearcasting(ctx: &mut HookCtx<'_>) -> bool {
150    let cfg = arcane_config(ctx);
151    let attempts = ctx
152        .spec_runtime::<ArcaneRuntime>()
153        .map(|runtime| runtime.clearcasting_attempts)
154        .unwrap_or_default();
155    let attempt = attempts.saturating_add(1);
156    let prd_chance = cfg.clearcasting_prd_constant * f64::from(attempt);
157    let success = proc_chance(ctx.rng(), prd_chance);
158
159    tracing::trace!(attempt, prd_chance, success, "CLEARCASTING_ROLL");
160
161    if !success {
162        if let Some(runtime) = ctx.spec_runtime_mut::<ArcaneRuntime>() {
163            runtime.clearcasting_attempts = attempts.saturating_add(1);
164        }
165
166        return false;
167    }
168
169    if let Some(runtime) = ctx.spec_runtime_mut::<ArcaneRuntime>() {
170        runtime.clearcasting_attempts = 0;
171    }
172
173    true
174}
175
176fn grant_clearcasting(ctx: &mut HookCtx<'_>) {
177    let cfg = arcane_config(ctx);
178    let had_clearcasting = ctx.is_aura_active(AURA_CLEARCASTING.raw());
179
180    ctx.apply_aura(AURA_CLEARCASTING.raw());
181
182    if !had_clearcasting {
183        ctx.set_spell_gate(SPELL_ARCANE_MISSILES.raw(), SpellGate::ReactionDelay, false);
184        ctx.schedule_hook(CLEARCASTING_REACTION_MS, enable_arcane_missiles);
185    }
186
187    if cfg.overpowered_missiles && proc_chance(ctx.rng(), OVERPOWERED_MISSILES_CHANCE) {
188        ctx.apply_aura(AURA_OVERPOWERED_MISSILES.raw());
189    }
190}
191
192fn enable_arcane_missiles(ctx: &mut HookCtx<'_>) {
193    ctx.set_spell_gate(SPELL_ARCANE_MISSILES.raw(), SpellGate::ReactionDelay, true);
194}
195
196fn trigger_salvo(ctx: &mut HookCtx<'_>, stacks: i32) {
197    if !arcane_config(ctx).arcane_salvo {
198        return;
199    }
200
201    let old_stacks = ctx.aura_stacks(AURA_ARCANE_SALVO.raw());
202
203    ctx.apply_aura_n(AURA_ARCANE_SALVO.raw(), stacks);
204
205    if old_stacks < SALVO_MAX_STACKS && ctx.aura_stacks(AURA_ARCANE_SALVO.raw()) >= SALVO_MAX_STACKS {
206        ctx.apply_aura(AURA_INTUITION.raw());
207    }
208}
209
210/// Arcane Charge uses 36032 e1/e2 and Savant 190740 e2/e3 per charge.
211fn arcane_charge_multiplier(ctx: &HookCtx<'_>, charge_count: f64, barrage: bool) -> f64 {
212    let cfg = arcane_config(ctx);
213    let base = if barrage {
214        cfg.arcane_charge_barrage_fraction
215    } else {
216        cfg.arcane_charge_blast_fraction
217    };
218    let savant_points = ctx.effect_points(if barrage {
219        EFFECT::SAVANT_BARRAGE
220    } else {
221        EFFECT::SAVANT_BLAST
222    });
223
224    resolved_arcane_charge_multiplier(charge_count, base, savant_points)
225}
226
227const fn resolved_arcane_charge_multiplier(
228    charge_count: f64,
229    base_fraction: f64,
230    savant_points: f64,
231) -> f64 {
232    let savant = savant_points / HUNDRED;
233
234    1.0 + charge_count * (base_fraction + savant)
235}
236
237pub(super) fn arcane_missiles_ticks(extra_waves: f64) -> u32 {
238    ARCANE_MISSILES_TICKS.saturating_add(wowlab_types::numeric::f64_to_u32_saturating_round(extra_waves.max(0.0)))
239}
240
241const fn force_of_will_barrage_splinters(salvo_stacks: i32, stacks_per_splinter: i32) -> i32 {
242    if stacks_per_splinter > 0 {
243        salvo_stacks / stacks_per_splinter
244    } else {
245        0
246    }
247}
248
249fn roll_augury_abounds(ctx: &mut HookCtx<'_>) {
250    let cfg = arcane_config(ctx);
251
252    if !cfg.augury_abounds {
253        return;
254    }
255
256    let now = ctx.now().as_millis();
257    let runtime = ctx.spec_runtime::<ArcaneRuntime>().copied().unwrap_or_default();
258
259    if now < runtime.augury_icd_until_ms {
260        return;
261    }
262
263    let attempts = runtime.augury_attempts.saturating_add(1);
264    let success = proc_chance(ctx.rng(), AUGURY_ABOUNDS_BASE_CHANCE * f64::from(attempts));
265
266    if let Some(runtime) = ctx.spec_runtime_mut::<ArcaneRuntime>() {
267        runtime.augury_icd_until_ms = now.saturating_add(AUGURY_ABOUNDS_ICD_MS);
268        runtime.augury_attempts = if success { 0 } else { attempts };
269    }
270
271    if success {
272        trigger_splinter_inner(ctx, AUGURY_ABOUNDS_SPLINTERS, false);
273    }
274}
275
276fn fire_splinter(ctx: &mut HookCtx<'_>, allow_augury: bool) {
277    let cfg = arcane_config(ctx);
278    let coef = ctx.effect_sp_coefficient(EFFECT::ARCANE_SPLINTER_DAMAGE);
279
280    ctx.deal_damage_sp(SPELL::ARCANE_SPLINTER, coef, DamageFlags::empty());
281
282    if cfg.infused_splinters && proc_chance(ctx.rng(), INFUSED_SPLINTERS_SALVO_CHANCE) {
283        trigger_salvo(ctx, INFUSED_SPLINTERS_SALVO_STACKS);
284    }
285
286    if cfg.spellfrost_teachings {
287        ctx.reduce_cooldown(SPELL_ARCANE_ORB.raw(), SPELLFROST_TEACHINGS_ORB_CDR_MS);
288    }
289
290    if allow_augury {
291        roll_augury_abounds(ctx);
292    }
293}
294
295fn trigger_splinter_inner(
296    ctx: &mut HookCtx<'_>,
297    count: i32,
298    allow_augury: bool,
299) {
300    let cfg = arcane_config(ctx);
301
302    if !cfg.splintering_sorcery {
303        return;
304    }
305
306    let per_trigger = if cfg.splinterstorm && ctx.is_aura_active(AURA_ARCANE_SURGE.raw()) {
307        SPLINTERSTORM_SPLINTERS_PER_TRIGGER
308    } else {
309        SPLINTERS_PER_TRIGGER
310    };
311
312    for _ in 0..(count.max(0) * per_trigger) {
313        fire_splinter(ctx, allow_augury);
314    }
315}
316
317fn trigger_splinter(ctx: &mut HookCtx<'_>, count: i32) {
318    trigger_splinter_inner(ctx, count, true);
319}
320
321fn fire_arcane_orb(ctx: &mut HookCtx<'_>, damage_fraction: f64) {
322    let cfg = arcane_config(ctx);
323    let targets = ctx.resolved_spell_effect_targets(
324        EFFECT::ARCANE_ORB_TARGETS,
325        SPELL::ARCANE_ORB_BOLT,
326        None,
327    );
328    let coef = ctx.effect_sp_coefficient(EFFECT::ARCANE_ORB_BOLT_DAMAGE);
329
330    ctx.gain_secondary_resource(1.0);
331    sync_charge_aura(ctx);
332
333    if cfg.expanded_mind {
334        trigger_salvo(ctx, EXPANDED_MIND_ORB_STACKS);
335    }
336
337    for target in targets {
338        ctx.with_resolved_target(target, |target_ctx| {
339            target_ctx.deal_damage_sp_to_target(
340                target,
341                SPELL::ARCANE_ORB_BOLT,
342                coef * damage_fraction,
343                DamageFlags::empty(),
344            );
345            target_ctx.gain_secondary_resource(1.0);
346            sync_charge_aura(target_ctx);
347
348            if cfg.splintering_orbs {
349                trigger_splinter(target_ctx, SPLINTERING_ORBS_SPLINTERS);
350            }
351        });
352    }
353}
354
355fn fire_orb_mastery(ctx: &mut HookCtx<'_>) {
356    let damage_fraction = arcane_config(ctx).orb_mastery_damage_fraction;
357
358    if damage_fraction <= 0.0 {
359        return;
360    }
361
362    fire_arcane_orb(ctx, damage_fraction);
363}
364
365pub(crate) fn arcane_blast_hook(ctx: &mut HookCtx<'_>) {
366    let charge_count = charges(ctx);
367    let mult = arcane_charge_multiplier(ctx, charge_count, false);
368
369    if mult > 1.0 {
370        let coef = ctx.effect_sp_coefficient(EFFECT::ARCANE_BLAST_DAMAGE);
371
372        ctx.deal_damage_sp(
373            SPELL::ARCANE_BLAST,
374            coef * (mult - 1.0),
375            DamageFlags::empty(),
376        );
377    }
378
379    sync_charge_aura(ctx);
380
381    if arcane_config(ctx).expanded_mind {
382        trigger_salvo(ctx, EXPANDED_MIND_BLAST_STACKS);
383    }
384
385    trigger_splinter(ctx, 1);
386    trigger_clearcasting(ctx);
387}
388
389pub(crate) fn arcane_barrage_hook(ctx: &mut HookCtx<'_>) {
390    let cfg = arcane_config(ctx);
391    let salvo_stacks = ctx.aura_stacks(AURA_ARCANE_SALVO.raw());
392    let orb_barrage_proc = cfg.orb_barrage
393        && salvo_stacks > 0
394        && proc_chance(
395            ctx.rng(),
396            f64::from(salvo_stacks) * ORB_BARRAGE_CHANCE_PER_SALVO_STACK,
397        );
398
399    if orb_barrage_proc {
400        fire_arcane_orb(ctx, 1.0);
401    }
402
403    let charge_count = charges(ctx);
404    let mut mult = arcane_charge_multiplier(ctx, charge_count, true);
405
406    mult *= 1.0 + f64::from(salvo_stacks) * cfg.arcane_salvo_fraction_per_stack;
407
408    if ctx.is_aura_active(AURA_INTUITION.raw()) {
409        mult *= cfg.intuition_damage_mult;
410    }
411
412    if mult > 1.0 {
413        let coef = ctx.effect_sp_coefficient(EFFECT::ARCANE_BARRAGE_DAMAGE);
414
415        ctx.deal_damage_sp(
416            SPELL::ARCANE_BARRAGE,
417            coef * (mult - 1.0),
418            DamageFlags::empty(),
419        );
420    }
421
422    if cfg.mana_adept_refund_per_charge > 0.0 {
423        let max_mana = ctx
424            .buf()
425            .resource(wowlab_types::combat::ResourceType::Mana)
426            .map_or(0.0, |resource| resource.max);
427
428        ctx.gain_resource(max_mana * charge_count * cfg.mana_adept_refund_per_charge);
429    }
430
431    ctx.spend_all_secondary_resource(0.0);
432    ctx.expire_aura(AURA_ARCANE_CHARGE.raw());
433    ctx.expire_aura(AURA_ARCANE_SALVO.raw());
434    ctx.expire_aura(AURA_INTUITION.raw());
435    trigger_splinter(
436        ctx,
437        force_of_will_barrage_splinters(salvo_stacks, cfg.force_of_will_salvo_per_splinter),
438    );
439    // A triggered Orb Barrage suppresses this Barrage's Clearcasting roll and BLP advance.
440
441    if !orb_barrage_proc {
442        trigger_clearcasting(ctx);
443    }
444}
445
446pub(crate) fn arcane_missiles_hook(ctx: &mut HookCtx<'_>) {
447    let cfg = arcane_config(ctx);
448    let overpowered = ctx.is_aura_active(AURA_OVERPOWERED_MISSILES.raw());
449    let overpowered_mult = if overpowered {
450        OVERPOWERED_MISSILES_MULT
451    } else {
452        1.0
453    };
454    let ticks = cfg.arcane_missiles_ticks;
455    let coef = ctx.effect_sp_coefficient(EFFECT::ARCANE_MISSILES_DAMAGE);
456
457    for _ in 0..ticks {
458        let charged = ctx.secondary_resource() > 0.0;
459        let charged_mult = if charged {
460            1.0 + cfg.charged_missiles_fraction
461        } else {
462            1.0
463        };
464
465        ctx.deal_damage_sp(
466            SPELL::ARCANE_MISSILES_DAMAGE,
467            coef * overpowered_mult * charged_mult,
468            DamageFlags::empty(),
469        );
470        trigger_salvo(ctx, MISSILES_SALVO_STACKS_PER_TICK);
471
472        if overpowered {
473            trigger_salvo(ctx, OVERPOWERED_SALVO_STACKS_PER_TICK);
474        }
475
476        if arcane_config(ctx).high_voltage && proc_chance(ctx.rng(), HIGH_VOLTAGE_CHANCE) {
477            ctx.gain_secondary_resource(1.0);
478            sync_charge_aura(ctx);
479        }
480
481        if cfg.charged_missiles_fraction > 0.0 {
482            ctx.spend_secondary_resource(1.0);
483            sync_charge_aura(ctx);
484        }
485
486        trigger_splinter(ctx, 1);
487    }
488
489    ctx.consume_aura_stack(AURA_CLEARCASTING.raw());
490
491    if overpowered {
492        ctx.expire_aura(AURA_OVERPOWERED_MISSILES.raw());
493    }
494
495    trigger_clearcasting(ctx);
496}
497
498pub(crate) fn arcane_orb_hook(ctx: &mut HookCtx<'_>) {
499    sync_charge_aura(ctx);
500    let cfg = arcane_config(ctx);
501
502    fire_arcane_orb(ctx, 1.0);
503
504    if cfg.orb_mastery_damage_fraction > 0.0 {
505        for orb in 1..=cfg.orb_mastery_extra_orbs {
506            ctx.schedule_hook(orb.saturating_mul(ORB_MASTERY_STAGGER_MS), fire_orb_mastery);
507        }
508    }
509
510    trigger_clearcasting(ctx);
511}
512
513pub(crate) fn arcane_surge_hook(ctx: &mut HookCtx<'_>) {
514    let cfg = arcane_config(ctx);
515    let mana_pct = ctx
516        .buf()
517        .resource(wowlab_types::combat::ResourceType::Mana)
518        .map_or(1.0, |res| {
519            if res.max > 0.0 {
520                res.current / res.max
521            } else {
522                1.0
523            }
524        });
525    let max_mana_mult = SURGE_MAX_MANA_MULT
526        * if cfg.mana_bomb {
527            1.0 + MANA_BOMB_BONUS
528        } else {
529            1.0
530        };
531    let extra = mana_pct * (max_mana_mult - 1.0);
532
533    if extra > 0.0 {
534        let coef = ctx.effect_sp_coefficient(EFFECT::ARCANE_SURGE_DAMAGE);
535
536        if coef > 0.0 {
537            ctx.deal_damage_sp(SPELL::ARCANE_SURGE, coef * extra, DamageFlags::empty());
538        }
539    }
540
541    if cfg.splinterstorm {
542        trigger_splinter(ctx, SPLINTERSTORM_ARCANE_SURGE_SPLINTERS);
543    }
544
545    grant_clearcasting(ctx);
546}
547
548pub(crate) fn touch_of_the_magi_hook(ctx: &mut HookCtx<'_>) {
549    let cfg = arcane_config(ctx);
550    let start_damage = ctx.total_damage();
551
552    if let Some(runtime) = ctx.spec_runtime_mut::<ArcaneRuntime>() {
553        runtime.touch_of_the_magi_start_damage = start_damage;
554    }
555
556    ctx.gain_secondary_resource(TOUCH_OF_THE_MAGI_CHARGES);
557    sync_charge_aura(ctx);
558    trigger_splinter(ctx, 1);
559    trigger_clearcasting(ctx);
560
561    if cfg.aegwynns_technique {
562        grant_clearcasting(ctx);
563    }
564}
565
566pub(crate) fn touch_of_the_magi_expire_hook(ctx: &mut HookCtx<'_>) {
567    let cfg = arcane_config(ctx);
568    let start_damage = ctx
569        .spec_runtime::<ArcaneRuntime>()
570        .map_or(0.0, |runtime| runtime.touch_of_the_magi_start_damage);
571    let captured = (ctx.total_damage() - start_damage).max(0.0);
572    let explosion = captured * TOUCH_OF_THE_MAGI_CAPTURE;
573
574    if explosion <= 0.0 {
575        return;
576    }
577
578    ctx.deal_damage_flat(SPELL::TOUCH_OF_THE_MAGI_EXPLOSION, explosion);
579
580    if cfg.touch_of_the_archmage {
581        let archmage =
582            explosion * TOUCH_OF_THE_ARCHMAGE_DAMAGE * (1.0 + TOUCH_OF_THE_ARCHMAGE_PRIMARY_BONUS);
583
584        ctx.deal_damage_flat(SPELL::TOUCH_OF_THE_ARCHMAGE, archmage);
585    }
586}
587
588#[cfg(test)]
589#[path = "hooks/charged_orb_tests.rs"]
590mod charged_orb_tests;
591
592#[cfg(test)]
593mod tests {
594    use googletest::prelude::*;
595
596    use super::*;
597    use crate::generated::specs::arcane_mage::AURA;
598    use wowlab_engine_ports::{CombatStats};
599    use wowlab_engine_telemetry::{TelemetrySink};
600use wowlab_engine_gamedata::{ResolvedGameData, SpellProps};
601    use wowlab_types::sim::{EnemyIdx, SimTime};
602
603    #[gtest]
604    fn arcane_charge_multiplier_combines_resolved_base_and_mastery_values() -> Result<()> {
605        let multiplier = resolved_arcane_charge_multiplier(4.0, 1.20, 33.13 * 0.84);
606        let expected = 1.0 + 4.0 * (1.20 + 0.3313 * 0.84);
607
608        verify_that!(multiplier, near(expected, 1e-12))
609    }
610
611    #[gtest]
612    fn amplification_adds_its_resolved_extra_waves() -> Result<()> {
613        verify_that!(arcane_missiles_ticks(2.0), eq(7))
614    }
615
616    #[gtest]
617    fn force_of_will_converts_complete_salvo_groups_to_splinters() -> Result<()> {
618        verify_that!(force_of_will_barrage_splinters(19, 5), eq(3))
619    }
620
621    #[gtest]
622    fn arcane_orb_damages_each_spatially_eligible_identity_once() -> Result<()> {
623        let encounter = crate::hooks::shared::test_support::spatial_hook_encounter()?;
624        let mut data = ResolvedGameData::builder();
625
626        data.set_creature_armor(0.0);
627        data.set_armor_constant(1.0);
628        data.set_level(80);
629        data.insert_spell_props(
630            SpellIdx::from_raw(SPELL::ARCANE_ORB),
631            SpellProps {
632                hostile_max_range: 40.0,
633                radius: 40.0,
634                ..SpellProps::default()
635            },
636        );
637        data.insert_effect_type(wowlab_types::sim::EffectRef::new(SpellIdx::from_raw(EFFECT::ARCANE_ORB_TARGETS.0), EFFECT::ARCANE_ORB_TARGETS.1),
638            2,
639        );
640        data.insert_implicit_targets(wowlab_types::sim::EffectRef::new(SpellIdx::from_raw(EFFECT::ARCANE_ORB_TARGETS.0), EFFECT::ARCANE_ORB_TARGETS.1),
641            18,
642            16,
643        );
644        data.insert_spell_props(
645            SpellIdx::from_raw(SPELL::ARCANE_ORB_BOLT),
646            SpellProps {
647                ignores_line_of_sight: true,
648                treat_as_area_effect: true,
649                ..SpellProps::default()
650            },
651        );
652        data.insert_sp_coef(
653            SpellIdx::from_raw(EFFECT::ARCANE_ORB_BOLT_DAMAGE.0),
654            EFFECT::ARCANE_ORB_BOLT_DAMAGE.1,
655            1.0,
656        );
657        data.insert_school_mask(SpellIdx::from_raw(SPELL::ARCANE_ORB_BOLT), 0x40);
658        let mut built = crate::test_combat_builder(CombatStats {
659            spell_power: 100.0,
660            ..CombatStats::default()
661        })
662        .secondary_resource("arcane_charges", 4.0)
663        .secondary_resource_type_id(16)
664        .game_data(data.build())
665        .encounter(encounter)
666        .aura("arcane_charge", AURA::ARCANE_CHARGE, |a| {
667            Ok(a.on_player().max_stacks(4))
668        })
669        .spell("arcane_orb", SPELL::ARCANE_ORB, Ok)
670        .spell("arcane_orb_bolt", SPELL::ARCANE_ORB_BOLT, Ok)
671        .build(crate::test_support::wait_rotation())
672        .or_fail()?;
673
674        built.state.set_spec_config(ArcaneConfig::default());
675        built
676            .state
677            .apply_enemy_damage(
678                EnemyIdx(7),
679                crate::hooks::shared::test_support::max_health(),
680                SimTime::ZERO,
681            )
682            .or_fail()?;
683        let before: Vec<_> = (0..10)
684            .map(|raw| {
685                built
686                    .state
687                    .enemy_health(EnemyIdx(raw), SimTime::ZERO)
688                    .or_fail()
689            })
690            .collect::<Result<Vec<_>>>()?;
691        let mut sink = TelemetrySink::default();
692        let mut rng = || 1.0;
693        let mut ctx = HookCtx::new(wowlab_engine_combat::HookCtxServices { state: &mut built.state, buf: &mut built.buffer, sink: &mut sink, rng: &mut rng }, wowlab_engine_combat::HookCtxRequest::for_target(SimTime::ZERO, EnemyIdx::PRIMARY));
694
695        arcane_orb_hook(&mut ctx);
696        verify_that!(ctx.secondary_resource(), eq(4.0))?;
697        drop(ctx);
698
699        let hit_ids = [0_u16, 1, 2, 3, 4, 8];
700
701        for raw in 0..10 {
702            let after = built
703                .state
704                .enemy_health(EnemyIdx(raw), SimTime::ZERO)
705                .or_fail()?;
706
707            if hit_ids.contains(&raw) {
708                verify_that!(after, lt(before[usize::from(raw)]))?;
709            } else {
710                verify_that!(after, eq(before[usize::from(raw)]))?;
711            }
712        }
713
714        verify_that!(sink.damage, len(eq(hit_ids.len())))?;
715
716        verify_true!(sink
717            .damage
718            .iter()
719            .all(|event| event.spell_id == SPELL::ARCANE_ORB_BOLT))
720    }
721}
722
723#[cfg(test)]
724mod savant_mastery_tests {
725    use googletest::prelude::*;
726
727    use super::*;
728    use wowlab_engine_ports::{CombatStats};
729use wowlab_engine_gamedata::{SpellProps};
730    use wowlab_types::sim::{EnemyIdx, SimTime};
731
732    /// 190740 e6 targets by `SpellLabel` 3408 (Frost Splinter 443722, Arcane Splinter 443763).
733    const SPLINTER_LABEL: i32 = 3408;
734    /// `Apply Percent Modifier w/ Label`, the aura subtype 190740 e6 carries.
735    const ADD_PERCENT_LABEL_MODIFIER: i32 = 218;
736    const MAGE_SPELL_FAMILY: i32 = 3;
737    const SAVANT_MASTERY_PCT: f64 = 33.0;
738    const SAVANT_TEST_ROTATION: &str =
739        r#"{"version":1,"name":"savant","variables":{},"actions":[{"type":"wait","seconds":1.0}],"lists":{}}"#;
740
741    #[gtest]
742    fn savant_mastery_applies_only_to_its_dbc_affect_list() -> Result<()> {
743        let savant = SpellIdx::from_raw(EFFECT::SAVANT_MASTERY_DAMAGE.0);
744        let (list, label) = (EFFECT::SAVANT_MASTERY_DAMAGE.1, EFFECT::SAVANT_SPLINTER_DAMAGE.1);
745        let mut data = wowlab_engine_gamedata::ResolvedGameData::builder();
746        let props = SpellProps {
747            mastery_affects_points: true,
748            ..SpellProps::default()
749        };
750
751        data.insert_spell_props(savant, props);
752        data.insert_sp_coef(savant, list, 1.0);
753        data.insert_sp_coef(savant, label, 1.0);
754        data.insert_spell_class_flags(savant, MAGE_SPELL_FAMILY, [0, 0, 0, 0]);
755        data.insert_effect_class_mask(savant, list, [1, 0, 0, 0]);
756        data.insert_effect_aura(wowlab_types::sim::EffectRef::new(savant, label), ADD_PERCENT_LABEL_MODIFIER);
757        data.insert_effect_misc_value_1(wowlab_types::sim::EffectRef::new(savant, label), SPLINTER_LABEL);
758        data.insert_spell_labels(SpellIdx::from_raw(SPELL::ARCANE_SPLINTER), vec![SPLINTER_LABEL]);
759
760        for (spells, mask) in [
761            ([SPELL::ARCANE_ORB_BOLT, SPELL::ARCANE_MISSILES_DAMAGE], [1, 0, 0, 0]),
762            ([SPELL::ARCANE_BLAST, SPELL::ARCANE_BARRAGE], [2, 0, 0, 0]),
763        ] {
764            for spell in spells {
765                data.insert_spell_class_flags(SpellIdx::from_raw(spell), MAGE_SPELL_FAMILY, mask);
766            }
767        }
768
769        let built = crate::test_combat_builder(CombatStats::default())
770            .game_data(data.build())
771            .mastery_spell(EFFECT::SAVANT_MASTERY_DAMAGE.0)
772            .mastery_hook(mastery)
773            .spell("arcane_blast", SPELL::ARCANE_BLAST, Ok)
774            .build(crate::test_support::rotation_from_json(SAVANT_TEST_ROTATION))
775            .or_fail()?;
776        let bonus = |spell_id: u32| {
777            mastery(&MasteryCtx {
778                state: &built.state,
779                buf: &built.buffer,
780                spell_id,
781                school: wowlab_types::combat::DamageSchool::Arcane,
782                mastery: SAVANT_MASTERY_PCT,
783                player_crit_pct: 0.0,
784                mastery_spell: savant,
785                is_periodic: false,
786                is_pet: false,
787                source: wowlab_types::sim::ActorId::Player,
788                target: Some(EnemyIdx::PRIMARY),
789                now: SimTime::ZERO,
790            })
791        };
792        let listed = 1.0 + SAVANT_MASTERY_PCT / HUNDRED;
793
794        for spell in [SPELL::ARCANE_ORB_BOLT, SPELL::ARCANE_MISSILES_DAMAGE, SPELL::ARCANE_SPLINTER] {
795            verify_that!(bonus(spell), near(listed, 1e-12))?;
796        }
797
798        // Arcane Blast, Barrage and Assault are Arcane but in neither affect list.
799
800        for spell in [SPELL::ARCANE_BLAST, SPELL::ARCANE_BARRAGE, SPELL::ARCANE_ASSAULT] {
801            verify_that!(bonus(spell), near(1.0, 1e-12))?;
802        }
803
804        Ok(())
805    }
806}