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, SimContext, SimTime, SpecAction,
4    SpecRuntimeError, apply_aura, apply_auto_attack_action_lifecycle, drain_deferred_work,
5    earliest_auto_attack_time, initialize_pet_actions, reset_combat, set_precombat_aura_stacks,
6    sync_resource,
7};
8use crate::handler::{CastCandidate, LockMode};
9#[cfg(feature = "rotation-optimizations")]
10use crate::state::{
11    program::{RotationDecisionMode, RotationDecisionSubscription},
12    runtime::RotationMutationAccumulator,
13};
14
15impl CombatHandler {
16    pub(super) fn on_sim_start_impl(&mut self, ctx: &mut SimContext) {
17        self.rng =
18            wowlab_engine_rng::SimRng::from_prefix(ctx.state.seed_prefix, ctx.state.iteration);
19        self.rng_seeded = true;
20        self.last_resource_sync = SimTime::ZERO;
21
22        for enemy in self
23            .instance
24            .enemies()
25            .filter(|enemy| enemy.is_active() && enemy.is_alive())
26        {
27            let Some(scope) = self
28                .program
29                .telemetry_scope(ActorId::Enemy(enemy.id()), enemy.id())
30            else {
31                continue;
32            };
33
34            ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
35                kind: EncounterTelemetryEventKind::Spawn,
36                time_ms: 0,
37                actor: ActorId::Enemy(enemy.id()),
38                previous_target: None,
39                target: Some(enemy.id()),
40                pull: scope.pull,
41                group: Some(scope.group),
42                transform: Some(enemy.current_transform()),
43            });
44        }
45
46        if let Some(target) = self.instance.current_target()
47            && let Some(scope) = self.program.telemetry_scope(ActorId::Player, target)
48        {
49            ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
50                kind: EncounterTelemetryEventKind::TargetChange,
51                time_ms: 0,
52                actor: ActorId::Player,
53                previous_target: None,
54                target: Some(target),
55                pull: scope.pull,
56                group: Some(scope.group),
57                transform: self.instance.actor_transform(ActorId::Player),
58            });
59        }
60
61        self.schedule_encounter_start();
62
63        crate::systems::ensure_initial_proc_heartbeat(
64            &mut self.instance,
65            SimTime::ZERO,
66            &mut self.rng,
67        );
68
69        self.initialize_precombat_state(ctx.telemetry);
70        self.initialize_periodic_drivers(ctx.telemetry);
71        initialize_pet_actions(&self.program, &mut self.instance);
72
73        self.schedule_initial_enemy_auto_attacks(SimTime::ZERO);
74
75        if let Some(t) = earliest_auto_attack_time(&self.program, &self.instance)
76            && let Some(target) = self.instance.current_target()
77        {
78            self.instance.schedule(Event::AutoAttack {
79                t,
80                source: ActorId::Player,
81                target,
82            });
83        }
84    }
85
86    pub(super) fn initialize_precombat_state(
87        &mut self,
88        telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
89    ) {
90        let target = self.instance.current_target();
91
92        for index in 0..self.program.precombat_auras.len() {
93            let aura_local = self
94                .program
95                .precombat_auras
96                .get(index)
97                .copied()
98                .expect("bounded precombat aura index");
99
100            self.with_hook_ctx(
101                telemetry,
102                HookCtxRequest::for_optional_target(SimTime::ZERO, target),
103                |hook| apply_aura(hook, aura_local),
104            );
105        }
106
107        for index in 0..self.program.talent_aura_stacks.len() {
108            let (aura_local, stacks) = self
109                .program
110                .talent_aura_stacks
111                .get(index)
112                .copied()
113                .expect("bounded talent aura index");
114
115            set_precombat_aura_stacks(
116                &self.program,
117                &mut self.instance,
118                aura_local,
119                stacks,
120                target,
121            );
122        }
123
124        self.with_hook_ctx(
125            telemetry,
126            HookCtxRequest::targetless(SimTime::ZERO),
127            drain_deferred_work,
128        );
129    }
130
131    pub(super) fn initialize_periodic_drivers(
132        &mut self,
133        telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
134    ) {
135        for index in 0..self.program.periodic_drivers.len() {
136            let driver = self
137                .program
138                .periodic_drivers
139                .get(index)
140                .copied()
141                .expect("bounded periodic driver index");
142
143            if driver.triggered_duration_ms.is_some() {
144                continue;
145            }
146
147            let interval_ms = crate::systems::effective_periodic_interval_for_action(
148                &crate::context::CombatView::new(&self.program, &self.instance),
149                self.program.damage.periodic_driver_action(index),
150                driver.tick_interval_ms.get(),
151                driver.hasted_ticks,
152            );
153
154            self.with_hook_ctx(
155                telemetry,
156                HookCtxRequest::targetless(SimTime::ZERO),
157                |ctx| ctx.schedule_periodic_driver(interval_ms, index, None, None),
158            );
159        }
160    }
161
162    // #t(fn: rust_cyclomatic_complexity) Cast readiness is one ordered state-machine transition; splitting the gates would obscure their precedence.
163    // #t(fn: rust_max_fn_lines) readiness keeps deadline gating, exact candidate acceptance, and cast-state publication in one causal transition.
164    pub(super) fn on_player_ready_impl(&mut self, ctx: &mut SimContext) -> Option<SpecAction> {
165        let now = ctx.state.current_time;
166
167        self.prepare_player_ready(now, ctx.telemetry)?;
168
169        if self.instance.runtime.casting.instant_cast_pending_at == Some(now) {
170            return None;
171        }
172
173        self.instance.runtime.casting.instant_cast_pending_at = None;
174
175        let (busy_ready, cast_active, gcd_ready) = self.readiness_deadlines(now);
176        let gcd_active = now < gcd_ready;
177        let lock_mode = match (cast_active, gcd_active) {
178            (false, false) => LockMode::Unlocked,
179            (true, false) => LockMode::Casting,
180            (false, true) => LockMode::Gcd,
181            (true, true) => LockMode::Both,
182        };
183
184        let (action, locked_wake) = self.next_rotation_action(lock_mode, now, gcd_ready);
185
186        match action {
187            Some(SpecAction::Cast {
188                spell,
189                empower_rank,
190                source,
191                target,
192            }) => {
193                let raw_spell_id = spell.engine_coordinates().as_u32();
194                let Some(candidate) = self.selected_candidate(raw_spell_id, target) else {
195                    self.instance.record_runtime_error(
196                        SpecRuntimeError::unresolved_rotation_cast_identity(raw_spell_id),
197                    );
198
199                    return None;
200                };
201                let (school_mask, damage_pushback) = {
202                    let profile = candidate.effective_profile(&self.program);
203
204                    (profile.school_mask, profile.damage_pushback)
205                };
206                let (is_off_gcd, usable_while_casting) = {
207                    let effective = candidate.effective(&self.program);
208
209                    (
210                        effective.behavior.off_gcd,
211                        effective.requirements.availability.usable_while_casting,
212                    )
213                };
214
215                if cast_active && !usable_while_casting {
216                    tracing::trace!(
217                        t = %now.as_secs_f64(),
218                        cast_end = %busy_ready.as_secs_f64(),
219                        "CAST_GATE"
220                    );
221
222                    return Some(SpecAction::Wait {
223                        until_ms: busy_ready,
224                    });
225                }
226
227                if let Some(wait) = self.gcd_queue_gate(now, gcd_ready, is_off_gcd) {
228                    return Some(Self::with_earlier_wake(wait, locked_wake));
229                }
230
231                let cast_ms = self.cast_time_ms_for_candidate(candidate, empower_rank);
232                let candidate = candidate.with_resolved_cast_time_ms(cast_ms);
233
234                self.instance.runtime.casting.selected_candidate = Some(candidate);
235
236                if is_off_gcd && cast_ms == 0 {
237                    if self.instance.runtime.casting.instant_off_gcd_casts_at != Some(now) {
238                        self.instance.runtime.casting.instant_off_gcd_casts_at = Some(now);
239                        self.instance.runtime.casting.instant_off_gcd_spells.clear();
240                    }
241
242                    if self
243                        .instance
244                        .runtime
245                        .casting
246                        .instant_off_gcd_spells
247                        .contains(candidate.requested_local)
248                    {
249                        let until_ms = if gcd_ready > now {
250                            gcd_ready
251                        } else {
252                            now.saturating_add(SimTime::from_millis(DEFAULT_GCD_MS))
253                        };
254
255                        return Some(SpecAction::Wait { until_ms });
256                    }
257
258                    self.instance
259                        .runtime
260                        .casting
261                        .instant_off_gcd_spells
262                        .insert(candidate.requested_local);
263                }
264
265                if is_off_gcd {
266                    tracing::trace!(t = %now.as_secs_f64(), spell_id = raw_spell_id, "OFF_GCD_CAST");
267                }
268
269                if source == ActorId::Player {
270                    self.break_player_action_auras(target, now, &mut *ctx.telemetry);
271                }
272
273                apply_auto_attack_action_lifecycle(
274                    &self.program,
275                    &mut self.instance,
276                    self.program.damage.spell_action(candidate.effective_local),
277                    source,
278                    target,
279                    now,
280                );
281                let chained = self.instance.runtime.casting.channel_chain_spell
282                    == Some(candidate.requested_local);
283
284                if chained {
285                    self.instance.runtime.casting.channel_chain_spell = None;
286                } else {
287                    crate::systems::set_gcd_for_candidate(
288                        &self.program,
289                        &mut self.instance,
290                        candidate,
291                        now,
292                    );
293                }
294
295                if cast_ms == 0 {
296                    // Claim before same-time wakes can select it again.
297                    self.instance.runtime.casting.instant_cast_pending_at = Some(now);
298                } else {
299                    let cast_end = now.saturating_add(SimTime::from_millis(cast_ms));
300
301                    self.instance.buffer.player_mut().cast_end = cast_end.as_secs_f64();
302                    let active = crate::state::ActiveCast::new(
303                        spell.engine_coordinates(),
304                        school_mask,
305                        cast_end,
306                    )
307                    .with_action(self.program.damage.spell_action(candidate.effective_local))
308                    .with_damage_pushback(damage_pushback);
309
310                    if source == ActorId::Player {
311                        self.instance
312                            .begin_actor_cast(source, active.with_empower(empower_rank, target));
313                        self.instance.runtime.casting.active_player_candidate = Some(candidate);
314                    } else {
315                        self.instance.begin_actor_cast(source, active);
316                    }
317                }
318
319                Some(SpecAction::Cast {
320                    spell,
321                    empower_rank,
322                    source,
323                    target,
324                })
325            }
326            #[cfg(feature = "rotation-optimizations")]
327            action if cast_active => Some(SpecAction::Wait {
328                until_ms: match action {
329                    Some(SpecAction::Wait { until_ms }) => until_ms.min(busy_ready),
330                    _ => busy_ready,
331                },
332            }),
333            #[cfg(not(feature = "rotation-optimizations"))]
334            _ if cast_active => Some(SpecAction::Wait {
335                until_ms: busy_ready,
336            }),
337            _ if gcd_active => Some(SpecAction::Wait {
338                until_ms: locked_wake.map_or(gcd_ready, |wake| wake.min(gcd_ready)),
339            }),
340            action => action,
341        }
342    }
343
344    pub(super) fn on_event_queue_empty_impl(&mut self, _ctx: &mut SimContext) {
345        self.record_encounter_stalled();
346    }
347
348    pub(super) fn encounter_termination_time_impl(&self) -> Option<SimTime> {
349        self.instance.runtime.encounter.terminated_at()
350    }
351
352    pub(super) fn flush_scheduled_impl(&mut self, push: &mut dyn FnMut(Event)) {
353        crate::builder::buffer_init::flush_dirty_projections(&self.program, &mut self.instance);
354
355        #[cfg(feature = "rotation-optimizations")]
356        if let Some(mutations) = self.instance.runtime.rotation_mutations.take() {
357            let mode = self.rotation_decision_mode_at(mutations.at());
358            let subscription = self.program.rotation.decision_subscription(mode);
359
360            assert!(
361                subscription.is_complete(),
362                "finalized rotation decision subscription became incomplete"
363            );
364
365            if rotation_mutations_intersect(subscription, &mutations) {
366                self.instance
367                    .schedule(Event::PlayerReady { t: mutations.at() });
368            }
369        }
370
371        for event in self.instance.drain_events() {
372            push(event);
373        }
374    }
375
376    pub(super) fn take_runtime_error_impl(&mut self) -> Option<SpecRuntimeError> {
377        self.instance.take_runtime_error()
378    }
379
380    pub(super) fn reset_impl(&mut self) {
381        reset_combat(
382            &self.program,
383            &mut self.instance,
384            &mut self.last_resource_sync,
385        );
386    }
387
388    pub(super) fn total_damage_impl(&self) -> f64 {
389        self.instance.runtime.total_damage
390    }
391
392    fn schedule_initial_enemy_auto_attacks(&mut self, now: SimTime) {
393        let enemy_count = self.program.enemy_definitions().len();
394
395        for index in 0..enemy_count {
396            let enemy_id = wowlab_types::sim::EnemyIdx::from_raw(
397                u16::try_from(index).expect("enemy count fits"),
398            );
399            let Some((enemy, swing_ms)) =
400                self.program.enemy_definition(enemy_id).and_then(|enemy| {
401                    self.instance
402                        .is_valid_target(enemy.id())
403                        .then_some((enemy.id(), enemy.auto_attack_swing_ms()?))
404                })
405            else {
406                continue;
407            };
408
409            self.instance.schedule_enemy_auto_attack(
410                enemy,
411                ActorId::Player,
412                now.saturating_add(SimTime::from_millis(swing_ms / FIRST_ENEMY_SWING_DIVISOR)),
413            );
414        }
415    }
416
417    fn live_player(&self) -> Option<()> {
418        self.instance
419            .is_friendly_actor_alive(ActorId::Player)
420            .then_some(())
421    }
422
423    fn prepare_player_ready(
424        &mut self,
425        now: SimTime,
426        telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
427    ) -> Option<()> {
428        self.live_player()?;
429
430        if self.instance.runtime.casting.active_channel.is_some()
431            && now >= SimTime::from_secs_f64(self.instance.buffer.player().channel_end)
432        {
433            crate::systems::end_channel(&self.program, &mut self.instance, now);
434        }
435
436        sync_resource(
437            &self.program,
438            &mut self.instance,
439            now,
440            &mut self.last_resource_sync,
441            telemetry,
442        );
443
444        let enemy_count = self.instance.enemies().len();
445
446        for index in 0..enemy_count {
447            let enemy = wowlab_types::sim::EnemyIdx::from_raw(
448                u16::try_from(index).expect("enemy count fits"),
449            );
450
451            self.instance.refresh_enemy_health(enemy, now);
452        }
453
454        crate::builder::buffer_init::flush_dirty_projections(&self.program, &mut self.instance);
455
456        Some(())
457    }
458
459    fn selected_candidate(
460        &self,
461        spell_id: u32,
462        target: wowlab_types::sim::EnemyIdx,
463    ) -> Option<CastCandidate> {
464        self.instance
465            .runtime
466            .casting
467            .selected_candidate
468            .filter(|candidate| {
469                candidate.requested_spell_id(&self.program) == spell_id
470                    && candidate.target == target
471            })
472    }
473
474    fn readiness_deadlines(&self, now: SimTime) -> (SimTime, bool, SimTime) {
475        let cast_ready = SimTime::from_secs_f64(self.instance.buffer.player().cast_end);
476        let channel_ready = SimTime::from_secs_f64(self.instance.buffer.player().channel_end);
477        let busy_ready = cast_ready.max(channel_ready);
478        let gcd_ready = SimTime::from_secs_f64(self.instance.buffer.player().gcd_end);
479
480        (busy_ready, now < busy_ready, gcd_ready)
481    }
482
483    #[cfg(feature = "rotation-optimizations")]
484    fn rotation_decision_mode_at(&self, now: SimTime) -> RotationDecisionMode {
485        let (_, cast_active, gcd_ready) = self.readiness_deadlines(now);
486
487        match (cast_active, now < gcd_ready) {
488            (false, false) => RotationDecisionMode::Unlocked,
489            (false, true) => RotationDecisionMode::Gcd,
490            (true, false) => RotationDecisionMode::Casting,
491            (true, true) => RotationDecisionMode::Both,
492        }
493    }
494
495    fn next_rotation_action(
496        &mut self,
497        lock_mode: LockMode,
498        now: SimTime,
499        gcd_ready: SimTime,
500    ) -> (Option<SpecAction>, Option<SimTime>) {
501        #[cfg(feature = "rotation-optimizations")]
502        {
503            return match lock_mode {
504                LockMode::Unlocked => (self.evaluate_rotation(now), None),
505                LockMode::Casting => (
506                    self.evaluate_rotation_while_locked(LockMode::Casting, now),
507                    None,
508                ),
509                LockMode::Gcd => self.next_gcd_locked_action(now, gcd_ready),
510                LockMode::Both => (
511                    self.evaluate_rotation_while_locked(LockMode::Both, now),
512                    None,
513                ),
514            };
515        }
516
517        #[cfg(not(feature = "rotation-optimizations"))]
518        {
519            let _ = gcd_ready;
520
521            match lock_mode {
522                LockMode::Unlocked | LockMode::Gcd => (self.evaluate_rotation(now), None),
523                LockMode::Casting | LockMode::Both => (
524                    self.evaluate_rotation_while_locked(LockMode::Casting, now),
525                    None,
526                ),
527            }
528        }
529    }
530
531    #[cfg(feature = "rotation-optimizations")]
532    fn next_gcd_locked_action(
533        &mut self,
534        now: SimTime,
535        gcd_ready: SimTime,
536    ) -> (Option<SpecAction>, Option<SimTime>) {
537        let locked_action = self.evaluate_rotation_while_locked(LockMode::Gcd, now);
538
539        if matches!(locked_action, Some(SpecAction::Cast { .. })) {
540            return (locked_action, None);
541        }
542
543        let locked_wake = match locked_action {
544            Some(SpecAction::Wait { until_ms }) if until_ms < gcd_ready => Some(until_ms),
545            _ => None,
546        };
547        let queue_eligible_at = self.gcd_queue_eligible_at(gcd_ready);
548
549        if now < queue_eligible_at {
550            let wake = locked_wake.map_or(queue_eligible_at, |wake| wake.min(queue_eligible_at));
551
552            return (None, Some(wake));
553        }
554
555        (self.evaluate_rotation(now), locked_wake)
556    }
557
558    fn gcd_queue_eligible_at(&self, gcd_ready: SimTime) -> SimTime {
559        gcd_ready.saturating_sub(SimTime::from_millis(
560            self.program.config.cast_latency.queue_window_ms,
561        ))
562    }
563
564    fn with_earlier_wake(action: SpecAction, locked_wake: Option<SimTime>) -> SpecAction {
565        match (action, locked_wake) {
566            (SpecAction::Wait { until_ms }, Some(wake)) => SpecAction::Wait {
567                until_ms: wake.min(until_ms),
568            },
569            (action, _) => action,
570        }
571    }
572
573    fn gcd_queue_gate(
574        &self,
575        now: SimTime,
576        gcd_ready: SimTime,
577        is_off_gcd: bool,
578    ) -> Option<SpecAction> {
579        if now >= gcd_ready || is_off_gcd {
580            return None;
581        }
582
583        let queue_eligible_at = self.gcd_queue_eligible_at(gcd_ready);
584
585        if now < queue_eligible_at {
586            return Some(SpecAction::Wait {
587                until_ms: queue_eligible_at,
588            });
589        }
590
591        let latency = if self.program.config.cast_latency.strict_gcd_queue {
592            self.program.config.cast_latency.gcd_ms
593        } else {
594            self.program.config.cast_latency.queue_ms
595        };
596
597        tracing::trace!(
598            t = %now.as_secs_f64(),
599            gcd = %gcd_ready.as_secs_f64(),
600            "GCD_GATE"
601        );
602
603        Some(SpecAction::Wait {
604            until_ms: gcd_ready.saturating_add(SimTime::from_millis(latency)),
605        })
606    }
607}
608
609#[cfg(feature = "rotation-optimizations")]
610fn rotation_mutations_intersect(
611    subscription: &RotationDecisionSubscription,
612    mutations: &RotationMutationAccumulator,
613) -> bool {
614    let cooldown_intersects = subscription
615        .cooldown_rises()
616        .iter()
617        .zip(mutations.cooldown_rises())
618        .any(|(subscribed, changed)| subscribed & changed != 0)
619        || subscription
620            .cooldown_falls()
621            .iter()
622            .zip(mutations.cooldown_falls())
623            .any(|(subscribed, changed)| subscribed & changed != 0);
624    let resource_intersects = subscription.resource_rises() & mutations.resource_rises() != 0
625        || subscription.resource_falls() & mutations.resource_falls() != 0;
626    let semantic_intersects = subscription.semantic_rises() & mutations.semantic_rises() != 0
627        || subscription.semantic_falls() & mutations.semantic_falls() != 0;
628
629    subscription.has_eligible_actions()
630        && (cooldown_intersects || resource_intersects || semantic_intersects)
631}
632use wowlab_engine_event_authority::EngineScheduledSpellAccess as _;