wowlab_engine_content/hooks/outlaw_rogue/
hooks.rs1use 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
92const 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 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}