Skip to main content

wowlab_engine_content/hooks/outlaw_rogue/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::CooldownOps as _;
3use wowlab_engine_combat::ResourceOps as _;
4use wowlab_engine_combat::AuraOps as _;
5use wowlab_engine_combat::{DamageFlags, HookCtx, SwingEvent, SwingHand};
6use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
7use wowlab_engine_rng::proc_chance;
8use wowlab_types::{constants::HUNDRED, sim::SpellIdx};
9
10use crate::generated::specs::outlaw_rogue::{
11    AURA_AUDACITY, AURA_BETWEEN_THE_EYES, AURA_BLADE_FLURRY, AURA_DEADLY_PURSUIT_CDR,
12    AURA_GRAVEDIGGER, AURA_LOADED_DICE, AURA_OPPORTUNITY, AURA_PALMED_BULLETS, AURA_SLICE_AND_DICE,
13    EFFECT, REPORTED_SPELL, SPELL, SPELL_ADRENALINE_RUSH, SPELL_BETWEEN_THE_EYES,
14    SPELL_BLADE_FLURRY, SPELL_BLADE_RUSH, SPELL_KILLING_SPREE,
15};
16use crate::hooks::shared::rogue::{bound_edge_case, bound_hand_of_fate, cut_to_the_chase};
17
18use super::{
19    config::outlaw_config,
20    fatebound::BINDING as FATEBOUND,
21    procs::{
22        apply_opportunity, opportunity_chance, proc_instant_poison, proc_main_gauche,
23        proc_melee_hit,
24    },
25    restless_blades::on_finisher,
26    roll_the_bones::rtb_tier,
27};
28
29pub(crate) fn swing_hook(ctx: &mut HookCtx<'_>, event: SwingEvent) {
30    let hand = event.hand;
31
32    proc_instant_poison(ctx);
33
34    match hand {
35        SwingHand::MainHand => proc_main_gauche(ctx),
36        SwingHand::OffHand => {}
37    }
38}
39
40const SINISTER_STRIKE_EXTRA_CP: f64 = 1.0;
41const RTB_BUILDER_CP_BONUS: f64 = 1.0;
42const RTB_TIER_BONUS_CP: usize = 2;
43
44const KILLING_SPREE_CP_PER_TICK: f64 = 1.0;
45
46pub(crate) fn sinister_strike_hook(ctx: &mut HookCtx<'_>) {
47    proc_melee_hit(ctx);
48
49    if rtb_tier(ctx) >= RTB_TIER_BONUS_CP {
50        ctx.gain_secondary_resource(RTB_BUILDER_CP_BONUS);
51    }
52
53    let chance = opportunity_chance(ctx);
54
55    if proc_chance(ctx.rng(), chance) {
56        apply_opportunity(ctx);
57        ctx.gain_secondary_resource(SINISTER_STRIKE_EXTRA_CP);
58
59        if rtb_tier(ctx) >= RTB_TIER_BONUS_CP {
60            ctx.gain_secondary_resource(RTB_BUILDER_CP_BONUS);
61        }
62
63        let coef = ctx
64            .game_data()
65            .effect_ap_coefficient(EFFECT::SINISTER_STRIKE_EXTRA_ATTACK_DAMAGE);
66
67        ctx.deal_damage_ap(
68            REPORTED_SPELL::SINISTER_STRIKE_EXTRA_ATTACK,
69            coef,
70            DamageFlags::PHYSICAL,
71        );
72        proc_melee_hit(ctx);
73    }
74}
75
76pub(crate) fn ambush_hook(ctx: &mut HookCtx<'_>) {
77    proc_melee_hit(ctx);
78
79    if rtb_tier(ctx) >= RTB_TIER_BONUS_CP {
80        ctx.gain_secondary_resource(RTB_BUILDER_CP_BONUS);
81    }
82
83    let modifier = outlaw_config(ctx).hidden_opportunity_mod;
84
85    let chance = opportunity_chance(ctx) * modifier;
86
87    if modifier > 0.0 && proc_chance(ctx.rng(), chance) {
88        apply_opportunity(ctx);
89    }
90}
91
92/// Palmed Bullets threshold (spell 1265931 effect 1, six stacks).
93const PALMED_BULLETS_GRAVEDIGGER_STACKS: i32 = 6;
94const BETWEEN_THE_EYES_MS_PER_POINT: u32 = 2000;
95
96pub(crate) fn dispatch_hook(ctx: &mut HookCtx<'_>) {
97    proc_melee_hit(ctx);
98    let cp = ctx.last_secondary_spent().max(1.0);
99
100    on_finisher(ctx);
101    cut_to_the_chase(ctx, AURA_SLICE_AND_DICE, cp);
102    bound_hand_of_fate(ctx, true, FATEBOUND);
103
104    let cfg = outlaw_config(ctx);
105
106    if cfg.scoundrel_cp_threshold > 0.0 && cp >= cfg.scoundrel_cp_threshold {
107        let coef = ctx
108            .game_data()
109            .effect_ap_coefficient(EFFECT::SCOUNDREL_STRIKE_DAMAGE);
110
111        ctx.deal_damage_ap(
112            REPORTED_SPELL::SCOUNDREL_STRIKE,
113            coef,
114            DamageFlags::PHYSICAL,
115        );
116        proc_instant_poison(ctx);
117    }
118
119    if cfg.palmed_bullets_chance_per_cp > 0.0
120        && proc_chance(ctx.rng(), cfg.palmed_bullets_chance_per_cp * cp)
121    {
122        ctx.apply_aura(AURA_PALMED_BULLETS.raw());
123
124        if ctx.aura_stacks(AURA_PALMED_BULLETS.raw()) >= PALMED_BULLETS_GRAVEDIGGER_STACKS
125            && !ctx.is_aura_active(AURA_GRAVEDIGGER.raw())
126        {
127            ctx.apply_aura(AURA_GRAVEDIGGER.raw());
128        }
129    }
130}
131
132pub(crate) fn between_the_eyes_hook(ctx: &mut HookCtx<'_>) {
133    proc_melee_hit(ctx);
134    let cp_spent = ctx.last_secondary_spent().max(0.0);
135    let duration_ms = (wowlab_types::numeric::f64_to_u32_saturating_trunc(cp_spent) + 1)
136        * BETWEEN_THE_EYES_MS_PER_POINT;
137
138    ctx.apply_aura_with_duration(AURA_BETWEEN_THE_EYES.raw(), duration_ms);
139    on_finisher(ctx);
140    bound_hand_of_fate(ctx, false, FATEBOUND);
141
142    let cfg = outlaw_config(ctx);
143
144    if cfg.ace_up_chance_per_cp > 0.0 && proc_chance(ctx.rng(), cfg.ace_up_chance_per_cp * cp_spent)
145    {
146        ctx.gain_secondary_resource(cfg.ace_up_cp_gain);
147        ctx.reset_cooldown(SPELL_BETWEEN_THE_EYES.raw());
148    }
149
150    if ctx.is_aura_active(AURA_GRAVEDIGGER.raw()) {
151        ctx.gain_secondary_resource(cfg.gravedigger_cp);
152        ctx.reset_cooldown(SPELL_BETWEEN_THE_EYES.raw());
153
154        for _ in 0..PALMED_BULLETS_GRAVEDIGGER_STACKS {
155            ctx.consume_aura_stack(AURA_PALMED_BULLETS.raw());
156        }
157
158        ctx.consume_aura(AURA_GRAVEDIGGER.raw());
159    }
160}
161
162pub(crate) fn slice_and_dice_hook(ctx: &mut HookCtx<'_>) {
163    on_finisher(ctx);
164    bound_hand_of_fate(ctx, false, FATEBOUND);
165}
166
167pub(crate) fn blade_flurry_hook(ctx: &mut HookCtx<'_>) {
168    let cfg = outlaw_config(ctx);
169
170    if cfg.deft_maneuvers_cp > 0.0 {
171        let targets = ctx.resolved_spell_effect_targets(
172            EFFECT::BLADE_FLURRY_INSTANT_ATTACK_DAMAGE,
173            SPELL::BLADE_FLURRY_INSTANT_ATTACK,
174            Some(cfg.blade_flurry_targets),
175        );
176        let combo_points = targets
177            .iter()
178            .fold(0.0, |total, _target| total + cfg.deft_maneuvers_cp);
179
180        ctx.gain_secondary_resource(combo_points);
181    }
182}
183
184pub(crate) fn killing_spree_hook(ctx: &mut HookCtx<'_>) {
185    let ticks =
186        wowlab_types::numeric::f64_to_u32_saturating_trunc(ctx.last_secondary_spent().max(0.0));
187    let mh = ctx
188        .game_data()
189        .effect_ap_coefficient(EFFECT::KILLING_SPREE_MH_DAMAGE);
190    let oh = ctx
191        .game_data()
192        .effect_ap_coefficient(EFFECT::KILLING_SPREE_OH_DAMAGE);
193
194    for i in 0..ticks {
195        ctx.deal_damage_ap(REPORTED_SPELL::KILLING_SPREE_MH, mh, DamageFlags::PHYSICAL);
196        ctx.deal_damage_ap(REPORTED_SPELL::KILLING_SPREE_OH, oh, DamageFlags::PHYSICAL);
197        proc_main_gauche(ctx);
198
199        if i > 0 {
200            ctx.gain_secondary_resource(KILLING_SPREE_CP_PER_TICK);
201        }
202    }
203
204    on_finisher(ctx);
205    bound_hand_of_fate(ctx, false, FATEBOUND);
206}
207
208pub(crate) fn pistol_shot_hook(ctx: &mut HookCtx<'_>) {
209    let cfg = outlaw_config(ctx);
210    let opportunity_stacks = ctx.aura_stacks(AURA_OPPORTUNITY.raw()).max(0);
211
212    proc_instant_poison(ctx);
213
214    if opportunity_stacks == 0 {
215        return;
216    }
217
218    if cfg.quick_draw_cp > 0.0 {
219        ctx.gain_secondary_resource(cfg.quick_draw_cp);
220    }
221
222    let chance = opportunity_chance(ctx);
223
224    if cfg.has_audacity && proc_chance(ctx.rng(), chance) {
225        ctx.apply_aura(AURA_AUDACITY.raw());
226    }
227
228    ctx.consume_aura_stack(AURA_OPPORTUNITY.raw());
229    let remaining = u32::try_from(opportunity_stacks.saturating_sub(1)).unwrap_or_default();
230    let extra_shots = remaining.min(cfg.fan_the_hammer_extra);
231    let coef = ctx
232        .game_data()
233        .effect_ap_coefficient(EFFECT::PISTOL_SHOT_DAMAGE)
234        * cfg.fan_the_hammer_mult;
235
236    for _ in 0..extra_shots {
237        // Each secondary shot snapshots Opportunity before consuming its charge.
238        ctx.deal_damage_ap(SPELL::PISTOL_SHOT, coef, DamageFlags::PHYSICAL);
239        proc_instant_poison(ctx);
240
241        if cfg.quick_draw_cp > 0.0 {
242            ctx.gain_secondary_resource(cfg.quick_draw_cp);
243        }
244
245        ctx.consume_aura_stack(AURA_OPPORTUNITY.raw());
246    }
247}
248
249pub(crate) fn blade_rush_hook(ctx: &mut HookCtx<'_>) {
250    let data = ctx.game_data();
251    let attack = SpellIdx::from_raw(EFFECT::BLADE_RUSH_ATTACK_DAMAGE.0);
252    let coef = data.ap_coef(attack, EFFECT::BLADE_RUSH_ATTACK_DAMAGE.1);
253    let primary_multiplier = data.base_points(attack, EFFECT::BLADE_RUSH_PRIMARY_MULT.1) / HUNDRED;
254    let secondary_multiplier = if ctx.is_aura_active(AURA_BLADE_FLURRY.raw()) {
255        1.0 + data.base_points(SpellIdx::from_raw(SPELL::BLADE_RUSH), 1) / HUNDRED
256    } else {
257        1.0
258    };
259    let primary = ctx.target();
260    let targets = ctx.resolved_spell_effect_targets(
261        EFFECT::BLADE_RUSH_ATTACK_DAMAGE,
262        REPORTED_SPELL::BLADE_RUSH_ATTACK,
263        None,
264    );
265
266    for target in targets {
267        let multiplier = if Some(target) == primary {
268            primary_multiplier
269        } else {
270            secondary_multiplier
271        };
272
273        ctx.deal_damage_ap_to_target(
274            target,
275            REPORTED_SPELL::BLADE_RUSH_ATTACK,
276            coef * multiplier,
277            DamageFlags::PHYSICAL,
278        );
279    }
280
281    proc_main_gauche(ctx);
282}
283
284pub(crate) fn preparation_hook(ctx: &mut HookCtx<'_>) {
285    ctx.reset_cooldown(SPELL_ADRENALINE_RUSH.raw());
286    ctx.reset_cooldown(SPELL_BETWEEN_THE_EYES.raw());
287    ctx.reset_cooldown(SPELL_BLADE_FLURRY.raw());
288    ctx.reset_cooldown(SPELL_BLADE_RUSH.raw());
289    ctx.reset_cooldown(SPELL_KILLING_SPREE.raw());
290}
291
292pub(crate) fn adrenaline_rush_hook(ctx: &mut HookCtx<'_>) {
293    let cfg = outlaw_config(ctx);
294
295    if cfg.has_loaded_dice {
296        ctx.apply_aura(AURA_LOADED_DICE.raw());
297    }
298
299    if cfg.has_improved_adrenaline_rush {
300        let max_cp = ctx.state().config.base_stats.secondary_resource_max;
301
302        ctx.gain_secondary_resource(max_cp);
303    }
304
305    bound_edge_case(ctx, FATEBOUND);
306}
307
308pub(crate) fn deadly_pursuit_wait_expire_hook(ctx: &mut HookCtx<'_>) {
309    ctx.apply_aura(AURA_DEADLY_PURSUIT_CDR.raw());
310}
311
312#[cfg(test)]
313mod tests {
314    use googletest::prelude::*;
315
316    use super::*;
317    use crate::hooks::outlaw_rogue::config::OutlawConfig;
318    use wowlab_engine_ports::{CombatStats};
319    use wowlab_engine_telemetry::{TelemetrySink};
320use wowlab_engine_gamedata::{SpellProps};
321    use wowlab_types::sim::{EnemyIdx, SimTime};
322
323    #[gtest]
324    fn deft_maneuvers_uses_filtered_ordered_and_capped_hit_identities() -> Result<()> {
325        let encounter = crate::hooks::shared::test_support::spatial_hook_encounter()?;
326        let mut data = wowlab_engine_gamedata::ResolvedGameData::builder();
327
328        data.set_creature_armor(0.0);
329        data.set_armor_constant(1.0);
330        data.set_level(80);
331        data.insert_spell_props(
332            SpellIdx::from_raw(SPELL::BLADE_FLURRY_INSTANT_ATTACK),
333            SpellProps {
334                hostile_max_range: 20.0,
335                radius: 8.0,
336                ..SpellProps::default()
337            },
338        );
339        data.insert_effect_type(
340            wowlab_types::sim::EffectRef::new(
341                SpellIdx::from_raw(SPELL::BLADE_FLURRY_INSTANT_ATTACK),
342                EFFECT::BLADE_FLURRY_INSTANT_ATTACK_DAMAGE.1,
343            ),
344            2,
345        );
346        data.insert_implicit_targets(
347            wowlab_types::sim::EffectRef::new(
348                SpellIdx::from_raw(SPELL::BLADE_FLURRY_INSTANT_ATTACK),
349                EFFECT::BLADE_FLURRY_INSTANT_ATTACK_DAMAGE.1,
350            ),
351            18,
352            16,
353        );
354        let mut built = crate::test_combat_builder(CombatStats::default())
355            .secondary_resource("combo_points", 10.0)
356            .secondary_resource_type_id(4)
357            .game_data(data.build())
358            .encounter(encounter)
359            .spell(
360                "blade_flurry_instant_attack",
361                SPELL::BLADE_FLURRY_INSTANT_ATTACK,
362                Ok,
363            )
364            .build(crate::test_support::wait_rotation())
365            .or_fail()?;
366
367        built.state.set_spec_config(OutlawConfig {
368            deft_maneuvers_cp: 1.0,
369            blade_flurry_targets: 4,
370            ..OutlawConfig::default()
371        });
372        built
373            .state
374            .apply_enemy_damage(
375                EnemyIdx(7),
376                crate::hooks::shared::test_support::max_health(),
377                SimTime::ZERO,
378            )
379            .or_fail()?;
380        let mut sink = TelemetrySink::default();
381        let mut rng = || 0.5;
382        let mut ctx = HookCtx::new(
383            wowlab_engine_combat::HookCtxServices {
384                state: &mut built.state,
385                buf: &mut built.buffer,
386                sink: &mut sink,
387                rng: &mut rng,
388            },
389            wowlab_engine_combat::HookCtxRequest::for_target(SimTime::ZERO, EnemyIdx::PRIMARY),
390        );
391
392        verify_that!(
393            ctx.resolved_spell_effect_targets(
394                EFFECT::BLADE_FLURRY_INSTANT_ATTACK_DAMAGE,
395                SPELL::BLADE_FLURRY_INSTANT_ATTACK,
396                Some(4),
397            ),
398            container_eq([EnemyIdx(0), EnemyIdx(1), EnemyIdx(3), EnemyIdx(4)])
399        )?;
400        blade_flurry_hook(&mut ctx);
401
402        verify_that!(ctx.secondary_resource(), eq(4.0))
403    }
404}