1use super::{
2 ActorId, CombatHandler, DEFAULT_GCD_MS, EncounterTelemetryEvent, EncounterTelemetryEventKind,
3 Event, FIRST_ENEMY_SWING_DIVISOR, HookCtxRequest, LocalAuraIdx, SimContext, SimTime,
4 SpecAction, SpecHandler, SpecRuntimeError, apply_aura, apply_auto_attack_action_lifecycle,
5 drain_deferred_work, earliest_auto_attack_time, initialize_pet_actions, project_encounter,
6 reset_combat, set_gcd, set_precombat_aura_stacks, sync_resource,
7};
8
9impl CombatHandler {
10 pub(super) fn on_sim_start_impl(&mut self, ctx: &mut SimContext) {
11 self.rng =
12 wowlab_engine_rng::SimRng::from_prefix(ctx.state.seed_prefix, ctx.state.iteration);
13 self.rng_seeded = true;
14 self.last_resource_sync = SimTime::ZERO;
15 ctx.telemetry.set_targets(self.state.telemetry_targets());
16
17 for enemy in self
18 .state
19 .enemies()
20 .filter(|enemy| enemy.is_active() && enemy.is_alive())
21 {
22 let Some(scope) = self
23 .state
24 .telemetry_scope(ActorId::Enemy(enemy.id()), enemy.id())
25 else {
26 continue;
27 };
28
29 ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
30 kind: EncounterTelemetryEventKind::Spawn,
31 time_ms: 0,
32 actor: ActorId::Enemy(enemy.id()),
33 previous_target: None,
34 target: Some(enemy.id()),
35 pull: scope.pull,
36 group: Some(scope.group),
37 transform: Some(enemy.current_transform()),
38 });
39 }
40
41 if let Some(target) = self.state.current_target() {
42 if let Some(scope) = self.state.telemetry_scope(ActorId::Player, target) {
43 ctx.telemetry.emit_encounter(EncounterTelemetryEvent {
44 kind: EncounterTelemetryEventKind::TargetChange,
45 time_ms: 0,
46 actor: ActorId::Player,
47 previous_target: None,
48 target: Some(target),
49 pull: scope.pull,
50 group: Some(scope.group),
51 transform: self.state.actor_transform(ActorId::Player),
52 });
53 }
54 }
55
56 self.schedule_encounter_start();
57
58 crate::systems::ensure_initial_proc_heartbeat(
59 &mut self.state,
60 SimTime::ZERO,
61 &mut self.rng,
62 );
63
64 let precombat: Vec<LocalAuraIdx> = self.state.defs.precombat_auras.clone();
65 let talent_stacks: Vec<(LocalAuraIdx, u8)> = self.state.defs.talent_aura_stacks.clone();
66 let target = self.state.current_target();
67
68 for aura_local in precombat {
69 self.with_hook_ctx(
70 &mut *ctx.telemetry,
71 HookCtxRequest::for_optional_target(SimTime::ZERO, target),
72 |hook| apply_aura(hook, aura_local),
73 );
74 }
75
76 for (aura_local, stacks) in talent_stacks {
77 set_precombat_aura_stacks(&self.state, &mut self.buf, aura_local, stacks, target);
78 }
79
80 self.with_hook_ctx(
81 &mut *ctx.telemetry,
82 HookCtxRequest::targetless(SimTime::ZERO),
83 drain_deferred_work,
84 );
85
86 let periodic_drivers = self.state.defs.periodic_drivers.clone();
87
88 for (index, driver) in periodic_drivers.into_iter().enumerate() {
89 if driver.triggered_duration_ms.is_some() {
90 continue;
91 }
92
93 let interval_ms = crate::systems::effective_periodic_interval_ms(
94 &crate::context::CombatView::new(&self.state, &self.buf),
95 driver.driver_spell_id,
96 driver.tick_interval_ms.get(),
97 driver.hasted_ticks,
98 );
99
100 self.with_hook_ctx(
101 &mut *ctx.telemetry,
102 HookCtxRequest::targetless(SimTime::ZERO),
103 |ctx| ctx.schedule_periodic_driver(interval_ms, index, None, None),
104 );
105 }
106
107 initialize_pet_actions(&mut self.state, &self.buf);
108
109 self.schedule_initial_enemy_auto_attacks(SimTime::ZERO);
110
111 if let Some(t) = earliest_auto_attack_time(&self.state, &self.buf) {
112 if let Some(target) = self.state.current_target() {
113 self.state.schedule(Event::AutoAttack {
114 t,
115 source: ActorId::Player,
116 target,
117 });
118 }
119 }
120 }
121
122 pub(super) fn on_player_ready_impl(&mut self, ctx: &mut SimContext) -> Option<SpecAction> {
124 let now = ctx.state.current_time;
125
126 self.prepare_player_ready(now, ctx.telemetry)?;
127
128 if self.state.runtime.casting.instant_cast_pending_at == Some(now) {
129 return Some(SpecAction::Wait {
130 until_ms: now.saturating_add(SimTime::from_millis(1)),
131 });
132 }
133
134 self.state.runtime.casting.instant_cast_pending_at = None;
135
136 let (busy_ready, cast_active, gcd_ready) = self.readiness_deadlines(now);
137 let gcd_active = now < gcd_ready;
138
139 let action = self.next_rotation_action(cast_active, now);
140
141 match action {
142 Some(SpecAction::Cast {
143 spell_id,
144 empower_rank,
145 source,
146 target,
147 }) => {
148 let raw_spell_id = spell_id.as_u32();
149 let (is_off_gcd, usable_while_casting) =
150 self.cast_readiness_properties(raw_spell_id);
151
152 if cast_active && !usable_while_casting {
153 tracing::trace!(
154 t = %now.as_secs_f64(),
155 cast_end = %busy_ready.as_secs_f64(),
156 "CAST_GATE"
157 );
158
159 return Some(SpecAction::Wait {
160 until_ms: busy_ready,
161 });
162 }
163
164 if let Some(wait) = self.gcd_queue_gate(now, gcd_ready, is_off_gcd) {
165 return Some(wait);
166 }
167
168 let cast_ms = self.cast_time_ms(spell_id, empower_rank);
169
170 if is_off_gcd && cast_ms == 0 {
171 if self.state.runtime.casting.instant_off_gcd_casts_at != Some(now) {
172 self.state.runtime.casting.instant_off_gcd_casts_at = Some(now);
173 self.state.runtime.casting.instant_off_gcd_spell_ids.clear();
174 }
175
176 if self
177 .state
178 .runtime
179 .casting
180 .instant_off_gcd_spell_ids
181 .contains(&raw_spell_id)
182 {
183 let until_ms = if gcd_ready > now {
184 gcd_ready
185 } else {
186 now.saturating_add(SimTime::from_millis(DEFAULT_GCD_MS))
187 };
188
189 return Some(SpecAction::Wait { until_ms });
190 }
191
192 self.state
193 .runtime
194 .casting
195 .instant_off_gcd_spell_ids
196 .push(raw_spell_id);
197 }
198
199 if is_off_gcd {
200 tracing::trace!(t = %now.as_secs_f64(), spell_id = raw_spell_id, "OFF_GCD_CAST");
201 }
202
203 if source == ActorId::Player {
204 self.break_player_action_auras(target, now, &mut *ctx.telemetry);
205 }
206
207 apply_auto_attack_action_lifecycle(
208 &mut self.state,
209 &mut self.buf,
210 raw_spell_id,
211 source,
212 target,
213 now,
214 );
215 let chained =
216 self.state.runtime.casting.channel_chain_spell_id == Some(raw_spell_id);
217
218 if chained {
219 self.state.runtime.casting.channel_chain_spell_id = None;
220 } else {
221 set_gcd(&mut self.state, &mut self.buf, raw_spell_id, now);
222 }
223
224 if cast_ms == 0 {
225 self.state.runtime.casting.instant_cast_pending_at = Some(now);
227 } else {
228 let cast_end = now.saturating_add(SimTime::from_millis(cast_ms));
229
230 self.buf.player_mut().cast_end = cast_end.as_secs_f64();
231 let schools = wowlab_engine_domain::dbc::SpellSchoolMask::from_dbc(
232 self.state
233 .config
234 .game_data
235 .school_mask(spell_id)
236 .unwrap_or_default(),
237 );
238
239 if source == ActorId::Player {
240 self.state.begin_player_cast(
241 spell_id,
242 schools,
243 cast_end,
244 empower_rank,
245 target,
246 );
247 } else {
248 self.state
249 .begin_actor_cast(source, spell_id, schools, cast_end, true);
250 }
251 }
252
253 Some(SpecAction::Cast {
254 spell_id,
255 empower_rank,
256 source,
257 target,
258 })
259 }
260 _ if cast_active => Some(SpecAction::Wait {
261 until_ms: busy_ready,
262 }),
263 _ if gcd_active => Some(SpecAction::Wait {
264 until_ms: gcd_ready,
265 }),
266 action => action,
267 }
268 }
269
270 pub(super) fn on_event_queue_empty_impl(&mut self, _ctx: &mut SimContext) {
271 self.record_encounter_stalled();
272 }
273
274 pub(super) fn encounter_termination_time_impl(&self) -> Option<SimTime> {
275 self.state.runtime.encounter.terminated_at()
276 }
277
278 pub(super) fn flush_scheduled_impl(&mut self, push: &mut dyn FnMut(Event)) {
279 for event in self.state.drain_events() {
280 push(event);
281 }
282 }
283
284 pub(super) fn take_runtime_error_impl(&mut self) -> Option<SpecRuntimeError> {
285 self.state.take_runtime_error()
286 }
287
288 pub(super) fn reset_impl(&mut self) {
289 reset_combat(&mut self.state, &mut self.buf, &mut self.last_resource_sync);
290 }
291
292 pub(super) fn total_damage_impl(&self) -> f64 {
293 self.state.runtime.total_damage
294 }
295
296 fn schedule_initial_enemy_auto_attacks(&mut self, now: SimTime) {
297 let enemy_swings: Vec<_> = self
298 .state
299 .enemy_definitions()
300 .filter_map(|enemy| {
301 self.state
302 .is_valid_target(enemy.id())
303 .then_some((enemy.id(), enemy.auto_attack_swing_ms()?))
304 })
305 .collect();
306
307 for (enemy, swing_ms) in enemy_swings {
308 self.state.schedule_enemy_auto_attack(
309 enemy,
310 ActorId::Player,
311 now.saturating_add(SimTime::from_millis(swing_ms / FIRST_ENEMY_SWING_DIVISOR)),
312 );
313 }
314 }
315
316 fn live_player(&self) -> Option<()> {
317 self.state
318 .is_friendly_actor_alive(ActorId::Player)
319 .then_some(())
320 }
321
322 fn prepare_player_ready(
323 &mut self,
324 now: SimTime,
325 telemetry: &mut wowlab_engine_telemetry::TelemetrySink,
326 ) -> Option<()> {
327 self.live_player()?;
328
329 if self.state.runtime.casting.active_channel_spell_id.is_some()
330 && now >= SimTime::from_secs_f64(self.buf.player().channel_end)
331 {
332 crate::systems::end_channel(&mut self.state, &mut self.buf, now);
333 }
334
335 sync_resource(
336 &mut self.state,
337 &mut self.buf,
338 now,
339 &mut self.last_resource_sync,
340 telemetry,
341 );
342
343 let enemy_ids: Vec<_> = self
344 .state
345 .enemies()
346 .map(wowlab_engine_domain::encounter::EnemyState::id)
347 .collect();
348
349 for enemy in enemy_ids {
350 self.state.refresh_enemy_health(enemy, now);
351 }
352
353 project_encounter(&self.state, &mut self.buf, now);
354
355 Some(())
356 }
357
358 fn cast_readiness_properties(&self, spell_id: u32) -> (bool, bool) {
359 self.state
360 .spell_data(spell_id)
361 .map_or((false, false), |(_, spell)| {
362 (
363 spell.behavior.off_gcd,
364 spell.requirements.availability.usable_while_casting,
365 )
366 })
367 }
368
369 fn readiness_deadlines(&self, now: SimTime) -> (SimTime, bool, SimTime) {
370 let cast_ready = SimTime::from_secs_f64(self.buf.player().cast_end);
371 let channel_ready = SimTime::from_secs_f64(self.buf.player().channel_end);
372 let busy_ready = cast_ready.max(channel_ready);
373 let gcd_ready = SimTime::from_secs_f64(self.buf.player().gcd_end);
374
375 (busy_ready, now < busy_ready, gcd_ready)
376 }
377
378 fn next_rotation_action(&mut self, cast_active: bool, now: SimTime) -> Option<SpecAction> {
379 if cast_active {
380 self.evaluate_rotation_while_casting(now)
381 } else {
382 self.evaluate_rotation(now)
383 }
384 }
385
386 fn gcd_queue_gate(
387 &self,
388 now: SimTime,
389 gcd_ready: SimTime,
390 is_off_gcd: bool,
391 ) -> Option<SpecAction> {
392 if now >= gcd_ready || is_off_gcd {
393 return None;
394 }
395
396 let queue_window = SimTime::from_millis(self.state.config.cast_latency.queue_window_ms);
397 let queue_eligible_at = gcd_ready.saturating_sub(queue_window);
398
399 if now < queue_eligible_at {
400 return Some(SpecAction::Wait {
401 until_ms: queue_eligible_at,
402 });
403 }
404
405 let latency = if self.state.config.cast_latency.strict_gcd_queue {
406 self.state.config.cast_latency.gcd_ms
407 } else {
408 self.state.config.cast_latency.queue_ms
409 };
410
411 tracing::trace!(
412 t = %now.as_secs_f64(),
413 gcd = %gcd_ready.as_secs_f64(),
414 "GCD_GATE"
415 );
416
417 Some(SpecAction::Wait {
418 until_ms: gcd_ready.saturating_add(SimTime::from_millis(latency)),
419 })
420 }
421}