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    context::HookCtxRequest,
16    handler::reset::set_precombat_aura_stacks,
17    systems::{apply_aura, initialize_all_spell_costs},
18};
19
20impl CombatHandler {
21    pub(in crate::handler) fn schedule_encounter_start(&mut self) {
22        if !self.instance.runtime.encounter.start_once() {
23            return;
24        }
25
26        let pull = self.instance.runtime.encounter.active_pull();
27        let epoch = self.instance.runtime.encounter.pull_epoch();
28
29        self.schedule_pull_timeline(pull, epoch, SimTime::ZERO, true);
30        self.schedule_run_timed_events();
31
32        if let Some(duration) = self.program.config.encounter.definition().fixed_duration_s {
33            self.instance.schedule(Event::EncounterTerminate {
34                t: SimTime::from_secs_f64(duration),
35                reason: EncounterTerminationReason::FixedDuration,
36            });
37        }
38    }
39
40    pub(in crate::handler) fn schedule_run_timed_events(&mut self) {
41        let fight_duration = self.program.config.encounter.fight_duration_secs();
42
43        for index in 0..self.program.config.raid_events.len() {
44            let config = self
45                .program
46                .config
47                .raid_events
48                .get(index)
49                .copied()
50                .expect("bounded raid-event index");
51
52            config
53                .schedule
54                .visit_occurrences(fight_duration, |occurrence| {
55                    self.instance.schedule(Event::RaidEvent {
56                        t: SimTime::from_secs_f64(occurrence),
57                        event: self.program.runtime_events.raid_event(
58                            u16::try_from(index).expect("finalized raid plan count fits u16"),
59                        ),
60                    });
61                });
62        }
63
64        for index in 0..self.program.config.external_buffs.len() {
65            let config = self
66                .program
67                .config
68                .external_buffs
69                .get(index)
70                .copied()
71                .expect("bounded external-buff index");
72
73            config
74                .schedule
75                .visit_occurrences(fight_duration, |occurrence| {
76                    self.instance.schedule(Event::ExternalBuff {
77                        t: SimTime::from_secs_f64(occurrence),
78                        buff: self.program.runtime_events.external_buff(
79                            u16::try_from(index).expect("finalized external plan count fits u16"),
80                        ),
81                    });
82                });
83        }
84    }
85
86    pub(in crate::handler) fn handle_wave_activation(
87        &mut self,
88        pull: PullId,
89        epoch: u32,
90        wave: WaveId,
91        now: SimTime,
92    ) {
93        if !self.instance.runtime.encounter.is_current(pull, epoch) {
94            return;
95        }
96
97        let definition = self.program.config.encounter.definition();
98        let Some(wave_definition) = definition.waves.get(wave.as_usize()) else {
99            return;
100        };
101        let dependencies_complete = wave_definition
102            .depends_on_groups
103            .iter()
104            .all(|group| self.instance.runtime.encounter.group_is_complete(*group));
105
106        if !dependencies_complete {
107            return;
108        }
109
110        if self.instance.runtime.encounter.activate_wave(wave) {
111            self.schedule_wave_enemies(
112                pull,
113                epoch,
114                wave,
115                self.instance.runtime.encounter.pull_started_at(),
116                now,
117            );
118        }
119    }
120
121    pub(in crate::handler) fn handle_enemy_activation(
122        &mut self,
123        pull: PullId,
124        epoch: u32,
125        target: EnemyIdx,
126        ctx: &mut SimContext,
127    ) {
128        let now = ctx.state.current_time;
129
130        if !self.instance.runtime.encounter.is_current(pull, epoch) {
131            return;
132        }
133
134        if !self.instance.activate_enemy(target, now) {
135            self.record_script_error(pull, format!("enemy activation {target:?} is invalid"));
136
137            return;
138        }
139
140        self.emit_enemy_lifecycle(
141            &mut *ctx.telemetry,
142            EncounterTelemetryEventKind::Spawn,
143            target,
144            now,
145            self.instance
146                .enemy(target)
147                .map(wowlab_engine_domain::encounter::EnemyState::current_transform),
148        );
149        self.schedule_scripted_death(target, now, self.instance.runtime.encounter.generation());
150        self.instance.schedule(Event::Retarget {
151            t: now,
152            scope: PullScope { pull, epoch },
153        });
154    }
155
156    pub(in crate::handler) fn handle_retarget(
157        &mut self,
158        pull: PullId,
159        epoch: u32,
160        ctx: &mut SimContext,
161    ) {
162        if !self.instance.runtime.encounter.is_current(pull, epoch) {
163            return;
164        }
165
166        let now = ctx.state.current_time;
167        let previous_target = self.instance.current_target();
168        let target = self.instance.retarget(&self.program, now);
169
170        if previous_target != target {
171            let group = target.and_then(|target| {
172                self.program
173                    .telemetry_scope(ActorId::Player, target)
174                    .map(|scope| scope.group)
175            });
176
177            ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
178                kind: EncounterTelemetryEventKind::TargetChange,
179                time_ms: now.as_millis(),
180                actor: ActorId::Player,
181                previous_target,
182                target,
183                pull,
184                group,
185                transform: self.instance.actor_transform(ActorId::Player),
186            });
187        }
188
189        self.apply_pending_target_precombat(now, &mut *ctx.telemetry);
190    }
191
192    pub(in crate::handler) fn handle_movement(
193        &mut self,
194        pull: PullId,
195        epoch: u32,
196        actor: PositionedActorRef,
197        transform: wowlab_types::sim::SpatialTransform,
198        ctx: &mut SimContext,
199    ) {
200        if !self.instance.runtime.encounter.is_current(pull, epoch) {
201            return;
202        }
203
204        if let Err(error) = self
205            .instance
206            .relocate_actor(actor, transform, ctx.state.current_time)
207        {
208            self.record_script_error(pull, format!("movement for {actor:?} failed: {error}"));
209
210            return;
211        }
212
213        let (actor_id, target, group) = match actor {
214            PositionedActorRef::Player => (ActorId::Player, None, None),
215            PositionedActorRef::Enemy(target) => {
216                let group = self
217                    .program
218                    .telemetry_scope(ActorId::Enemy(target), target)
219                    .map(|scope| scope.group);
220
221                (ActorId::Enemy(target), Some(target), group)
222            }
223        };
224
225        ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
226            kind: EncounterTelemetryEventKind::Movement,
227            time_ms: ctx.state.current_time.as_millis(),
228            actor: actor_id,
229            previous_target: None,
230            target,
231            pull,
232            group,
233            transform: Some(transform),
234        });
235    }
236
237    pub(super) fn schedule_pull_timeline(
238        &mut self,
239        pull_id: PullId,
240        epoch: u32,
241        started_at: SimTime,
242        initial: bool,
243    ) {
244        let Some(pull_index) = self
245            .program
246            .config
247            .encounter
248            .definition()
249            .pulls
250            .get(pull_id.as_usize())
251            .filter(|pull| pull.id == pull_id)
252            .map(|_| pull_id.as_usize())
253        else {
254            return;
255        };
256        let wave_count = self
257            .program
258            .config
259            .encounter
260            .definition()
261            .pulls
262            .get(pull_index)
263            .expect("validated pull index")
264            .wave_ids
265            .len();
266
267        for index in 0..wave_count {
268            let wave_id = self
269                .program
270                .config
271                .encounter
272                .definition()
273                .pulls
274                .get(pull_index)
275                .expect("validated pull index")
276                .wave_ids
277                .get(index)
278                .copied()
279                .expect("bounded wave index");
280            let Some((minimum_activation_s, has_dependencies)) = self
281                .program
282                .config
283                .encounter
284                .definition()
285                .waves
286                .get(wave_id.as_usize())
287                .map(|wave| {
288                    (
289                        wave.minimum_activation_s,
290                        !wave.depends_on_groups.is_empty(),
291                    )
292                })
293            else {
294                continue;
295            };
296
297            if initial && self.instance.runtime.encounter.wave_is_active(wave_id) {
298                self.schedule_wave_enemies(pull_id, epoch, wave_id, started_at, started_at);
299            } else if !has_dependencies {
300                self.instance.schedule(Event::WaveActivate {
301                    t: at_offset(started_at, minimum_activation_s),
302                    scope: PullScope {
303                        pull: pull_id,
304                        epoch,
305                    },
306                    wave: wave_id,
307                });
308            }
309        }
310
311        let event_count = self
312            .program
313            .config
314            .encounter
315            .definition()
316            .pulls
317            .get(pull_index)
318            .expect("validated pull index")
319            .events
320            .len();
321
322        for index in 0..event_count {
323            let event = self
324                .program
325                .config
326                .encounter
327                .definition()
328                .pulls
329                .get(pull_index)
330                .expect("validated pull index")
331                .events
332                .get(index)
333                .expect("bounded encounter-event index");
334
335            match event {
336                EncounterScriptEvent::Move {
337                    at_s,
338                    actor,
339                    transform,
340                } => self.instance.schedule(Event::EncounterMove {
341                    t: at_offset(started_at, *at_s),
342                    scope: PullScope {
343                        pull: pull_id,
344                        epoch,
345                    },
346                    actor: *actor,
347                    transform: *transform,
348                }),
349                EncounterScriptEvent::Despawn {
350                    at_s,
351                    enemy,
352                    counts_as_completion,
353                } => self.instance.schedule(Event::EncounterDespawn {
354                    t: at_offset(started_at, *at_s),
355                    scope: PullScope {
356                        pull: pull_id,
357                        epoch,
358                    },
359                    target: *enemy,
360                    kind: EncounterDespawnKind::Authored {
361                        counts_as_completion: *counts_as_completion,
362                    },
363                }),
364            }
365        }
366    }
367
368    pub(super) fn schedule_wave_enemies(
369        &mut self,
370        pull: PullId,
371        epoch: u32,
372        wave: WaveId,
373        pull_started_at: SimTime,
374        ready_at: SimTime,
375    ) {
376        let Some(wave_index) = self
377            .program
378            .config
379            .encounter
380            .definition()
381            .waves
382            .get(wave.as_usize())
383            .map(|_| wave.as_usize())
384        else {
385            return;
386        };
387        let group_count = self
388            .program
389            .config
390            .encounter
391            .definition()
392            .waves
393            .get(wave_index)
394            .expect("validated wave index")
395            .group_ids
396            .len();
397
398        for group_index in 0..group_count {
399            let group_id = self
400                .program
401                .config
402                .encounter
403                .definition()
404                .waves
405                .get(wave_index)
406                .expect("validated wave index")
407                .group_ids
408                .get(group_index)
409                .copied()
410                .expect("bounded group index");
411            let enemy_count = self
412                .program
413                .config
414                .encounter
415                .definition()
416                .groups
417                .get(group_id.as_usize())
418                .map_or(0, |group| group.enemy_ids.len());
419
420            for enemy_index in 0..enemy_count {
421                let enemy = self
422                    .program
423                    .config
424                    .encounter
425                    .definition()
426                    .groups
427                    .get(group_id.as_usize())
428                    .expect("validated group index")
429                    .enemy_ids
430                    .get(enemy_index)
431                    .copied()
432                    .expect("bounded enemy index");
433                let Some(spawn_at) = self
434                    .program
435                    .config
436                    .encounter
437                    .definition()
438                    .enemies
439                    .get(enemy.as_usize())
440                    .map(|definition| definition.spawn_at_s)
441                else {
442                    continue;
443                };
444                let activation_at = at_offset(pull_started_at, spawn_at).max(ready_at);
445
446                if self.instance.is_valid_target(enemy) {
447                    self.schedule_scripted_death(
448                        enemy,
449                        activation_at,
450                        self.instance.runtime.encounter.generation(),
451                    );
452                } else {
453                    self.instance.schedule(Event::EnemyActivate {
454                        t: activation_at,
455                        scope: PullScope { pull, epoch },
456                        target: enemy,
457                    });
458                }
459            }
460        }
461    }
462
463    pub(super) fn schedule_scripted_death(
464        &mut self,
465        enemy: EnemyIdx,
466        activated_at: SimTime,
467        generation: PullLifecycleGeneration,
468    ) {
469        let Some(HealthModel::ScriptedLinear { death_at_s, .. }) = self
470            .program
471            .enemy_definition(enemy)
472            .map(wowlab_engine_domain::encounter::EnemyActorDefinition::health_model)
473        else {
474            return;
475        };
476
477        self.instance.schedule(Event::Death {
478            t: at_offset(activated_at, death_at_s),
479            target: enemy,
480            scope: DeathEventScope::ScriptedLifetime(generation),
481        });
482    }
483
484    pub(super) fn schedule_newly_ready_waves(&mut self, pull: PullId, epoch: u32, now: SimTime) {
485        let pull_started_at = self.instance.runtime.encounter.pull_started_at();
486        let Some(wave_count) = self
487            .program
488            .config
489            .encounter
490            .definition()
491            .pulls
492            .get(pull.as_usize())
493            .map(|pull| pull.wave_ids.len())
494        else {
495            return;
496        };
497
498        for index in 0..wave_count {
499            let wave_id = self
500                .program
501                .config
502                .encounter
503                .definition()
504                .pulls
505                .get(pull.as_usize())
506                .expect("validated pull index")
507                .wave_ids
508                .get(index)
509                .copied()
510                .expect("bounded wave index");
511            let Some(wave) = self
512                .program
513                .config
514                .encounter
515                .definition()
516                .waves
517                .get(wave_id.as_usize())
518            else {
519                continue;
520            };
521            let minimum_activation_s = wave.minimum_activation_s;
522            let ready = !self.instance.runtime.encounter.wave_is_active(wave_id)
523                && wave
524                    .depends_on_groups
525                    .iter()
526                    .all(|group| self.instance.runtime.encounter.group_is_complete(*group));
527
528            if ready {
529                self.instance.schedule(Event::WaveActivate {
530                    t: at_offset(pull_started_at, minimum_activation_s).max(now),
531                    scope: PullScope { pull, epoch },
532                    wave: wave_id,
533                });
534            }
535        }
536    }
537
538    pub(super) fn apply_pending_target_precombat(
539        &mut self,
540        now: SimTime,
541        telemetry: &mut TelemetrySink,
542    ) {
543        let Some(target) = self
544            .instance
545            .current_target()
546            .filter(|target| self.instance.is_valid_target(*target))
547        else {
548            return;
549        };
550
551        if !self
552            .instance
553            .runtime
554            .encounter
555            .take_current_target_precombat()
556        {
557            return;
558        }
559
560        for index in 0..self.program.precombat_auras.len() {
561            let aura = self
562                .program
563                .precombat_auras
564                .get(index)
565                .copied()
566                .expect("bounded precombat aura index");
567
568            if self.program.aura(aura).on != wowlab_types::sim::AuraOn::Target {
569                continue;
570            }
571
572            self.with_hook_ctx(
573                &mut *telemetry,
574                HookCtxRequest::for_target(now, target),
575                |hook| apply_aura(hook, aura),
576            );
577        }
578
579        for index in 0..self.program.talent_aura_stacks.len() {
580            let (aura, count) = self
581                .program
582                .talent_aura_stacks
583                .get(index)
584                .copied()
585                .expect("bounded talent aura index");
586
587            if self.program.aura(aura).on != wowlab_types::sim::AuraOn::Target {
588                continue;
589            }
590
591            set_precombat_aura_stacks(&self.program, &mut self.instance, aura, count, Some(target));
592        }
593
594        initialize_all_spell_costs(&self.program, &mut self.instance);
595    }
596
597    pub(super) fn schedule_retarget(&mut self, now: SimTime) {
598        self.instance.schedule(Event::Retarget {
599            t: now,
600            scope: PullScope {
601                pull: self.instance.runtime.encounter.active_pull(),
602                epoch: self.instance.runtime.encounter.pull_epoch(),
603            },
604        });
605    }
606
607    pub(super) fn schedule_forced_optional_despawns(
608        &mut self,
609        pull: PullId,
610        epoch: u32,
611        now: SimTime,
612    ) {
613        let definition = self.program.config.encounter.definition();
614
615        for group_index in 0..definition.groups.len() {
616            let enemy_count = definition
617                .groups
618                .get(group_index)
619                .expect("bounded group index")
620                .enemy_ids
621                .len();
622
623            if definition
624                .groups
625                .get(group_index)
626                .expect("bounded group index")
627                .required
628            {
629                continue;
630            }
631
632            for enemy_index in 0..enemy_count {
633                let target = definition
634                    .groups
635                    .get(group_index)
636                    .expect("bounded group index")
637                    .enemy_ids
638                    .get(enemy_index)
639                    .copied()
640                    .expect("bounded enemy index");
641
642                if self
643                    .instance
644                    .enemy_belongs_to_pull(&self.program, target, pull)
645                    && self.instance.is_valid_target(target)
646                {
647                    self.instance.schedule(Event::EncounterDespawn {
648                        t: now,
649                        scope: PullScope { pull, epoch },
650                        target,
651                        kind: EncounterDespawnKind::ForcedOptional,
652                    });
653                }
654            }
655        }
656    }
657}