1use super::{
2 ActorId, CombatHandler, DEFAULT_GCD_MS, EncounterTelemetryEvent, EncounterTelemetryEventKind,
3 Event, FIRST_ENEMY_SWING_DIVISOR, HookCtxRequest, SimContext, SimTime, SpecAction,
4 SpecRuntimeError, apply_aura, apply_auto_attack_action_lifecycle, drain_deferred_work,
5 earliest_auto_attack_time, initialize_pet_actions, reset_combat, set_precombat_aura_stacks,
6 sync_resource,
7};
8use crate::handler::{CastCandidate, LockMode};
9#[cfg(feature = "rotation-optimizations")]
10use crate::state::{
11 program::{RotationDecisionMode, RotationDecisionSubscription},
12 runtime::RotationMutationAccumulator,
13};
14
15impl CombatHandler {
16 pub(super) fn on_sim_start_impl(&mut self, ctx: &mut SimContext) {
17 self.rng =
18 wowlab_engine_rng::SimRng::from_prefix(ctx.state.seed_prefix, ctx.state.iteration);
19 self.rng_seeded = true;
20 self.last_resource_sync = SimTime::ZERO;
21
22 for enemy in self
23 .instance
24 .enemies()
25 .filter(|enemy| enemy.is_active() && enemy.is_alive())
26 {
27 let Some(scope) = self
28 .program
29 .telemetry_scope(ActorId::Enemy(enemy.id()), enemy.id())
30 else {
31 continue;
32 };
33
34 ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
35 kind: EncounterTelemetryEventKind::Spawn,
36 time_ms: 0,
37 actor: ActorId::Enemy(enemy.id()),
38 previous_target: None,
39 target: Some(enemy.id()),
40 pull: scope.pull,
41 group: Some(scope.group),
42 transform: Some(enemy.current_transform()),
43 });
44 }
45
46 if let Some(target) = self.instance.current_target()
47 && let Some(scope) = self.program.telemetry_scope(ActorId::Player, target)
48 {
49 ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
50 kind: EncounterTelemetryEventKind::TargetChange,
51 time_ms: 0,
52 actor: ActorId::Player,
53 previous_target: None,
54 target: Some(target),
55 pull: scope.pull,
56 group: Some(scope.group),
57 transform: self.instance.actor_transform(ActorId::Player),
58 });
59 }
60
61 self.schedule_encounter_start();
62
63 crate::systems::ensure_initial_proc_heartbeat(
64 &mut self.instance,
65 SimTime::ZERO,
66 &mut self.rng,
67 );
68
69 self.initialize_precombat_state(ctx.telemetry);
70 self.initialize_periodic_drivers(ctx.telemetry);
71 initialize_pet_actions(&self.program, &mut self.instance);
72
73 self.schedule_initial_enemy_auto_attacks(SimTime::ZERO);
74
75 if let Some(t) = earliest_auto_attack_time(&self.program, &self.instance)
76 && let Some(target) = self.instance.current_target()
77 {
78 self.instance.schedule(Event::AutoAttack {
79 t,
80 source: ActorId::Player,
81 target,
82 });
83 }
84 }
85
86 pub(super) fn initialize_precombat_state(
87 &mut self,
88 telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
89 ) {
90 let target = self.instance.current_target();
91
92 for index in 0..self.program.precombat_auras.len() {
93 let aura_local = self
94 .program
95 .precombat_auras
96 .get(index)
97 .copied()
98 .expect("bounded precombat aura index");
99
100 self.with_hook_ctx(
101 telemetry,
102 HookCtxRequest::for_optional_target(SimTime::ZERO, target),
103 |hook| apply_aura(hook, aura_local),
104 );
105 }
106
107 for index in 0..self.program.talent_aura_stacks.len() {
108 let (aura_local, stacks) = self
109 .program
110 .talent_aura_stacks
111 .get(index)
112 .copied()
113 .expect("bounded talent aura index");
114
115 set_precombat_aura_stacks(
116 &self.program,
117 &mut self.instance,
118 aura_local,
119 stacks,
120 target,
121 );
122 }
123
124 self.with_hook_ctx(
125 telemetry,
126 HookCtxRequest::targetless(SimTime::ZERO),
127 drain_deferred_work,
128 );
129 }
130
131 pub(super) fn initialize_periodic_drivers(
132 &mut self,
133 telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
134 ) {
135 for index in 0..self.program.periodic_drivers.len() {
136 let driver = self
137 .program
138 .periodic_drivers
139 .get(index)
140 .copied()
141 .expect("bounded periodic driver index");
142
143 if driver.triggered_duration_ms.is_some() {
144 continue;
145 }
146
147 let interval_ms = crate::systems::effective_periodic_interval_for_action(
148 &crate::context::CombatView::new(&self.program, &self.instance),
149 self.program.damage.periodic_driver_action(index),
150 driver.tick_interval_ms.get(),
151 driver.hasted_ticks,
152 );
153
154 self.with_hook_ctx(
155 telemetry,
156 HookCtxRequest::targetless(SimTime::ZERO),
157 |ctx| ctx.schedule_periodic_driver(interval_ms, index, None, None),
158 );
159 }
160 }
161
162 pub(super) fn on_player_ready_impl(&mut self, ctx: &mut SimContext) -> Option<SpecAction> {
165 let now = ctx.state.current_time;
166
167 self.prepare_player_ready(now, ctx.telemetry)?;
168
169 if self.instance.runtime.casting.instant_cast_pending_at == Some(now) {
170 return None;
171 }
172
173 self.instance.runtime.casting.instant_cast_pending_at = None;
174
175 let (busy_ready, cast_active, gcd_ready) = self.readiness_deadlines(now);
176 let gcd_active = now < gcd_ready;
177 let lock_mode = match (cast_active, gcd_active) {
178 (false, false) => LockMode::Unlocked,
179 (true, false) => LockMode::Casting,
180 (false, true) => LockMode::Gcd,
181 (true, true) => LockMode::Both,
182 };
183
184 let (action, locked_wake) = self.next_rotation_action(lock_mode, now, gcd_ready);
185
186 match action {
187 Some(SpecAction::Cast {
188 spell,
189 empower_rank,
190 source,
191 target,
192 }) => {
193 let raw_spell_id = spell.engine_coordinates().as_u32();
194 let Some(candidate) = self.selected_candidate(raw_spell_id, target) else {
195 self.instance.record_runtime_error(
196 SpecRuntimeError::unresolved_rotation_cast_identity(raw_spell_id),
197 );
198
199 return None;
200 };
201 let (school_mask, damage_pushback) = {
202 let profile = candidate.effective_profile(&self.program);
203
204 (profile.school_mask, profile.damage_pushback)
205 };
206 let (is_off_gcd, usable_while_casting) = {
207 let effective = candidate.effective(&self.program);
208
209 (
210 effective.behavior.off_gcd,
211 effective.requirements.availability.usable_while_casting,
212 )
213 };
214
215 if cast_active && !usable_while_casting {
216 tracing::trace!(
217 t = %now.as_secs_f64(),
218 cast_end = %busy_ready.as_secs_f64(),
219 "CAST_GATE"
220 );
221
222 return Some(SpecAction::Wait {
223 until_ms: busy_ready,
224 });
225 }
226
227 if let Some(wait) = self.gcd_queue_gate(now, gcd_ready, is_off_gcd) {
228 return Some(Self::with_earlier_wake(wait, locked_wake));
229 }
230
231 let cast_ms = self.cast_time_ms_for_candidate(candidate, empower_rank);
232 let candidate = candidate.with_resolved_cast_time_ms(cast_ms);
233
234 self.instance.runtime.casting.selected_candidate = Some(candidate);
235
236 if is_off_gcd && cast_ms == 0 {
237 if self.instance.runtime.casting.instant_off_gcd_casts_at != Some(now) {
238 self.instance.runtime.casting.instant_off_gcd_casts_at = Some(now);
239 self.instance.runtime.casting.instant_off_gcd_spells.clear();
240 }
241
242 if self
243 .instance
244 .runtime
245 .casting
246 .instant_off_gcd_spells
247 .contains(candidate.requested_local)
248 {
249 let until_ms = if gcd_ready > now {
250 gcd_ready
251 } else {
252 now.saturating_add(SimTime::from_millis(DEFAULT_GCD_MS))
253 };
254
255 return Some(SpecAction::Wait { until_ms });
256 }
257
258 self.instance
259 .runtime
260 .casting
261 .instant_off_gcd_spells
262 .insert(candidate.requested_local);
263 }
264
265 if is_off_gcd {
266 tracing::trace!(t = %now.as_secs_f64(), spell_id = raw_spell_id, "OFF_GCD_CAST");
267 }
268
269 if source == ActorId::Player {
270 self.break_player_action_auras(target, now, &mut *ctx.telemetry);
271 }
272
273 apply_auto_attack_action_lifecycle(
274 &self.program,
275 &mut self.instance,
276 self.program.damage.spell_action(candidate.effective_local),
277 source,
278 target,
279 now,
280 );
281 let chained = self.instance.runtime.casting.channel_chain_spell
282 == Some(candidate.requested_local);
283
284 if chained {
285 self.instance.runtime.casting.channel_chain_spell = None;
286 } else {
287 crate::systems::set_gcd_for_candidate(
288 &self.program,
289 &mut self.instance,
290 candidate,
291 now,
292 );
293 }
294
295 if cast_ms == 0 {
296 self.instance.runtime.casting.instant_cast_pending_at = Some(now);
298 } else {
299 let cast_end = now.saturating_add(SimTime::from_millis(cast_ms));
300
301 self.instance.buffer.player_mut().cast_end = cast_end.as_secs_f64();
302 let active = crate::state::ActiveCast::new(
303 spell.engine_coordinates(),
304 school_mask,
305 cast_end,
306 )
307 .with_action(self.program.damage.spell_action(candidate.effective_local))
308 .with_damage_pushback(damage_pushback);
309
310 if source == ActorId::Player {
311 self.instance
312 .begin_actor_cast(source, active.with_empower(empower_rank, target));
313 self.instance.runtime.casting.active_player_candidate = Some(candidate);
314 } else {
315 self.instance.begin_actor_cast(source, active);
316 }
317 }
318
319 Some(SpecAction::Cast {
320 spell,
321 empower_rank,
322 source,
323 target,
324 })
325 }
326 #[cfg(feature = "rotation-optimizations")]
327 action if cast_active => Some(SpecAction::Wait {
328 until_ms: match action {
329 Some(SpecAction::Wait { until_ms }) => until_ms.min(busy_ready),
330 _ => busy_ready,
331 },
332 }),
333 #[cfg(not(feature = "rotation-optimizations"))]
334 _ if cast_active => Some(SpecAction::Wait {
335 until_ms: busy_ready,
336 }),
337 _ if gcd_active => Some(SpecAction::Wait {
338 until_ms: locked_wake.map_or(gcd_ready, |wake| wake.min(gcd_ready)),
339 }),
340 action => action,
341 }
342 }
343
344 pub(super) fn on_event_queue_empty_impl(&mut self, _ctx: &mut SimContext) {
345 self.record_encounter_stalled();
346 }
347
348 pub(super) fn encounter_termination_time_impl(&self) -> Option<SimTime> {
349 self.instance.runtime.encounter.terminated_at()
350 }
351
352 pub(super) fn flush_scheduled_impl(&mut self, push: &mut dyn FnMut(Event)) {
353 crate::builder::buffer_init::flush_dirty_projections(&self.program, &mut self.instance);
354
355 #[cfg(feature = "rotation-optimizations")]
356 if let Some(mutations) = self.instance.runtime.rotation_mutations.take() {
357 let mode = self.rotation_decision_mode_at(mutations.at());
358 let subscription = self.program.rotation.decision_subscription(mode);
359
360 assert!(
361 subscription.is_complete(),
362 "finalized rotation decision subscription became incomplete"
363 );
364
365 if rotation_mutations_intersect(subscription, &mutations) {
366 self.instance
367 .schedule(Event::PlayerReady { t: mutations.at() });
368 }
369 }
370
371 for event in self.instance.drain_events() {
372 push(event);
373 }
374 }
375
376 pub(super) fn take_runtime_error_impl(&mut self) -> Option<SpecRuntimeError> {
377 self.instance.take_runtime_error()
378 }
379
380 pub(super) fn reset_impl(&mut self) {
381 reset_combat(
382 &self.program,
383 &mut self.instance,
384 &mut self.last_resource_sync,
385 );
386 }
387
388 pub(super) fn total_damage_impl(&self) -> f64 {
389 self.instance.runtime.total_damage
390 }
391
392 fn schedule_initial_enemy_auto_attacks(&mut self, now: SimTime) {
393 let enemy_count = self.program.enemy_definitions().len();
394
395 for index in 0..enemy_count {
396 let enemy_id = wowlab_types::sim::EnemyIdx::from_raw(
397 u16::try_from(index).expect("enemy count fits"),
398 );
399 let Some((enemy, swing_ms)) =
400 self.program.enemy_definition(enemy_id).and_then(|enemy| {
401 self.instance
402 .is_valid_target(enemy.id())
403 .then_some((enemy.id(), enemy.auto_attack_swing_ms()?))
404 })
405 else {
406 continue;
407 };
408
409 self.instance.schedule_enemy_auto_attack(
410 enemy,
411 ActorId::Player,
412 now.saturating_add(SimTime::from_millis(swing_ms / FIRST_ENEMY_SWING_DIVISOR)),
413 );
414 }
415 }
416
417 fn live_player(&self) -> Option<()> {
418 self.instance
419 .is_friendly_actor_alive(ActorId::Player)
420 .then_some(())
421 }
422
423 fn prepare_player_ready(
424 &mut self,
425 now: SimTime,
426 telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
427 ) -> Option<()> {
428 self.live_player()?;
429
430 if self.instance.runtime.casting.active_channel.is_some()
431 && now >= SimTime::from_secs_f64(self.instance.buffer.player().channel_end)
432 {
433 crate::systems::end_channel(&self.program, &mut self.instance, now);
434 }
435
436 sync_resource(
437 &self.program,
438 &mut self.instance,
439 now,
440 &mut self.last_resource_sync,
441 telemetry,
442 );
443
444 let enemy_count = self.instance.enemies().len();
445
446 for index in 0..enemy_count {
447 let enemy = wowlab_types::sim::EnemyIdx::from_raw(
448 u16::try_from(index).expect("enemy count fits"),
449 );
450
451 self.instance.refresh_enemy_health(enemy, now);
452 }
453
454 crate::builder::buffer_init::flush_dirty_projections(&self.program, &mut self.instance);
455
456 Some(())
457 }
458
459 fn selected_candidate(
460 &self,
461 spell_id: u32,
462 target: wowlab_types::sim::EnemyIdx,
463 ) -> Option<CastCandidate> {
464 self.instance
465 .runtime
466 .casting
467 .selected_candidate
468 .filter(|candidate| {
469 candidate.requested_spell_id(&self.program) == spell_id
470 && candidate.target == target
471 })
472 }
473
474 fn readiness_deadlines(&self, now: SimTime) -> (SimTime, bool, SimTime) {
475 let cast_ready = SimTime::from_secs_f64(self.instance.buffer.player().cast_end);
476 let channel_ready = SimTime::from_secs_f64(self.instance.buffer.player().channel_end);
477 let busy_ready = cast_ready.max(channel_ready);
478 let gcd_ready = SimTime::from_secs_f64(self.instance.buffer.player().gcd_end);
479
480 (busy_ready, now < busy_ready, gcd_ready)
481 }
482
483 #[cfg(feature = "rotation-optimizations")]
484 fn rotation_decision_mode_at(&self, now: SimTime) -> RotationDecisionMode {
485 let (_, cast_active, gcd_ready) = self.readiness_deadlines(now);
486
487 match (cast_active, now < gcd_ready) {
488 (false, false) => RotationDecisionMode::Unlocked,
489 (false, true) => RotationDecisionMode::Gcd,
490 (true, false) => RotationDecisionMode::Casting,
491 (true, true) => RotationDecisionMode::Both,
492 }
493 }
494
495 fn next_rotation_action(
496 &mut self,
497 lock_mode: LockMode,
498 now: SimTime,
499 gcd_ready: SimTime,
500 ) -> (Option<SpecAction>, Option<SimTime>) {
501 #[cfg(feature = "rotation-optimizations")]
502 {
503 return match lock_mode {
504 LockMode::Unlocked => (self.evaluate_rotation(now), None),
505 LockMode::Casting => (
506 self.evaluate_rotation_while_locked(LockMode::Casting, now),
507 None,
508 ),
509 LockMode::Gcd => self.next_gcd_locked_action(now, gcd_ready),
510 LockMode::Both => (
511 self.evaluate_rotation_while_locked(LockMode::Both, now),
512 None,
513 ),
514 };
515 }
516
517 #[cfg(not(feature = "rotation-optimizations"))]
518 {
519 let _ = gcd_ready;
520
521 match lock_mode {
522 LockMode::Unlocked | LockMode::Gcd => (self.evaluate_rotation(now), None),
523 LockMode::Casting | LockMode::Both => (
524 self.evaluate_rotation_while_locked(LockMode::Casting, now),
525 None,
526 ),
527 }
528 }
529 }
530
531 #[cfg(feature = "rotation-optimizations")]
532 fn next_gcd_locked_action(
533 &mut self,
534 now: SimTime,
535 gcd_ready: SimTime,
536 ) -> (Option<SpecAction>, Option<SimTime>) {
537 let locked_action = self.evaluate_rotation_while_locked(LockMode::Gcd, now);
538
539 if matches!(locked_action, Some(SpecAction::Cast { .. })) {
540 return (locked_action, None);
541 }
542
543 let locked_wake = match locked_action {
544 Some(SpecAction::Wait { until_ms }) if until_ms < gcd_ready => Some(until_ms),
545 _ => None,
546 };
547 let queue_eligible_at = self.gcd_queue_eligible_at(gcd_ready);
548
549 if now < queue_eligible_at {
550 let wake = locked_wake.map_or(queue_eligible_at, |wake| wake.min(queue_eligible_at));
551
552 return (None, Some(wake));
553 }
554
555 (self.evaluate_rotation(now), locked_wake)
556 }
557
558 fn gcd_queue_eligible_at(&self, gcd_ready: SimTime) -> SimTime {
559 gcd_ready.saturating_sub(SimTime::from_millis(
560 self.program.config.cast_latency.queue_window_ms,
561 ))
562 }
563
564 fn with_earlier_wake(action: SpecAction, locked_wake: Option<SimTime>) -> SpecAction {
565 match (action, locked_wake) {
566 (SpecAction::Wait { until_ms }, Some(wake)) => SpecAction::Wait {
567 until_ms: wake.min(until_ms),
568 },
569 (action, _) => action,
570 }
571 }
572
573 fn gcd_queue_gate(
574 &self,
575 now: SimTime,
576 gcd_ready: SimTime,
577 is_off_gcd: bool,
578 ) -> Option<SpecAction> {
579 if now >= gcd_ready || is_off_gcd {
580 return None;
581 }
582
583 let queue_eligible_at = self.gcd_queue_eligible_at(gcd_ready);
584
585 if now < queue_eligible_at {
586 return Some(SpecAction::Wait {
587 until_ms: queue_eligible_at,
588 });
589 }
590
591 let latency = if self.program.config.cast_latency.strict_gcd_queue {
592 self.program.config.cast_latency.gcd_ms
593 } else {
594 self.program.config.cast_latency.queue_ms
595 };
596
597 tracing::trace!(
598 t = %now.as_secs_f64(),
599 gcd = %gcd_ready.as_secs_f64(),
600 "GCD_GATE"
601 );
602
603 Some(SpecAction::Wait {
604 until_ms: gcd_ready.saturating_add(SimTime::from_millis(latency)),
605 })
606 }
607}
608
609#[cfg(feature = "rotation-optimizations")]
610fn rotation_mutations_intersect(
611 subscription: &RotationDecisionSubscription,
612 mutations: &RotationMutationAccumulator,
613) -> bool {
614 let cooldown_intersects = subscription
615 .cooldown_rises()
616 .iter()
617 .zip(mutations.cooldown_rises())
618 .any(|(subscribed, changed)| subscribed & changed != 0)
619 || subscription
620 .cooldown_falls()
621 .iter()
622 .zip(mutations.cooldown_falls())
623 .any(|(subscribed, changed)| subscribed & changed != 0);
624 let resource_intersects = subscription.resource_rises() & mutations.resource_rises() != 0
625 || subscription.resource_falls() & mutations.resource_falls() != 0;
626 let semantic_intersects = subscription.semantic_rises() & mutations.semantic_rises() != 0
627 || subscription.semantic_falls() & mutations.semantic_falls() != 0;
628
629 subscription.has_eligible_actions()
630 && (cooldown_intersects || resource_intersects || semantic_intersects)
631}
632use wowlab_engine_event_authority::EngineScheduledSpellAccess as _;