Skip to main content

wowlab_engine_combat/handler/
mod.rs

1// #t(file: rust_pub_api_docs) docs will be added after stabilization
2
3//! [`wowlab_engine_ports::SpecHandler`] implementation backed by `CombatState` + `DenseBuffer`.
4
5pub(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
23/// Placeholder seed replaced with the real per-iteration seed in `on_sim_start` before any roll; never used to sample.
24const PLACEHOLDER_RNG: (u64, u32) = (0, 0);
25
26// docref:start spec-handlers-combat-handler
27#[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    /// Guards against a roll reaching the placeholder RNG before `on_sim_start` installs the real seed.
34    pub(super) rng_seeded: bool,
35    pub(super) names: Vec<Box<str>>,
36    pub(super) last_resource_sync: SimTime,
37}
38// docref:end spec-handlers-combat-handler
39
40impl 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    /// Runs an operation with a combat context borrowing this handler's runtime state.
74    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    /// Runs an operation with a hook context borrowing this handler's runtime state.
97    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    /// Evaluate a busy actor's APL with ordinary spells masked so a `usable_while_casting` action can surface.
162    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    /// Trace each change in why a spell is masked out of rotation evaluation.
190    ///
191    /// `REJECT_CANCAST` only fires for the spell the rotation actually selected.
192    /// A spell that is unavailable all fight therefore never appears in a trace.
193    /// A `BUFFER_STATE` dump reads `is_enabled = 0` for it, because it is taken after this pre-pass.
194    /// This is the only place the real mask reason is observable.
195    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            // #t(rust_log_in_loop) one line per mask-reason change is the whole point of the signal.
210            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    // #t(fn: rust_cyclomatic_complexity) sequential rotation evaluation with multiple result types
225    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                    // The pre-pass masks unavailable spells to `is_enabled = 0`, so report the
272                    // reason it was actually masked for instead of the mask itself.
273                    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}