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