1use wowlab_engine_combat::{BuffEffect, BuiltCombatSystem, CombatState, LocalSpellIdx};
2use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
3use wowlab_engine_domain::rotation::DenseBuffer;
4use wowlab_engine_ports::HandlerParams;
5use wowlab_engine_rng::prd_constant_capped;
6use wowlab_types::{constants::HUNDRED, sim::SpellIdx};
7
8use crate::{
9 generated::specs::arcane_mage::{
10 AURA, AURA_ARCANE_CHARGE, AURA_ARCANE_SALVO, AURA_ENLIGHTENED, AURA_INTUITION, EFFECT,
11 TALENT,
12 },
13 hooks::gated,
14};
15
16use super::hooks::{
17 CLEARCASTING_CONSTANT_CAP, CLEARCASTING_UNDOCUMENTED_BONUS, arcane_missiles_ticks,
18};
19
20#[derive(Clone, Copy, Debug, Default)]
21pub(super) struct ArcaneRuntime {
22 pub(super) augury_attempts: u32,
23 pub(super) augury_icd_until_ms: u32,
24 pub(super) clearcasting_attempts: u32,
25 pub(super) touch_of_the_magi_start_damage: f64,
26}
27
28crate::hooks::define_spec_config! {
29 pub(super) struct ArcaneConfig {
30 arcane_missiles_ticks: u32 { provenance: "Arcane Missiles base ticks plus Amplification Waves.", gate: true, source: selected_value(EFFECT::AMPLIFICATION_WAVES.0, EFFECT::AMPLIFICATION_WAVES.1), transform: arcane_missiles_ticks },
31 arcane_familiar: bool { provenance: "Arcane Familiar enables its periodic assault.", gate: params.talent_picked_any(&[TALENT::ARCANE_FAMILIAR]), source: true, transform: |value| value },
32 arcane_familiar_extra_assaults: u32 { provenance: "Energized Familiar assault count beyond the baseline assault.", gate: true, source: selected_value(EFFECT::ENERGIZED_FAMILIAR_ASSAULTS.0, EFFECT::ENERGIZED_FAMILIAR_ASSAULTS.1), transform: |value: f64| wowlab_types::numeric::f64_to_u32_saturating_round(value.max(1.0)) - 1 },
33 aegwynns_technique: bool { provenance: "Aegwynn's Technique enables its familiar rider.", gate: params.talent_picked_any(&[TALENT::AEGWYNNS_TECHNIQUE]), source: true, transform: |value| value },
34 attuned_familiar_chance: f64 { provenance: "Attuned Familiar effect proc chance.", gate: params.talent_picked(TALENT::ATTUNED_FAMILIAR), source: fraction(EFFECT::ATTUNED_FAMILIAR_CHANCE.0, EFFECT::ATTUNED_FAMILIAR_CHANCE.1), transform: |value| value },
35 arcane_salvo: bool { provenance: "Either Arcane Salvo binding enables salvo stacking.", gate: params.talent_picked_any(&[TALENT::ARCANE_SALVO, TALENT::ARCANE_SALVO_DRIVER]), source: true, transform: |value| value },
36 augury_abounds: bool { provenance: "Augury Abounds enables accumulated splinter rolls.", gate: params.talent_picked_any(&[TALENT::AUGURY_ABOUNDS]), source: true, transform: |value| value },
37 charged_missiles_fraction: f64 { provenance: "Charged Missiles talent e1 damage fraction.", gate: true, source: selected_fraction(EFFECT::CHARGED_MISSILES_DAMAGE.0, EFFECT::CHARGED_MISSILES_DAMAGE.1), transform: |value| value },
38 clearcasting_prd_constant: f64 { provenance: "Clearcasting base and Illuminated Thoughts chance converted to capped PRD.", gate: true, source: clearcasting_rate, transform: |value| prd_constant_capped(value, CLEARCASTING_CONSTANT_CAP) },
39 expanded_mind: bool { provenance: "Either Expanded Mind binding enables extra Salvo stacks.", gate: params.talent_picked_any(&[TALENT::EXPANDED_MIND, TALENT::EXPANDED_MIND_DRIVER]), source: true, transform: |value| value },
40 force_of_will_salvo_per_splinter: i32 { provenance: "Force of Will barrage divisor interpreted as Salvo stacks per splinter.", gate: params.talent_picked(TALENT::FORCE_OF_WILL_DRIVER), source: params.game_data.effect_base_points(EFFECT::FORCE_OF_WILL_BARRAGE_DIVISOR), transform: wowlab_types::numeric::f64_to_i32_saturating_round },
41 enlightened_damage_fraction: f64 { provenance: "Enlightened damage effect fraction.", gate: true, source: fraction(EFFECT::ENLIGHTENED_DAMAGE.0, EFFECT::ENLIGHTENED_DAMAGE.1), transform: |value| value },
42 enlightened_regen_fraction: f64 { provenance: "Enlightened regeneration effect fraction.", gate: true, source: fraction(EFFECT::ENLIGHTENED_REGEN.0, EFFECT::ENLIGHTENED_REGEN.1), transform: |value| value },
43 energized_familiar_chance: f64 { provenance: "Energized Familiar effect proc chance.", gate: true, source: selected_fraction(EFFECT::ENERGIZED_FAMILIAR_CHANCE.0, EFFECT::ENERGIZED_FAMILIAR_CHANCE.1), transform: |value| value },
44 energized_familiar_gain_fraction: f64 { provenance: "Energized Familiar mana-gain effect fraction.", gate: params.talent_picked_any(&[TALENT::ENERGIZED_FAMILIAR]), source: fraction(EFFECT::ENERGIZED_FAMILIAR_GAIN.0, EFFECT::ENERGIZED_FAMILIAR_GAIN.1), transform: |value| value },
45 high_voltage: bool { provenance: "High Voltage enables charge generation from Missiles.", gate: params.talent_picked_any(&[TALENT::HIGH_VOLTAGE]), source: true, transform: |value| value },
46 infused_splinters: bool { provenance: "Infused Splinters enables Salvo generation.", gate: params.talent_picked_any(&[TALENT::INFUSED_SPLINTERS]), source: true, transform: |value| value },
47 mana_bomb: bool { provenance: "Either Mana Bomb binding enables Arcane Surge scaling.", gate: params.talent_picked_any(&[TALENT::MANA_BOMB, TALENT::MANA_BOMB_DRIVER]), source: true, transform: |value| value },
48 mana_adept_refund_per_charge: f64 { provenance: "Mana Adept effect refund fraction per Arcane Charge.", gate: params.talent_picked_any(&[TALENT::MANA_ADEPT]), source: fraction(EFFECT::MANA_ADEPT_BONUS.0, EFFECT::MANA_ADEPT_BONUS.1), transform: |value| value },
49 orb_barrage: bool { provenance: "Orb Barrage enables its Salvo-stack proc.", gate: params.talent_picked_any(&[TALENT::ORB_BARRAGE]), source: true, transform: |value| value },
50 orb_mastery_damage_fraction: f64 { provenance: "Orb Mastery effect damage fraction.", gate: params.talent_picked(TALENT::ORB_MASTERY_DRIVER), source: fraction(EFFECT::ORB_MASTERY_DAMAGE.0, EFFECT::ORB_MASTERY_DAMAGE.1), transform: |value| value },
51 orb_mastery_extra_orbs: u32 { provenance: "Orb Mastery effect extra-orb count.", gate: params.talent_picked(TALENT::ORB_MASTERY_DRIVER), source: params.game_data.effect_base_points(EFFECT::ORB_MASTERY_COUNT), transform: |value: f64| wowlab_types::numeric::f64_to_u32_saturating_round(value.max(0.0)) },
52 overpowered_missiles: bool { provenance: "Either Overpowered Missiles binding enables doubled ticks.", gate: params.talent_picked_any(&[TALENT::OVERPOWERED_MISSILES, TALENT::OVERPOWERED_MISSILES_DRIVER]), source: true, transform: |value| value },
53 arcane_charge_barrage_fraction: f64 { provenance: "Arcane Charge Barrage effect fraction per charge.", gate: true, source: fraction(EFFECT::ARCANE_CHARGE_BARRAGE.0, EFFECT::ARCANE_CHARGE_BARRAGE.1), transform: |value| value },
54 arcane_charge_blast_fraction: f64 { provenance: "Arcane Charge aura e1 Blast damage fraction.", gate: true, source: fraction(EFFECT::ARCANE_CHARGE_BLAST.0, EFFECT::ARCANE_CHARGE_BLAST.1), transform: |value| value },
55 arcane_salvo_fraction_per_stack: f64 { provenance: "Arcane Salvo aura e1 fraction per stack.", gate: true, source: fraction(EFFECT::ARCANE_SALVO_DAMAGE.0, EFFECT::ARCANE_SALVO_DAMAGE.1), transform: |value| value },
56 intuition_damage_mult: f64 { provenance: "Intuition aura e1 damage fraction converted to a multiplier.", gate: true, source: fraction(EFFECT::INTUITION_DAMAGE.0, EFFECT::INTUITION_DAMAGE.1), transform: |value| 1.0 + value },
57 spellfrost_teachings: bool { provenance: "Spellfrost Teachings enables Arcane Orb cooldown reduction.", gate: params.talent_picked_any(&[TALENT::SPELLFROST_TEACHINGS]), source: true, transform: |value| value },
58 splintering_orbs: bool { provenance: "Splintering Orbs enables extra orb splinters.", gate: params.talent_picked_any(&[TALENT::SPLINTERING_ORBS]), source: true, transform: |value| value },
59 splintering_sorcery: bool { provenance: "Splintering Sorcery enables cast splinters.", gate: params.talent_picked_any(&[TALENT::SPLINTERING_SORCERY]), source: true, transform: |value| value },
60 splinterstorm: bool { provenance: "Splinterstorm enables its splinter triggers.", gate: params.talent_picked_any(&[TALENT::SPLINTERSTORM]), source: true, transform: |value| value },
61 touch_of_the_archmage: bool { provenance: "Touch of the Archmage enables its Touch rider.", gate: params.talent_picked_any(&[TALENT::TOUCH_OF_THE_ARCHMAGE]), source: true, transform: |value| value },
62 }
63 accessor pub(super) fn arcane_config(ctx);
64 register pub(super) fn register_arcane(built, params) -> ();
65 prepare {
66 let fraction = |spell_id, effect| params.game_data.base_points(SpellIdx::from_raw(spell_id), effect) / HUNDRED;
67 let selected_fraction = |spell_id, effect| gated(params.talent_picked_any(&[spell_id]), || fraction(spell_id, effect));
68 let selected_value = |spell_id, effect| gated(params.talent_picked_any(&[spell_id]), || params.game_data.base_points(SpellIdx::from_raw(spell_id), effect));
69 let clearcasting_rate = fraction(EFFECT::CLEARCASTING_PROC.0, EFFECT::CLEARCASTING_PROC.1) + selected_fraction(EFFECT::ILLUMINATED_THOUGHTS_CHANCE.0, EFFECT::ILLUMINATED_THOUGHTS_CHANCE.1) + CLEARCASTING_UNDOCUMENTED_BONUS;
70 }
71 auras {}
72 finish |cfg| {
73 for (local, source) in [
74 (AURA_ARCANE_CHARGE, AURA::ARCANE_CHARGE),
75 (AURA_ARCANE_SALVO, AURA::ARCANE_SALVO),
76 (AURA_INTUITION, AURA::INTUITION),
77 ] {
78 built.patch_aura(local, |aura| {
79 for effect in &mut aura.effects {
80 if matches!(
81 effect,
82 Some(BuffEffect::SpellDamageFromEffect {
83 source_spell_id,
84 ..
85 }) if *source_spell_id == source
86 ) {
87 *effect = None;
88 }
89 }
90 });
91 }
92 built.state.set_spec_config(cfg);
93 built.state.set_spec_runtime(ArcaneRuntime::default());
94 built.set_aura_effect(AURA_ENLIGHTENED, BuffEffect::DynamicDamageMult(enlightened_damage_mult));
95 built.set_aura_effect(AURA_ENLIGHTENED, BuffEffect::DynamicRegenPercent(enlightened_regen_percent));
96 }
97}
98
99fn enlightened_damage_mult(
100 state: &CombatState,
101 buf: &DenseBuffer,
102 _target: Option<wowlab_types::sim::EnemyIdx>,
103) -> f64 {
104 let fraction = state
105 .spec_config::<ArcaneConfig>()
106 .map_or(0.0, |cfg| cfg.enlightened_damage_fraction);
107 let mana_pct = buf
108 .resource(wowlab_types::combat::ResourceType::Mana)
109 .map_or(1.0, |resource| {
110 if resource.max > 0.0 {
111 resource.current / resource.max
112 } else {
113 1.0
114 }
115 });
116
117 enlightened_multiplier(mana_pct, fraction)
118}
119
120fn enlightened_regen_percent(state: &CombatState, buf: &DenseBuffer) -> f64 {
121 let fraction = state
122 .spec_config::<ArcaneConfig>()
123 .map_or(0.0, |cfg| cfg.enlightened_regen_fraction);
124 let mana_pct = buf
125 .resource(wowlab_types::combat::ResourceType::Mana)
126 .map_or(1.0, |resource| {
127 if resource.max > 0.0 {
128 resource.current / resource.max
129 } else {
130 1.0
131 }
132 });
133
134 (1.0 - mana_pct.clamp(0.0, 1.0)) * fraction * HUNDRED
135}
136
137fn enlightened_multiplier(mana_pct: f64, damage_fraction: f64) -> f64 {
138 1.0 + mana_pct.clamp(0.0, 1.0) * damage_fraction
139}
140
141const fn arcane_max_mana_multiplier(savant_fraction: f64, familiar_fraction: f64) -> f64 {
142 (1.0 + savant_fraction) * (1.0 + familiar_fraction)
143}
144
145pub(super) fn apply_arcane_max_mana(built: &mut BuiltCombatSystem, params: &HandlerParams<'_>) {
146 let familiar_fraction = gated(params.talent_picked_any(&[TALENT::ARCANE_FAMILIAR]), || {
147 params
148 .game_data
149 .effect_base_points(EFFECT::ARCANE_FAMILIAR_BONUS)
150 / HUNDRED
151 });
152 let savant_fraction = params.game_data.resolved_effect_points(
153 EFFECT::SAVANT_BASE,
154 built.state.config.base_stats.stats.mastery,
155 ) / HUNDRED;
156 let multiplier = arcane_max_mana_multiplier(savant_fraction, familiar_fraction);
157
158 if multiplier <= 1.0 {
159 return;
160 }
161
162 let old_max = built.state.config.base_stats.resource_max;
163
164 built.patch_base_stats(|base| {
165 base.resource_max *= multiplier;
166 base.resource_start *= multiplier;
167 base.base_regen *= multiplier;
168 });
169 let max_delta = built.state.config.base_stats.resource_max - old_max;
170 let spell_count = built.spell_count();
171
172 for index in 0..spell_count {
173 let local =
174 LocalSpellIdx::new(u8::try_from(index).expect("spell count validated below 256"));
175
176 built.patch_spell(local, |spell| {
177 spell.cost.resource_cost += spell.cost.resource_cost_pct.resolve(0.0, max_delta);
178 });
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use googletest::prelude::*;
185
186 use super::*;
187
188 #[gtest]
189 fn enlightened_scales_damage_from_live_mana_fraction() -> Result<()> {
190 verify_that!(enlightened_multiplier(0.75, 0.20), near(1.15, f64::EPSILON))
191 }
192
193 #[gtest]
194 fn savant_and_arcane_familiar_multiply_the_base_mana_pool() -> Result<()> {
195 let multiplier = arcane_max_mana_multiplier(33.13 * 0.0132, 0.10);
196 let expected = (1.0 + 33.13 * 0.0132) * 1.10;
197
198 verify_that!(multiplier, near(expected, 1e-12))
199 }
200}