wowlab_engine_combat/handler/orchestration/
completion.rs1use 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.program.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.instance.finalize_scripted_enemy_death(target, now) {
54 Ok(_) => {}
55 Err(error) => {
56 self.instance.record_runtime_error(error);
57
58 return;
59 }
60 }
61
62 if self
63 .instance
64 .enemy(target)
65 .is_some_and(wowlab_engine_domain::encounter::EnemyState::is_alive)
66 {
67 return;
68 }
69
70 if !self
71 .instance
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.instance
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.program.config.encounter.definition().termination;
93
94 if termination == TerminationPolicy::PrimaryRequiredEnemyDead && target == EnemyIdx::PRIMARY
95 {
96 self.instance.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.instance.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.instance.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 .instance
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.instance.runtime.encounter.is_current(pull, epoch)
163 || !self.instance.runtime.encounter.complete_group(group)
164 {
165 return;
166 }
167
168 self.schedule_newly_ready_waves(pull, epoch, now);
169
170 let termination = self.program.config.encounter.definition().termination;
171
172 if termination == TerminationPolicy::RequiredGroupsComplete
173 && self.all_required_groups_complete()
174 {
175 self.instance.schedule(Event::EncounterTerminate {
176 t: now,
177 reason: EncounterTerminationReason::RequiredGroupsComplete,
178 });
179 }
180
181 if self.active_pull_required_groups_complete()
182 && self.instance.runtime.encounter.queue_pull_completion()
183 {
184 self.instance.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.instance.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.instance.schedule(Event::PullComplete {
208 t: now,
209 scope: PullScope { pull, epoch },
210 finalize: true,
211 });
212
213 return;
214 }
215
216 if !self.instance.runtime.encounter.complete_pull(pull) {
217 return;
218 }
219
220 let definition = self.program.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.instance.current_target();
245 let target_precombat_pending = self.apply_player_reset(reset_flags, next_pull, now, ctx);
246
247 self.instance
248 .runtime
249 .encounter
250 .begin_next_pull(next_pull, now);
251 self.instance
252 .mark_projection_dirty(crate::state::ProjectionDirty::POPULATION, now);
253 let next_group = self
254 .program
255 .enemy_definition(next_preferred_target)
256 .map(wowlab_engine_domain::encounter::EnemyActorDefinition::group_id);
257
258 ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
259 kind: EncounterTelemetryEventKind::PullTransition,
260 time_ms: now.as_millis(),
261 actor: ActorId::Player,
262 previous_target,
263 target: Some(next_preferred_target),
264 pull: next_pull,
265 group: next_group,
266 transform: self.instance.actor_transform(ActorId::Player),
267 });
268
269 if target_precombat_pending {
270 self.instance.runtime.encounter.queue_target_precombat();
271 }
272
273 let next_epoch = self.instance.runtime.encounter.pull_epoch();
274
275 self.schedule_pull_timeline(next_pull, next_epoch, now, false);
276 }
277
278 pub(in crate::handler) fn mark_encounter_terminated(&mut self, now: SimTime) {
279 self.instance.runtime.encounter.terminate(now);
280 }
281
282 pub(in crate::handler) fn record_encounter_stalled(&mut self) {
283 if self.instance.runtime.encounter.terminated_at().is_some() {
284 return;
285 }
286
287 let definition = self.program.config.encounter.definition();
288 let pull = self.instance.runtime.encounter.active_pull();
289 let required_groups = definition
290 .pulls
291 .get(pull.as_usize())
292 .into_iter()
293 .flat_map(|pull| pull.wave_ids.iter())
294 .filter_map(|wave| definition.waves.get(wave.as_usize()));
295 let required_groups = required_groups.flat_map(|wave| wave.group_ids.iter().copied());
296 let incomplete_required_groups = required_groups
297 .filter(|group| {
298 definition
299 .groups
300 .get(group.as_usize())
301 .is_some_and(|definition| {
302 definition.required
303 && !self.instance.runtime.encounter.group_is_complete(*group)
304 })
305 })
306 .collect();
307 let inactive_live_enemies = self
308 .instance
309 .enemies()
310 .filter(|enemy| enemy.is_alive() && !enemy.is_active())
311 .map(wowlab_engine_domain::encounter::EnemyState::id)
312 .collect();
313
314 self.instance
315 .record_runtime_error(SpecRuntimeError::encounter_stalled(
316 pull,
317 incomplete_required_groups,
318 inactive_live_enemies,
319 ));
320 }
321
322 pub(super) fn accepts_death_scope(&self, scope: DeathEventScope) -> bool {
323 match scope {
324 DeathEventScope::Unscoped => true,
325 DeathEventScope::ScriptedLifetime(generation) => self
326 .instance
327 .runtime
328 .encounter
329 .is_current_generation(generation),
330 }
331 }
332
333 pub(super) fn accepts_death_target(&self, target: EnemyIdx) -> bool {
334 let active_pull = self.instance.runtime.encounter.active_pull();
335
336 self.instance
337 .enemy_belongs_to_pull(&self.program, target, active_pull)
338 && !self
339 .instance
340 .enemy(target)
341 .is_some_and(|enemy| enemy.is_alive() && !enemy.is_active())
342 }
343
344 pub(super) fn on_despawn_transition(
345 &mut self,
346 target: EnemyIdx,
347 ctx: &mut SimContext,
348 ) -> EnemyDespawnOutcome {
349 let now = ctx.state.current_time;
350 let outcome = self.instance.transition_enemy_despawn(target, now);
351
352 if outcome == EnemyDespawnOutcome::Despawned {
353 self.emit_enemy_lifecycle(
354 &mut *ctx.telemetry,
355 EncounterTelemetryEventKind::Despawn,
356 target,
357 now,
358 self.instance
359 .enemy(target)
360 .map(wowlab_engine_domain::encounter::EnemyState::current_transform),
361 );
362 self.cleanup_inactive_enemy_effects(target, now, &mut *ctx.telemetry);
363 self.schedule_retarget(now);
364 }
365
366 outcome
367 }
368
369 pub(super) fn maybe_schedule_group_completion(&mut self, enemy: EnemyIdx, now: SimTime) {
370 let definition = self.program.config.encounter.definition();
371 let Some(group_id) = definition
372 .enemies
373 .get(enemy.as_usize())
374 .map(|actor| actor.group_id)
375 else {
376 return;
377 };
378 let Some(group) = definition.groups.get(group_id.as_usize()) else {
379 return;
380 };
381 let complete = group.enemy_ids.iter().all(|member| {
382 matches!(
383 self.instance.runtime.encounter.enemy_completion(*member),
384 Some(EnemyCompletion::Dead | EnemyCompletion::CompletionDespawn)
385 )
386 });
387
388 if complete
389 && self
390 .instance
391 .runtime
392 .encounter
393 .queue_group_completion(group_id)
394 {
395 let pull = self.instance.runtime.encounter.active_pull();
396
397 self.instance.schedule(Event::GroupComplete {
398 t: now,
399 scope: PullScope {
400 pull,
401 epoch: self.instance.runtime.encounter.pull_epoch(),
402 },
403 group: group_id,
404 });
405 }
406 }
407
408 pub(super) fn active_pull_required_groups_complete(&self) -> bool {
409 let definition = self.program.config.encounter.definition();
410 let pull = self.instance.runtime.encounter.active_pull();
411 let Some(pull_definition) = definition.pulls.get(pull.as_usize()) else {
412 return false;
413 };
414
415 pull_definition.wave_ids.iter().all(|wave| {
416 definition.waves.get(wave.as_usize()).is_some_and(|wave| {
417 wave.group_ids.iter().all(|group| {
418 definition
419 .groups
420 .get(group.as_usize())
421 .is_some_and(|group_definition| {
422 !group_definition.required
423 || self.instance.runtime.encounter.group_is_complete(*group)
424 })
425 })
426 })
427 })
428 }
429
430 pub(super) fn all_required_groups_complete(&self) -> bool {
431 self.program
432 .config
433 .encounter
434 .definition()
435 .groups
436 .iter()
437 .filter(|group| group.required)
438 .all(|group| self.instance.runtime.encounter.group_is_complete(group.id))
439 }
440
441 pub(super) fn all_required_enemies_dead(&self) -> bool {
442 let definition = self.program.config.encounter.definition();
443
444 definition
445 .groups
446 .iter()
447 .filter(|group| group.required)
448 .all(|group| {
449 group.enemy_ids.iter().all(|enemy| {
450 self.instance.runtime.encounter.enemy_completion(*enemy)
451 == Some(EnemyCompletion::Dead)
452 })
453 })
454 }
455
456 pub(super) fn record_script_error(&mut self, pull: PullId, message: String) {
457 self.instance
458 .record_runtime_error(SpecRuntimeError::encounter_script(pull, message));
459 }
460}