Skip to main content

wowlab_engine_combat/context/
mod.rs

1//! Combat context types for passing state through the combat pipeline.
2
3use wowlab_engine_domain::rotation::DenseBuffer;
4use wowlab_engine_gamedata::ResolvedGameData;
5use wowlab_engine_ports::{Event, SpecConfig, SpecRuntime};
6use wowlab_engine_telemetry::TelemetrySink;
7use wowlab_types::sim::{ActorId, EnemyIdx, SimTime, SpellIdx};
8
9use crate::{state::CombatState, systems};
10
11mod aura;
12mod channel;
13pub(crate) mod combat_ctx;
14pub(crate) mod damage;
15pub(crate) mod hook;
16
17pub(crate) use combat_ctx::{ActorView, CombatCtx, CombatCtxRequest, CombatView, HookView};
18pub(crate) use damage::DamageOutcome;
19pub(crate) use hook::{AuraOps, DamageOps};
20
21#[cfg(test)]
22mod tests;
23
24/// Provenance for a cost-free registered action dispatched from a hook.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26#[non_exhaustive]
27pub enum FreeActionSource {
28    /// A child action selected by one tick of a registered channel.
29    ChannelChild,
30}
31
32/// Constrained context exposing only the operations cast hooks may perform.
33#[must_use]
34pub struct HookCtx<'a> {
35    pub(crate) state: &'a mut CombatState,
36    pub(crate) buf: &'a mut DenseBuffer,
37    pub(crate) sink: &'a mut TelemetrySink,
38    pub(crate) now: SimTime,
39    pub(crate) source_damage_flags: systems::DamageFlags,
40    pub(crate) source_npc_id: Option<u32>,
41    pub(crate) source: ActorId,
42    pub(crate) target: Option<EnemyIdx>,
43    pub(crate) effect_target: Option<ActorId>,
44    pub(crate) rng: &'a mut dyn FnMut() -> f64,
45    /// Spell or aura that invoked this hook; provenance for resource gains and telemetry.
46    driver_spell_id: u32,
47    empower_rank: u8,
48    free_action_source: Option<FreeActionSource>,
49}
50
51/// Mutable combat services borrowed by one hook invocation.
52#[must_use]
53pub struct HookCtxServices<'a> {
54    pub state: &'a mut CombatState,
55    pub buf: &'a mut DenseBuffer,
56    pub sink: &'a mut TelemetrySink,
57    pub rng: &'a mut dyn FnMut() -> f64,
58}
59
60impl std::fmt::Debug for HookCtxServices<'_> {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("HookCtxServices")
63            .field("state", self.state)
64            .field("buf", self.buf)
65            .field("sink", self.sink)
66            .field("rng", &"<callback>")
67            .finish()
68    }
69}
70
71/// Coordinates and provenance for constructing a hook context.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73#[must_use]
74pub struct HookCtxRequest {
75    pub now: SimTime,
76    pub source: ActorId,
77    pub target: Option<EnemyIdx>,
78    source_damage_flags: systems::DamageFlags,
79    source_npc_id: Option<u32>,
80    effect_target: Option<ActorId>,
81}
82
83impl HookCtxRequest {
84    pub const fn targetless(now: SimTime) -> Self {
85        Self {
86            now,
87            source: ActorId::Player,
88            target: None,
89            source_damage_flags: systems::DamageFlags::empty(),
90            source_npc_id: None,
91            effect_target: None,
92        }
93    }
94
95    pub const fn for_target(now: SimTime, target: EnemyIdx) -> Self {
96        Self {
97            now,
98            source: ActorId::Player,
99            target: Some(target),
100            source_damage_flags: systems::DamageFlags::empty(),
101            source_npc_id: None,
102            effect_target: None,
103        }
104    }
105
106    pub const fn for_optional_target(now: SimTime, target: Option<EnemyIdx>) -> Self {
107        Self {
108            now,
109            source: ActorId::Player,
110            target,
111            source_damage_flags: systems::DamageFlags::empty(),
112            source_npc_id: None,
113            effect_target: None,
114        }
115    }
116
117    pub const fn with_source(mut self, source: ActorId) -> Self {
118        self.source = source;
119
120        self
121    }
122
123    pub(crate) const fn with_source_damage_flags(mut self, flags: systems::DamageFlags) -> Self {
124        self.source_damage_flags = flags;
125
126        self
127    }
128
129    pub(crate) const fn with_source_npc_id(mut self, npc_id: Option<u32>) -> Self {
130        self.source_npc_id = npc_id;
131
132        self
133    }
134
135    pub(crate) const fn with_effect_target(mut self, target: ActorId) -> Self {
136        self.effect_target = Some(target);
137
138        self
139    }
140}
141
142impl<'a> HookCtx<'a> {
143    pub fn new(services: HookCtxServices<'a>, request: HookCtxRequest) -> Self {
144        let HookCtxServices {
145            state,
146            buf,
147            sink,
148            rng,
149        } = services;
150
151        Self {
152            state,
153            buf,
154            sink,
155            now: request.now,
156            source_damage_flags: request.source_damage_flags,
157            source_npc_id: request.source_npc_id,
158            source: request.source,
159            target: request.target,
160            effect_target: request.effect_target,
161            rng,
162            driver_spell_id: 0,
163            empower_rank: 0,
164            free_action_source: None,
165        }
166    }
167
168    pub const fn view(&self) -> HookView<'_> {
169        HookView {
170            state: self.state,
171            buf: self.buf,
172            target: self.target,
173            now: self.now,
174        }
175    }
176
177    /// Provenance for this hook's cost-free action, if it was dispatched as one.
178    #[must_use]
179    pub const fn free_action_source(&self) -> Option<FreeActionSource> {
180        self.free_action_source
181    }
182
183    #[must_use]
184    pub const fn source(&self) -> ActorId {
185        self.source
186    }
187
188    #[must_use]
189    pub const fn target(&self) -> Option<EnemyIdx> {
190        self.target
191    }
192
193    /// One-based empower rank selected for the cast whose hook is running.
194    #[must_use]
195    pub const fn empower_rank(&self) -> Option<u8> {
196        if self.empower_rank == 0 {
197            None
198        } else {
199            Some(self.empower_rank)
200        }
201    }
202
203    /// Update this friendly actor's canonical health and fire active threshold crossings.
204    pub fn set_health_fraction(&mut self, fraction: f64) {
205        let actor = self.source;
206        let Some(before) = self
207            .state
208            .set_friendly_actor_health_fraction(actor, fraction)
209        else {
210            return;
211        };
212        let after = self
213            .state
214            .actor_health_fraction(actor, self.now)
215            .unwrap_or(before);
216
217        systems::process_actor_health_change(self.state, self.buf, actor, before, after, self.now);
218    }
219
220    /// Resolve concrete hostile identities using parent-spell geometry and impact-spell LOS rules.
221    #[must_use]
222    pub fn resolved_spell_effect_targets(
223        &mut self,
224        geometry_effect: (u32, u8),
225        impact_spell_id: u32,
226        max_targets: Option<u8>,
227    ) -> Vec<EnemyIdx> {
228        let Some(anchor) = self.target else {
229            return Vec::new();
230        };
231
232        crate::targeting::resolve_spell_targets(
233            self.state,
234            self.buf,
235            crate::targeting::SpellTargetRequest {
236                source: self.source,
237                source_position: None,
238                anchor,
239                geometry_spell: SpellIdx::from_raw(geometry_effect.0),
240                geometry_effect: geometry_effect.1,
241                impact_spell: SpellIdx::from_raw(impact_spell_id),
242                max_targets,
243                destination: None,
244            },
245            self.now,
246            Some(&mut *self.rng),
247        )
248        .unwrap_or_else(|error| {
249            tracing::error!(
250                geometry_spell_id = geometry_effect.0,
251                geometry_effect = geometry_effect.1,
252                impact_spell_id,
253                %error,
254                "invalid spell target shape in cast hook"
255            );
256
257            Vec::new()
258        })
259    }
260
261    #[inline]
262    #[must_use]
263    pub fn state(&self) -> &CombatState {
264        self.state
265    }
266
267    /// Selected rank for a decoded talent, or zero when it is not selected.
268    #[inline]
269    #[must_use]
270    pub fn talent_rank(&self, talent_spell_id: u32) -> u8 {
271        self.state
272            .defs
273            .talent_ranks
274            .get(&talent_spell_id)
275            .copied()
276            .unwrap_or(0)
277    }
278
279    /// Whether a decoded talent is selected at any rank.
280    #[inline]
281    #[must_use]
282    pub fn talent_selected(&self, talent_spell_id: u32) -> bool {
283        self.talent_rank(talent_spell_id) > 0
284    }
285
286    /// Selected talent's authoritative `SpellAuraOptions.ProcChance`, or zero when unselected.
287    #[inline]
288    #[must_use]
289    pub fn selected_talent_proc_chance(&self, talent_spell_id: u32) -> f64 {
290        if !self.talent_selected(talent_spell_id) {
291            return 0.0;
292        }
293
294        self.state
295            .config
296            .game_data
297            .proc_chance(SpellIdx::from_raw(talent_spell_id))
298            .unwrap_or(0.0)
299    }
300
301    #[inline]
302    #[must_use]
303    pub fn spec_config<T>(&self) -> Option<&T>
304    where
305        T: SpecConfig,
306    {
307        self.state.spec_config::<T>()
308    }
309
310    #[inline]
311    #[must_use]
312    pub fn spec_runtime<T>(&self) -> Option<&T>
313    where
314        T: SpecRuntime,
315    {
316        self.state.spec_runtime::<T>()
317    }
318
319    #[inline]
320    pub fn spec_runtime_mut<T>(&mut self) -> Option<&mut T>
321    where
322        T: SpecRuntime,
323    {
324        self.state.spec_runtime_mut::<T>()
325    }
326
327    #[inline]
328    #[must_use]
329    pub fn total_damage(&self) -> f64 {
330        self.state.runtime.total_damage
331    }
332
333    #[inline]
334    #[must_use]
335    pub fn buf(&self) -> &DenseBuffer {
336        self.buf
337    }
338
339    #[inline]
340    pub fn buf_mut(&mut self) -> &mut DenseBuffer {
341        self.buf
342    }
343
344    #[inline]
345    #[must_use]
346    pub fn game_data(&self) -> &ResolvedGameData {
347        &self.state.config.game_data
348    }
349
350    /// The spell or aura whose hook is running; `0` when the caller declared none.
351    #[must_use]
352    pub const fn driver_spell_id(&self) -> u32 {
353        self.driver_spell_id
354    }
355
356    /// Borrow the iteration's deterministic uniform RNG.
357    pub fn rng(&mut self) -> &mut dyn FnMut() -> f64 {
358        self.rng
359    }
360
361    pub(crate) const fn set_source(&mut self, source: ActorId) {
362        self.source = source;
363    }
364
365    pub(crate) const fn with_empower_rank(mut self, rank: u8) -> Self {
366        self.empower_rank = rank;
367
368        self
369    }
370
371    pub(crate) const fn with_free_action_source(mut self, source: FreeActionSource) -> Self {
372        self.free_action_source = Some(source);
373
374        self
375    }
376
377    /// Records the spell or aura whose hook is running, so its resource gains carry provenance.
378    pub(crate) const fn with_driver_spell(mut self, driver_spell_id: u32) -> Self {
379        self.driver_spell_id = driver_spell_id;
380
381        self
382    }
383
384    pub(crate) fn combat_ctx(&mut self, target: EnemyIdx) -> CombatCtx<'_> {
385        self.combat_ctx_with_source(self.source, target)
386    }
387
388    pub(crate) fn combat_ctx_with_source(
389        &mut self,
390        source: ActorId,
391        target: EnemyIdx,
392    ) -> CombatCtx<'_> {
393        CombatCtx {
394            state: self.state,
395            buf: self.buf,
396            sink: self.sink,
397            now: self.now,
398            rng: self.rng,
399            source,
400            target,
401        }
402    }
403
404    const fn driver_resource_gain_source(&self) -> crate::state::ResourceGainSource {
405        if self.driver_spell_id == 0 {
406            crate::state::ResourceGainSource::Unattributed
407        } else {
408            crate::state::ResourceGainSource::Spell(self.driver_spell_id)
409        }
410    }
411
412    fn schedule_pending_timer(&mut self, delay_ms: u32, pending: crate::state::PendingHookTimer) {
413        let timers = &mut self.state.runtime.pending.pending_hook_timers;
414        let timer_id = if let Some(free) = timers.iter().position(Option::is_none) {
415            // BOUNDS: `free` comes from `position()` on this same vec.
416            timers[free] = Some(pending);
417
418            free
419        } else {
420            timers.push(Some(pending));
421
422            timers.len() - 1
423        };
424        let t = self.now.saturating_add(SimTime::from_millis(delay_ms));
425
426        self.state.schedule(Event::HookTimer {
427            t,
428            timer_id: u32::try_from(timer_id).expect("timer arena length fits in u32"),
429            source: pending.source,
430            target: pending.target,
431        });
432    }
433}
434impl std::fmt::Debug for HookCtx<'_> {
435    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436        f.debug_struct("HookCtx")
437            .field("now", &self.now)
438            .field("source_damage_flags", &self.source_damage_flags)
439            .field("source_npc_id", &self.source_npc_id)
440            .field("source", &self.source)
441            .field("target", &self.target)
442            .finish_non_exhaustive()
443    }
444}