Skip to main content

wowlab_engine_combat/handler/spec_handler/
lifecycle.rs

1use super::{
2    ActorId, CombatHandler, DEFAULT_GCD_MS, EncounterTelemetryEvent, EncounterTelemetryEventKind,
3    Event, FIRST_ENEMY_SWING_DIVISOR, HookCtxRequest, LocalAuraIdx, SimContext, SimTime,
4    SpecAction, SpecHandler, SpecRuntimeError, apply_aura, apply_auto_attack_action_lifecycle,
5    drain_deferred_work, earliest_auto_attack_time, initialize_pet_actions, project_encounter,
6    reset_combat, set_gcd, set_precombat_aura_stacks, sync_resource,
7};
8
9impl CombatHandler {
10    pub(super) fn on_sim_start_impl(&mut self, ctx: &mut SimContext) {
11        self.rng =
12            wowlab_engine_rng::SimRng::from_prefix(ctx.state.seed_prefix, ctx.state.iteration);
13        self.rng_seeded = true;
14        self.last_resource_sync = SimTime::ZERO;
15        ctx.telemetry.set_targets(self.state.telemetry_targets());
16
17        for enemy in self
18            .state
19            .enemies()
20            .filter(|enemy| enemy.is_active() && enemy.is_alive())
21        {
22            let Some(scope) = self
23                .state
24                .telemetry_scope(ActorId::Enemy(enemy.id()), enemy.id())
25            else {
26                continue;
27            };
28
29            ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
30                kind: EncounterTelemetryEventKind::Spawn,
31                time_ms: 0,
32                actor: ActorId::Enemy(enemy.id()),
33                previous_target: None,
34                target: Some(enemy.id()),
35                pull: scope.pull,
36                group: Some(scope.group),
37                transform: Some(enemy.current_transform()),
38            });
39        }
40
41        if let Some(target) = self.state.current_target() {
42            if let Some(scope) = self.state.telemetry_scope(ActorId::Player, target) {
43                ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
44                    kind: EncounterTelemetryEventKind::TargetChange,
45                    time_ms: 0,
46                    actor: ActorId::Player,
47                    previous_target: None,
48                    target: Some(target),
49                    pull: scope.pull,
50                    group: Some(scope.group),
51                    transform: self.state.actor_transform(ActorId::Player),
52                });
53            }
54        }
55
56        self.schedule_encounter_start();
57
58        crate::systems::ensure_initial_proc_heartbeat(
59            &mut self.state,
60            SimTime::ZERO,
61            &mut self.rng,
62        );
63
64        let precombat: Vec<LocalAuraIdx> = self.state.defs.precombat_auras.clone();
65        let talent_stacks: Vec<(LocalAuraIdx, u8)> = self.state.defs.talent_aura_stacks.clone();
66        let target = self.state.current_target();
67
68        for aura_local in precombat {
69            self.with_hook_ctx(
70                &mut *ctx.telemetry,
71                HookCtxRequest::for_optional_target(SimTime::ZERO, target),
72                |hook| apply_aura(hook, aura_local),
73            );
74        }
75
76        for (aura_local, stacks) in talent_stacks {
77            set_precombat_aura_stacks(&self.state, &mut self.buf, aura_local, stacks, target);
78        }
79
80        self.with_hook_ctx(
81            &mut *ctx.telemetry,
82            HookCtxRequest::targetless(SimTime::ZERO),
83            drain_deferred_work,
84        );
85
86        let periodic_drivers = self.state.defs.periodic_drivers.clone();
87
88        for (index, driver) in periodic_drivers.into_iter().enumerate() {
89            if driver.triggered_duration_ms.is_some() {
90                continue;
91            }
92
93            let interval_ms = crate::systems::effective_periodic_interval_ms(
94                &crate::context::CombatView::new(&self.state, &self.buf),
95                driver.driver_spell_id,
96                driver.tick_interval_ms.get(),
97                driver.hasted_ticks,
98            );
99
100            self.with_hook_ctx(
101                &mut *ctx.telemetry,
102                HookCtxRequest::targetless(SimTime::ZERO),
103                |ctx| ctx.schedule_periodic_driver(interval_ms, index, None, None),
104            );
105        }
106
107        initialize_pet_actions(&mut self.state, &self.buf);
108
109        self.schedule_initial_enemy_auto_attacks(SimTime::ZERO);
110
111        if let Some(t) = earliest_auto_attack_time(&self.state, &self.buf) {
112            if let Some(target) = self.state.current_target() {
113                self.state.schedule(Event::AutoAttack {
114                    t,
115                    source: ActorId::Player,
116                    target,
117                });
118            }
119        }
120    }
121
122    // #t(rust_cyclomatic_complexity) Cast readiness is one ordered state-machine transition; splitting the gates would obscure their precedence.
123    pub(super) fn on_player_ready_impl(&mut self, ctx: &mut SimContext) -> Option<SpecAction> {
124        let now = ctx.state.current_time;
125
126        self.prepare_player_ready(now, ctx.telemetry)?;
127
128        if self.state.runtime.casting.instant_cast_pending_at == Some(now) {
129            return Some(SpecAction::Wait {
130                until_ms: now.saturating_add(SimTime::from_millis(1)),
131            });
132        }
133
134        self.state.runtime.casting.instant_cast_pending_at = None;
135
136        let (busy_ready, cast_active, gcd_ready) = self.readiness_deadlines(now);
137        let gcd_active = now < gcd_ready;
138
139        let action = self.next_rotation_action(cast_active, now);
140
141        match action {
142            Some(SpecAction::Cast {
143                spell_id,
144                empower_rank,
145                source,
146                target,
147            }) => {
148                let raw_spell_id = spell_id.as_u32();
149                let (is_off_gcd, usable_while_casting) =
150                    self.cast_readiness_properties(raw_spell_id);
151
152                if cast_active && !usable_while_casting {
153                    tracing::trace!(
154                        t = %now.as_secs_f64(),
155                        cast_end = %busy_ready.as_secs_f64(),
156                        "CAST_GATE"
157                    );
158
159                    return Some(SpecAction::Wait {
160                        until_ms: busy_ready,
161                    });
162                }
163
164                if let Some(wait) = self.gcd_queue_gate(now, gcd_ready, is_off_gcd) {
165                    return Some(wait);
166                }
167
168                let cast_ms = self.cast_time_ms(spell_id, empower_rank);
169
170                if is_off_gcd && cast_ms == 0 {
171                    if self.state.runtime.casting.instant_off_gcd_casts_at != Some(now) {
172                        self.state.runtime.casting.instant_off_gcd_casts_at = Some(now);
173                        self.state.runtime.casting.instant_off_gcd_spell_ids.clear();
174                    }
175
176                    if self
177                        .state
178                        .runtime
179                        .casting
180                        .instant_off_gcd_spell_ids
181                        .contains(&raw_spell_id)
182                    {
183                        let until_ms = if gcd_ready > now {
184                            gcd_ready
185                        } else {
186                            now.saturating_add(SimTime::from_millis(DEFAULT_GCD_MS))
187                        };
188
189                        return Some(SpecAction::Wait { until_ms });
190                    }
191
192                    self.state
193                        .runtime
194                        .casting
195                        .instant_off_gcd_spell_ids
196                        .push(raw_spell_id);
197                }
198
199                if is_off_gcd {
200                    tracing::trace!(t = %now.as_secs_f64(), spell_id = raw_spell_id, "OFF_GCD_CAST");
201                }
202
203                if source == ActorId::Player {
204                    self.break_player_action_auras(target, now, &mut *ctx.telemetry);
205                }
206
207                apply_auto_attack_action_lifecycle(
208                    &mut self.state,
209                    &mut self.buf,
210                    raw_spell_id,
211                    source,
212                    target,
213                    now,
214                );
215                let chained =
216                    self.state.runtime.casting.channel_chain_spell_id == Some(raw_spell_id);
217
218                if chained {
219                    self.state.runtime.casting.channel_chain_spell_id = None;
220                } else {
221                    set_gcd(&mut self.state, &mut self.buf, raw_spell_id, now);
222                }
223
224                if cast_ms == 0 {
225                    // Claim before same-time wakes can select it again.
226                    self.state.runtime.casting.instant_cast_pending_at = Some(now);
227                } else {
228                    let cast_end = now.saturating_add(SimTime::from_millis(cast_ms));
229
230                    self.buf.player_mut().cast_end = cast_end.as_secs_f64();
231                    let schools = wowlab_engine_domain::dbc::SpellSchoolMask::from_dbc(
232                        self.state
233                            .config
234                            .game_data
235                            .school_mask(spell_id)
236                            .unwrap_or_default(),
237                    );
238
239                    if source == ActorId::Player {
240                        self.state.begin_player_cast(
241                            spell_id,
242                            schools,
243                            cast_end,
244                            empower_rank,
245                            target,
246                        );
247                    } else {
248                        self.state
249                            .begin_actor_cast(source, spell_id, schools, cast_end, true);
250                    }
251                }
252
253                Some(SpecAction::Cast {
254                    spell_id,
255                    empower_rank,
256                    source,
257                    target,
258                })
259            }
260            _ if cast_active => Some(SpecAction::Wait {
261                until_ms: busy_ready,
262            }),
263            _ if gcd_active => Some(SpecAction::Wait {
264                until_ms: gcd_ready,
265            }),
266            action => action,
267        }
268    }
269
270    pub(super) fn on_event_queue_empty_impl(&mut self, _ctx: &mut SimContext) {
271        self.record_encounter_stalled();
272    }
273
274    pub(super) fn encounter_termination_time_impl(&self) -> Option<SimTime> {
275        self.state.runtime.encounter.terminated_at()
276    }
277
278    pub(super) fn flush_scheduled_impl(&mut self, push: &mut dyn FnMut(Event)) {
279        for event in self.state.drain_events() {
280            push(event);
281        }
282    }
283
284    pub(super) fn take_runtime_error_impl(&mut self) -> Option<SpecRuntimeError> {
285        self.state.take_runtime_error()
286    }
287
288    pub(super) fn reset_impl(&mut self) {
289        reset_combat(&mut self.state, &mut self.buf, &mut self.last_resource_sync);
290    }
291
292    pub(super) fn total_damage_impl(&self) -> f64 {
293        self.state.runtime.total_damage
294    }
295
296    fn schedule_initial_enemy_auto_attacks(&mut self, now: SimTime) {
297        let enemy_swings: Vec<_> = self
298            .state
299            .enemy_definitions()
300            .filter_map(|enemy| {
301                self.state
302                    .is_valid_target(enemy.id())
303                    .then_some((enemy.id(), enemy.auto_attack_swing_ms()?))
304            })
305            .collect();
306
307        for (enemy, swing_ms) in enemy_swings {
308            self.state.schedule_enemy_auto_attack(
309                enemy,
310                ActorId::Player,
311                now.saturating_add(SimTime::from_millis(swing_ms / FIRST_ENEMY_SWING_DIVISOR)),
312            );
313        }
314    }
315
316    fn live_player(&self) -> Option<()> {
317        self.state
318            .is_friendly_actor_alive(ActorId::Player)
319            .then_some(())
320    }
321
322    fn prepare_player_ready(
323        &mut self,
324        now: SimTime,
325        telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
326    ) -> Option<()> {
327        self.live_player()?;
328
329        if self.state.runtime.casting.active_channel_spell_id.is_some()
330            && now >= SimTime::from_secs_f64(self.buf.player().channel_end)
331        {
332            crate::systems::end_channel(&mut self.state, &mut self.buf, now);
333        }
334
335        sync_resource(
336            &mut self.state,
337            &mut self.buf,
338            now,
339            &mut self.last_resource_sync,
340            telemetry,
341        );
342
343        let enemy_ids: Vec<_> = self
344            .state
345            .enemies()
346            .map(wowlab_engine_domain::encounter::EnemyState::id)
347            .collect();
348
349        for enemy in enemy_ids {
350            self.state.refresh_enemy_health(enemy, now);
351        }
352
353        project_encounter(&self.state, &mut self.buf, now);
354
355        Some(())
356    }
357
358    fn cast_readiness_properties(&self, spell_id: u32) -> (bool, bool) {
359        self.state
360            .spell_data(spell_id)
361            .map_or((false, false), |(_, spell)| {
362                (
363                    spell.behavior.off_gcd,
364                    spell.requirements.availability.usable_while_casting,
365                )
366            })
367    }
368
369    fn readiness_deadlines(&self, now: SimTime) -> (SimTime, bool, SimTime) {
370        let cast_ready = SimTime::from_secs_f64(self.buf.player().cast_end);
371        let channel_ready = SimTime::from_secs_f64(self.buf.player().channel_end);
372        let busy_ready = cast_ready.max(channel_ready);
373        let gcd_ready = SimTime::from_secs_f64(self.buf.player().gcd_end);
374
375        (busy_ready, now < busy_ready, gcd_ready)
376    }
377
378    fn next_rotation_action(&mut self, cast_active: bool, now: SimTime) -> Option<SpecAction> {
379        if cast_active {
380            self.evaluate_rotation_while_casting(now)
381        } else {
382            self.evaluate_rotation(now)
383        }
384    }
385
386    fn gcd_queue_gate(
387        &self,
388        now: SimTime,
389        gcd_ready: SimTime,
390        is_off_gcd: bool,
391    ) -> Option<SpecAction> {
392        if now >= gcd_ready || is_off_gcd {
393            return None;
394        }
395
396        let queue_window = SimTime::from_millis(self.state.config.cast_latency.queue_window_ms);
397        let queue_eligible_at = gcd_ready.saturating_sub(queue_window);
398
399        if now < queue_eligible_at {
400            return Some(SpecAction::Wait {
401                until_ms: queue_eligible_at,
402            });
403        }
404
405        let latency = if self.state.config.cast_latency.strict_gcd_queue {
406            self.state.config.cast_latency.gcd_ms
407        } else {
408            self.state.config.cast_latency.queue_ms
409        };
410
411        tracing::trace!(
412            t = %now.as_secs_f64(),
413            gcd = %gcd_ready.as_secs_f64(),
414            "GCD_GATE"
415        );
416
417        Some(SpecAction::Wait {
418            until_ms: gcd_ready.saturating_add(SimTime::from_millis(latency)),
419        })
420    }
421}