wowlab_engine_content/hooks/balance_druid/
handler.rs1
2use wowlab_engine_combat::{
3 apply_aura, extend_aura,
4 CombatHandler,
5};
6use wowlab_engine_ports::{HandlerParams, SimContext, SpecAction};
7
8use wowlab_types::{
9 sim::{AuraIdx, AuraKey, SpellIdx},
10 constants::MS_PER_SECOND,
11};
12
13use crate::generated::specs::balance_druid::{AURA_STELLAR_AMPLIFICATION, SPELL};
14
15use super::config::register_balance;
16
17#[derive(Debug)]
18pub(crate) struct BalanceHandler {
19 inner: CombatHandler,
20 stellar_amp_cap_ms: u32,
21}
22
23impl BalanceHandler {
24 pub(crate) fn new(
25 parts: crate::composition::ContentHandlerParts,
26 params: &HandlerParams<'_>,
27 ) -> Self {
28 let mut built = parts.into_built();
29 let cfg = register_balance(&mut built, params);
30
31 Self {
32 inner: CombatHandler::from_built(built),
33 stellar_amp_cap_ms: cfg.stellar_amp_cap_ms,
34 }
35 }
36
37 fn trigger_stellar_amplification(
39 &mut self,
40 target: wowlab_types::sim::EnemyIdx,
41 ctx: &mut SimContext,
42 ) {
43 if self.stellar_amp_cap_ms == 0 {
44 return;
45 }
46
47 let now = ctx.state.current_time;
48 let now_s = now.as_secs_f64();
49 let def = *self.inner.state().aura(AURA_STELLAR_AMPLIFICATION);
50 let key = AuraKey::new(
51 AuraIdx(def.aura_id),
52 wowlab_types::sim::ActorId::Player,
53 wowlab_types::sim::ActorId::Enemy(target),
54 def.on,
55 );
56 let remaining_ms = self
57 .inner
58 .buf_mut()
59 .aura(key)
60 .filter(|a| a.is_occupied() && a.expires_at > now_s)
61 .map_or(0u32, |a| wowlab_types::numeric::f64_to_u32_saturating_trunc((a.expires_at - now_s) * MS_PER_SECOND));
62
63 self.inner
64 .with_hook_ctx(ctx.telemetry, wowlab_engine_combat::HookCtxRequest::for_target(now, target), |aura_ctx| {
65 if remaining_ms == 0 {
66 apply_aura(aura_ctx, AURA_STELLAR_AMPLIFICATION);
67 } else {
68 let ext = def
69 .base_duration_ms
70 .min(self.stellar_amp_cap_ms.saturating_sub(remaining_ms));
71
72 if ext > 0 {
73 extend_aura(aura_ctx, AURA_STELLAR_AMPLIFICATION, ext);
74 }
75 }
76 });
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use googletest::prelude::*;
83
84 use super::*;
85 use wowlab_engine_combat::{HookCtx, apply_aura_with_duration};
86 use wowlab_engine_ports::{CombatStats, ConsumableFlags, SimState, SpecHandler};
87 use wowlab_engine_telemetry::{TelemetrySink};
88
89 use wowlab_types::{
90 sim::{ActorId, EnemyIdx, SimTime},
91 game::RaceId,
92};
93
94 const BASE_DURATION_MS: u32 = 5_000;
95 const SECOND_DURATION_MS: u32 = 2_000;
96 const EXTENSION_CAP_MS: u32 = 20_000;
97
98 fn build_handler() -> Result<BalanceHandler> {
99 let stats = CombatStats::default();
100 let encounter = wowlab_engine_ports::test_support::introspection_fixture(300.0).or_fail()?;
101 let game_data = wowlab_engine_ports::test_support::introspection_game_data(&encounter);
102 let rotation = crate::test_support::wait_rotation();
103 let params = crate::test_support::handler_params(
104 crate::test_support::HandlerParamsFixture {
105 game_data,
106 rotation: &rotation,
107 stats: &stats,
108 talent_selections: &[],
109 encounter: &encounter,
110 consumables: ConsumableFlags::default(),
111 race: RaceId::Human,
112 },
113 )
114 .or_fail()?;
115
116 crate::composition::compose_handler(
117 crate::generated::specs::balance_druid::build_combat_system,
118 ¶ms,
119 |parts, _| {
120 let mut built = parts.into_built();
121
122 if !built.patch_aura(AURA_STELLAR_AMPLIFICATION, |aura| {
123 aura.base_duration_ms = BASE_DURATION_MS;
124 }) {
125 return Err(wowlab_engine_ports::EngineError::spec_construction(
126 "missing Stellar Amplification fixture aura",
127 ));
128 }
129
130 Ok(BalanceHandler {
131 inner: CombatHandler::from_built(built),
132 stellar_amp_cap_ms: EXTENSION_CAP_MS,
133 })
134 },
135 )
136 .or_fail()
137 }
138
139 #[gtest]
140 fn starsurge_completion_extends_only_its_locked_target_after_retarget() -> Result<()> {
141 let mut handler = build_handler()?;
142 let first = EnemyIdx::PRIMARY;
143 let second = EnemyIdx(1);
144 let mut sim_state = SimState::new(0, SimTime::from_secs_f64(300.0), 1);
145 let mut sink = TelemetrySink::default();
146
147 handler.on_sim_start(&mut SimContext {
148 state: &sim_state,
149 telemetry: &mut sink,
150 });
151
152 let (state, buf) = handler.inner.state_and_buf_mut();
153 let def = *state.aura(AURA_STELLAR_AMPLIFICATION);
154 let mut rng = || 0.5;
155
156 apply_aura(
157 &mut HookCtx::new(wowlab_engine_combat::HookCtxServices { state, buf, sink: &mut sink, rng: &mut rng }, wowlab_engine_combat::HookCtxRequest::for_target(SimTime::ZERO, first)),
158 AURA_STELLAR_AMPLIFICATION,
159 );
160 apply_aura_with_duration(
161 &mut HookCtx::new(wowlab_engine_combat::HookCtxServices { state, buf, sink: &mut sink, rng: &mut rng }, wowlab_engine_combat::HookCtxRequest::for_target(SimTime::ZERO, second)),
162 AURA_STELLAR_AMPLIFICATION,
163 Some(SECOND_DURATION_MS),
164 );
165 let first_key = AuraKey::new(
166 AuraIdx(def.aura_id),
167 ActorId::Player,
168 ActorId::Enemy(first),
169 def.on,
170 );
171 let second_key = AuraKey::new(
172 AuraIdx(def.aura_id),
173 ActorId::Player,
174 ActorId::Enemy(second),
175 def.on,
176 );
177 let first_before = buf.aura(first_key).or_fail()?.expires_at;
178 let second_before = buf.aura(second_key).or_fail()?.expires_at;
179
180 verify_that!(first_before, not(eq(second_before)))?;
181
182 handler.inner.state_mut().runtime.current_target = Some(second);
183 sim_state.current_time = SimTime::from_millis(1_000);
184 handler.on_cast_complete(
185 wowlab_engine_ports::Event::CastComplete {
186 t: sim_state.current_time,
187 spell_id: SpellIdx::from_raw(SPELL::STARSURGE),
188 empower_rank: 0,
189 source: ActorId::Player,
190 target: first,
191 },
192 &mut SimContext {
193 state: &sim_state,
194 telemetry: &mut sink,
195 },
196 );
197
198 let (_, buf) = handler.inner.state_and_buf_mut();
199
200 verify_that!(
201 buf.aura(first_key).or_fail()?.expires_at,
202 eq(first_before + f64::from(BASE_DURATION_MS) / MS_PER_SECOND)
203 )?;
204
205 verify_that!(buf.aura(second_key).or_fail()?.expires_at, eq(second_before))
206 }
207}
208
209crate::hooks::define_spec_handler! {
210impl BalanceHandler(self, inner) {
211 fn on_sim_start(&mut self, ctx: &mut SimContext) {
212 self.inner.on_sim_start(ctx);
213 }
214
215 fn on_cast_complete(&mut self, event: wowlab_engine_ports::Event, ctx: &mut SimContext) {
216 let wowlab_engine_ports::Event::CastComplete {
217 spell_id, target, ..
218 } = event
219 else {
220 panic!("on_cast_complete received a non-cast-complete event");
221 };
222 self.inner.on_cast_complete(event, ctx);
223 if spell_id.as_u32() == SPELL::STARSURGE {
224 self.trigger_stellar_amplification(target, ctx);
225 }
226 }
227
228 fn on_spell_impact(&mut self, impact_id: u32, ctx: &mut SimContext) {
229 self.inner.on_spell_impact(impact_id, ctx);
230 }
231
232}
233delegate {
234 fn on_player_ready(&mut self, ctx: &mut SimContext) -> Option<SpecAction>;
235 fn on_aura_tick(&mut self, event: wowlab_engine_ports::AuraEventRef, ctx: &mut SimContext);
236 fn on_aura_expire(&mut self, event: wowlab_engine_ports::AuraEventRef, ctx: &mut SimContext);
237 fn on_auto_attack(&mut self, source: wowlab_types::sim::ActorId, target: wowlab_types::sim::EnemyIdx, ctx: &mut SimContext);
238 fn on_cooldown_ready(&mut self, cooldown_key: SpellIdx, ctx: &mut SimContext);
239 fn flush_scheduled(&mut self, push: &mut dyn FnMut(wowlab_engine_ports::Event));
240 fn total_damage(&self) -> f64;
241 fn cast_time_ms(&self, spell_id: SpellIdx, empower_rank: u8) -> u32;
242 fn introspect(&self) -> wowlab_types::game::SpecIntrospection;
243 fn paperdoll(&self) -> Option<wowlab_engine_ports::Paperdoll>;
244 fn attach_decision_trace(
245 &mut self,
246 sink: std::sync::Arc<dyn wowlab_engine_ports::DecisionTraceSink>,
247 );
248}
249}