1use wowlab_engine_combat::BuffEffect;
2use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
3use wowlab_types::{constants::HUNDRED, sim::SpellIdx};
4
5use crate::generated::specs::windwalker_monk::{
6 AURA_COMBO_STRIKES, EFFECT, REPORTED_SPELL, SET_BONUS, SPELL, SPELL_FISTS_OF_FURY,
7 SPELL_RISING_SUN_KICK, TALENT,
8};
9const DUAL_THREAT_TWO_HAND_MULT: f64 = 3.6 / 2.6 * 2.0;
11
12static SUNFIRE_SPIRAL_SPELLS: [u32; 2] =
13 [SPELL::RISING_SUN_KICK, REPORTED_SPELL::GLORY_OF_THE_DAWN];
14#[derive(Clone, Copy, Debug, Default)]
15pub(super) struct WindwalkerRuntime {
16 pub(super) dual_threat_ready: bool,
17 pub(super) last_combo_spell: u32,
18 pub(super) spinning_crane_kick_tier_bonus: f64,
19}
20
21crate::hooks::define_spec_config! {
22pub(super) struct WindwalkerConfig {
23 dual_threat_chance: f64 { provenance: "Dual Threat 451823 e1 replacement chance.", gate: params.talent_picked(TALENT::DUAL_THREAT), source: base(EFFECT::DUAL_THREAT_CHANCE), transform: |value| value / HUNDRED },
24 dual_threat_damage_mult: f64 { provenance: "Dual Threat two-hand adjustment; dual-wield uses 1.0.", gate: params.talent_picked(TALENT::DUAL_THREAT), source: params.game_data.off_hand().is_none(), transform: |two_hand| if two_hand { DUAL_THREAT_TWO_HAND_MULT } else { 1.0 }, fallback: 1.0 },
25 hit_combo: bool { provenance: "Hit Combo 196740 gates stacking the 196741 buff.", gate: params.talent_picked(TALENT::HIT_COMBO), source: true, transform: |value| value },
26 teachings: bool { provenance: "Teachings of the Monastery 116645 selection.", gate: params.talent_picked(TALENT::TEACHINGS_OF_THE_MONASTERY), source: true, transform: |value| value },
27 totm_reset_chance: f64 { provenance: "Teachings 116645 e1 Rising Sun Kick reset chance.", gate: true, source: base(EFFECT::TOTM_RESET), transform: |value| value / HUNDRED },
28 combo_breaker_chance: f64 { provenance: "Combo Breaker 137384 e1 proc chance.", gate: params.talent_picked(TALENT::COMBO_BREAKER), source: base(EFFECT::COMBO_BREAKER_CHANCE), transform: |value| value / HUNDRED },
29 glory_of_the_dawn: bool { provenance: "Glory of the Dawn 392958 selection.", gate: params.talent_picked(TALENT::GLORY_OF_THE_DAWN), source: true, transform: |value| value },
30 momentum_boost: bool { provenance: "Momentum Boost 451294 selection.", gate: params.talent_picked(TALENT::MOMENTUM_BOOST), source: true, transform: |value| value },
31 sequenced_strikes_chance: f64 { provenance: "Sequenced Strikes 451515 e1 proc chance.", gate: params.talent_picked(TALENT::SEQUENCED_STRIKES), source: base(EFFECT::SEQUENCED_CHANCE), transform: |value| value / HUNDRED },
32 flurry_strikes: bool { provenance: "Flurry Strikes 450615 Shado-Pan keystone.", gate: flurry_strikes, source: true, transform: |value| value },
33 flurry_charges_per_swing: i32 { provenance: "Flurry Strikes 450615 e2 two-hander charges per swing.", gate: true, source: base(EFFECT::FLURRY_CHARGES_2H), transform: wowlab_types::numeric::f64_to_i32_saturating_trunc },
34 flurry_crit_charge_mult: i32 { provenance: "One Versus Many 450988 e1 crit charge bonus.", gate: params.talent_picked(TALENT::ONE_VERSUS_MANY), source: 1 + wowlab_types::numeric::f64_to_i32_saturating_trunc(base(EFFECT::OVM_CHARGE_BONUS)), transform: |value| value, fallback: 1 },
35 flurry_coef: f64 { provenance: "Flurry Strike 450617 e1 AP coefficient.", gate: true, source: ap(EFFECT::FLURRY_DMG), transform: |value| value },
36 shado_coef: f64 { provenance: "Shado Over the Battlefield 451250 e1 AP coefficient.", gate: true, source: ap(EFFECT::SHADO_DMG), transform: |value| value },
37 wisdom_of_the_wall: bool { provenance: "Wisdom of the Wall 1272821 requires Flurry Strikes.", gate: flurry_strikes && params.talent_picked(TALENT::WISDOM_OF_THE_WALL), source: true, transform: |value| value },
38 wotw_count: i32 { provenance: "Wisdom of the Wall 1272821 e1 strike count.", gate: true, source: base(EFFECT::WOTW_COUNT), transform: wowlab_types::numeric::f64_to_i32_saturating_trunc },
39 stand_ready: bool { provenance: "Stand Ready 1262603 requires Flurry Strikes.", gate: flurry_strikes && params.talent_picked(TALENT::STAND_READY), source: true, transform: |value| value },
40 stand_ready_count: i32 { provenance: "Stand Ready 1262603 e1 strike count.", gate: true, source: base(EFFECT::STAND_READY_COUNT), transform: wowlab_types::numeric::f64_to_i32_saturating_trunc },
41 stand_ready_mult: f64 { provenance: "Stand Ready 1262603 e2 effectiveness.", gate: true, source: base(EFFECT::STAND_READY_MULT), transform: |value| value / HUNDRED },
42 jadefire_stomp: bool { provenance: "Jadefire Stomp 457974 selection; its damage rides Fists of Fury's final tick.", gate: params.talent_picked(TALENT::JADEFIRE_STOMP), source: true, transform: |value| value },
43 zenith_stomp: bool { provenance: "Zenith Stomp 1272694 selection.", gate: params.talent_picked(TALENT::ZENITH_STOMP), source: true, transform: |value| value },
44 combat_wisdom: bool { provenance: "Combat Wisdom 121817 selection.", gate: params.talent_picked(TALENT::COMBAT_WISDOM), source: true, transform: |value| value },
45 initial_chi: f64 { provenance: "Combat Wisdom 121817 e1 initial chi.", gate: true, source: base(EFFECT::COMBAT_WISDOM_CHI), transform: |value| value },
46 mid2_fof_extra_mult: f64 { provenance: "MID2 Windwalker two-piece extra Fists of Fury hit effectiveness.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_WW_2PC), source: base(EFFECT::MID2_WW_2PC_EFFECTIVENESS), transform: |value| value / HUNDRED },
47 mid2_unbroken_rhythm: bool { provenance: "MID2 Windwalker four-piece enables Unbroken Rhythm from Fists of Fury hits.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_WW_4PC), source: true, transform: |value| value },
48 mid2_sck_bonus_per_stack: f64 { provenance: "Unbroken Rhythm Spinning Crane Kick bonus per stack.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_WW_4PC), source: base(EFFECT::UNBROKEN_RHYTHM_SCK), transform: |value| value / HUNDRED },
49}
50accessor pub(super) fn windwalker_config(ctx);
51register pub(super) fn register_windwalker(built, params) -> Result<WindwalkerConfig, wowlab_engine_ports::EngineError>;
52prepare {
53 let data = ¶ms.game_data;
54 let base = |e: (u32, u8)| data.base_points(SpellIdx::from_raw(e.0), e.1);
55 let ap = |effect| data.effect_ap_coefficient(effect);
56
57 let flurry_strikes = params.talent_picked(TALENT::FLURRY_STRIKES);
58}
59auras {
60 { provenance: "Sunfire Spiral Combo Strikes damage multiplier.", gate: params.talent_picked(TALENT::SUNFIRE_SPIRAL), aura: AURA_COMBO_STRIKES, effect: BuffEffect::DamageMultSpells(1.0 + base(EFFECT::SUNFIRE_SPIRAL_PCT) / HUNDRED, &SUNFIRE_SPIRAL_SPELLS) },
61}
62wiring {
63 { provenance: "Dance of Chi-Ji uses its DBC RPPM driver and proc-category recovery.", gate: params.talent_picked(TALENT::DANCE_OF_CHI_JI), apply: {
64 built.bind_system_rppm(wowlab_engine_combat::generated_identities::system_rppm::DANCE_OF_CHI_JI, TALENT::DANCE_OF_CHI_JI)?;
65 } },
66 { provenance: "Dual Threat conditionally replaces landed player swings before white damage.", gate: params.talent_picked(TALENT::DUAL_THREAT), apply: {
67 built.patch_auto_attacks(|attacks| for attack in attacks.iter_mut().filter(|attack| !attack.is_pet) {
68 attack.replacement_hook = Some(super::hooks::dual_threat_replacement);
69 });
70 } },
71 { provenance: "Brawler's Intensity Rising Sun Kick cooldown reduction.", gate: params.talent_picked(TALENT::BRAWLERS_INTENSITY), apply: {
72 let cdr_ms = wowlab_types::numeric::f64_to_u32_saturating_trunc(-base(EFFECT::BRAWLERS_RSK_CDR));
73 built.patch_spell(SPELL_RISING_SUN_KICK, |spell| {
74 spell.cooldown.cooldown_duration_ms = spell.cooldown.cooldown_duration_ms.saturating_sub(cdr_ms);
75 });
76 } },
77}
78finish |cfg| {
79 built.patch_spell(SPELL_FISTS_OF_FURY, |spell| {
81 spell.damage = wowlab_engine_combat::RuntimeDamageDef::None;
82 spell.channel.tick_damage = wowlab_engine_combat::RuntimeDamageDef::None;
83 });
84
85 built.set_spec_config(cfg);
86 built.set_spec_runtime(WindwalkerRuntime::default());
87 Ok(cfg)
88}
89}
90
91#[cfg(test)]
92mod tests {
93 use googletest::prelude::*;
94 use wowlab_engine_combat::{AuraOps as _, HookCtx};
95 use wowlab_engine_domain::dbc::{AuraSubtypeKind, ModifierOperation, ModifierPropertyKind};
96 use wowlab_engine_gamedata::{AuraProps, ResolvedGameData, SpellProps};
97 use wowlab_engine_ports::{CombatStats, ConsumableFlags};
98 use wowlab_engine_telemetry::TelemetrySink;
99 use wowlab_types::{
100 combat::ResourceType,
101 game::RaceId,
102 sim::{SimTime, SpellIdx},
103 };
104
105 use super::*;
106 use crate::generated::specs::windwalker_monk::AURA;
107
108 const UNUSUAL_FLAT_COST: f64 = -37.0;
109 const BASE_COST: f64 = 100.0;
110
111 #[gtest]
112 fn zenith_cost_uses_one_dbc_effect_and_the_production_affected_spell_mask() -> Result<()> {
113 let encounter = wowlab_engine_ports::test_support::introspection_fixture(60.0).or_fail()?;
114 let environment = wowlab_engine_ports::test_support::introspection_game_data(&encounter);
115 let zenith = SpellIdx::from_raw(AURA::ZENITH);
116 let flying_serpent_kick = SpellIdx::from_raw(SPELL::FLYING_SERPENT_KICK);
117 let tiger_palm = SpellIdx::from_raw(SPELL::TIGER_PALM);
118 let mut data = ResolvedGameData::builder();
119
120 data.set_armor_constant(environment.armor_constant());
121 data.set_creature_armor(environment.creature_armor());
122 data.set_armor_constant_mod(environment.armor_constant_mod());
123 data.insert_aura_props(
124 zenith,
125 AuraProps {
126 duration_ms: 15_000,
127 ..AuraProps::default()
128 },
129 );
130 data.insert_spell_props(zenith, SpellProps::default());
131 data.insert_effect_aura(
132 wowlab_types::sim::EffectRef::new(zenith, 7),
133 AuraSubtypeKind::AddFlatModifier as i32,
134 );
135 data.insert_effect_misc_value_0(
136 wowlab_types::sim::EffectRef::new(zenith, 7),
137 ModifierPropertyKind::ResourceCost1 as i32,
138 );
139 data.insert_base_points(zenith, 7, UNUSUAL_FLAT_COST);
140 data.insert_effect_class_mask(zenith, 7, [1, 0, 0, 0]);
141 data.insert_spell_class_flags(zenith, 1, [0, 0, 0, 0]);
142 data.insert_spell_class_flags(flying_serpent_kick, 1, [1, 0, 0, 0]);
143 data.insert_spell_class_flags(tiger_palm, 1, [2, 0, 0, 0]);
144
145 for spell in [flying_serpent_kick, tiger_palm] {
146 data.insert_spell_props(
147 spell,
148 SpellProps {
149 secondary_cost_entry: 1,
150 ..SpellProps::default()
151 },
152 );
153 }
154
155 let data = data.build();
156 let stats = CombatStats::default();
157 let rotation = crate::test_support::wait_rotation();
158 let params =
159 crate::test_support::handler_params(crate::test_support::HandlerParamsFixture {
160 game_data: data.clone(),
161 rotation: &rotation,
162 stats: &stats,
163 talent_selections: &[],
164 encounter: &encounter,
165 consumables: ConsumableFlags::default(),
166 race: RaceId::Human,
167 })
168 .or_fail()?;
169 let mut draft = crate::test_combat_builder(CombatStats::default())
170 .game_data(data.clone())
171 .secondary_resource("chi", BASE_COST)
172 .secondary_resource_type_id(ResourceType::Chi.into())
173 .aura(
174 "whirling_dragon_punch",
175 AURA::WHIRLING_DRAGON_PUNCH,
176 |aura| Ok(aura.duration_ms(1_000)),
177 )
178 .aura("blackout_kick", AURA::BLACKOUT_KICK, |aura| {
179 Ok(aura.duration_ms(1_000))
180 })
181 .aura("zenith", AURA::ZENITH, |aura| {
182 aura.apply_base_from_data(&data, AURA::ZENITH)
183 })
184 .spell("flying_serpent_kick", SPELL::FLYING_SERPENT_KICK, |spell| {
185 Ok(spell.secondary_cost(BASE_COST))
186 })
187 .spell("tiger_palm", SPELL::TIGER_PALM, |spell| {
188 Ok(spell.secondary_cost(BASE_COST))
189 })
190 .into_draft()
191 .or_fail()?;
192
193 register_windwalker(&mut draft, ¶ms)?;
194
195 let local = draft.aura_local(AURA::ZENITH).or_fail()?;
196 let effects: Vec<_> = draft
197 .aura(local)
198 .or_fail()?
199 .effects
200 .iter()
201 .flatten()
202 .collect();
203
204 verify_that!(effects.len(), eq(1))?;
205 verify_true!(matches!(
206 effects[0],
207 BuffEffect::SpellCostFromEffect {
208 value: UNUSUAL_FLAT_COST,
209 source_spell_id: AURA::ZENITH,
210 effect_index: 7,
211 cost_entry: 1,
212 operation: ModifierOperation::Flat,
213 }
214 ))?;
215 let mut built = draft
216 .finalize(&crate::test_support::wait_rotation())
217 .or_fail()?;
218 let mut sink = TelemetrySink::default();
219 let mut rng = || 0.5;
220
221 HookCtx::new(
222 wowlab_engine_combat::HookCtxServices {
223 program: &built.program,
224 instance: &mut built.instance,
225 sink: &mut sink,
226 rng: &mut rng,
227 },
228 wowlab_engine_combat::HookCtxRequest::targetless(SimTime::ZERO),
229 )
230 .apply_aura(local);
231
232 let flying_serpent_kick_cost = built
233 .instance
234 .buffer
235 .spell(flying_serpent_kick)
236 .or_fail()?
237 .secondary_cost;
238 let tiger_palm_cost = built
239 .instance
240 .buffer
241 .spell(tiger_palm)
242 .or_fail()?
243 .secondary_cost;
244
245 verify_that!(
246 flying_serpent_kick_cost,
247 near(BASE_COST + UNUSUAL_FLAT_COST, f64::EPSILON)
248 )?;
249
250 verify_that!(tiger_palm_cost, near(BASE_COST, f64::EPSILON))
251 }
252}