Skip to main content

wowlab_engine_content/hooks/outlaw_rogue/
config.rs

1use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
2use wowlab_types::{constants::HUNDRED, constants::MS_PER_SECOND};
3
4use wowlab_engine_combat::{
5    BuffEffect, DamageEffectRef, DamageReplication, DamageSource, DriverSpellModifier,
6    DriverSpellModifierResult,
7};
8
9use crate::{
10    generated::specs::outlaw_rogue::{
11        AURA, AURA_BLADE_FLURRY, AURA_FATEBOUND_COIN_HEADS, EFFECT, HERO, SPELL, TALENT,
12    },
13    hooks::{shared::rogue::COIN_FAIR_ODDS, try_gated},
14};
15
16// Restless Blades effect 1 stores tenths of a second.
17const RESTLESS_BLADES_CDR_DIVISOR: f64 = 10.0;
18
19const DEFT_MANEUVERS_CP: f64 = 1.0;
20
21// Mean Streak applies half its listed percentage to matching-face odds.
22const MEAN_STREAK_ODDS_FACTOR: f64 = 0.5;
23
24static DELIVERED_DOOM_SPELLS: [u32; 2] = [SPELL::DISPATCH, SPELL::BETWEEN_THE_EYES];
25static SUMMARILY_DISPATCHED_SPELLS: [u32; 1] = [SPELL::DISPATCH];
26
27crate::hooks::define_spec_config! {
28pub(super) struct OutlawConfig {
29    restless_blades_cdr_ms_per_cp: u32 { provenance: "Restless Blades CDR per combo point.", gate: params.talent_selected(TALENT::RESTLESS_BLADES), source: data.require_effect_base_points(EFFECT::RESTLESS_BLADES_CDR)?, transform: |value| wowlab_types::numeric::f64_to_u32_saturating_trunc(value / RESTLESS_BLADES_CDR_DIVISOR * MS_PER_SECOND) },
30    restless_blades_rtb_bonus: f64 { provenance: "Jackpot Restless Blades bonus.", gate: true, source: data.require_effect_base_points(EFFECT::JACKPOT_RB)?, transform: |value| value / HUNDRED },
31    ruthlessness_cp_per_cp: f64 { provenance: "Ruthlessness points per resource.", gate: params.talent_selected(TALENT::RUTHLESSNESS), source: data.require_effect_points_per_resource(EFFECT::RUTHLESSNESS_CP)?, transform: |value| value / HUNDRED },
32    quick_draw_cp: f64 { provenance: "Quick Draw energize.", gate: params.talent_selected(TALENT::QUICK_DRAW), source: data.require_effect_base_points(EFFECT::QUICK_DRAW_ENERGIZE)?, transform: |value| value },
33    hidden_opportunity_mod: f64 { provenance: "Hidden Opportunity damage fraction.", gate: params.talent_selected(TALENT::HIDDEN_OPPORTUNITY), source: data.require_effect_base_points(EFFECT::HIDDEN_OPPORTUNITY_DAMAGE)?, transform: |value| value / HUNDRED },
34    has_audacity: bool { provenance: "Audacity selection.", gate: params.talent_selected(TALENT::AUDACITY), source: true, transform: |value| value },
35    has_loaded_dice: bool { provenance: "Loaded Dice selection.", gate: params.talent_selected(TALENT::LOADED_DICE), source: true, transform: |value| value },
36    has_improved_adrenaline_rush: bool { provenance: "Improved Adrenaline Rush selection.", gate: params.talent_selected(TALENT::IMPROVED_ADRENALINE_RUSH), source: true, transform: |value| value },
37    has_deadly_pursuit: bool { provenance: "Deadly Pursuit selection.", gate: params.talent_selected(TALENT::DEADLY_PURSUIT), source: true, transform: |value| value },
38    sleight_of_hand_chance: f64 { provenance: "Sleight of Hand proc chance.", gate: params.talent_selected(TALENT::SLEIGHT_OF_HAND), source: data.require_effect_base_points(EFFECT::SLEIGHT_OF_HAND_CHANCE)?, transform: |value| value / HUNDRED },
39    deft_maneuvers_cp: f64 { provenance: "Deft Maneuvers fixed combo-point gain.", gate: params.talent_selected(TALENT::DEFT_MANEUVERS), source: DEFT_MANEUVERS_CP, transform: |value| value },
40    blade_flurry_targets: u8 { provenance: "Blade Flurry target cap.", gate: true, source: bf_targets_raw, transform: wowlab_types::numeric::f64_to_u8_saturating_trunc },
41    fan_the_hammer_extra: u32 { provenance: "Fan the Hammer extra casts.", gate: fan_the_hammer, source: data.require_effect_base_points(EFFECT::FAN_THE_HAMMER_EXTRA)?, transform: wowlab_types::numeric::f64_to_u32_saturating_trunc },
42    fan_the_hammer_mult: f64 { provenance: "Fan the Hammer damage reduction.", gate: fan_the_hammer, source: data.require_effect_base_points(EFFECT::FAN_THE_HAMMER_REDUCTION)?, transform: |value| 1.0 - value / HUNDRED },
43    hand_of_fate_cp: f64 { provenance: "Hand of Fate combo-point gain.", gate: fatebound, source: data.require_effect_base_points(EFFECT::HAND_OF_FATE_CP)?, transform: |value| value },
44    coin_matching_odds: f64 { provenance: "Fatebound matching odds composed from Mean Streak and Destiny Defined.", gate: fatebound, source: matching_odds, transform: |value| value },
45    deal_fate_cp: f64 { provenance: "Deal Fate combo-point gain.", gate: params.talent_selected(HERO::FATEBOUND::SPELL::DEAL_FATE), source: data.require_effect_base_points(EFFECT::DEAL_FATE_CP)?, transform: |value| value },
46    delivered_doom_mult: f64 { provenance: "Delivered Doom finisher damage multiplier.", gate: params.talent_selected(HERO::FATEBOUND::SPELL::DELIVERED_DOOM), source: data.require_effect_base_points(EFFECT::DELIVERED_DOOM_DMG)?, transform: |value| 1.0 + value / HUNDRED, fallback: 1.0 },
47    delivered_doom_cp: f64 { provenance: "Delivered Doom combo-point threshold.", gate: params.talent_selected(HERO::FATEBOUND::SPELL::DELIVERED_DOOM), source: data.require_effect_base_points(EFFECT::DELIVERED_DOOM_CP)?, transform: |value| value },
48    ace_up_chance_per_cp: f64 { provenance: "Rank-scaled Ace Up Your Sleeve chance per combo point.", gate: ace_ranks > 0.0, source: data.require_effect_base_points(EFFECT::ACE_UP_CHANCE)? * ace_ranks, transform: |value| value / HUNDRED },
49    ace_up_cp_gain: f64 { provenance: "Ace Up Your Sleeve energize.", gate: ace_ranks > 0.0, source: data.require_effect_base_points(EFFECT::ACE_UP_ENERGIZE)?, transform: |value| value },
50    summarily_dispatched_mult: f64 { provenance: "Summarily Dispatched Dispatch damage.", gate: params.talent_ranks(TALENT::SUMMARILY_DISPATCHED) > 0, source: data.require_effect_base_points(EFFECT::SUMMARILY_DISPATCHED_DMG)?, transform: |value| value / HUNDRED },
51    summarily_dispatched_btte_bonus: f64 { provenance: "Between the Eyes magnifies Summarily Dispatched while it is up.", gate: params.talent_ranks(TALENT::SUMMARILY_DISPATCHED) > 0, source: data.require_effect_base_points(EFFECT::BETWEEN_THE_EYES_SUMMARILY_DISPATCHED)?, transform: |value| value / HUNDRED },
52    scoundrel_cp_threshold: f64 { provenance: "Gravedigger tier 2 Scoundrel threshold.", gate: params.talent_selected(TALENT::GRAVEDIGGER_2), source: data.require_effect_base_points(EFFECT::SCOUNDREL_CP_THRESHOLD)?, transform: |value| value },
53    palmed_bullets_chance_per_cp: f64 { provenance: "Gravedigger tier 3 Palmed Bullets chance.", gate: gravedigger3, source: data.require_effect_base_points(EFFECT::PALMED_BULLETS_CHANCE)?, transform: |value| value / HUNDRED },
54    gravedigger_cp: f64 { provenance: "Gravedigger energize.", gate: gravedigger3, source: data.require_effect_base_points(EFFECT::GRAVEDIGGER_ENERGIZE)?, transform: |value| value },
55    lucky_coin_flips: i32 { provenance: "Lucky Coin flip count.", gate: lucky_coin, source: data.require_effect_base_points(EFFECT::LUCKY_COIN_FLIPS)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc },
56    lucky_coin_odds: f64 { provenance: "Lucky Coin matching-odds bonus.", gate: lucky_coin, source: data.require_effect_base_points(EFFECT::LUCKY_COIN_ODDS)?, transform: |value| value / HUNDRED },
57    has_edge_case: bool { provenance: "Edge Case selection.", gate: fatebound && params.talent_selected(HERO::FATEBOUND::SPELL::EDGE_CASE), source: true, transform: |value| value },
58    rush_energy: f64 { provenance: "Rush to the Inevitable energy.", gate: rush, source: data.require_effect_base_points(EFFECT::RUSH_ENERGY)?, transform: |value| value },
59    rush_energy_edge: f64 { provenance: "Rush to the Inevitable Edge Case energy.", gate: rush, source: data.require_effect_base_points(EFFECT::RUSH_ENERGY_EDGE)?, transform: |value| value },
60    controlled_chaos_streak: i32 { provenance: "Controlled Chaos streak length.", gate: controlled_chaos, source: data.require_effect_base_points(EFFECT::CONTROLLED_CHAOS_STREAK)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc },
61}
62accessor pub(super) fn outlaw_config(ctx);
63register pub(super) fn register_outlaw(built, params) -> Result<(), wowlab_engine_ports::EngineError>;
64prepare {
65    let data = &params.game_data;
66    let ace_ranks = f64::from(params.talent_ranks(TALENT::ACE_UP_YOUR_SLEEVE));
67    let fan_the_hammer = params.talent_selected(TALENT::FAN_THE_HAMMER);
68    let fatebound = params.talent_selected(HERO::FATEBOUND::SPELL::HAND_OF_FATE);
69    let lucky_coin = fatebound && params.talent_selected(HERO::FATEBOUND::SPELL::LUCKY_COIN);
70    let rush = fatebound && params.talent_selected(HERO::FATEBOUND::SPELL::RUSH_TO_THE_INEVITABLE);
71    let controlled_chaos = fatebound && params.talent_selected(HERO::FATEBOUND::SPELL::CONTROLLED_CHAOS);
72    let gravedigger3 = params.talent_selected(TALENT::GRAVEDIGGER_3);
73    let bf_targets_raw = data.require_effect_base_points(EFFECT::BLADE_FLURRY_TARGETS)?;
74    let bf_targets = wowlab_types::numeric::f64_to_u8_saturating_trunc(bf_targets_raw);
75    let bf_cleave = data.require_effect_base_points(EFFECT::BLADE_FLURRY_CLEAVE)? / HUNDRED;
76    let coin_heads_initial = try_gated(fatebound, || -> Result<f64, wowlab_engine_ports::EngineError> {
77            Ok(data.require_effect_base_points(EFFECT::COIN_HEADS_INITIAL)? / HUNDRED
78                + try_gated(params.talent_selected(HERO::FATEBOUND::SPELL::RAVENHOLDT_MINT), || data.require_effect_base_points(EFFECT::RAVENHOLDT_MINT_HEADS))? / HUNDRED)
79        })?;
80    let coin_heads_per_stack = try_gated(fatebound, || data.require_effect_base_points(EFFECT::COIN_HEADS_PER_STACK))? / HUNDRED;
81    let matching_odds = COIN_FAIR_ODDS
82        + try_gated(fatebound && params.talent_selected(HERO::FATEBOUND::SPELL::MEAN_STREAK), || data.require_effect_base_points(EFFECT::MEAN_STREAK_ODDS))? * MEAN_STREAK_ODDS_FACTOR / HUNDRED
83        + try_gated(fatebound && params.talent_selected(HERO::FATEBOUND::SPELL::DESTINY_DEFINED), || data.require_effect_base_points(EFFECT::DESTINY_DEFINED_ODDS))? / HUNDRED;
84}
85auras {
86    { provenance: "Blade Flurry copies resolved physical damage to nearby enemies.", gate: true, aura: AURA_BLADE_FLURRY, effect: BuffEffect::DamageReplication(DamageReplication { fraction: bf_cleave, effect: DamageEffectRef::new(EFFECT::BLADE_FLURRY_DAMAGE.0, EFFECT::BLADE_FLURRY_DAMAGE.1), source: DamageSource::Player, source_spells: &[], include_magic: false, include_indirect: false, include_primary: false, max_targets: bf_targets, reduced_targets: 0 }) },
87    { provenance: "Fatebound Coin Heads stacking damage.", gate: fatebound, aura: AURA_FATEBOUND_COIN_HEADS, effect: BuffEffect::DamageMultStacking { initial: coin_heads_initial, per_stack: coin_heads_per_stack } },
88}
89wiring {
90    { provenance: "Summarily Dispatched is applied under its own driver so Between the Eyes can magnify it, which a static fold cannot express.", gate: params.talent_ranks(TALENT::SUMMARILY_DISPATCHED) > 0, apply: {
91        built.register_driver_spell_modifier(DriverSpellModifier::new(
92            TALENT::SUMMARILY_DISPATCHED,
93            &SUMMARILY_DISPATCHED_SPELLS,
94            summarily_dispatched_modifier,
95        ));
96    } },
97    { provenance: "Fatebound Coin Heads carries its scripted initial-stack bonus and its per-stack bonus as one additive modifier, so the data-derived rows would double the per-stack half.", gate: fatebound, apply: {
98        for effect in [EFFECT::COIN_HEADS_PER_STACK, EFFECT::COIN_HEADS_PER_STACK_PERIODIC] {
99            built.mask_aura_dbc_effect(AURA_FATEBOUND_COIN_HEADS, effect.0, effect.1);
100        }
101    } },
102    { provenance: "Delivered Doom evaluates the finisher's spent combo points under its real talent driver.", gate: params.talent_selected(HERO::FATEBOUND::SPELL::DELIVERED_DOOM), apply: {
103        built.register_driver_spell_modifier(DriverSpellModifier::new(
104            HERO::FATEBOUND::SPELL::DELIVERED_DOOM,
105            &DELIVERED_DOOM_SPELLS,
106            delivered_doom_modifier,
107        ));
108    } },
109}
110finish |cfg| {
111    built.state.set_spec_config(cfg);
112    Ok(())
113}
114}
115
116fn summarily_dispatched_modifier(
117    view: wowlab_engine_combat::HookView<'_>,
118) -> DriverSpellModifierResult {
119    let Some(config) = view.state.spec_config::<OutlawConfig>() else {
120        return DriverSpellModifierResult::default();
121    };
122    let between_the_eyes_up = view
123        .state
124        .aura_local(AURA::BETWEEN_THE_EYES)
125        .is_some_and(|local| {
126            wowlab_engine_combat::is_aura_active(
127                &view.combat(),
128                local,
129                wowlab_types::sim::ActorId::Player,
130                view.target,
131            )
132        });
133    let bonus = if between_the_eyes_up {
134        config.summarily_dispatched_btte_bonus
135    } else {
136        0.0
137    };
138
139    DriverSpellModifierResult::damage_mult(1.0 + config.summarily_dispatched_mult * (1.0 + bonus))
140}
141
142fn delivered_doom_modifier(view: wowlab_engine_combat::HookView<'_>) -> DriverSpellModifierResult {
143    let Some(config) = view.state.spec_config::<OutlawConfig>() else {
144        return DriverSpellModifierResult::default();
145    };
146
147    if view.last_secondary_spent() >= config.delivered_doom_cp {
148        DriverSpellModifierResult::damage_mult(config.delivered_doom_mult)
149    } else {
150        DriverSpellModifierResult::default()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use googletest::prelude::*;
157    use wowlab_engine_combat::AuraOps as _;
158    use wowlab_engine_combat::HookCtx;
159    use wowlab_engine_domain::dbc::{AuraSubtypeKind, ModifierOperation, ModifierPropertyKind};
160    use wowlab_engine_ports::{CombatStats, ConsumableFlags, TalentSelection};
161    use wowlab_engine_telemetry::{TelemetrySink};
162use wowlab_engine_gamedata::{AuraProps, ResolvedGameData, SpellProps};
163    use wowlab_types::{
164        game::RaceId,
165        sim::{SimTime, SpellIdx},
166    };
167
168    use super::*;
169    use crate::generated::specs::outlaw_rogue::{AURA, AURA_GRAVEDIGGER};
170
171    const UNUSUAL_COST_PERCENT: f64 = -37.0;
172
173    #[gtest]
174    fn gravedigger_cost_uses_one_dbc_effect_and_its_affected_spell_filter() -> Result<()> {
175        let encounter = wowlab_engine_ports::test_support::introspection_fixture(60.0).or_fail()?;
176        let environment = wowlab_engine_ports::test_support::introspection_game_data(&encounter);
177        let gravedigger = SpellIdx::from_raw(AURA::GRAVEDIGGER);
178        let between_the_eyes = SpellIdx::from_raw(SPELL::BETWEEN_THE_EYES);
179        let dispatch = SpellIdx::from_raw(SPELL::DISPATCH);
180        let mut data = ResolvedGameData::builder();
181
182        data.set_armor_constant(environment.armor_constant());
183        data.set_creature_armor(environment.creature_armor());
184        data.set_armor_constant_mod(environment.armor_constant_mod());
185        data.insert_aura_props(
186            gravedigger,
187            AuraProps {
188                duration_ms: 30_000,
189                ..AuraProps::default()
190            },
191        );
192        data.insert_spell_props(gravedigger, SpellProps::default());
193        data.insert_effect_aura(
194            wowlab_types::sim::EffectRef::new(gravedigger, 1),
195            AuraSubtypeKind::AddPercentModifier as i32,
196        );
197        data.insert_effect_misc_value_0(
198            wowlab_types::sim::EffectRef::new(gravedigger, 1),
199            ModifierPropertyKind::ResourceCost1 as i32,
200        );
201        data.insert_base_points(gravedigger, 1, UNUSUAL_COST_PERCENT);
202        data.insert_effect_class_mask(gravedigger, 1, [1, 0, 0, 0]);
203        data.insert_spell_class_flags(gravedigger, 1, [0, 0, 0, 0]);
204        data.insert_spell_class_flags(between_the_eyes, 1, [1, 0, 0, 0]);
205        data.insert_spell_class_flags(dispatch, 1, [2, 0, 0, 0]);
206
207        for spell in [between_the_eyes, dispatch] {
208            data.insert_spell_props(
209                spell,
210                SpellProps {
211                    primary_cost_entry: 1,
212                    ..SpellProps::default()
213                },
214            );
215        }
216
217        for (effect, value) in [
218            (EFFECT::JACKPOT_RB, 0.0),
219            (EFFECT::BLADE_FLURRY_TARGETS, 2.0),
220            (EFFECT::BLADE_FLURRY_CLEAVE, 50.0),
221            (EFFECT::PALMED_BULLETS_CHANCE, 10.0),
222            (EFFECT::GRAVEDIGGER_ENERGIZE, 6.0),
223        ] {
224            data.insert_base_points(SpellIdx::from_raw(effect.0), effect.1, value);
225        }
226
227        let data = data.build();
228        let stats = CombatStats::default();
229        let talents = [TalentSelection {
230            spell_id: TALENT::GRAVEDIGGER_3,
231            override_spell_id: 0,
232            replaces_spell_id: 0,
233            ranks: 1,
234            precombat_aura: false,
235            hero_tree: None,
236            effect_overrides: Vec::new(),
237        }];
238        let rotation = crate::test_support::wait_rotation();
239        let params =
240            crate::test_support::handler_params(crate::test_support::HandlerParamsFixture {
241                game_data: data.clone(),
242                rotation: &rotation,
243                stats: &stats,
244                talent_selections: &talents,
245                encounter: &encounter,
246                consumables: ConsumableFlags::default(),
247                race: RaceId::Human,
248            })
249            .or_fail()?;
250
251        let mut builder = crate::test_combat_builder(CombatStats::default()).game_data(data);
252
253        for local in 0..=AURA_GRAVEDIGGER.raw() {
254            let aura_id = if local == AURA_GRAVEDIGGER.raw() {
255                AURA::GRAVEDIGGER
256            } else if local == AURA_BLADE_FLURRY.raw() {
257                AURA::BLADE_FLURRY
258            } else {
259                900_000 + u32::from(local)
260            };
261
262            builder = builder.aura(&format!("outlaw_aura_{local}"), aura_id, |aura| {
263                if local == AURA_GRAVEDIGGER.raw() {
264                    aura.apply_base_from_data(&params.game_data, AURA::GRAVEDIGGER)
265                } else {
266                    Ok(aura.duration_ms(12_000))
267                }
268            });
269        }
270
271        let mut built = builder
272            .spell("between_the_eyes", SPELL::BETWEEN_THE_EYES, |spell| {
273                Ok(spell.cost(100.0))
274            })
275            .spell("dispatch", SPELL::DISPATCH, |spell| Ok(spell.cost(100.0)))
276            .build(crate::test_support::wait_rotation())
277            .or_fail()?;
278
279        register_outlaw(&mut built, &params)?;
280
281        let local = built.state.aura_local(AURA::GRAVEDIGGER).or_fail()?;
282        let effects: Vec<_> = built
283            .aura(local)
284            .or_fail()?
285            .effects
286            .iter()
287            .flatten()
288            .collect();
289
290        verify_that!(effects.len(), eq(1))?;
291        verify_true!(matches!(
292            effects[0],
293            BuffEffect::SpellCostFromEffect {
294                value: UNUSUAL_COST_PERCENT,
295                source_spell_id: AURA::GRAVEDIGGER,
296                effect_index: 1,
297                cost_entry: 1,
298                operation: ModifierOperation::Percent,
299            }
300        ))?;
301        let mut sink = TelemetrySink::default();
302        let mut rng = || 0.5;
303
304        HookCtx::new(
305            wowlab_engine_combat::HookCtxServices {
306                state: &mut built.state,
307                buf: &mut built.buffer,
308                sink: &mut sink,
309                rng: &mut rng,
310            },
311            wowlab_engine_combat::HookCtxRequest::targetless(SimTime::ZERO),
312        )
313        .apply_aura(AURA_GRAVEDIGGER.raw());
314
315        let between_the_eyes_cost = built.buffer.spell(between_the_eyes).or_fail()?.cost;
316        let dispatch_cost = built.buffer.spell(dispatch).or_fail()?.cost;
317
318        verify_that!(between_the_eyes_cost, near(63.0, f64::EPSILON))?;
319
320        verify_that!(dispatch_cost, near(100.0, f64::EPSILON))
321    }
322}