Skip to main content

wowlab_engine_combat/handler/orchestration/
completion.rs

1use wowlab_engine_ports::{
2    DeathEventScope, EncounterDespawnKind, EncounterTerminationReason, EnemyDespawnOutcome, Event,
3    PullScope, SimContext, SpecRuntimeError,
4};
5use wowlab_engine_telemetry::{
6    EncounterTelemetryEvent, EncounterTelemetryEventKind, TelemetrySink,
7};
8use wowlab_types::sim::{ActorId, EnemyIdx, GroupId, PullId, SimTime, TerminationPolicy};
9
10use super::super::CombatHandler;
11use crate::state::EnemyCompletion;
12
13impl CombatHandler {
14    pub(in crate::handler) fn emit_enemy_lifecycle(
15        &self,
16        sink: &mut TelemetrySink,
17        kind: EncounterTelemetryEventKind,
18        target: EnemyIdx,
19        now: SimTime,
20        transform: Option<wowlab_types::sim::SpatialTransform>,
21    ) {
22        let Some(scope) = self.state.telemetry_scope(ActorId::Enemy(target), target) else {
23            return;
24        };
25
26        sink.emit_encounter(EncounterTelemetryEvent {
27            kind,
28            time_ms: now.as_millis(),
29            actor: ActorId::Enemy(target),
30            previous_target: None,
31            target: Some(target),
32            pull: scope.pull,
33            group: Some(scope.group),
34            transform,
35        });
36    }
37
38    pub(in crate::handler) fn handle_encounter_death(
39        &mut self,
40        target: EnemyIdx,
41        scope: DeathEventScope,
42        now: SimTime,
43        telemetry: &mut TelemetrySink,
44    ) {
45        if !self.accepts_death_scope(scope) {
46            return;
47        }
48
49        if !self.accepts_death_target(target) {
50            return;
51        }
52
53        match self.state.finalize_scripted_enemy_death(target, now) {
54            Ok(_) => {}
55            Err(error) => {
56                self.state.record_runtime_error(error);
57
58                return;
59            }
60        }
61
62        if self
63            .state
64            .enemy(target)
65            .is_some_and(wowlab_engine_domain::encounter::EnemyState::is_alive)
66        {
67            return;
68        }
69
70        if !self
71            .state
72            .runtime
73            .encounter
74            .record_enemy_completion(target, EnemyCompletion::Dead)
75        {
76            return;
77        }
78
79        self.emit_enemy_lifecycle(
80            telemetry,
81            EncounterTelemetryEventKind::Death,
82            target,
83            now,
84            self.state
85                .enemy(target)
86                .map(wowlab_engine_domain::encounter::EnemyState::current_transform),
87        );
88        self.cleanup_inactive_enemy_effects(target, now, telemetry);
89        self.schedule_retarget(now);
90        self.maybe_schedule_group_completion(target, now);
91
92        let termination = self.state.config.encounter.definition().termination;
93
94        if termination == TerminationPolicy::PrimaryRequiredEnemyDead && target == EnemyIdx::PRIMARY
95        {
96            self.state.schedule(Event::EncounterTerminate {
97                t: now,
98                reason: EncounterTerminationReason::PrimaryRequiredEnemyDead,
99            });
100        }
101
102        if termination == TerminationPolicy::AllEnemiesInRequiredGroupsDead
103            && self.all_required_enemies_dead()
104        {
105            self.state.schedule(Event::EncounterTerminate {
106                t: now,
107                reason: EncounterTerminationReason::AllEnemiesInRequiredGroupsDead,
108            });
109        }
110    }
111
112    pub(in crate::handler) fn handle_encounter_despawn(
113        &mut self,
114        pull: PullId,
115        epoch: u32,
116        target: EnemyIdx,
117        kind: EncounterDespawnKind,
118        ctx: &mut SimContext,
119    ) {
120        if !self.state.runtime.encounter.is_current(pull, epoch) {
121            return;
122        }
123
124        let outcome = self.on_despawn_transition(target, ctx);
125
126        if outcome != EnemyDespawnOutcome::Despawned {
127            if matches!(kind, EncounterDespawnKind::Authored { .. }) {
128                self.record_script_error(pull, format!("despawn target {target:?} is not active"));
129            }
130
131            return;
132        }
133
134        let completion = match kind {
135            EncounterDespawnKind::Authored {
136                counts_as_completion: true,
137            } => EnemyCompletion::CompletionDespawn,
138            EncounterDespawnKind::Authored {
139                counts_as_completion: false,
140            } => EnemyCompletion::NonCompletionDespawn,
141            EncounterDespawnKind::ForcedOptional => EnemyCompletion::ForcedOptionalDespawn,
142        };
143
144        if self
145            .state
146            .runtime
147            .encounter
148            .record_enemy_completion(target, completion)
149            && completion == EnemyCompletion::CompletionDespawn
150        {
151            self.maybe_schedule_group_completion(target, ctx.state.current_time);
152        }
153    }
154
155    pub(in crate::handler) fn handle_group_completion(
156        &mut self,
157        pull: PullId,
158        epoch: u32,
159        group: GroupId,
160        now: SimTime,
161    ) {
162        if !self.state.runtime.encounter.is_current(pull, epoch)
163            || !self.state.runtime.encounter.complete_group(group)
164        {
165            return;
166        }
167
168        self.schedule_newly_ready_waves(pull, epoch, now);
169
170        let termination = self.state.config.encounter.definition().termination;
171
172        if termination == TerminationPolicy::RequiredGroupsComplete
173            && self.all_required_groups_complete()
174        {
175            self.state.schedule(Event::EncounterTerminate {
176                t: now,
177                reason: EncounterTerminationReason::RequiredGroupsComplete,
178            });
179        }
180
181        if self.active_pull_required_groups_complete()
182            && self.state.runtime.encounter.queue_pull_completion()
183        {
184            self.state.schedule(Event::PullComplete {
185                t: now,
186                scope: PullScope { pull, epoch },
187                finalize: false,
188            });
189        }
190    }
191
192    pub(in crate::handler) fn handle_pull_completion(
193        &mut self,
194        pull: PullId,
195        epoch: u32,
196        finalize: bool,
197        ctx: &mut SimContext,
198    ) {
199        if !self.state.runtime.encounter.is_current(pull, epoch) {
200            return;
201        }
202
203        let now = ctx.state.current_time;
204
205        if !finalize {
206            self.schedule_forced_optional_despawns(pull, epoch, now);
207            self.state.schedule(Event::PullComplete {
208                t: now,
209                scope: PullScope { pull, epoch },
210                finalize: true,
211            });
212
213            return;
214        }
215
216        if !self.state.runtime.encounter.complete_pull(pull) {
217            return;
218        }
219
220        let definition = self.state.config.encounter.definition();
221        let Some(next_raw) = pull.0.checked_add(1) else {
222            return;
223        };
224        let next_pull = PullId(next_raw);
225
226        if next_pull.as_usize() >= definition.pulls.len() {
227            return;
228        }
229
230        let Some(reset_flags) = definition
231            .pulls
232            .get(pull.as_usize())
233            .map(|pull| pull.reset_after_completion)
234        else {
235            return;
236        };
237        let Some(next_preferred_target) = definition
238            .pulls
239            .get(next_pull.as_usize())
240            .map(|pull| pull.preferred_target)
241        else {
242            return;
243        };
244        let previous_target = self.state.current_target();
245        let target_precombat_pending = self.apply_player_reset(reset_flags, next_pull, now, ctx);
246
247        self.state.runtime.encounter.begin_next_pull(next_pull, now);
248        let next_group = self
249            .state
250            .enemy_definition(next_preferred_target)
251            .map(wowlab_engine_domain::encounter::EnemyActorDefinition::group_id);
252
253        ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
254            kind: EncounterTelemetryEventKind::PullTransition,
255            time_ms: now.as_millis(),
256            actor: ActorId::Player,
257            previous_target,
258            target: Some(next_preferred_target),
259            pull: next_pull,
260            group: next_group,
261            transform: self.state.actor_transform(ActorId::Player),
262        });
263
264        if target_precombat_pending {
265            self.state.runtime.encounter.queue_target_precombat();
266        }
267
268        let next_epoch = self.state.runtime.encounter.pull_epoch();
269
270        self.schedule_pull_timeline(next_pull, next_epoch, now, false);
271    }
272
273    pub(in crate::handler) fn mark_encounter_terminated(&mut self, now: SimTime) {
274        self.state.runtime.encounter.terminate(now);
275    }
276
277    pub(in crate::handler) fn record_encounter_stalled(&mut self) {
278        if self.state.runtime.encounter.terminated_at().is_some() {
279            return;
280        }
281
282        let definition = self.state.config.encounter.definition();
283        let pull = self.state.runtime.encounter.active_pull();
284        let required_groups = definition
285            .pulls
286            .get(pull.as_usize())
287            .into_iter()
288            .flat_map(|pull| pull.wave_ids.iter())
289            .filter_map(|wave| definition.waves.get(wave.as_usize()));
290        let required_groups = required_groups.flat_map(|wave| wave.group_ids.iter().copied());
291        let incomplete_required_groups = required_groups
292            .filter(|group| {
293                definition
294                    .groups
295                    .get(group.as_usize())
296                    .is_some_and(|definition| {
297                        definition.required
298                            && !self.state.runtime.encounter.group_is_complete(*group)
299                    })
300            })
301            .collect();
302        let inactive_live_enemies = self
303            .state
304            .enemies()
305            .filter(|enemy| enemy.is_alive() && !enemy.is_active())
306            .map(wowlab_engine_domain::encounter::EnemyState::id)
307            .collect();
308
309        self.state
310            .record_runtime_error(SpecRuntimeError::encounter_stalled(
311                pull,
312                incomplete_required_groups,
313                inactive_live_enemies,
314            ));
315    }
316
317    pub(super) fn accepts_death_scope(&self, scope: DeathEventScope) -> bool {
318        match scope {
319            DeathEventScope::Unscoped => true,
320            DeathEventScope::ScriptedLifetime(generation) => self
321                .state
322                .runtime
323                .encounter
324                .is_current_generation(generation),
325        }
326    }
327
328    pub(super) fn accepts_death_target(&self, target: EnemyIdx) -> bool {
329        let active_pull = self.state.runtime.encounter.active_pull();
330
331        self.state.enemy_belongs_to_pull(target, active_pull)
332            && !self
333                .state
334                .enemy(target)
335                .is_some_and(|enemy| enemy.is_alive() && !enemy.is_active())
336    }
337
338    pub(super) fn on_despawn_transition(
339        &mut self,
340        target: EnemyIdx,
341        ctx: &mut SimContext,
342    ) -> EnemyDespawnOutcome {
343        let now = ctx.state.current_time;
344        let outcome = self.state.transition_enemy_despawn(target, now);
345
346        if outcome == EnemyDespawnOutcome::Despawned {
347            self.emit_enemy_lifecycle(
348                &mut *ctx.telemetry,
349                EncounterTelemetryEventKind::Despawn,
350                target,
351                now,
352                self.state
353                    .enemy(target)
354                    .map(wowlab_engine_domain::encounter::EnemyState::current_transform),
355            );
356            self.cleanup_inactive_enemy_effects(target, now, &mut *ctx.telemetry);
357            self.schedule_retarget(now);
358        }
359
360        outcome
361    }
362
363    pub(super) fn maybe_schedule_group_completion(&mut self, enemy: EnemyIdx, now: SimTime) {
364        let definition = self.state.config.encounter.definition();
365        let Some(group_id) = definition
366            .enemies
367            .get(enemy.as_usize())
368            .map(|actor| actor.group_id)
369        else {
370            return;
371        };
372        let Some(group) = definition.groups.get(group_id.as_usize()) else {
373            return;
374        };
375        let complete = group.enemy_ids.iter().all(|member| {
376            matches!(
377                self.state.runtime.encounter.enemy_completion(*member),
378                Some(EnemyCompletion::Dead | EnemyCompletion::CompletionDespawn)
379            )
380        });
381
382        if complete
383            && self
384                .state
385                .runtime
386                .encounter
387                .queue_group_completion(group_id)
388        {
389            let pull = self.state.runtime.encounter.active_pull();
390
391            self.state.schedule(Event::GroupComplete {
392                t: now,
393                scope: PullScope {
394                    pull,
395                    epoch: self.state.runtime.encounter.pull_epoch(),
396                },
397                group: group_id,
398            });
399        }
400    }
401
402    pub(super) fn active_pull_required_groups_complete(&self) -> bool {
403        let definition = self.state.config.encounter.definition();
404        let pull = self.state.runtime.encounter.active_pull();
405        let Some(pull_definition) = definition.pulls.get(pull.as_usize()) else {
406            return false;
407        };
408
409        pull_definition.wave_ids.iter().all(|wave| {
410            definition.waves.get(wave.as_usize()).is_some_and(|wave| {
411                wave.group_ids.iter().all(|group| {
412                    definition
413                        .groups
414                        .get(group.as_usize())
415                        .is_some_and(|group_definition| {
416                            !group_definition.required
417                                || self.state.runtime.encounter.group_is_complete(*group)
418                        })
419                })
420            })
421        })
422    }
423
424    pub(super) fn all_required_groups_complete(&self) -> bool {
425        self.state
426            .config
427            .encounter
428            .definition()
429            .groups
430            .iter()
431            .filter(|group| group.required)
432            .all(|group| self.state.runtime.encounter.group_is_complete(group.id))
433    }
434
435    pub(super) fn all_required_enemies_dead(&self) -> bool {
436        let definition = self.state.config.encounter.definition();
437
438        definition
439            .groups
440            .iter()
441            .filter(|group| group.required)
442            .all(|group| {
443                group.enemy_ids.iter().all(|enemy| {
444                    self.state.runtime.encounter.enemy_completion(*enemy)
445                        == Some(EnemyCompletion::Dead)
446                })
447            })
448    }
449
450    pub(super) fn record_script_error(&mut self, pull: PullId, message: String) {
451        self.state
452            .record_runtime_error(SpecRuntimeError::encounter_script(pull, message));
453    }
454}