Skip to main content

wowlab_engine_content/hooks/assassination_rogue/
config.rs

1use wowlab_engine_combat::{
2    BuffEffect, DriverSpellModifier, DriverSpellModifierResult, MasteryCtx, PeriodicDriver,
3    RuntimeDamageDef,
4};
5use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
6use wowlab_types::constants::HUNDRED;
7
8use crate::generated::specs::assassination_rogue::{
9    AURA, AURA_FATEBOUND_COIN_HEADS, EFFECT, HERO, REPORTED_SPELL, SET_BONUS, SPELL, SPELL_AMBUSH,
10    SPELL_DEATHMARK, SPELL_ENVENOM, SPELL_GARROTE, SPELL_RUPTURE, TALENT,
11};
12#[rustfmt::skip]
13static LETHAL_DOSE_SPELLS: [u32; 10] = [
14    SPELL::GARROTE,
15    SPELL::RUPTURE,
16    REPORTED_SPELL::DEADLY_POISON_DOT,
17    SPELL::DEADLY_POISON_INSTANT,
18    SPELL::DEATHMARK,
19    SPELL::INTERNAL_BLEEDING_DRIVER,
20    REPORTED_SPELL::AMPLIFYING_POISON,
21    SPELL::KINGSBANE,
22    SPELL::MUTILATED_FLESH,
23    SPELL::IMPLACABLE_STRIKES_NATURE,
24];
25
26#[rustfmt::skip]
27static ZOLDYCK_RECIPE_SPELLS: [u32; 14] = [
28    SPELL::GARROTE,
29    SPELL::RUPTURE,
30    REPORTED_SPELL::DEADLY_POISON_DOT,
31    SPELL::AMBUSH,
32    SPELL::MUTILATE_MH,
33    SPELL::MUTILATE_OH,
34    SPELL::ENVENOM,
35    SPELL::DEADLY_POISON_INSTANT,
36    SPELL::DEATHMARK,
37    SPELL::INTERNAL_BLEEDING_DRIVER,
38    REPORTED_SPELL::AMPLIFYING_POISON,
39    SPELL::KINGSBANE,
40    SPELL::MUTILATED_FLESH,
41    SPELL::IMPLACABLE_STRIKES_NATURE,
42];
43
44crate::hooks::define_spec_config! {
45/// Talent-gated Assassination hook parameters.
46pub(super) struct AssassinationConfig {
47    seal_fate_chance: f64 { provenance: "Seal Fate chance from 14190 e1.", gate: params.talent_selected(TALENT::SEAL_FATE), source: params.require_effect_base_points(EFFECT::SEAL_FATE_CHANCE)?, transform: |value| value / HUNDRED, },
48    seal_fate_cp: f64 { provenance: "Seal Fate combo points from 14190 e2.", gate: params.talent_selected(TALENT::SEAL_FATE), source: params.require_effect_base_points(EFFECT::SEAL_FATE_CP)?, transform: |value| value, },
49    deal_fate_cp: f64 { provenance: "Deal Fate combo points from its manifest effect binding.", gate: params.talent_selected(HERO::FATEBOUND::SPELL::DEAL_FATE), source: params.require_effect_base_points(EFFECT::DEAL_FATE_CP)?, transform: |value| value, },
50    blindside_chance: f64 { provenance: "Blindside base chance 328085 e1 above the execute threshold.", gate: params.talent_selected(TALENT::BLINDSIDE), source: params.require_effect_base_points(EFFECT::BLINDSIDE_CHANCE)?, transform: |value| value / HUNDRED, },
51    blindside_execute_chance: f64 { provenance: "Blindside chance 328085 e2 below its execute threshold.", gate: params.talent_selected(TALENT::BLINDSIDE), source: params.require_effect_base_points(EFFECT::BLINDSIDE_EXECUTE_CHANCE)?, transform: |value| value / HUNDRED, },
52    blindside_execute_threshold: f64 { provenance: "Blindside execute threshold from 328085 e3.", gate: params.talent_selected(TALENT::BLINDSIDE), source: params.require_effect_base_points(EFFECT::BLINDSIDE_EXECUTE_THRESHOLD)?, transform: |value| value / HUNDRED, },
53    doomblade_pct: f64 { provenance: "Doomblade 1217954 damage fraction.", gate: params.talent_selected(TALENT::DOOMBLADE), source: params.require_effect_base_points(EFFECT::DOOMBLADE_PCT)?, transform: |value| value / HUNDRED, },
54    systemic_failure_mult: f64 { provenance: "Systemic Failure multiplicative damage modifier.", gate: params.talent_selected(TALENT::SYSTEMIC_FAILURE), source: params.require_effect_base_points(EFFECT::SYSTEMIC_FAILURE_PCT)?, transform: |value| 1.0 + value / HUNDRED, fallback: 1.0, },
55    lethal_dose_per_effect: f64 { provenance: "Lethal Dose damage per active bleed or poison; resolved talent data already includes selected ranks.", gate: params.talent_ranks(TALENT::LETHAL_DOSE) > 0, source: params.require_effect_base_points(EFFECT::LETHAL_DOSE_PER_EFFECT)?, transform: |value| value / HUNDRED, },
56    zoldyck_mult: f64 { provenance: "Zoldyck Recipe execute-range damage multiplier.", gate: params.talent_selected(TALENT::ZOLDYCK_RECIPE), source: params.require_effect_base_points(EFFECT::ZOLDYCK_DAMAGE)?, transform: |value| 1.0 + value / HUNDRED, fallback: 1.0, },
57    zoldyck_threshold: f64 { provenance: "Zoldyck Recipe target-health threshold.", gate: params.talent_selected(TALENT::ZOLDYCK_RECIPE), source: params.require_effect_base_points(EFFECT::ZOLDYCK_THRESHOLD)?, transform: |value| value / HUNDRED, },
58    delivered_doom_mult: f64 { provenance: "Delivered Doom Envenom damage multiplier.", gate: params.talent_selected(HERO::FATEBOUND::SPELL::DELIVERED_DOOM_TALENT), source: params.require_effect_base_points(EFFECT::DELIVERED_DOOM_DMG)?, transform: |value| 1.0 + value / HUNDRED, fallback: 1.0, },
59    delivered_doom_cp: f64 { provenance: "Delivered Doom combo-point threshold.", gate: params.talent_selected(HERO::FATEBOUND::SPELL::DELIVERED_DOOM_TALENT), source: params.require_effect_base_points(EFFECT::DELIVERED_DOOM_CP)?, transform: |value| value, },
60    inspiring_strike_mult: f64 { provenance: "Inspiring Strike damage multiplier while refreshed Envenom is active.", gate: params.talent_selected(TALENT::INSPIRING_STRIKE), source: params.require_effect_base_points(EFFECT::INSPIRING_STRIKE_DAMAGE)?, transform: |value| 1.0 + value / HUNDRED, fallback: 1.0, },
61    regicides_reward_divisor: i32 { provenance: "Kingsbane ramp stacks required per Regicide's Reward haste stack.", gate: params.talent_selected(TALENT::REGICIDES_REWARD), source: params.require_effect_base_points(EFFECT::REGICIDES_REWARD_DIVISOR)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc, },
62    four_pc_mult: f64 { provenance: "MID1 Assassination 4pc 1264857 e2 damage multiplier.", gate: has_4pc, source: params.require_effect_base_points(EFFECT::MID1_ASSASSINATION_4PC_DAMAGE)?, transform: |value| 1.0 + value / HUNDRED, fallback: 1.0, },
63    garrote_guaranteed_poison: bool { provenance: "MID1 Assassination 2pc guarantees Garrote poison application.", gate: has_2pc, source: true, transform: |value| value, },
64    has_internal_bleeding: bool { provenance: "Internal Bleeding selection gate.", gate: params.talent_selected(TALENT::INTERNAL_BLEEDING), source: true, transform: |value| value, },
65    has_deadly_poison: bool { provenance: "Deadly Poison selection gate.", gate: params.talent_selected(TALENT::DEADLY_POISON), source: true, transform: |value| value, },
66    has_amplifying_poison: bool { provenance: "Amplifying Poison selection gate.", gate: params.talent_selected(TALENT::AMPLIFYING_POISON), source: true, transform: |value| value, },
67    envenom_poison_bonus: f64 { provenance: "Envenom 32645 e2 poison chance bonus.", gate: true, source: params.require_effect_base_points(EFFECT::ENVENOM_POISON_BONUS)?, transform: |value| value / HUNDRED, },
68    rapid_injection_mult: f64 { provenance: "Rapid Injection per-rank damage multiplier.", gate: params.talent_ranks(TALENT::RAPID_INJECTION) > 0, source: params.require_effect_base_points(EFFECT::RAPID_INJECTION_PCT)?, transform: |value| 1.0 + value / HUNDRED * f64::from(params.talent_ranks(TALENT::RAPID_INJECTION)), fallback: 1.0, },
69    amplifying_consume_stacks: i32 { provenance: "Amplifying Poison stacks consumed by Envenom.", gate: params.talent_selected(TALENT::AMPLIFYING_POISON), source: params.require_effect_base_points(EFFECT::AMPLIFYING_CONSUME_STACKS)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc, },
70    amplifying_bonus_mult: f64 { provenance: "Amplifying Poison Envenom bonus multiplier.", gate: params.talent_selected(TALENT::AMPLIFYING_POISON), source: params.require_effect_base_points(EFFECT::AMPLIFYING_ENVENOM_BONUS)?, transform: |value| 1.0 + value / HUNDRED, },
71    alacrity_chance_per_cp: f64 { provenance: "Alacrity chance per combo point.", gate: params.talent_selected(TALENT::ALACRITY), source: params.require_effect_base_points(EFFECT::ALACRITY_CHANCE_PER_CP)?, transform: |value| value / HUNDRED, },
72    dashing_scoundrel_energy: f64 { provenance: "Dashing Scoundrel energy gain.", gate: params.talent_selected(TALENT::DASHING_SCOUNDREL), source: params.require_effect_base_points(EFFECT::DASHING_SCOUNDREL_ENERGY)?, transform: |value| value, },
73    has_implacable_regen: bool { provenance: "Implacable rank-one regeneration gate.", gate: params.talent_selected(TALENT::IMPLACABLE_1), source: true, transform: |value| value, },
74    has_implacable_strikes: bool { provenance: "Implacable rank-three strike gate.", gate: params.talent_selected(TALENT::IMPLACABLE_3), source: true, transform: |value| value, },
75    implacable_strike_cp: f64 { provenance: "Implacable strike combo-point gain.", gate: params.talent_selected(TALENT::IMPLACABLE_3), source: params.require_effect_base_points(EFFECT::IMPLACABLE_STRIKE_CP)?, transform: |value| value, },
76    has_finish_the_job: bool { provenance: "Finish the Job selection gate.", gate: params.talent_selected(TALENT::FINISH_THE_JOB), source: true, transform: |value| value, },
77    hand_of_fate_cp: f64 { provenance: "Hand of Fate combo-point gain.", gate: hand_of_fate, source: params.require_effect_base_points(EFFECT::HAND_OF_FATE_CP)?, transform: |value| value, },
78    mean_streak_odds: f64 { provenance: "Mean Streak matching-face odds modifier.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::MEAN_STREAK), source: params.require_effect_base_points(EFFECT::MEAN_STREAK_ODDS)?, transform: |value| value / HUNDRED, },
79    lucky_coin_flips: i32 { provenance: "Lucky Coin extra flips.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::LUCKY_COIN), source: params.require_effect_base_points(EFFECT::LUCKY_COIN_FLIPS)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc, },
80    lucky_coin_odds: f64 { provenance: "Lucky Coin proc odds.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::LUCKY_COIN), source: params.require_effect_base_points(EFFECT::LUCKY_COIN_ODDS)?, transform: |value| value / HUNDRED, },
81    has_edge_case: bool { provenance: "Edge Case selection gate.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::EDGE_CASE), source: true, transform: |value| value, },
82    rush_energy: f64 { provenance: "Rush to the Inevitable energy gain.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::RUSH_TO_THE_INEVITABLE), source: params.require_effect_base_points(EFFECT::RUSH_ENERGY)?, transform: |value| value, },
83    rush_energy_edge: f64 { provenance: "Rush to the Inevitable Edge Case energy gain.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::RUSH_TO_THE_INEVITABLE), source: params.require_effect_base_points(EFFECT::RUSH_ENERGY_EDGE)?, transform: |value| value, },
84    overflowing_extra_coins: u32 { provenance: "Overflowing Purse coin count beyond the baseline flip.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::OVERFLOWING_PURSE), source: params.require_effect_base_points(EFFECT::OVERFLOWING_PURSE_COINS)?, transform: |value| wowlab_types::numeric::f64_to_u32_saturating_trunc(value).saturating_sub(1), },
85    overflowing_chance: f64 { provenance: "Overflowing Purse proc chance.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::OVERFLOWING_PURSE), source: params.require_effect_base_points(EFFECT::OVERFLOWING_PURSE_CHANCE)?, transform: |value| value / HUNDRED, },
86    controlled_chaos_streak: i32 { provenance: "Controlled Chaos forced-alternation streak.", gate: hand_of_fate && params.talent_selected(HERO::FATEBOUND::SPELL::CONTROLLED_CHAOS), source: params.require_effect_base_points(EFFECT::CONTROLLED_CHAOS_STREAK)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc, },
87    deathmark_energy_per_tick: f64 { provenance: "Deathmark periodic Energy gain.", gate: true, source: params.require_effect_base_points(EFFECT::DEATHMARK_ENERGY)?, transform: |value| value, },
88    deathmark_period_ms: u32 { provenance: "Deathmark periodic cadence.", gate: true, source: params.game_data.effect_lookup(EFFECT::DEATHMARK_PERIOD).period(), transform: wowlab_types::numeric::f64_to_u32_saturating_round, },
89    deathmark_duration_ms: u32 { provenance: "Deathmark duration from resolved aura data.", gate: true, source: params.game_data.require_aura_duration_ms(wowlab_types::sim::SpellIdx::from_raw(SPELL::DEATHMARK))?, transform: |value| value, },
90}
91accessor pub(super) fn sin_config(ctx);
92register pub(super) fn register_assassination(built, params) -> Result<(), wowlab_engine_ports::EngineError>;
93prepare {
94    let has_2pc = params
95        .set_bonus_auras
96        .contains(&SET_BONUS::MID1_ASSASSINATION_2PC);
97    let has_4pc = params
98        .set_bonus_auras
99        .contains(&SET_BONUS::MID1_ASSASSINATION_4PC);
100    let hand_of_fate = params.talent_selected(HERO::FATEBOUND::SPELL::HAND_OF_FATE);
101    let coin_heads_initial = (params.require_effect_base_points(EFFECT::COIN_HEADS_INITIAL)?
102        + if params.talent_selected(HERO::FATEBOUND::SPELL::RAVENHOLDT_MINT) {
103            params.require_effect_base_points(EFFECT::RAVENHOLDT_MINT_HEADS)?
104        } else {
105            0.0
106        }) / HUNDRED;
107    let coin_heads_per_stack = params.require_effect_base_points(EFFECT::COIN_HEADS_PER_STACK)? / HUNDRED;
108}
109auras {
110    {
111        provenance: "Fatebound Heads combines its base damage with Ravenholdt Mint before per-stack growth.",
112        gate: hand_of_fate,
113        aura: AURA_FATEBOUND_COIN_HEADS,
114        effect: BuffEffect::DamageMultStacking { initial: coin_heads_initial, per_stack: coin_heads_per_stack },
115    },
116}
117wiring {
118    { 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: hand_of_fate, apply: {
119        for effect in [EFFECT::COIN_HEADS_PER_STACK, EFFECT::COIN_HEADS_PER_STACK_PERIODIC] {
120            built.mask_aura_dbc_effect(AURA_FATEBOUND_COIN_HEADS, effect.0, effect.1);
121        }
122    } },
123    { provenance: "Lethal Dose evaluates active target bleeds and poisons for affected damage events.", gate: params.talent_selected(TALENT::LETHAL_DOSE), apply: {
124        built.register_driver_spell_modifier(DriverSpellModifier::new(
125            TALENT::LETHAL_DOSE,
126            &LETHAL_DOSE_SPELLS,
127            lethal_dose_modifier,
128        ));
129    } },
130    { provenance: "Zoldyck Recipe evaluates target health for mastery-affected damage events.", gate: params.talent_selected(TALENT::ZOLDYCK_RECIPE), apply: {
131        built.register_driver_spell_modifier(DriverSpellModifier::new(
132            TALENT::ZOLDYCK_RECIPE,
133            &ZOLDYCK_RECIPE_SPELLS,
134            zoldyck_recipe_modifier,
135        ));
136    } },
137    { provenance: "Inspiring Strike is a triggered Envenom-overlap buff, not a passive precombat aura.", gate: params.talent_selected(TALENT::INSPIRING_STRIKE), apply: {
138        built.remove_precombat_aura(crate::generated::specs::assassination_rogue::AURA_INSPIRING_STRIKE);
139    } },
140}
141finish |cfg| {
142    // Data-derived parent damage would double-count child hits or invent direct DoT hits.
143    for local in [
144        SPELL_AMBUSH,
145        SPELL_ENVENOM,
146        SPELL_GARROTE,
147        SPELL_RUPTURE,
148        SPELL_DEATHMARK,
149    ] {
150        built.patch_spell(local, |spell| {
151            spell.damage = RuntimeDamageDef::None;
152        });
153    }
154
155    match (
156        std::num::NonZeroU32::new(cfg.deathmark_period_ms),
157        std::num::NonZeroU32::new(cfg.deathmark_duration_ms),
158    ) {
159        (Some(period), Some(duration)) => built.register_periodic_driver(
160            PeriodicDriver::new(
161                SPELL::DEATHMARK,
162                period.get(),
163                super::hooks::deathmark_energize_tick,
164            )
165            .hasted()
166            .triggered_for(duration.get()),
167        ),
168        _ if !params.game_data.is_empty() => {
169            return Err(wowlab_engine_ports::EngineError::spec_construction(
170                "Deathmark periodic driver resolved a zero duration or interval",
171            ));
172        }
173        _ => {}
174    }
175
176    built.state.set_spec_config(cfg);
177    Ok(())
178}
179}
180
181fn lethal_dose_modifier(view: wowlab_engine_combat::HookView<'_>) -> DriverSpellModifierResult {
182    let per_effect = view
183        .state
184        .spec_config::<AssassinationConfig>()
185        .map_or(0.0, |config| config.lethal_dose_per_effect);
186    let active = [
187        AURA::ATROPHIC_POISON,
188        AURA::CRIPPLING_POISON,
189        AURA::DEADLY_POISON_DOT,
190        AURA::AMPLIFYING_POISON_DEBUFF,
191        AURA::GARROTE,
192        AURA::RUPTURE,
193        AURA::INTERNAL_BLEEDING,
194        AURA::DEATHMARK,
195        AURA::KINGSBANE,
196    ]
197    .into_iter()
198    .filter(|aura_id| {
199        view.state.aura_local(*aura_id).is_some_and(|local| {
200            wowlab_engine_combat::is_aura_active(
201                &view.combat(),
202                local,
203                wowlab_types::sim::ActorId::Player,
204                view.target,
205            )
206        })
207    })
208    .count();
209
210    DriverSpellModifierResult::damage_mult(
211        1.0 + per_effect * wowlab_types::numeric::usize_to_f64(active),
212    )
213}
214
215fn zoldyck_recipe_modifier(view: wowlab_engine_combat::HookView<'_>) -> DriverSpellModifierResult {
216    let Some(config) = view.state.spec_config::<AssassinationConfig>() else {
217        return DriverSpellModifierResult::default();
218    };
219    let health = view
220        .target
221        .and_then(|target| view.state.enemy_health_fraction(target, view.now))
222        .unwrap_or(1.0);
223
224    if health < config.zoldyck_threshold {
225        DriverSpellModifierResult::damage_mult(config.zoldyck_mult)
226    } else {
227        DriverSpellModifierResult::default()
228    }
229}
230
231/// Returns Potent Assassin mastery damage for spell 76803.
232#[must_use]
233pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
234    if ctx.is_periodic {
235        return if ctx.affects_spell(EFFECT::POTENT_ASSASSIN_PERIODIC.1) {
236            ctx.bonus(EFFECT::POTENT_ASSASSIN_PERIODIC.1)
237        } else {
238            1.0
239        };
240    }
241
242    [
243        EFFECT::POTENT_ASSASSIN_DIRECT.1,
244        EFFECT::POTENT_ASSASSIN_DIRECT_LABEL.1,
245        EFFECT::POTENT_ASSASSIN_SECONDARY_DIRECT_LABEL.1,
246    ]
247    .into_iter()
248    .find(|&effect| ctx.affects_spell(effect))
249    .map_or(1.0, |effect| ctx.bonus(effect))
250}