Skip to main content

wowlab_engine_combat/handler/orchestration/
timeline.rs

1use wowlab_engine_domain::encounter::HealthModel;
2use wowlab_engine_ports::{
3    DeathEventScope, EncounterDespawnKind, EncounterTerminationReason, Event,
4    PullLifecycleGeneration, PullScope, SimContext,
5};
6use wowlab_engine_telemetry::{
7    EncounterTelemetryEvent, EncounterTelemetryEventKind, TelemetrySink,
8};
9use wowlab_types::sim::{
10    ActorId, EncounterScriptEvent, EnemyIdx, PositionedActorRef, PullId, SimTime, WaveId,
11};
12
13use super::{super::CombatHandler, at_offset};
14use crate::{
15    builder::buffer_init::project_encounter,
16    context::HookCtxRequest,
17    handler::reset::set_precombat_aura_stacks,
18    state::LocalAuraIdx,
19    systems::{apply_aura, sync_spell_costs},
20};
21
22impl CombatHandler {
23    pub(in crate::handler) fn schedule_encounter_start(&mut self) {
24        if !self.state.runtime.encounter.start_once() {
25            return;
26        }
27
28        let pull = self.state.runtime.encounter.active_pull();
29        let epoch = self.state.runtime.encounter.pull_epoch();
30
31        self.schedule_pull_timeline(pull, epoch, SimTime::ZERO, true);
32        self.schedule_run_timed_events();
33
34        if let Some(duration) = self.state.config.encounter.definition().fixed_duration_s {
35            self.state.schedule(Event::EncounterTerminate {
36                t: SimTime::from_secs_f64(duration),
37                reason: EncounterTerminationReason::FixedDuration,
38            });
39        }
40    }
41
42    pub(in crate::handler) fn schedule_run_timed_events(&mut self) {
43        let fight_duration = self.state.config.encounter.fight_duration_secs();
44        let raid_events = self.state.config.raid_events.clone();
45        let external_buffs = self.state.config.external_buffs.clone();
46
47        for (index, config) in raid_events.into_iter().enumerate() {
48            for occurrence in config.schedule.occurrences(fight_duration) {
49                self.state.schedule(Event::RaidEvent {
50                    t: SimTime::from_secs_f64(occurrence),
51                    index,
52                });
53            }
54        }
55
56        for (index, config) in external_buffs.into_iter().enumerate() {
57            for occurrence in config.schedule.occurrences(fight_duration) {
58                self.state.schedule(Event::ExternalBuff {
59                    t: SimTime::from_secs_f64(occurrence),
60                    index,
61                });
62            }
63        }
64    }
65
66    pub(in crate::handler) fn handle_wave_activation(
67        &mut self,
68        pull: PullId,
69        epoch: u32,
70        wave: WaveId,
71        now: SimTime,
72    ) {
73        if !self.state.runtime.encounter.is_current(pull, epoch) {
74            return;
75        }
76
77        let definition = self.state.config.encounter.definition();
78        let Some(wave_definition) = definition.waves.get(wave.as_usize()) else {
79            return;
80        };
81        let dependencies_complete = wave_definition
82            .depends_on_groups
83            .iter()
84            .all(|group| self.state.runtime.encounter.group_is_complete(*group));
85
86        if !dependencies_complete {
87            return;
88        }
89
90        if self.state.runtime.encounter.activate_wave(wave) {
91            self.schedule_wave_enemies(
92                pull,
93                epoch,
94                wave,
95                self.state.runtime.encounter.pull_started_at(),
96                now,
97            );
98        }
99    }
100
101    pub(in crate::handler) fn handle_enemy_activation(
102        &mut self,
103        pull: PullId,
104        epoch: u32,
105        target: EnemyIdx,
106        ctx: &mut SimContext,
107    ) {
108        let now = ctx.state.current_time;
109
110        if !self.state.runtime.encounter.is_current(pull, epoch) {
111            return;
112        }
113
114        if !self.state.activate_enemy(target, now) {
115            self.record_script_error(pull, format!("enemy activation {target:?} is invalid"));
116
117            return;
118        }
119
120        self.emit_enemy_lifecycle(
121            &mut *ctx.telemetry,
122            EncounterTelemetryEventKind::Spawn,
123            target,
124            now,
125            self.state
126                .enemy(target)
127                .map(wowlab_engine_domain::encounter::EnemyState::current_transform),
128        );
129        self.schedule_scripted_death(target, now, self.state.runtime.encounter.generation());
130        self.state.schedule(Event::Retarget {
131            t: now,
132            scope: PullScope { pull, epoch },
133        });
134    }
135
136    pub(in crate::handler) fn handle_retarget(
137        &mut self,
138        pull: PullId,
139        epoch: u32,
140        ctx: &mut SimContext,
141    ) {
142        if !self.state.runtime.encounter.is_current(pull, epoch) {
143            return;
144        }
145
146        let now = ctx.state.current_time;
147        let previous_target = self.state.current_target();
148        let target = self.state.retarget();
149
150        if previous_target != target {
151            let group = target.and_then(|target| {
152                self.state
153                    .telemetry_scope(ActorId::Player, target)
154                    .map(|scope| scope.group)
155            });
156
157            ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
158                kind: EncounterTelemetryEventKind::TargetChange,
159                time_ms: now.as_millis(),
160                actor: ActorId::Player,
161                previous_target,
162                target,
163                pull,
164                group,
165                transform: self.state.actor_transform(ActorId::Player),
166            });
167        }
168
169        self.apply_pending_target_precombat(now, &mut *ctx.telemetry);
170        project_encounter(&self.state, &mut self.buf, now);
171        self.state.schedule(Event::PlayerReady { t: now });
172    }
173
174    pub(in crate::handler) fn handle_movement(
175        &mut self,
176        pull: PullId,
177        epoch: u32,
178        actor: PositionedActorRef,
179        transform: wowlab_types::sim::SpatialTransform,
180        ctx: &mut SimContext,
181    ) {
182        if !self.state.runtime.encounter.is_current(pull, epoch) {
183            return;
184        }
185
186        if let Err(error) = self.state.relocate_actor(actor, transform) {
187            self.record_script_error(pull, format!("movement for {actor:?} failed: {error}"));
188
189            return;
190        }
191
192        let (actor_id, target, group) = match actor {
193            PositionedActorRef::Player => (ActorId::Player, None, None),
194            PositionedActorRef::Enemy(target) => {
195                let group = self
196                    .state
197                    .telemetry_scope(ActorId::Enemy(target), target)
198                    .map(|scope| scope.group);
199
200                (ActorId::Enemy(target), Some(target), group)
201            }
202        };
203
204        ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
205            kind: EncounterTelemetryEventKind::Movement,
206            time_ms: ctx.state.current_time.as_millis(),
207            actor: actor_id,
208            previous_target: None,
209            target,
210            pull,
211            group,
212            transform: Some(transform),
213        });
214    }
215
216    pub(super) fn schedule_pull_timeline(
217        &mut self,
218        pull_id: PullId,
219        epoch: u32,
220        started_at: SimTime,
221        initial: bool,
222    ) {
223        let Some(pull) = self
224            .state
225            .config
226            .encounter
227            .definition()
228            .pulls
229            .get(pull_id.as_usize())
230            .filter(|pull| pull.id == pull_id)
231            .cloned()
232        else {
233            return;
234        };
235
236        let waves: Vec<_> = pull
237            .wave_ids
238            .iter()
239            .filter_map(|wave| {
240                self.state
241                    .config
242                    .encounter
243                    .definition()
244                    .waves
245                    .get(wave.as_usize())
246                    .cloned()
247            })
248            .collect();
249
250        for wave in waves {
251            if initial && self.state.runtime.encounter.wave_is_active(wave.id) {
252                self.schedule_wave_enemies(pull_id, epoch, wave.id, started_at, started_at);
253            } else if wave.depends_on_groups.is_empty() {
254                self.state.schedule(Event::WaveActivate {
255                    t: at_offset(started_at, wave.minimum_activation_s),
256                    scope: PullScope {
257                        pull: pull_id,
258                        epoch,
259                    },
260                    wave: wave.id,
261                });
262            }
263        }
264
265        for event in pull.events {
266            match event {
267                EncounterScriptEvent::Move {
268                    at_s,
269                    actor,
270                    transform,
271                } => self.state.schedule(Event::EncounterMove {
272                    t: at_offset(started_at, at_s),
273                    scope: PullScope {
274                        pull: pull_id,
275                        epoch,
276                    },
277                    actor,
278                    transform,
279                }),
280                EncounterScriptEvent::Despawn {
281                    at_s,
282                    enemy,
283                    counts_as_completion,
284                } => self.state.schedule(Event::EncounterDespawn {
285                    t: at_offset(started_at, at_s),
286                    scope: PullScope {
287                        pull: pull_id,
288                        epoch,
289                    },
290                    target: enemy,
291                    kind: EncounterDespawnKind::Authored {
292                        counts_as_completion,
293                    },
294                }),
295            }
296        }
297    }
298
299    pub(super) fn schedule_wave_enemies(
300        &mut self,
301        pull: PullId,
302        epoch: u32,
303        wave: WaveId,
304        pull_started_at: SimTime,
305        ready_at: SimTime,
306    ) {
307        let Some(wave_definition) = self
308            .state
309            .config
310            .encounter
311            .definition()
312            .waves
313            .get(wave.as_usize())
314            .cloned()
315        else {
316            return;
317        };
318        let enemy_ids: Vec<_> = wave_definition
319            .group_ids
320            .iter()
321            .filter_map(|group| {
322                self.state
323                    .config
324                    .encounter
325                    .definition()
326                    .groups
327                    .get(group.as_usize())
328            })
329            .flat_map(|group| group.enemy_ids.iter().copied())
330            .collect();
331        let spawns: Vec<_> = enemy_ids
332            .into_iter()
333            .filter_map(|enemy| {
334                self.state
335                    .config
336                    .encounter
337                    .definition()
338                    .enemies
339                    .get(enemy.as_usize())
340                    .map(|definition| (enemy, definition.spawn_at_s))
341            })
342            .collect();
343
344        for (enemy, spawn_at) in spawns {
345            let activation_at = at_offset(pull_started_at, spawn_at).max(ready_at);
346
347            if self.state.is_valid_target(enemy) {
348                self.schedule_scripted_death(
349                    enemy,
350                    activation_at,
351                    self.state.runtime.encounter.generation(),
352                );
353            } else {
354                self.state.schedule(Event::EnemyActivate {
355                    t: activation_at,
356                    scope: PullScope { pull, epoch },
357                    target: enemy,
358                });
359            }
360        }
361    }
362
363    pub(super) fn schedule_scripted_death(
364        &mut self,
365        enemy: EnemyIdx,
366        activated_at: SimTime,
367        generation: PullLifecycleGeneration,
368    ) {
369        let Some(HealthModel::ScriptedLinear { death_at_s, .. }) = self
370            .state
371            .enemy_definition(enemy)
372            .map(wowlab_engine_domain::encounter::EnemyActorDefinition::health_model)
373        else {
374            return;
375        };
376
377        self.state.schedule(Event::Death {
378            t: at_offset(activated_at, death_at_s),
379            target: enemy,
380            scope: DeathEventScope::ScriptedLifetime(generation),
381        });
382    }
383
384    pub(super) fn schedule_newly_ready_waves(&mut self, pull: PullId, epoch: u32, now: SimTime) {
385        let pull_started_at = self.state.runtime.encounter.pull_started_at();
386        let definition = self.state.config.encounter.definition();
387        let Some(pull_definition) = definition.pulls.get(pull.as_usize()) else {
388            return;
389        };
390        let waves: Vec<_> = pull_definition
391            .wave_ids
392            .iter()
393            .filter_map(|wave| definition.waves.get(wave.as_usize()).cloned())
394            .collect();
395
396        for wave in waves {
397            let ready = !self.state.runtime.encounter.wave_is_active(wave.id)
398                && wave
399                    .depends_on_groups
400                    .iter()
401                    .all(|group| self.state.runtime.encounter.group_is_complete(*group));
402
403            if ready {
404                self.state.schedule(Event::WaveActivate {
405                    t: at_offset(pull_started_at, wave.minimum_activation_s).max(now),
406                    scope: PullScope { pull, epoch },
407                    wave: wave.id,
408                });
409            }
410        }
411    }
412
413    pub(super) fn apply_pending_target_precombat(
414        &mut self,
415        now: SimTime,
416        telemetry: &mut TelemetrySink,
417    ) {
418        let Some(target) = self
419            .state
420            .current_target()
421            .filter(|target| self.state.is_valid_target(*target))
422        else {
423            return;
424        };
425
426        if !self.state.runtime.encounter.take_current_target_precombat() {
427            return;
428        }
429
430        let precombat: Vec<LocalAuraIdx> = self
431            .state
432            .defs
433            .precombat_auras
434            .iter()
435            .copied()
436            .filter(|aura| self.state.aura(*aura).on == wowlab_types::sim::AuraOn::Target)
437            .collect();
438
439        for aura in precombat {
440            self.with_hook_ctx(
441                &mut *telemetry,
442                HookCtxRequest::for_target(now, target),
443                |hook| apply_aura(hook, aura),
444            );
445        }
446
447        let stacks: Vec<_> = self
448            .state
449            .defs
450            .talent_aura_stacks
451            .iter()
452            .copied()
453            .filter(|(aura, _)| self.state.aura(*aura).on == wowlab_types::sim::AuraOn::Target)
454            .collect();
455
456        for (aura, count) in stacks {
457            set_precombat_aura_stacks(&self.state, &mut self.buf, aura, count, Some(target));
458        }
459
460        sync_spell_costs(&self.state, &mut self.buf);
461    }
462
463    pub(super) fn schedule_retarget(&mut self, now: SimTime) {
464        self.state.schedule(Event::Retarget {
465            t: now,
466            scope: PullScope {
467                pull: self.state.runtime.encounter.active_pull(),
468                epoch: self.state.runtime.encounter.pull_epoch(),
469            },
470        });
471    }
472
473    pub(super) fn schedule_forced_optional_despawns(
474        &mut self,
475        pull: PullId,
476        epoch: u32,
477        now: SimTime,
478    ) {
479        let definition = self.state.config.encounter.definition();
480        let targets: Vec<_> = definition
481            .groups
482            .iter()
483            .filter(|group| !group.required)
484            .flat_map(|group| {
485                group.enemy_ids.iter().copied().filter(|enemy| {
486                    self.state.enemy_belongs_to_pull(*enemy, pull)
487                        && self.state.is_valid_target(*enemy)
488                })
489            })
490            .collect();
491
492        for target in targets {
493            self.state.schedule(Event::EncounterDespawn {
494                t: now,
495                scope: PullScope { pull, epoch },
496                target,
497                kind: EncounterDespawnKind::ForcedOptional,
498            });
499        }
500    }
501}