Skip to main content

wowlab_engine_content/hooks/balance_druid/
handler.rs

1
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    // Declarative auras cannot express Stellar Amplification's capped refresh extension.
38    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            &params,
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}