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 builder::buffer_init::project_encounter,
16 context::HookCtxRequest,
17 handler::reset::set_precombat_aura_stacks,
18 state::LocalAuraIdx,
19 systems::{apply_aura, sync_spell_costs},
20};
21
22impl CombatHandler {
23 pub(in crate::handler) fn schedule_encounter_start(&mut self) {
24 if !self.state.runtime.encounter.start_once() {
25 return;
26 }
27
28 let pull = self.state.runtime.encounter.active_pull();
29 let epoch = self.state.runtime.encounter.pull_epoch();
30
31 self.schedule_pull_timeline(pull, epoch, SimTime::ZERO, true);
32 self.schedule_run_timed_events();
33
34 if let Some(duration) = self.state.config.encounter.definition().fixed_duration_s {
35 self.state.schedule(Event::EncounterTerminate {
36 t: SimTime::from_secs_f64(duration),
37 reason: EncounterTerminationReason::FixedDuration,
38 });
39 }
40 }
41
42 pub(in crate::handler) fn schedule_run_timed_events(&mut self) {
43 let fight_duration = self.state.config.encounter.fight_duration_secs();
44 let raid_events = self.state.config.raid_events.clone();
45 let external_buffs = self.state.config.external_buffs.clone();
46
47 for (index, config) in raid_events.into_iter().enumerate() {
48 for occurrence in config.schedule.occurrences(fight_duration) {
49 self.state.schedule(Event::RaidEvent {
50 t: SimTime::from_secs_f64(occurrence),
51 index,
52 });
53 }
54 }
55
56 for (index, config) in external_buffs.into_iter().enumerate() {
57 for occurrence in config.schedule.occurrences(fight_duration) {
58 self.state.schedule(Event::ExternalBuff {
59 t: SimTime::from_secs_f64(occurrence),
60 index,
61 });
62 }
63 }
64 }
65
66 pub(in crate::handler) fn handle_wave_activation(
67 &mut self,
68 pull: PullId,
69 epoch: u32,
70 wave: WaveId,
71 now: SimTime,
72 ) {
73 if !self.state.runtime.encounter.is_current(pull, epoch) {
74 return;
75 }
76
77 let definition = self.state.config.encounter.definition();
78 let Some(wave_definition) = definition.waves.get(wave.as_usize()) else {
79 return;
80 };
81 let dependencies_complete = wave_definition
82 .depends_on_groups
83 .iter()
84 .all(|group| self.state.runtime.encounter.group_is_complete(*group));
85
86 if !dependencies_complete {
87 return;
88 }
89
90 if self.state.runtime.encounter.activate_wave(wave) {
91 self.schedule_wave_enemies(
92 pull,
93 epoch,
94 wave,
95 self.state.runtime.encounter.pull_started_at(),
96 now,
97 );
98 }
99 }
100
101 pub(in crate::handler) fn handle_enemy_activation(
102 &mut self,
103 pull: PullId,
104 epoch: u32,
105 target: EnemyIdx,
106 ctx: &mut SimContext,
107 ) {
108 let now = ctx.state.current_time;
109
110 if !self.state.runtime.encounter.is_current(pull, epoch) {
111 return;
112 }
113
114 if !self.state.activate_enemy(target, now) {
115 self.record_script_error(pull, format!("enemy activation {target:?} is invalid"));
116
117 return;
118 }
119
120 self.emit_enemy_lifecycle(
121 &mut *ctx.telemetry,
122 EncounterTelemetryEventKind::Spawn,
123 target,
124 now,
125 self.state
126 .enemy(target)
127 .map(wowlab_engine_domain::encounter::EnemyState::current_transform),
128 );
129 self.schedule_scripted_death(target, now, self.state.runtime.encounter.generation());
130 self.state.schedule(Event::Retarget {
131 t: now,
132 scope: PullScope { pull, epoch },
133 });
134 }
135
136 pub(in crate::handler) fn handle_retarget(
137 &mut self,
138 pull: PullId,
139 epoch: u32,
140 ctx: &mut SimContext,
141 ) {
142 if !self.state.runtime.encounter.is_current(pull, epoch) {
143 return;
144 }
145
146 let now = ctx.state.current_time;
147 let previous_target = self.state.current_target();
148 let target = self.state.retarget();
149
150 if previous_target != target {
151 let group = target.and_then(|target| {
152 self.state
153 .telemetry_scope(ActorId::Player, target)
154 .map(|scope| scope.group)
155 });
156
157 ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
158 kind: EncounterTelemetryEventKind::TargetChange,
159 time_ms: now.as_millis(),
160 actor: ActorId::Player,
161 previous_target,
162 target,
163 pull,
164 group,
165 transform: self.state.actor_transform(ActorId::Player),
166 });
167 }
168
169 self.apply_pending_target_precombat(now, &mut *ctx.telemetry);
170 project_encounter(&self.state, &mut self.buf, now);
171 self.state.schedule(Event::PlayerReady { t: now });
172 }
173
174 pub(in crate::handler) fn handle_movement(
175 &mut self,
176 pull: PullId,
177 epoch: u32,
178 actor: PositionedActorRef,
179 transform: wowlab_types::sim::SpatialTransform,
180 ctx: &mut SimContext,
181 ) {
182 if !self.state.runtime.encounter.is_current(pull, epoch) {
183 return;
184 }
185
186 if let Err(error) = self.state.relocate_actor(actor, transform) {
187 self.record_script_error(pull, format!("movement for {actor:?} failed: {error}"));
188
189 return;
190 }
191
192 let (actor_id, target, group) = match actor {
193 PositionedActorRef::Player => (ActorId::Player, None, None),
194 PositionedActorRef::Enemy(target) => {
195 let group = self
196 .state
197 .telemetry_scope(ActorId::Enemy(target), target)
198 .map(|scope| scope.group);
199
200 (ActorId::Enemy(target), Some(target), group)
201 }
202 };
203
204 ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
205 kind: EncounterTelemetryEventKind::Movement,
206 time_ms: ctx.state.current_time.as_millis(),
207 actor: actor_id,
208 previous_target: None,
209 target,
210 pull,
211 group,
212 transform: Some(transform),
213 });
214 }
215
216 pub(super) fn schedule_pull_timeline(
217 &mut self,
218 pull_id: PullId,
219 epoch: u32,
220 started_at: SimTime,
221 initial: bool,
222 ) {
223 let Some(pull) = self
224 .state
225 .config
226 .encounter
227 .definition()
228 .pulls
229 .get(pull_id.as_usize())
230 .filter(|pull| pull.id == pull_id)
231 .cloned()
232 else {
233 return;
234 };
235
236 let waves: Vec<_> = pull
237 .wave_ids
238 .iter()
239 .filter_map(|wave| {
240 self.state
241 .config
242 .encounter
243 .definition()
244 .waves
245 .get(wave.as_usize())
246 .cloned()
247 })
248 .collect();
249
250 for wave in waves {
251 if initial && self.state.runtime.encounter.wave_is_active(wave.id) {
252 self.schedule_wave_enemies(pull_id, epoch, wave.id, started_at, started_at);
253 } else if wave.depends_on_groups.is_empty() {
254 self.state.schedule(Event::WaveActivate {
255 t: at_offset(started_at, wave.minimum_activation_s),
256 scope: PullScope {
257 pull: pull_id,
258 epoch,
259 },
260 wave: wave.id,
261 });
262 }
263 }
264
265 for event in pull.events {
266 match event {
267 EncounterScriptEvent::Move {
268 at_s,
269 actor,
270 transform,
271 } => self.state.schedule(Event::EncounterMove {
272 t: at_offset(started_at, at_s),
273 scope: PullScope {
274 pull: pull_id,
275 epoch,
276 },
277 actor,
278 transform,
279 }),
280 EncounterScriptEvent::Despawn {
281 at_s,
282 enemy,
283 counts_as_completion,
284 } => self.state.schedule(Event::EncounterDespawn {
285 t: at_offset(started_at, at_s),
286 scope: PullScope {
287 pull: pull_id,
288 epoch,
289 },
290 target: enemy,
291 kind: EncounterDespawnKind::Authored {
292 counts_as_completion,
293 },
294 }),
295 }
296 }
297 }
298
299 pub(super) fn schedule_wave_enemies(
300 &mut self,
301 pull: PullId,
302 epoch: u32,
303 wave: WaveId,
304 pull_started_at: SimTime,
305 ready_at: SimTime,
306 ) {
307 let Some(wave_definition) = self
308 .state
309 .config
310 .encounter
311 .definition()
312 .waves
313 .get(wave.as_usize())
314 .cloned()
315 else {
316 return;
317 };
318 let enemy_ids: Vec<_> = wave_definition
319 .group_ids
320 .iter()
321 .filter_map(|group| {
322 self.state
323 .config
324 .encounter
325 .definition()
326 .groups
327 .get(group.as_usize())
328 })
329 .flat_map(|group| group.enemy_ids.iter().copied())
330 .collect();
331 let spawns: Vec<_> = enemy_ids
332 .into_iter()
333 .filter_map(|enemy| {
334 self.state
335 .config
336 .encounter
337 .definition()
338 .enemies
339 .get(enemy.as_usize())
340 .map(|definition| (enemy, definition.spawn_at_s))
341 })
342 .collect();
343
344 for (enemy, spawn_at) in spawns {
345 let activation_at = at_offset(pull_started_at, spawn_at).max(ready_at);
346
347 if self.state.is_valid_target(enemy) {
348 self.schedule_scripted_death(
349 enemy,
350 activation_at,
351 self.state.runtime.encounter.generation(),
352 );
353 } else {
354 self.state.schedule(Event::EnemyActivate {
355 t: activation_at,
356 scope: PullScope { pull, epoch },
357 target: enemy,
358 });
359 }
360 }
361 }
362
363 pub(super) fn schedule_scripted_death(
364 &mut self,
365 enemy: EnemyIdx,
366 activated_at: SimTime,
367 generation: PullLifecycleGeneration,
368 ) {
369 let Some(HealthModel::ScriptedLinear { death_at_s, .. }) = self
370 .state
371 .enemy_definition(enemy)
372 .map(wowlab_engine_domain::encounter::EnemyActorDefinition::health_model)
373 else {
374 return;
375 };
376
377 self.state.schedule(Event::Death {
378 t: at_offset(activated_at, death_at_s),
379 target: enemy,
380 scope: DeathEventScope::ScriptedLifetime(generation),
381 });
382 }
383
384 pub(super) fn schedule_newly_ready_waves(&mut self, pull: PullId, epoch: u32, now: SimTime) {
385 let pull_started_at = self.state.runtime.encounter.pull_started_at();
386 let definition = self.state.config.encounter.definition();
387 let Some(pull_definition) = definition.pulls.get(pull.as_usize()) else {
388 return;
389 };
390 let waves: Vec<_> = pull_definition
391 .wave_ids
392 .iter()
393 .filter_map(|wave| definition.waves.get(wave.as_usize()).cloned())
394 .collect();
395
396 for wave in waves {
397 let ready = !self.state.runtime.encounter.wave_is_active(wave.id)
398 && wave
399 .depends_on_groups
400 .iter()
401 .all(|group| self.state.runtime.encounter.group_is_complete(*group));
402
403 if ready {
404 self.state.schedule(Event::WaveActivate {
405 t: at_offset(pull_started_at, wave.minimum_activation_s).max(now),
406 scope: PullScope { pull, epoch },
407 wave: wave.id,
408 });
409 }
410 }
411 }
412
413 pub(super) fn apply_pending_target_precombat(
414 &mut self,
415 now: SimTime,
416 telemetry: &mut TelemetrySink,
417 ) {
418 let Some(target) = self
419 .state
420 .current_target()
421 .filter(|target| self.state.is_valid_target(*target))
422 else {
423 return;
424 };
425
426 if !self.state.runtime.encounter.take_current_target_precombat() {
427 return;
428 }
429
430 let precombat: Vec<LocalAuraIdx> = self
431 .state
432 .defs
433 .precombat_auras
434 .iter()
435 .copied()
436 .filter(|aura| self.state.aura(*aura).on == wowlab_types::sim::AuraOn::Target)
437 .collect();
438
439 for aura in precombat {
440 self.with_hook_ctx(
441 &mut *telemetry,
442 HookCtxRequest::for_target(now, target),
443 |hook| apply_aura(hook, aura),
444 );
445 }
446
447 let stacks: Vec<_> = self
448 .state
449 .defs
450 .talent_aura_stacks
451 .iter()
452 .copied()
453 .filter(|(aura, _)| self.state.aura(*aura).on == wowlab_types::sim::AuraOn::Target)
454 .collect();
455
456 for (aura, count) in stacks {
457 set_precombat_aura_stacks(&self.state, &mut self.buf, aura, count, Some(target));
458 }
459
460 sync_spell_costs(&self.state, &mut self.buf);
461 }
462
463 pub(super) fn schedule_retarget(&mut self, now: SimTime) {
464 self.state.schedule(Event::Retarget {
465 t: now,
466 scope: PullScope {
467 pull: self.state.runtime.encounter.active_pull(),
468 epoch: self.state.runtime.encounter.pull_epoch(),
469 },
470 });
471 }
472
473 pub(super) fn schedule_forced_optional_despawns(
474 &mut self,
475 pull: PullId,
476 epoch: u32,
477 now: SimTime,
478 ) {
479 let definition = self.state.config.encounter.definition();
480 let targets: Vec<_> = definition
481 .groups
482 .iter()
483 .filter(|group| !group.required)
484 .flat_map(|group| {
485 group.enemy_ids.iter().copied().filter(|enemy| {
486 self.state.enemy_belongs_to_pull(*enemy, pull)
487 && self.state.is_valid_target(*enemy)
488 })
489 })
490 .collect();
491
492 for target in targets {
493 self.state.schedule(Event::EncounterDespawn {
494 t: now,
495 scope: PullScope { pull, epoch },
496 target,
497 kind: EncounterDespawnKind::ForcedOptional,
498 });
499 }
500 }
501}