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.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}