1use wowlab_engine_combat::{ImpactProc, PeriodicDriver};
2use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
3use wowlab_types::constants::HUNDRED;
4#[cfg(test)]
5use wowlab_types::sim::SpellIdx;
6
7use crate::generated::specs::frost_mage::{EFFECT, SET_BONUS, TALENT};
8
9use super::hooks::{
10 HAND_OF_FROST_STACK_CHANCE_SCALE, HEART_OF_ICE_EXTRA_CONSUME, ICE_LANCE_BASE_CONSUME,
11 POLISHED_FOCUS_EXTRA_CONSUME, frostbite_impact, frostbolt_impact_filter, icicles_hook,
12};
13
14#[derive(Clone, Copy, Debug, Default)]
15pub(super) struct FrostRuntime {
16 pub(super) augury_attempts: u32,
17 pub(super) augury_icd_until_ms: u32,
18 pub(super) rof_ticks_fired: u32,
19}
20
21crate::hooks::define_spec_config! {
22 pub(super) struct FrostConfig {
23 augury_abounds: bool { provenance: "Augury Abounds enables accumulated splinter rolls.", gate: params.talent_picked_any(&[TALENT::AUGURY_ABOUNDS]), source: true, transform: |value| value },
24 brain_freeze_chance: f64 { provenance: "Brain Freeze 190447 e1 resolved proc chance.", gate: params.talent_picked(TALENT::BRAIN_FREEZE), source: params.game_data.effect_base_points(EFFECT::BRAIN_FREEZE_CHANCE), transform: |value| value / HUNDRED },
25 deep_shatter_crit_factor: f64 { provenance: "Deep Shatter effect bonus as a fraction.", gate: params.talent_picked(TALENT::DEEP_SHATTER), source: params.game_data.effect_base_points(EFFECT::DEEP_SHATTER_BONUS), transform: |value| value / HUNDRED },
26 everlasting_frost: bool { provenance: "Everlasting Frost enables its Ice Lance rider.", gate: params.talent_picked_any(&[TALENT::EVERLASTING_FROST]), source: true, transform: |value| value },
27 flash_freeze_fof_chance: f64 { provenance: "Flash Freeze 379993 e1 resolved Glacial Spike Fingers of Frost chance.", gate: params.talent_picked(TALENT::FLASH_FREEZE), source: params.game_data.effect_base_points(EFFECT::FLASH_FREEZE_FOF_CHANCE), transform: |value| value / HUNDRED },
28 force_of_will: bool { provenance: "Force of Will enables its splinter rider.", gate: params.talent_picked_any(&[TALENT::FORCE_OF_WILL]), source: true, transform: |value| value },
29 fingers_of_frost_chance: f64 { provenance: "Fingers of Frost 112965 e1 resolved proc chance.", gate: params.talent_picked(TALENT::FINGERS_OF_FROST), source: params.game_data.effect_base_points(EFFECT::FINGERS_OF_FROST_CHANCE), transform: |value| value / HUNDRED },
30 glacial_assault: bool { provenance: "Glacial Assault enables its impact rider.", gate: params.talent_picked_any(&[TALENT::GLACIAL_ASSAULT]), source: true, transform: |value| value },
31 glaciate: bool { provenance: "Glaciate enables its damage conversion.", gate: params.talent_picked_any(&[TALENT::GLACIATE]), source: true, transform: |value| value },
32 hand_of_frost: bool { provenance: "Hand of Frost rank one enables accumulated rolls.", gate: params.talent_picked_any(&[TALENT::HAND_OF_FROST_1]), source: true, transform: |value| value },
33 hand_of_frost_chance_per_stack: f64 { provenance: "Hand of Frost effect chance scaled per stack.", gate: true, source: params.game_data.effect_base_points(EFFECT::HAND_OF_FROST_CHANCE), transform: |value| value * HAND_OF_FROST_STACK_CHANCE_SCALE / HUNDRED },
34 improved_flurry: bool { provenance: "Improved Flurry enables its freezing application.", gate: params.talent_picked_any(&[TALENT::IMPROVED_FLURRY]), source: true, transform: |value| value },
35 infused_splinters: bool { provenance: "Infused Splinters enables freezing from splinters.", gate: params.talent_picked_any(&[TALENT::INFUSED_SPLINTERS]), source: true, transform: |value| value },
36 icicles_period_ms: u32 { provenance: "Icicles passive periodic stack cadence.", gate: true, source: params.game_data.effect_lookup(EFFECT::ICICLES_PERIOD).period(), transform: wowlab_types::numeric::f64_to_u32_saturating_round },
37 lance_consume: i32 { provenance: "Shatter base Ice Lance consumption plus Heart of Ice and Polished Focus.", gate: true, source: ICE_LANCE_BASE_CONSUME + i32::from(params.talent_picked_any(&[TALENT::HEART_OF_ICE])) * HEART_OF_ICE_EXTRA_CONSUME + i32::from(params.talent_picked_any(&[TALENT::POLISHED_FOCUS])) * POLISHED_FOCUS_EXTRA_CONSUME, transform: |value| value },
38 permafrost_lances: bool { provenance: "Permafrost Lances enables its lance rider.", gate: params.talent_picked_any(&[TALENT::PERMAFROST_LANCES]), source: true, transform: |value| value },
39 signature_spell: bool { provenance: "Signature Spell enables its splinter trigger.", gate: params.talent_picked_any(&[TALENT::SIGNATURE_SPELL]), source: true, transform: |value| value },
40 set2_fof_chance: f64 { provenance: "MID1 Frost 2pc Fingers of Frost chance.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID1_FROST_2PC), source: params.game_data.effect_base_points(EFFECT::MID1_FROST_2PC_FOF_CHANCE), transform: |value| value / HUNDRED },
41 set4_fof_shatter_mult: f64 { provenance: "MID1 Frost 4pc Fingers of Frost Shatter multiplier.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID1_FROST_4PC), source: params.game_data.effect_base_points(EFFECT::MID1_FROST_4PC_FOF_SHATTER), transform: |value| value / HUNDRED },
42 spellfrost_teachings: bool { provenance: "Spellfrost Teachings enables Arcane Orb CDR.", gate: params.talent_picked_any(&[TALENT::SPELLFROST_TEACHINGS]), source: true, transform: |value| value },
43 splintering_orbs: bool { provenance: "Splintering Orbs enables additional splinters.", gate: params.talent_picked_any(&[TALENT::SPLINTERING_ORBS]), source: true, transform: |value| value },
44 splintering_sorcery: bool { provenance: "Splintering Sorcery enables cast splinters.", gate: params.talent_picked_any(&[TALENT::SPLINTERING_SORCERY]), source: true, transform: |value| value },
45 splinterstorm: bool { provenance: "Splinterstorm enables its splinter triggers.", gate: params.talent_picked_any(&[TALENT::SPLINTERSTORM]), source: true, transform: |value| value },
46 thermal_void: bool { provenance: "Either Thermal Void binding enables extension behavior.", gate: params.talent_picked_any(&[TALENT::THERMAL_VOID, TALENT::THERMAL_VOID_TRAIT]), source: true, transform: |value| value },
47 wintertide: bool { provenance: "Either Wintertide binding enables its lance rider.", gate: params.talent_picked_any(&[TALENT::WINTERTIDE, TALENT::WINTERTIDE_TRAIT]), source: true, transform: |value| value },
48 }
49 accessor pub(super) fn frost_config(ctx);
50 register pub(super) fn register_frost(built, params) -> Result<(), wowlab_engine_ports::EngineError>;
51 prepare {}
52 auras {}
53 wiring {
54 { provenance: "Frostbite critical Frostbolt impact proc.", gate: params.talent_picked_any(&[TALENT::FROSTBITE]), apply: {
55 built.register_impact_proc(ImpactProc::new(frostbite_impact).with_spell_filter(frostbolt_impact_filter).skip_periodic().crit_only());
56 } },
57 }
58 finish |cfg| {
59 if let Some(icicles_period) = std::num::NonZeroU32::new(cfg.icicles_period_ms) {
60 built.register_periodic_driver(
61 PeriodicDriver::new(TALENT::ICICLES, icicles_period.get(), icicles_hook).hasted(),
62 );
63 } else if !params.game_data.is_empty() {
64 return Err(wowlab_engine_ports::EngineError::spec_construction(
65 "Icicles periodic driver resolved a zero-millisecond interval",
66 ));
67 }
68 built.state.set_spec_config(cfg);
69 built.state.set_spec_runtime(FrostRuntime::default());
70 Ok(())
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use googletest::prelude::*;
77 use wowlab_engine_ports::{CombatStats, ConsumableFlags, TalentSelection};
78use wowlab_engine_gamedata::{ResolvedGameData};
79 use wowlab_types::game::RaceId;
80
81 use super::*;
82
83 const UNUSUAL_FINGERS_CHANCE_PERCENT: f64 = 37.0;
84 const UNUSUAL_BRAIN_FREEZE_CHANCE_PERCENT: f64 = 61.0;
85 const UNUSUAL_FLASH_FREEZE_CHANCE_PERCENT: f64 = 73.0;
86
87 fn selection(spell_id: u32) -> TalentSelection {
88 TalentSelection {
89 spell_id,
90 override_spell_id: 0,
91 replaces_spell_id: 0,
92 ranks: 1,
93 precombat_aura: false,
94 hero_tree: None,
95 effect_overrides: Vec::new(),
96 }
97 }
98
99 fn registered_config(
100 fingers_selected: bool,
101 brain_freeze_selected: bool,
102 flash_freeze_selected: bool,
103 ) -> Result<FrostConfig> {
104 let encounter = wowlab_engine_ports::test_support::introspection_fixture(60.0).or_fail()?;
105 let environment = wowlab_engine_ports::test_support::introspection_game_data(&encounter);
106 let mut data = ResolvedGameData::builder();
107
108 data.set_armor_constant(environment.armor_constant());
109 data.set_creature_armor(environment.creature_armor());
110 data.set_armor_constant_mod(environment.armor_constant_mod());
111 data.insert_base_points(
112 SpellIdx::from_raw(EFFECT::FINGERS_OF_FROST_CHANCE.0),
113 EFFECT::FINGERS_OF_FROST_CHANCE.1,
114 UNUSUAL_FINGERS_CHANCE_PERCENT,
115 );
116 data.insert_base_points(
117 SpellIdx::from_raw(EFFECT::BRAIN_FREEZE_CHANCE.0),
118 EFFECT::BRAIN_FREEZE_CHANCE.1,
119 UNUSUAL_BRAIN_FREEZE_CHANCE_PERCENT,
120 );
121 data.insert_base_points(
122 SpellIdx::from_raw(EFFECT::FLASH_FREEZE_FOF_CHANCE.0),
123 EFFECT::FLASH_FREEZE_FOF_CHANCE.1,
124 UNUSUAL_FLASH_FREEZE_CHANCE_PERCENT,
125 );
126 data.insert_period(
127 SpellIdx::from_raw(EFFECT::ICICLES_PERIOD.0),
128 EFFECT::ICICLES_PERIOD.1,
129 1_000.0,
130 );
131 let data = data.build();
132 let stats = CombatStats::default();
133 let selected = [
134 fingers_selected.then(|| selection(TALENT::FINGERS_OF_FROST)),
135 brain_freeze_selected.then(|| selection(TALENT::BRAIN_FREEZE)),
136 flash_freeze_selected.then(|| selection(TALENT::FLASH_FREEZE)),
137 ]
138 .into_iter()
139 .flatten()
140 .collect::<Vec<_>>();
141 let rotation = crate::test_support::wait_rotation();
142 let params =
143 crate::test_support::handler_params(crate::test_support::HandlerParamsFixture {
144 game_data: data.clone(),
145 rotation: &rotation,
146 stats: &stats,
147 talent_selections: &selected,
148 encounter: &encounter,
149 consumables: ConsumableFlags::default(),
150 race: RaceId::Human,
151 })
152 .or_fail()?;
153 let mut built = crate::test_combat_builder(CombatStats::default())
154 .game_data(data)
155 .build(crate::test_support::wait_rotation())
156 .or_fail()?;
157
158 register_frost(&mut built, ¶ms)?;
159
160 built.state.spec_config::<FrostConfig>().copied().or_fail()
161 }
162
163 #[gtest]
164 fn proc_chances_use_unusual_resolved_effect_values() -> Result<()> {
165 let config = registered_config(true, true, true)?;
166
167 verify_that!(
168 config.fingers_of_frost_chance,
169 near(UNUSUAL_FINGERS_CHANCE_PERCENT / HUNDRED, f64::EPSILON)
170 )?;
171
172 verify_that!(
173 config.brain_freeze_chance,
174 near(UNUSUAL_BRAIN_FREEZE_CHANCE_PERCENT / HUNDRED, f64::EPSILON)
175 )?;
176
177 verify_that!(
178 config.flash_freeze_fof_chance,
179 near(UNUSUAL_FLASH_FREEZE_CHANCE_PERCENT / HUNDRED, f64::EPSILON)
180 )
181 }
182
183 #[gtest]
184 fn proc_chances_are_independently_talent_gated() -> Result<()> {
185 let fingers_only = registered_config(true, false, false)?;
186 let brain_freeze_only = registered_config(false, true, false)?;
187 let flash_freeze_only = registered_config(false, false, true)?;
188
189 verify_that!(
190 fingers_only.fingers_of_frost_chance,
191 near(UNUSUAL_FINGERS_CHANCE_PERCENT / HUNDRED, f64::EPSILON)
192 )?;
193 verify_that!(fingers_only.brain_freeze_chance, eq(0.0))?;
194 verify_that!(fingers_only.flash_freeze_fof_chance, eq(0.0))?;
195 verify_that!(brain_freeze_only.fingers_of_frost_chance, eq(0.0))?;
196 verify_that!(brain_freeze_only.flash_freeze_fof_chance, eq(0.0))?;
197 verify_that!(flash_freeze_only.fingers_of_frost_chance, eq(0.0))?;
198 verify_that!(flash_freeze_only.brain_freeze_chance, eq(0.0))?;
199
200 verify_that!(
201 brain_freeze_only.brain_freeze_chance,
202 near(UNUSUAL_BRAIN_FREEZE_CHANCE_PERCENT / HUNDRED, f64::EPSILON)
203 )?;
204
205 verify_that!(
206 flash_freeze_only.flash_freeze_fof_chance,
207 near(UNUSUAL_FLASH_FREEZE_CHANCE_PERCENT / HUNDRED, f64::EPSILON)
208 )
209 }
210}