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
22const ARCANE_MISSILES_TICKS: u32 = 5;
24const ORB_BARRAGE_CHANCE_PER_SALVO_STACK: f64 = 0.04;
26const ORB_MASTERY_STAGGER_MS: u32 = 150;
27const SALVO_MAX_STACKS: i32 = 20;
29const SURGE_MAX_MANA_MULT: f64 = 2.0;
31const MANA_BOMB_BONUS: f64 = 0.50;
32const 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;
36pub(super) const CLEARCASTING_UNDOCUMENTED_BONUS: f64 = 0.02;
38pub(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;
43const HIGH_VOLTAGE_CHANCE: f64 = 0.50;
45const 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;
51const INFUSED_SPLINTERS_SALVO_CHANCE: f64 = 0.25;
53const INFUSED_SPLINTERS_SALVO_STACKS: i32 = 1;
54const SPELLFROST_TEACHINGS_ORB_CDR_MS: u32 = 250;
56const SPLINTERING_ORBS_SPLINTERS: i32 = 2;
58const 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;
63const SPLINTERSTORM_SPLINTERS_PER_TRIGGER: i32 = 2;
65const SPLINTERSTORM_ARCANE_SURGE_SPLINTERS: i32 = 8;
67const 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#[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
209fn 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 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 const SPLINTER_LABEL: i32 = 3408;
901 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 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}