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
23const ARCANE_MISSILES_TICKS: u32 = 5;
25const ORB_BARRAGE_CHANCE_PER_SALVO_STACK: f64 = 0.04;
27const ORB_MASTERY_STAGGER_MS: u32 = 150;
28const SALVO_MAX_STACKS: i32 = 20;
30const SURGE_MAX_MANA_MULT: f64 = 2.0;
32const MANA_BOMB_BONUS: f64 = 0.50;
33const 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;
37pub(super) const CLEARCASTING_UNDOCUMENTED_BONUS: f64 = 0.02;
39pub(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;
44const HIGH_VOLTAGE_CHANCE: f64 = 0.50;
46const 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;
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]
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
210fn 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 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 const SPLINTER_LABEL: i32 = 3408;
734 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 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}