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}