wowlab_engine_combat/handler/
mod.rs1pub(crate) mod can_cast;
6mod introspection;
7mod orchestration;
8mod reset;
9mod spec_handler;
10
11use can_cast::{CastReject, cast_rejection, pool_wait, reject_to_wait};
12use wowlab_engine_domain::rotation::{DenseBuffer, RotationEngine, RuntimeBackend};
13use wowlab_engine_ports::{SpecAction, SpecRuntimeError};
14use wowlab_engine_rng::SimRng;
15use wowlab_engine_telemetry::TelemetrySink;
16use wowlab_types::sim::{SimTime, SpellIdx};
17
18use crate::{
19 context::{CombatCtx, CombatCtxRequest, HookCtx, HookCtxRequest},
20 state::CombatState,
21};
22
23const PLACEHOLDER_RNG: (u64, u32) = (0, 0);
25
26#[derive(Debug)]
28pub struct CombatHandler {
29 pub(super) state: CombatState,
30 pub(super) buf: DenseBuffer,
31 pub(super) engine: RotationEngine,
32 pub(super) rng: SimRng,
33 pub(super) rng_seeded: bool,
35 pub(super) names: Vec<Box<str>>,
36 pub(super) last_resource_sync: SimTime,
37}
38impl CombatHandler {
41 #[must_use]
42 pub fn from_built(mut built: crate::BuiltCombatSystem) -> Self {
43 built.size_scratch_buffers();
44
45 Self {
46 state: built.state,
47 buf: built.buffer,
48 engine: built.rotation,
49 rng: SimRng::from_prefix(PLACEHOLDER_RNG.0, PLACEHOLDER_RNG.1),
50 rng_seeded: false,
51 names: built.names,
52 last_resource_sync: SimTime::ZERO,
53 }
54 }
55
56 #[must_use]
57 pub fn state(&self) -> &CombatState {
58 &self.state
59 }
60
61 pub fn state_mut(&mut self) -> &mut CombatState {
62 &mut self.state
63 }
64
65 pub fn buf_mut(&mut self) -> &mut DenseBuffer {
66 &mut self.buf
67 }
68
69 pub fn state_and_buf_mut(&mut self) -> (&mut CombatState, &mut DenseBuffer) {
70 (&mut self.state, &mut self.buf)
71 }
72
73 pub fn with_combat_ctx<R>(
75 &mut self,
76 sink: &mut TelemetrySink,
77 request: CombatCtxRequest,
78 apply: impl FnOnce(&mut CombatCtx<'_>) -> R,
79 ) -> R {
80 self.assert_rng_seeded();
81 let rng = &mut self.rng;
82 let mut rng_fn = || rng.next_f64();
83 let mut ctx = CombatCtx {
84 state: &mut self.state,
85 buf: &mut self.buf,
86 sink,
87 now: request.now,
88 rng: &mut rng_fn,
89 source: request.source,
90 target: request.target,
91 };
92
93 apply(&mut ctx)
94 }
95
96 pub fn with_hook_ctx<R>(
98 &mut self,
99 sink: &mut TelemetrySink,
100 request: HookCtxRequest,
101 apply: impl FnOnce(&mut HookCtx<'_>) -> R,
102 ) -> R {
103 self.assert_rng_seeded();
104 let rng = &mut self.rng;
105 let mut rng_fn = || rng.next_f64();
106 let mut ctx = HookCtx::new(
107 crate::context::HookCtxServices {
108 state: &mut self.state,
109 buf: &mut self.buf,
110 sink,
111 rng: &mut rng_fn,
112 },
113 request,
114 );
115
116 apply(&mut ctx)
117 }
118
119 pub(super) fn assert_rng_seeded(&self) {
120 debug_assert!(
121 self.rng_seeded,
122 "CombatHandler RNG used before on_sim_start installed the per-iteration seed"
123 );
124 }
125
126 pub(super) fn evaluate_rotation(&mut self, now: SimTime) -> Option<SpecAction> {
127 let mut masked = Vec::with_capacity(self.state.defs.spells.len());
128 let mut masked_reasons = Vec::with_capacity(self.state.defs.spells.len());
129
130 for spell in &self.state.defs.spells {
131 let Err(reason) = cast_rejection(
132 &crate::context::CombatView::new(&self.state, &self.buf),
133 spell.spell_id,
134 now,
135 ) else {
136 continue;
137 };
138
139 if let Some(slot) = self.buf.spell_mut(spell.idx()) {
140 if slot.is_enabled != 0 {
141 masked.push((spell.idx(), slot.is_enabled));
142 masked_reasons.push((spell.spell_id, reason));
143 slot.is_enabled = 0;
144 }
145 }
146 }
147
148 self.log_mask_transitions(now, &masked_reasons);
149
150 let action = self.evaluate_rotation_unmasked(now, &masked_reasons);
151
152 for (spell, enabled) in masked {
153 if let Some(slot) = self.buf.spell_mut(spell) {
154 slot.is_enabled = enabled;
155 }
156 }
157
158 action
159 }
160
161 pub(super) fn evaluate_rotation_while_casting(&mut self, now: SimTime) -> Option<SpecAction> {
163 let mut disabled = Vec::with_capacity(self.state.defs.spells.len());
164
165 for spell in &self.state.defs.spells {
166 if spell.requirements.availability.usable_while_casting {
167 continue;
168 }
169
170 if let Some(slot) = self.buf.spell_mut(spell.idx()) {
171 if slot.is_enabled != 0 {
172 disabled.push((spell.idx(), slot.is_enabled));
173 slot.is_enabled = 0;
174 }
175 }
176 }
177
178 let action = self.evaluate_rotation(now);
179
180 for (spell, enabled) in disabled {
181 if let Some(slot) = self.buf.spell_mut(spell) {
182 slot.is_enabled = enabled;
183 }
184 }
185
186 action
187 }
188
189 fn log_mask_transitions(&mut self, now: SimTime, masked_reasons: &[(u32, CastReject)]) {
196 for &(spell_id, reason) in masked_reasons {
197 let discriminant = std::mem::discriminant(&reason);
198
199 if self
200 .state
201 .runtime
202 .mask_reasons
203 .insert(spell_id, discriminant)
204 == Some(discriminant)
205 {
206 continue;
207 }
208
209 tracing::trace!(
211 t = %now.as_secs_f64(),
212 spell_id,
213 reason = %reason,
214 "SPELL_MASKED"
215 );
216 }
217
218 self.state
219 .runtime
220 .mask_reasons
221 .retain(|spell_id, _| masked_reasons.iter().any(|(masked, _)| masked == spell_id));
222 }
223
224 fn evaluate_rotation_unmasked(
226 &mut self,
227 now: SimTime,
228 masked_reasons: &[(u32, CastReject)],
229 ) -> Option<SpecAction> {
230 tracing::trace!(t = %now.as_secs_f64(), buffer = %self.buf.dump(), "BUFFER_STATE");
231 let result = self.engine.evaluate(&mut self.buf, now.as_secs_f64());
232
233 tracing::trace!(t = %now.as_secs_f64(), kind = result.kind, spell_id = result.spell_id, "EVAL_RESULT");
234
235 if result.is_cast() {
236 let spell_id = result.spell_id;
237 let Some(empower_rank) =
238 resolved_empower_rank(&self.state, spell_id, result.empower_rank)
239 else {
240 let max_rank = self
241 .state
242 .spell_data(spell_id)
243 .map_or(0, |(_, spell)| spell.empower_rank_count);
244
245 self.state
246 .record_runtime_error(SpecRuntimeError::invalid_empower_rank(
247 spell_id,
248 result.empower_rank,
249 max_rank,
250 ));
251
252 return None;
253 };
254
255 match cast_rejection(
256 &crate::context::CombatView::new(&self.state, &self.buf),
257 spell_id,
258 now,
259 ) {
260 Ok(()) => {
261 tracing::trace!(t = %now.as_secs_f64(), spell_id, "CAST");
262
263 return Some(SpecAction::Cast {
264 spell_id: SpellIdx::from_raw(spell_id),
265 empower_rank,
266 source: wowlab_types::sim::ActorId::Player,
267 target: self.state.current_target()?,
268 });
269 }
270 Err(reason) => {
271 let reason = masked_reasons
274 .iter()
275 .find_map(|&(masked_id, masked_reason)| {
276 (masked_id == spell_id).then_some(masked_reason)
277 })
278 .unwrap_or(reason);
279
280 tracing::trace!(
281 t = %now.as_secs_f64(),
282 spell_id,
283 reason = %reason,
284 "REJECT_CANCAST"
285 );
286
287 return Some(reject_to_wait(
288 &crate::context::CombatView::new(&self.state, &self.buf),
289 spell_id,
290 now,
291 ));
292 }
293 }
294 }
295
296 if result.is_wait() {
297 let wait_until =
298 now.saturating_add(SimTime::from_secs_f64(f64::from(result.wait_time)));
299
300 return Some(SpecAction::Wait {
301 until_ms: wait_until,
302 });
303 }
304
305 if result.is_pool() {
306 return Some(pool_wait(&self.state, &self.buf, now));
307 }
308
309 if result.is_use_item() {
310 if let Some(gear_slot) = result.item_slot() {
311 if let Some(&(_, spell_id)) = self
312 .state
313 .defs
314 .item_use_spells
315 .iter()
316 .find(|(s, _)| *s == gear_slot)
317 {
318 match cast_rejection(
319 &crate::context::CombatView::new(&self.state, &self.buf),
320 spell_id,
321 now,
322 ) {
323 Ok(()) => {
324 tracing::trace!(t = %now.as_secs_f64(), spell_id, slot = ?gear_slot, "USE_ITEM_CAST");
325
326 return Some(SpecAction::Cast {
327 spell_id: SpellIdx::from_raw(spell_id),
328 empower_rank: resolved_empower_rank(
329 &self.state,
330 spell_id,
331 result.empower_rank,
332 )?,
333 source: wowlab_types::sim::ActorId::Player,
334 target: self.state.current_target()?,
335 });
336 }
337 Err(reason) => {
338 tracing::trace!(spell_id, slot = ?gear_slot, reason = %reason, "USE_ITEM_REJECT_CANCAST");
339
340 return Some(reject_to_wait(
341 &crate::context::CombatView::new(&self.state, &self.buf),
342 spell_id,
343 now,
344 ));
345 }
346 }
347 }
348
349 tracing::trace!(slot = ?gear_slot, "use_item: no spell mapped");
350 } else {
351 tracing::warn!(raw = result.spell_id, "use_item: invalid slot");
352 }
353 }
354
355 tracing::trace!(t = %now.as_secs_f64(), "ROTATION_NONE");
356
357 Some(pool_wait(&self.state, &self.buf, now))
358 }
359}
360
361fn resolved_empower_rank(state: &CombatState, spell_id: u32, requested_rank: u8) -> Option<u8> {
362 let (_, spell) = state.spell_data(spell_id)?;
363
364 if spell.empower_rank_count == 0 {
365 return (requested_rank == 0 || spell.max_empower_aura_id != 0).then_some(0);
366 }
367
368 let rank = requested_rank.max(1);
369
370 (rank <= spell.empower_rank_count).then_some(rank)
371}