Skip to main content

wowlab_engine_content/hooks/balance_druid/
handler.rs

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