Skip to main content

wowlab_engine_content/hooks/frost_death_knight/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::CooldownOps as _;
3use wowlab_engine_combat::ResourceOps as _;
4use wowlab_engine_combat::ProcOps as _;
5use wowlab_engine_combat::AuraOps as _;
6use wowlab_engine_combat::{DamageFlags, HookCtx, MasteryCtx, SwingEvent, SwingHand};
7use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
8use wowlab_engine_rng::proc_chance;
9use wowlab_types::{combat::DamageSchool, sim::SpellIdx};
10
11use crate::generated::specs::frost_death_knight::{
12    AURA_BONEGRINDER_CRIT, AURA_BONEGRINDER_FROST, AURA_BRITTLE, AURA_CHOSEN_OF_FROSTBROOD_HASTE,
13    AURA_DARK_TALONS_ICY_TALONS, AURA_EMPOWER_RUNE_WEAPON, AURA_EMPOWERED_STRIKES,
14    AURA_EXTERMINATE, AURA_FROST_FEVER, AURA_FROSTREAPER, AURA_FROZEN_DOMINION_MASTERY,
15    AURA_ICY_ONSLAUGHT, AURA_ICY_TALONS, AURA_KILLING_MACHINE, AURA_KILLING_STREAK, AURA_MID1_4PC,
16    AURA_PILLAR_OF_FROST, AURA_RAZORICE, AURA_REMORSELESS_WINTER, AURA_RIME, AURA_RUNE_MASTERY,
17    EFFECT, REPORTED_SPELL, SPELL, SPELL_EMPOWER_RUNE_WEAPON, TALENT,
18};
19
20/// Runic-power display divisor (power type 6 `display_modifier`).
21const RP_DISPLAY_DIV: f64 = 10.0;
22
23/// Frost Strike RP cost (49143 `power_costs` 350 / 10).
24const FROST_STRIKE_RP_COST: f64 = 35.0;
25
26/// Breath of Sindragosa RP cost (1249658 `power_costs` 600 / 10).
27const BOS_RP_COST: f64 = 60.0;
28
29/// Runic Empowerment chance uses spell 81229 effect 1 per RP spent.
30const RUNIC_EMPOWERMENT_RP_FACTOR: f64 = 0.001;
31
32/// Hyperpyrexia proc chance (spell 456238 proc chance 35%).
33const HYPERPYREXIA_PROC_CHANCE: f64 = 0.35;
34
35/// Frostbound Will recharge reduction (spell 1238680 effect 1).
36const FROSTBOUND_WILL_ERW_MS: u32 = 6_000;
37
38/// Bonegrinder conversion threshold (spell 377101 max stacks minus one).
39const BONEGRINDER_CRIT_MAX: i32 = 5;
40
41const ERW_RECHARGE_MS: u32 = 24_000;
42
43/// Applies Frozen Heart mastery, including Frostreaper's Obliterate conversion.
44#[must_use]
45pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
46    if ctx.school == DamageSchool::Frost || ctx.spell_id == EFFECT::OBLITERATE_PHYS.0 {
47        ctx.bonus(1)
48    } else {
49        1.0
50    }
51}
52
53fn static_frost_mult(ctx: &HookCtx<'_>) -> f64 {
54    (1.0 + ctx.effect_percent(EFFECT::MOTFW_ALL_PCT))
55        * (1.0 + ctx.effect_percent(EFFECT::FROST_DK_SPEC_PCT))
56}
57
58fn deal_effect(ctx: &mut HookCtx<'_>, effect: (u32, u8), mult: f64) {
59    let coef = ctx
60        .game_data()
61        .ap_coef(SpellIdx::from_raw(effect.0), effect.1);
62
63    ctx.deal_damage_ap(effect.0, coef * mult, DamageFlags::empty());
64}
65
66/// An effect's AP coefficient scaled by its spell's static passive fold.
67fn effect_folded_coef(ctx: &HookCtx<'_>, effect: (u32, u8)) -> f64 {
68    let idx = SpellIdx::from_raw(effect.0);
69
70    ctx.game_data().ap_coef(idx, effect.1) * ctx.game_data().passive_damage_mult(idx, false)
71}
72
73fn roll_hyperpyrexia(ctx: &mut HookCtx<'_>, trigger_coef: f64) {
74    if !proc_chance(ctx.rng(), HYPERPYREXIA_PROC_CHANCE) {
75        return;
76    }
77
78    let coef = trigger_coef * ctx.effect_percent(EFFECT::HYPERPYREXIA_PORTION_PCT);
79
80    ctx.deal_damage_ap(REPORTED_SPELL::HYPERPYREXIA, coef, DamageFlags::empty());
81}
82
83fn roll_dark_talons(ctx: &mut HookCtx<'_>) {
84    let chance = ctx.effect_percent(EFFECT::DARK_TALONS_CHANCE);
85
86    if proc_chance(ctx.rng(), chance) {
87        ctx.apply_aura(AURA_DARK_TALONS_ICY_TALONS.raw());
88    }
89}
90
91/// An RE proc expires Icy Onslaught and suppresses the caster's IO stack gain.
92fn runic_empowerment(ctx: &mut HookCtx<'_>, rp_spent: f64) -> bool {
93    let chance = ctx.effect_base_points(EFFECT::RUNIC_EMPOWERMENT_PCT)
94        * RUNIC_EMPOWERMENT_RP_FACTOR
95        * rp_spent;
96
97    if !proc_chance(ctx.rng(), chance) {
98        return false;
99    }
100
101    ctx.gain_resource(1.0);
102    ctx.expire_aura(AURA_ICY_ONSLAUGHT.raw());
103
104    let chance = ctx.effect_percent(EFFECT::FRIGID_EXECUTIONER_PCT);
105
106    if proc_chance(ctx.rng(), chance) {
107        ctx.gain_resource(1.0);
108    }
109
110    true
111}
112
113fn obliteration(ctx: &mut HookCtx<'_>) {
114    if !ctx.is_aura_active(AURA_PILLAR_OF_FROST.raw()) {
115        return;
116    }
117
118    ctx.apply_aura(AURA_KILLING_MACHINE.raw());
119
120    let chance = ctx.effect_percent(EFFECT::OBLITERATION_RUNE_PCT);
121
122    if proc_chance(ctx.rng(), chance) {
123        ctx.gain_resource(1.0);
124    }
125}
126
127fn trigger_frostreaper(ctx: &mut HookCtx<'_>, driver_spell_id: u32, aura_local: u8) -> bool {
128    if !ctx.roll_system_rppm(driver_spell_id) {
129        return false;
130    }
131
132    let refreshed = ctx.is_aura_active(aura_local);
133
134    ctx.apply_aura(aura_local);
135
136    refreshed
137}
138
139pub(crate) fn obliterate_hook(ctx: &mut HookCtx<'_>) {
140    // Both hits go out under their child ids: the generic static fold already
141    // carries MotFW/Frostreaper/spec passives, and Killing Machine's e1/e3
142    // affect lists name the children (325461/1264084), never the parent.
143    deal_effect(ctx, EFFECT::OBLITERATE_PHYS, 1.0);
144    deal_effect(ctx, EFFECT::OBLITERATE_FROST, 1.0);
145
146    if trigger_frostreaper(ctx, TALENT::FROSTREAPER, AURA_FROSTREAPER.raw()) {
147        deal_effect(ctx, EFFECT::FROSTREAPER_DAMAGE, static_frost_mult(ctx));
148    }
149
150    ctx.consume_aura_stack(AURA_EMPOWERED_STRIKES.raw());
151    ctx.consume_aura(AURA_EMPOWER_RUNE_WEAPON.raw());
152
153    if ctx.consume_aura_stack(AURA_EXTERMINATE.raw()) {
154        deal_effect(ctx, EFFECT::EXTERMINATE_HIT, 1.0);
155        deal_effect(ctx, EFFECT::EXTERMINATE_AOE, 1.0);
156        ctx.apply_aura(AURA_KILLING_MACHINE.raw());
157    }
158
159    let km_stacks = ctx.aura_stacks(AURA_KILLING_MACHINE.raw());
160
161    if km_stacks > 0 {
162        ctx.consume_aura(AURA_KILLING_MACHINE.raw());
163        ctx.apply_aura_n(AURA_KILLING_STREAK.raw(), km_stacks);
164        roll_dark_talons(ctx);
165        let aa_mult = ctx.effect_percent(EFFECT::ARCTIC_ASSAULT_PCT)
166            * f64::from(km_stacks)
167            * static_frost_mult(ctx);
168
169        deal_effect(ctx, EFFECT::GLACIAL_ADVANCE_DAMAGE, aa_mult);
170        ctx.apply_aura(AURA_RAZORICE.raw());
171
172        let chance = ctx.effect_percent(EFFECT::MURDEROUS_EFFICIENCY_PCT);
173
174        if proc_chance(ctx.rng(), chance) {
175            ctx.gain_resource(1.0);
176        }
177
178        ctx.apply_aura(AURA_BONEGRINDER_CRIT.raw());
179
180        if ctx.aura_stacks(AURA_BONEGRINDER_CRIT.raw()) >= BONEGRINDER_CRIT_MAX {
181            ctx.expire_aura(AURA_BONEGRINDER_CRIT.raw());
182            ctx.apply_aura(AURA_BONEGRINDER_FROST.raw());
183        }
184    }
185
186    let chance = ctx.effect_percent(EFFECT::RIME_PROC_PCT);
187
188    if proc_chance(ctx.rng(), chance) {
189        ctx.apply_aura(AURA_RIME.raw());
190    }
191
192    ctx.apply_aura(AURA_RUNE_MASTERY.raw());
193
194    roll_hyperpyrexia(ctx, effect_folded_coef(ctx, EFFECT::OBLITERATE_PHYS));
195    roll_hyperpyrexia(ctx, effect_folded_coef(ctx, EFFECT::OBLITERATE_FROST));
196}
197
198pub(crate) fn frost_strike_hook(ctx: &mut HookCtx<'_>) {
199    let fs_mult = static_frost_mult(ctx)
200        * (1.0 + ctx.effect_percent(EFFECT::RUNIC_OVERFLOW_FS_PCT))
201        * (1.0 + ctx.effect_percent(EFFECT::DEATHLY_BLOWS_FS_PCT))
202        * (1.0 + ctx.effect_percent(EFFECT::COF_SPELLS_PCT));
203    let fs_coef = ctx
204        .game_data()
205        .effect_ap_coefficient(EFFECT::FROST_STRIKE_2H);
206
207    ctx.deal_damage_ap(SPELL::FROST_STRIKE, fs_coef * fs_mult, DamageFlags::empty());
208
209    if ctx.consume_aura(AURA_FROSTREAPER.raw()) {
210        deal_effect(ctx, EFFECT::FROSTREAPER_DAMAGE, static_frost_mult(ctx));
211    }
212
213    ctx.apply_aura(AURA_ICY_TALONS.raw());
214
215    if !runic_empowerment(ctx, FROST_STRIKE_RP_COST) {
216        ctx.apply_aura(AURA_ICY_ONSLAUGHT.raw());
217    }
218
219    obliteration(ctx);
220    roll_hyperpyrexia(ctx, fs_coef * fs_mult);
221}
222
223pub(crate) fn howling_blast_hook(ctx: &mut HookCtx<'_>) {
224    ctx.apply_aura(AURA_FROST_FEVER.raw());
225
226    let mut hb_mult = static_frost_mult(ctx) * (1.0 + ctx.effect_percent(EFFECT::COF_SPELLS_PCT));
227
228    if ctx.consume_aura(AURA_RIME.raw()) {
229        roll_dark_talons(ctx);
230        let rime_pct = ctx.effect_percent(EFFECT::RIME_HB_BONUS_PCT)
231            + ctx.effect_percent(EFFECT::NORTHWINDS_RIME_PCT);
232
233        hb_mult *= (1.0 + rime_pct)
234            * (1.0 + ctx.effect_percent(EFFECT::ICEBREAKER_PCT))
235            * (1.0 + ctx.effect_percent(EFFECT::BIND_IN_DARKNESS_HB_PCT));
236
237        let km_chance = ctx.effect_percent(EFFECT::HOWLING_BLADES_KM_PCT);
238
239        for blade in [EFFECT::HOWLING_BLADES_FIRST, EFFECT::HOWLING_BLADES_SECOND] {
240            deal_effect(ctx, blade, 1.0);
241
242            if proc_chance(ctx.rng(), km_chance) {
243                ctx.apply_aura(AURA_KILLING_MACHINE.raw());
244            }
245        }
246
247        let rfc_rp = ctx.effect_base_points(EFFECT::RAGE_OF_FROZEN_CHAMPION_RP) / RP_DISPLAY_DIV;
248
249        ctx.gain_secondary_resource(rfc_rp);
250        ctx.reduce_cooldown(SPELL_EMPOWER_RUNE_WEAPON.raw(), FROSTBOUND_WILL_ERW_MS);
251    } else {
252        ctx.apply_aura(AURA_RUNE_MASTERY.raw());
253    }
254
255    deal_effect(ctx, EFFECT::HOWLING_BLAST_DAMAGE, hb_mult);
256    obliteration(ctx);
257}
258
259/// Brittle rolls 374504's proc chance on every disease tick (sc_death_knight.cpp:7769-7775).
260pub(crate) fn frost_fever_tick_hook(ctx: &mut HookCtx<'_>) {
261    let chance = ctx.selected_talent_proc_chance(TALENT::BRITTLE);
262
263    if proc_chance(ctx.rng(), chance) {
264        ctx.apply_aura(AURA_BRITTLE.raw());
265    }
266}
267
268pub(crate) fn remorseless_winter_hook(ctx: &mut HookCtx<'_>) {
269    ctx.apply_aura(AURA_REMORSELESS_WINTER.raw());
270}
271
272fn trigger_frozen_dominion(
273    ctx: &mut HookCtx<'_>,
274    talent_id: u32,
275    remorseless_winter_local: u8,
276    mastery_local: u8,
277) {
278    if !ctx.talent_selected(talent_id) {
279        return;
280    }
281
282    ctx.apply_aura(remorseless_winter_local);
283    ctx.apply_aura(mastery_local);
284}
285
286/// Applies Frozen Dominion's Remorseless Winter.
287pub(crate) fn pillar_of_frost_hook(ctx: &mut HookCtx<'_>) {
288    // SimC grants one 377253 stack per distinct enemy hit by this 1233152
289    // activation. The engine currently resolves a single target, and the
290    // periodic aura ticks immediately on application.
291    trigger_frozen_dominion(
292        ctx,
293        TALENT::FROZEN_DOMINION,
294        AURA_REMORSELESS_WINTER.raw(),
295        AURA_FROZEN_DOMINION_MASTERY.raw(),
296    );
297
298    if mid1_4pc(ctx) {
299        ctx.apply_aura(AURA_MID1_4PC.raw());
300    }
301}
302
303pub(crate) fn empower_rune_weapon_hook(ctx: &mut HookCtx<'_>) {
304    let rp = ctx.effect_base_points(EFFECT::ERW_RP) / RP_DISPLAY_DIV;
305
306    ctx.gain_secondary_resource(rp);
307    ctx.apply_aura(AURA_EMPOWER_RUNE_WEAPON.raw());
308    ctx.apply_aura(AURA_KILLING_MACHINE.raw());
309
310    if mid1_2pc(ctx) {
311        ctx.apply_aura(AURA_EMPOWERED_STRIKES.raw());
312    }
313}
314
315fn mid1_2pc(ctx: &HookCtx<'_>) -> bool {
316    ctx.state()
317        .spec_config::<super::config::FrostDkConfig>()
318        .is_some_and(|config| config.mid1_2pc)
319}
320
321fn mid1_4pc(ctx: &HookCtx<'_>) -> bool {
322    ctx.state()
323        .spec_config::<super::config::FrostDkConfig>()
324        .is_some_and(|config| config.mid1_4pc)
325}
326
327pub(crate) fn breath_of_sindragosa_hook(ctx: &mut HookCtx<'_>) {
328    breath_of_sindragosa_damage(ctx);
329    ctx.apply_aura(AURA_ICY_TALONS.raw());
330    runic_empowerment(ctx, BOS_RP_COST);
331    ctx.reduce_cooldown(SPELL_EMPOWER_RUNE_WEAPON.raw(), ERW_RECHARGE_MS);
332}
333
334pub(crate) fn breath_of_sindragosa_tick_hook(ctx: &mut HookCtx<'_>) {
335    breath_of_sindragosa_damage(ctx);
336}
337
338pub(crate) fn breath_of_sindragosa_expire_hook(ctx: &mut HookCtx<'_>) {
339    ctx.gain_resource(ctx.effect_base_points(EFFECT::BOS_RUNE_REFUND));
340}
341
342fn breath_of_sindragosa_damage(ctx: &mut HookCtx<'_>) {
343    ctx.deal_effect_damage_ap_with_geometry(
344        EFFECT::BOS_DAMAGE,
345        EFFECT::BOS_DAMAGE,
346        1.0,
347        DamageFlags::empty(),
348    );
349}
350
351pub(crate) fn frostwyrms_fury_hook(ctx: &mut HookCtx<'_>) {
352    let multiplier = static_frost_mult(ctx) * (1.0 + ctx.effect_percent(EFFECT::COF_FWF_PCT));
353
354    ctx.deal_effect_damage_ap_with_geometry(
355        EFFECT::FROSTWYRMS_FURY_DAMAGE,
356        EFFECT::FROSTWYRMS_FURY_DAMAGE,
357        multiplier,
358        DamageFlags::empty(),
359    );
360    let coef = ctx.effect_ap_coefficient(EFFECT::FROSTWYRMS_FURY_DAMAGE) * multiplier;
361
362    ctx.deal_damage_ap(
363        REPORTED_SPELL::CHOSEN_OF_FROSTBROOD_RECALL,
364        coef,
365        DamageFlags::empty(),
366    );
367    ctx.apply_aura(AURA_CHOSEN_OF_FROSTBROOD_HASTE.raw());
368}
369
370/// Collapses Reaper's Mark's single-target package to cast time.
371pub(crate) fn reapers_mark_hook(ctx: &mut HookCtx<'_>) {
372    deal_effect(ctx, EFFECT::WAVE_OF_SOULS_DAMAGE, 1.0);
373    deal_effect(ctx, EFFECT::WAVE_OF_SOULS_DAMAGE, 1.0);
374
375    let hp_pct = ctx.target_health_fraction();
376    let grim_reaper = 1.0 + (1.0 - hp_pct) * ctx.effect_percent(EFFECT::GRIM_REAPER_MAX_PCT);
377    let stacks = ctx.effect_base_points(EFFECT::REAPERS_MARK_STACKS);
378
379    deal_effect(ctx, EFFECT::REAPERS_MARK_EXPLOSION, stacks * grim_reaper);
380
381    // Echoing Fury's cast-time charge is consumed before the delayed explosion.
382
383    for _ in 0..wowlab_types::numeric::f64_to_i64_saturating_trunc(
384        ctx.effect_base_points(EFFECT::ECHOING_FURY_STACKS),
385    ) {
386        deal_effect(ctx, EFFECT::EXTERMINATE_HIT, 1.0);
387        deal_effect(ctx, EFFECT::EXTERMINATE_AOE, 1.0);
388        ctx.apply_aura(AURA_KILLING_MACHINE.raw());
389    }
390
391    ctx.apply_aura_n(
392        AURA_EXTERMINATE.raw(),
393        wowlab_types::numeric::f64_to_i32_saturating_trunc(
394            ctx.effect_base_points(EFFECT::EXTERMINATE_STACKS),
395        ),
396    );
397
398    ctx.apply_aura(AURA_KILLING_MACHINE.raw());
399    ctx.apply_aura(AURA_FROST_FEVER.raw());
400    ctx.apply_aura(AURA_RUNE_MASTERY.raw());
401}
402
403pub(crate) fn swing_hook(ctx: &mut HookCtx<'_>, event: SwingEvent) {
404    let hand = event.hand;
405    let is_crit = event.is_crit;
406
407    if hand == SwingHand::OffHand {
408        return;
409    }
410
411    if is_crit {
412        ctx.apply_aura(AURA_KILLING_MACHINE.raw());
413        ctx.deal_effect_damage_ap_with_geometry(
414            EFFECT::ICY_DEATH_TORRENT_DAMAGE,
415            EFFECT::ICY_DEATH_TORRENT_DAMAGE,
416            1.0,
417            DamageFlags::empty(),
418        );
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use googletest::prelude::*;
425    use wowlab_engine_combat::AuraOps as _;
426    use wowlab_engine_combat::{BuffEffect, BuiltCombatSystem, HookCtx, LocalAuraIdx};
427    use wowlab_engine_domain::dbc::{AuraSubtypeKind, SpellEffectKind};
428    use wowlab_engine_ports::{CombatStats, TalentSelection};
429    use wowlab_engine_telemetry::{TelemetrySink};
430use wowlab_engine_gamedata::{AuraProps, ResolvedGameData, SpellProps};
431    use wowlab_types::sim::{SimTime, SpellIdx};
432
433    use super::trigger_frozen_dominion;
434
435    const FROZEN_DOMINION: u32 = 377_226;
436    const FROZEN_DOMINION_MASTERY: u32 = 377_253;
437    const FROZEN_DOMINION_MASTERY_PER_STACK: f64 = 4.0;
438    const REMORSELESS_WINTER: u32 = 1_233_152;
439    const REMORSELESS_WINTER_LOCAL: LocalAuraIdx = LocalAuraIdx::new(0);
440    const MASTERY_LOCAL: LocalAuraIdx = LocalAuraIdx::new(1);
441
442    fn activation_fixture(selected: bool) -> BuiltCombatSystem {
443        let talents = selected.then_some(TalentSelection {
444            spell_id: FROZEN_DOMINION,
445            override_spell_id: 0,
446            replaces_spell_id: 0,
447            ranks: 1,
448            precombat_aura: false,
449            hero_tree: None,
450            effect_overrides: Vec::new(),
451        });
452
453        crate::test_combat_builder(CombatStats::default())
454            .aura("remorseless_winter", REMORSELESS_WINTER, |a| {
455                Ok(a.on_player().duration_ms(12_000))
456            })
457            .aura("frozen_dominion_mastery", FROZEN_DOMINION_MASTERY, |a| {
458                Ok(a.on_player().duration_ms(15_000).max_stacks(5))
459            })
460            .talent_spell_ids(&[("Frozen Dominion", FROZEN_DOMINION)])
461            .talent_selections(talents.as_slice())
462            .build(crate::test_support::wait_rotation())
463            .expect("build Frozen Dominion activation fixture")
464    }
465
466    #[gtest]
467    fn frozen_dominion_activation_requires_selected_talent() -> Result<()> {
468        let mut built = activation_fixture(false);
469        let mut telemetry = TelemetrySink::default();
470        let mut rng = || 0.5;
471        let ctx = &mut HookCtx::new(
472            wowlab_engine_combat::HookCtxServices {
473                state: &mut built.state,
474                buf: &mut built.buffer,
475                sink: &mut telemetry,
476                rng: &mut rng,
477            },
478            wowlab_engine_combat::HookCtxRequest::targetless(SimTime::ZERO),
479        );
480
481        trigger_frozen_dominion(
482            ctx,
483            FROZEN_DOMINION,
484            REMORSELESS_WINTER_LOCAL.raw(),
485            MASTERY_LOCAL.raw(),
486        );
487
488        verify_that!(ctx.aura_stacks(REMORSELESS_WINTER_LOCAL.raw()), eq(0))?;
489
490        verify_that!(ctx.aura_stacks(MASTERY_LOCAL.raw()), eq(0))
491    }
492
493    #[gtest]
494    fn selected_frozen_dominion_activates_winter_and_one_mastery_stack() -> Result<()> {
495        let mut built = activation_fixture(true);
496        let mut telemetry = TelemetrySink::default();
497        let mut rng = || 0.5;
498        let ctx = &mut HookCtx::new(
499            wowlab_engine_combat::HookCtxServices {
500                state: &mut built.state,
501                buf: &mut built.buffer,
502                sink: &mut telemetry,
503                rng: &mut rng,
504            },
505            wowlab_engine_combat::HookCtxRequest::targetless(SimTime::ZERO),
506        );
507
508        trigger_frozen_dominion(
509            ctx,
510            FROZEN_DOMINION,
511            REMORSELESS_WINTER_LOCAL.raw(),
512            MASTERY_LOCAL.raw(),
513        );
514
515        verify_that!(ctx.aura_stacks(REMORSELESS_WINTER_LOCAL.raw()), eq(1))?;
516        verify_that!(ctx.aura_stacks(MASTERY_LOCAL.raw()), eq(1))?;
517        verify_that!(
518            ctx.aura_remaining_ms(REMORSELESS_WINTER_LOCAL.raw()),
519            some(eq(12_000))
520        )?;
521
522        verify_that!(ctx.aura_remaining_ms(MASTERY_LOCAL.raw()), some(eq(15_000)))
523    }
524
525    #[gtest]
526    fn frozen_dominion_mastery_definition_is_lowered_from_dbc() -> Result<()> {
527        let spell = SpellIdx::from_raw(FROZEN_DOMINION_MASTERY);
528        let mut data = ResolvedGameData::builder();
529
530        data.insert_spell_props(spell, SpellProps::default());
531        data.insert_aura_props(
532            spell,
533            AuraProps {
534                duration_ms: 15_000,
535                doses: 1,
536                max_stacks: 5,
537                tick_period_ms: 0,
538                pandemic: false,
539            },
540        );
541        data.insert_effect_type(
542            wowlab_types::sim::EffectRef::new(spell, 1),
543            SpellEffectKind::ApplyAura as i32,
544        );
545        data.insert_effect_aura(
546            wowlab_types::sim::EffectRef::new(spell, 1),
547            AuraSubtypeKind::ModMasteryPercent as i32,
548        );
549        data.insert_base_points(spell, 1, FROZEN_DOMINION_MASTERY_PER_STACK);
550        let data = data.build();
551        let built = crate::test_combat_builder(CombatStats::default())
552            .game_data(data.clone())
553            .aura("frozen_dominion_mastery", FROZEN_DOMINION_MASTERY, |a| {
554                Ok(a.on_player()
555                    .apply_base_from_data(&data, FROZEN_DOMINION_MASTERY)?
556                    .max_stacks(data.require_aura_max_stacks(spell)?))
557            })
558            .build(crate::test_support::wait_rotation())
559            .expect("build DBC-backed Frozen Dominion mastery fixture");
560        let local = built.state.aura_local(FROZEN_DOMINION_MASTERY).or_fail()?;
561        let aura = built.aura(local).or_fail()?;
562
563        verify_that!(aura.base_duration_ms, eq(15_000))?;
564        verify_that!(aura.max_stacks, eq(5))?;
565
566        verify_true!(aura.effects.iter().flatten().any(|effect| {
567            matches!(
568                effect,
569                BuffEffect::Mastery(value)
570                    if (*value - FROZEN_DOMINION_MASTERY_PER_STACK).abs() <= f64::EPSILON
571            )
572        }))
573    }
574}
575
576#[cfg(test)]
577#[path = "hooks/bonegrinder_tests.rs"]
578mod bonegrinder_tests;
579
580#[cfg(test)]
581#[path = "hooks/frostreaper_tests.rs"]
582mod frostreaper_tests;
583
584#[cfg(test)]
585#[path = "hooks/icy_talons_tests.rs"]
586mod icy_talons_tests;