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! {
45pub(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 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#[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}