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::BoundDenseBuffer;
4use wowlab_engine_ports::{Event, SpecConfig, SpecRuntime};
5use wowlab_engine_telemetry::TelemetrySink;
6use wowlab_types::sim::{ActorId, EnemyIdx, SimTime};
7
8use crate::{
9    state::{CombatInstance, CombatProgram},
10    systems,
11};
12
13mod aura;
14mod channel;
15pub(crate) mod combat_ctx;
16pub(crate) mod damage;
17pub(crate) mod hook;
18
19pub(crate) use combat_ctx::{ActorView, CombatCtx, CombatCtxRequest, CombatView, HookView};
20pub(crate) use damage::DamageOutcome;
21pub(crate) use hook::{AuraOps, DamageOps};
22
23#[cfg(test)]
24mod tests;
25
26/// Provenance for a cost-free registered action dispatched from a hook.
27#[derive(Clone, Copy, Debug, PartialEq)]
28#[non_exhaustive]
29pub enum FreeActionSource {
30    /// A child action selected by one tick of a registered channel.
31    ChannelChild,
32}
33
34/// Constrained context exposing only the operations cast hooks may perform.
35#[must_use]
36pub struct HookCtx<'a> {
37    pub(crate) program: &'a CombatProgram,
38    pub(crate) instance: &'a mut CombatInstance,
39    pub(crate) sink: &'a mut TelemetrySink,
40    pub(crate) now: SimTime,
41    pub(crate) source_damage_flags: systems::DamageFlags,
42    pub(crate) source_npc_id: Option<u32>,
43    pub(crate) pet_stat_inheritance: crate::state::PetStatInheritance,
44    pub(crate) source: ActorId,
45    pub(crate) target: Option<EnemyIdx>,
46    pub(crate) effect_target: Option<ActorId>,
47    pub(crate) rng: &'a mut dyn FnMut() -> f64,
48    /// Spell or aura that invoked this hook; provenance for resource gains and telemetry.
49    driver_spell_id: u32,
50    empower_rank: u8,
51    free_action_source: Option<FreeActionSource>,
52}
53
54/// Mutable combat services borrowed by one hook invocation.
55#[must_use]
56// #t(rust_similar_structs) explicit test-support visibility mirrors the same fields without changing the production surface.
57pub struct HookCtxServices<'a> {
58    #[cfg(feature = "test-support")]
59    #[doc(hidden)]
60    pub program: &'a CombatProgram,
61    #[cfg(not(feature = "test-support"))]
62    pub(crate) program: &'a CombatProgram,
63    #[cfg(feature = "test-support")]
64    #[doc(hidden)]
65    pub instance: &'a mut CombatInstance,
66    #[cfg(not(feature = "test-support"))]
67    pub(crate) instance: &'a mut CombatInstance,
68    #[cfg(feature = "test-support")]
69    #[doc(hidden)]
70    pub sink: &'a mut TelemetrySink,
71    #[cfg(not(feature = "test-support"))]
72    pub(crate) sink: &'a mut TelemetrySink,
73    #[cfg(feature = "test-support")]
74    #[doc(hidden)]
75    pub rng: &'a mut dyn FnMut() -> f64,
76    #[cfg(not(feature = "test-support"))]
77    pub(crate) rng: &'a mut dyn FnMut() -> f64,
78}
79
80impl std::fmt::Debug for HookCtxServices<'_> {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("HookCtxServices")
83            .field("program", self.program)
84            .field("instance", self.instance)
85            .field("sink", self.sink)
86            .field("rng", &"<callback>")
87            .finish()
88    }
89}
90
91/// Coordinates and provenance for constructing a hook context.
92#[derive(Clone, Copy, Debug, PartialEq)]
93#[must_use]
94pub struct HookCtxRequest {
95    pub now: SimTime,
96    pub source: ActorId,
97    pub target: Option<EnemyIdx>,
98    source_damage_flags: systems::DamageFlags,
99    source_npc_id: Option<u32>,
100    pet_stat_inheritance: crate::state::PetStatInheritance,
101    effect_target: Option<ActorId>,
102}
103
104impl HookCtxRequest {
105    pub const fn targetless(now: SimTime) -> Self {
106        Self {
107            now,
108            source: ActorId::Player,
109            target: None,
110            source_damage_flags: systems::DamageFlags::empty(),
111            source_npc_id: None,
112            pet_stat_inheritance: crate::state::PetStatInheritance::IDENTITY,
113            effect_target: None,
114        }
115    }
116
117    pub const fn for_target(now: SimTime, target: EnemyIdx) -> Self {
118        Self {
119            now,
120            source: ActorId::Player,
121            target: Some(target),
122            source_damage_flags: systems::DamageFlags::empty(),
123            source_npc_id: None,
124            pet_stat_inheritance: crate::state::PetStatInheritance::IDENTITY,
125            effect_target: None,
126        }
127    }
128
129    pub const fn for_optional_target(now: SimTime, target: Option<EnemyIdx>) -> Self {
130        Self {
131            now,
132            source: ActorId::Player,
133            target,
134            source_damage_flags: systems::DamageFlags::empty(),
135            source_npc_id: None,
136            pet_stat_inheritance: crate::state::PetStatInheritance::IDENTITY,
137            effect_target: None,
138        }
139    }
140
141    pub const fn with_source(mut self, source: ActorId) -> Self {
142        self.source = source;
143
144        self
145    }
146
147    pub(crate) const fn with_source_damage_flags(mut self, flags: systems::DamageFlags) -> Self {
148        self.source_damage_flags = flags;
149
150        self
151    }
152
153    pub(crate) const fn with_source_npc_id(mut self, npc_id: Option<u32>) -> Self {
154        self.source_npc_id = npc_id;
155
156        self
157    }
158
159    pub(crate) const fn with_pet_stat_inheritance(
160        mut self,
161        inheritance: crate::state::PetStatInheritance,
162    ) -> Self {
163        self.pet_stat_inheritance = inheritance;
164
165        self
166    }
167
168    pub(crate) const fn with_effect_target(mut self, target: ActorId) -> Self {
169        self.effect_target = Some(target);
170
171        self
172    }
173}
174
175impl<'a> HookCtx<'a> {
176    pub fn new(services: HookCtxServices<'a>, request: HookCtxRequest) -> Self {
177        let HookCtxServices {
178            program,
179            instance,
180            sink,
181            rng,
182        } = services;
183
184        Self {
185            program,
186            instance,
187            sink,
188            now: request.now,
189            source_damage_flags: request.source_damage_flags,
190            source_npc_id: request.source_npc_id,
191            pet_stat_inheritance: request.pet_stat_inheritance,
192            source: request.source,
193            target: request.target,
194            effect_target: request.effect_target,
195            rng,
196            driver_spell_id: 0,
197            empower_rank: 0,
198            free_action_source: None,
199        }
200    }
201
202    /// Constructs a synthetic impact only after binding its generated identity to this program.
203    #[cfg(feature = "test-support")]
204    #[doc(hidden)]
205    #[must_use]
206    pub fn __test_impact_event(
207        &self,
208        action: crate::DamageActionId,
209        data: crate::ImpactEventData,
210    ) -> Option<crate::ImpactEvent> {
211        let local = self.program.damage.generated_action(action)?;
212
213        Some(crate::ImpactEvent::bound(action, local, data))
214    }
215
216    pub const fn view(&self) -> HookView<'_> {
217        HookView {
218            program: self.program,
219            instance: self.instance,
220            target: self.target,
221            now: self.now,
222        }
223    }
224
225    /// Provenance for this hook's cost-free action, if it was dispatched as one.
226    #[must_use]
227    pub const fn free_action_source(&self) -> Option<FreeActionSource> {
228        self.free_action_source
229    }
230
231    #[must_use]
232    pub const fn source(&self) -> ActorId {
233        self.source
234    }
235
236    #[must_use]
237    pub const fn target(&self) -> Option<EnemyIdx> {
238        self.target
239    }
240
241    /// One-based empower rank selected for the cast whose hook is running.
242    #[must_use]
243    pub const fn empower_rank(&self) -> Option<u8> {
244        if self.empower_rank == 0 {
245            None
246        } else {
247            Some(self.empower_rank)
248        }
249    }
250
251    /// Update this friendly actor's canonical health and fire active threshold crossings.
252    pub fn set_health_fraction(&mut self, fraction: f64) {
253        let actor = self.source;
254        let Some(before) = self
255            .instance
256            .set_friendly_actor_health_fraction(actor, fraction)
257        else {
258            return;
259        };
260        let after = self
261            .instance
262            .actor_health_fraction(self.program, actor, self.now)
263            .unwrap_or(before);
264
265        systems::process_actor_health_change(
266            self.program,
267            self.instance,
268            actor,
269            before,
270            after,
271            self.now,
272        );
273    }
274
275    /// Resolve concrete hostile identities using parent-spell geometry and impact-spell LOS rules.
276    ///
277    /// Finalized target scratch is restored after the callback, including nested resolution.
278    pub fn with_resolved_spell_effect_targets<T>(
279        &mut self,
280        geometry_effect: crate::DamageEffectId,
281        impact_action: crate::DamageActionId,
282        max_targets: Option<u8>,
283        use_targets: impl FnOnce(&mut Self, &[EnemyIdx]) -> T,
284    ) -> T {
285        let Some(anchor) = self.target else {
286            return use_targets(self, &[]);
287        };
288        let geometry_token = geometry_effect;
289        let Some((geometry_effect, planned_geometry)) =
290            self.program.damage.effect_id_plan(geometry_token)
291        else {
292            self.instance.record_runtime_error(
293                wowlab_engine_ports::SpecRuntimeError::unresolved_damage_identity(
294                    geometry_token.action().spell_id(),
295                    geometry_token.effect_index(),
296                ),
297            );
298
299            return use_targets(self, &[]);
300        };
301        let Some(impact_action) = self
302            .program
303            .damage
304            .action_id_request(impact_action)
305            .map(crate::state::program::DamageRequestBinding::effect_action)
306        else {
307            self.instance.record_runtime_error(
308                wowlab_engine_ports::SpecRuntimeError::unresolved_damage_identity(
309                    impact_action.spell_id(),
310                    0,
311                ),
312            );
313
314            return use_targets(self, &[]);
315        };
316        let Some(target) = planned_geometry.target else {
317            self.instance.record_runtime_error(
318                wowlab_engine_ports::SpecRuntimeError::unresolved_damage_identity(
319                    geometry_token.action().spell_id(),
320                    geometry_token.effect_index(),
321                ),
322            );
323
324            return use_targets(self, &[]);
325        };
326        let Some(mut workspace) = crate::targeting::take_target_workspace(self.instance) else {
327            self.instance.record_runtime_error(
328                wowlab_engine_ports::SpecRuntimeError::damage_target_resolution(
329                    geometry_token.action().spell_id(),
330                    geometry_token.effect_index(),
331                    crate::targeting::TARGET_WORKSPACE_EXHAUSTED.to_string(),
332                ),
333            );
334
335            return use_targets(self, &[]);
336        };
337
338        let resolution = crate::targeting::resolve_spell_targets_into(
339            self.program,
340            self.instance,
341            crate::targeting::SpellTargetRequest {
342                source: self.source,
343                source_position: None,
344                anchor,
345                geometry_effect,
346                impact_action,
347                target,
348                max_targets,
349                destination: None,
350            },
351            self.now,
352            Some(&mut *self.rng),
353            &mut workspace,
354        );
355
356        if let Err(error) = resolution {
357            workspace.clear();
358            self.instance.record_runtime_error(
359                wowlab_engine_ports::SpecRuntimeError::damage_target_resolution(
360                    geometry_token.action().spell_id(),
361                    geometry_token.effect_index(),
362                    error.to_string(),
363                ),
364            );
365        }
366
367        let value = use_targets(self, workspace.targets());
368
369        crate::targeting::restore_target_workspace(self.instance, workspace);
370
371        value
372    }
373
374    #[inline]
375    #[must_use]
376    pub fn program(&self) -> combat_ctx::HookProgramView<'_> {
377        combat_ctx::HookProgramView {
378            program: self.program,
379        }
380    }
381
382    /// Sum the current player's finalized buff contributions for this hook target.
383    #[inline]
384    #[must_use]
385    pub fn buff_totals(&self) -> crate::BuffTotals {
386        systems::buff_totals(self.program, self.instance, self.target)
387    }
388
389    #[inline]
390    #[must_use]
391    pub fn instance(&self) -> &CombatInstance {
392        self.instance
393    }
394
395    /// Selected rank for a decoded talent, or zero when it is not selected.
396    #[inline]
397    #[must_use]
398    pub fn talent_rank(&self, talent: crate::DamageActionId) -> u8 {
399        self.program.talent_rank(talent)
400    }
401
402    /// Whether a decoded talent is selected at any rank.
403    #[inline]
404    #[must_use]
405    pub fn talent_selected(&self, talent: crate::DamageActionId) -> bool {
406        self.talent_rank(talent) > 0
407    }
408
409    #[inline]
410    #[must_use]
411    pub fn spec_config<T>(&self) -> Option<&T>
412    where
413        T: SpecConfig,
414    {
415        self.program.spec_config::<T>()
416    }
417
418    #[inline]
419    #[must_use]
420    pub fn spec_runtime<T>(&self) -> Option<&T>
421    where
422        T: SpecRuntime,
423    {
424        self.instance.spec_runtime::<T>()
425    }
426
427    #[inline]
428    pub fn spec_runtime_mut<T>(&mut self) -> Option<&mut T>
429    where
430        T: SpecRuntime,
431    {
432        self.instance.spec_runtime_mut::<T>()
433    }
434
435    #[inline]
436    #[must_use]
437    pub fn total_damage(&self) -> f64 {
438        self.instance.runtime.total_damage
439    }
440
441    #[inline]
442    #[must_use]
443    pub fn buf(&self) -> &BoundDenseBuffer {
444        &self.instance.buffer
445    }
446
447    /// The spell or aura whose hook is running; `0` when the caller declared none.
448    #[must_use]
449    pub const fn driver_spell_id(&self) -> u32 {
450        self.driver_spell_id
451    }
452
453    /// Borrow the iteration's deterministic uniform RNG.
454    pub fn rng(&mut self) -> &mut dyn FnMut() -> f64 {
455        self.rng
456    }
457
458    pub(crate) const fn set_source(&mut self, source: ActorId) {
459        self.source = source;
460    }
461
462    pub(crate) const fn with_empower_rank(mut self, rank: u8) -> Self {
463        self.empower_rank = rank;
464
465        self
466    }
467
468    pub(crate) const fn with_free_action_source(mut self, source: FreeActionSource) -> Self {
469        self.free_action_source = Some(source);
470
471        self
472    }
473
474    /// Records the spell or aura whose hook is running, so its resource gains carry provenance.
475    pub(crate) const fn with_driver_spell(mut self, driver_spell_id: u32) -> Self {
476        self.driver_spell_id = driver_spell_id;
477
478        self
479    }
480
481    pub(crate) fn combat_ctx(&mut self, target: EnemyIdx) -> CombatCtx<'_> {
482        self.combat_ctx_with_source(self.source, target)
483    }
484
485    pub(crate) fn combat_ctx_with_source(
486        &mut self,
487        source: ActorId,
488        target: EnemyIdx,
489    ) -> CombatCtx<'_> {
490        CombatCtx {
491            program: self.program,
492            instance: self.instance,
493            sink: self.sink,
494            now: self.now,
495            rng: self.rng,
496            source,
497            target,
498        }
499    }
500
501    fn spell_identity(&self, local: crate::LocalSpellIdx) -> crate::LocalSpellIdx {
502        self.program
503            .resolve_spell_identity(local)
504            .expect("foreign specialization spell binding")
505    }
506
507    fn aura_identity(&self, local: crate::LocalAuraIdx) -> crate::LocalAuraIdx {
508        self.program
509            .resolve_aura_identity(local)
510            .expect("foreign specialization aura binding")
511    }
512
513    const fn driver_resource_gain_source(&self) -> crate::state::ResourceGainSource {
514        if self.driver_spell_id == 0 {
515            crate::state::ResourceGainSource::Unattributed
516        } else {
517            crate::state::ResourceGainSource::Spell(self.driver_spell_id)
518        }
519    }
520
521    fn schedule_pending_timer(&mut self, delay_ms: u32, pending: crate::state::PendingHookTimer) {
522        let timers = &mut self.instance.runtime.pending.pending_hook_timers;
523        let timer_id = if let Some(free) = timers.iter().position(Option::is_none) {
524            // BOUNDS: `free` comes from `position()` on this same vec.
525            timers[free] = Some(pending);
526
527            free
528        } else {
529            timers.push(Some(pending));
530            self.instance.runtime.pending.hook_timer_generations.push(0);
531
532            timers.len() - 1
533        };
534        let generation = self
535            .instance
536            .runtime
537            .pending
538            .hook_timer_generations
539            .get_mut(timer_id)
540            .expect("hook timer generation parallels its arena slot");
541
542        *generation = generation.wrapping_add(1).max(1);
543        let timer = self.program.runtime_events.hook_timer(
544            u32::try_from(timer_id).expect("timer arena length fits in u32"),
545            *generation,
546        );
547        let t = self.now.saturating_add(SimTime::from_millis(delay_ms));
548
549        self.instance.schedule(Event::HookTimer {
550            t,
551            timer,
552            source: pending.source,
553            target: pending.target,
554        });
555    }
556}
557impl std::fmt::Debug for HookCtx<'_> {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        f.debug_struct("HookCtx")
560            .field("now", &self.now)
561            .field("source_damage_flags", &self.source_damage_flags)
562            .field("source_npc_id", &self.source_npc_id)
563            .field("source", &self.source)
564            .field("target", &self.target)
565            .finish_non_exhaustive()
566    }
567}