Skip to main content

wowlab_engine_combat/state/runtime/
state.rs

1// #t(file: rust_unchecked_indexing) Actor and flattened control ordinals are exhaustively bounded by their finite identity domains.
2// #t(file: rust_magic_numbers) Actor-family offsets encode the finite player, pet, guardian, and enemy ordinal layout.
3// #t(file: rust_where_clauses) The private one-bound dense table is clearest with its bound adjacent to the type parameter.
4// #t(file: rust_multiple_inherent_impl) Value-specialized ActorTable impls intentionally expose only operations valid for each state family.
5// #t(file: rust_assoc_fn_no_self) Control ordinals are colocated with their sole flattened-table consumers.
6
7#[cfg(feature = "rotation-optimizations")]
8use wowlab_types::combat::ResourceType;
9use wowlab_types::sim::PetIdx;
10
11use super::{
12    AccumulatedRng, ActorId, DenseAuraCooldownPool, DenseDamagePool, DenseExpiryPool,
13    DenseRollingTickPool, DenseSpellGatePool, EncounterRuntime, EnemyIdx, EnemyState, Event,
14    GuardianInstance, HealthPool, ImpactEffectProc, IncomingImmunityWindow, IntMap, LocalSpellIdx,
15    PendingHookTimer, PendingSpellImpact, PetActionState, RppmTracker, RuneState, ScratchBuffers,
16    ShuffledRng, SimTime, SpatialTransform, SpecRuntime, SpecRuntimeError, ThresholdTracker,
17};
18use crate::state::{
19    ActiveCast, AttackPosition, ControlDeadline, CrowdControlKind, DiminishingGroup,
20    DiminishingState, ImpactChanceScale, MovementState, SchoolLockout,
21};
22
23#[derive(Clone, Copy, Debug)]
24pub(crate) struct ActiveChannelIdentity {
25    pub(crate) local: LocalSpellIdx,
26    pub(crate) spell: wowlab_types::sim::SpellIdx,
27    pub(crate) handle: wowlab_engine_domain::rotation::SpellSlotHandle,
28    pub(crate) tick_cost_aura: Option<crate::state::CastAuraRequirement>,
29    pub(crate) tick_damage_aura: Option<crate::state::CastAuraRequirement>,
30}
31
32/// Mutable non-buffer state that changes within a single iteration and is cleared on reset.
33#[derive(Debug)]
34#[cfg_attr(
35    not(feature = "test-support"),
36    expect(
37        unreachable_pub,
38        reason = "the type is named only by the explicit test-support capability"
39    )
40)]
41pub struct CombatRuntime {
42    pub(super) spec_runtime: Box<dyn SpecRuntime>,
43    pub(super) enemies: Vec<EnemyState>,
44    pub(crate) encounter: EncounterRuntime,
45    pub(crate) player_transform: SpatialTransform,
46    pub(crate) attack_position: AttackPosition,
47    #[cfg(feature = "test-support")]
48    #[doc(hidden)]
49    pub current_target: Option<EnemyIdx>,
50    #[cfg(not(feature = "test-support"))]
51    pub(crate) current_target: Option<EnemyIdx>,
52    pub(crate) health: RuntimeHealth,
53    pub(super) runtime_error: Option<SpecRuntimeError>,
54    pub(crate) total_damage: f64,
55    pub(crate) pending_events: Vec<Event>,
56    #[cfg(feature = "rotation-optimizations")]
57    pub(crate) rotation_mutations: RotationMutationAccumulator,
58    pub(crate) resources: RuntimeResources,
59    pub(crate) casting: RuntimeCasting,
60    pub(crate) control: RuntimeControl,
61    pub(crate) scratch: ScratchBuffers,
62    pub(crate) deferred_work: crate::state::DeferredWorkQueue,
63    pub(crate) procs: RuntimeProcs,
64    /// Last reason each spell was masked out of rotation evaluation, keyed by spell id.
65    ///
66    /// Only the discriminant is kept, so the trace fires once per change rather than per tick.
67    pub(crate) mask_reasons:
68        Option<IntMap<u32, std::mem::Discriminant<crate::handler::can_cast::CastReject>>>,
69    #[cfg(test)]
70    pub(crate) rotation_spells_scanned: usize,
71    pub(crate) recharge_rate_mults: Vec<f64>,
72    #[cfg(feature = "rotation-optimizations")]
73    pub(crate) cooldown_duration_mults: Vec<f64>,
74    #[cfg(feature = "rotation-optimizations")]
75    pub(crate) initial_recharge_rate_mults: Vec<f64>,
76    #[cfg(feature = "rotation-optimizations")]
77    pub(crate) initial_cooldown_duration_mults: Vec<f64>,
78    pub(crate) pools: RuntimePools,
79    pub(crate) pending: RuntimePending,
80    pub(crate) companions: RuntimeCompanions,
81}
82
83#[derive(Debug)]
84pub(crate) struct RuntimeHealth {
85    pub(crate) player_health: HealthPool,
86    pub(crate) pet_health: Vec<Option<HealthPool>>,
87    pub(crate) immunity_windows: Vec<IncomingImmunityWindow>,
88    pub(crate) active_absorbs: crate::state::BoundedVec<crate::state::ActiveAbsorb>,
89    pub(crate) next_absorb_application_order: u64,
90    pub(crate) enemy_swing_deadlines: Vec<Option<SimTime>>,
91    pub(crate) enemy_invulnerable_until: Vec<SimTime>,
92}
93
94#[derive(Debug)]
95pub(crate) struct RuntimeResources {
96    pub(crate) last_primary_spent: f64,
97    pub(crate) last_optional_primary_spent: f64,
98    pub(crate) last_secondary_spent: f64,
99    pub(crate) primary_base_regen_override: Option<f64>,
100    pub(crate) runes: RuneState,
101}
102
103#[derive(Debug)]
104pub(crate) struct RuntimeCasting {
105    pub(crate) selected_candidate: Option<crate::handler::can_cast::CastCandidate>,
106    pub(crate) active_player_candidate: Option<crate::handler::can_cast::CastCandidate>,
107    pub(crate) instant_cast_pending_at: Option<SimTime>,
108    pub(crate) instant_off_gcd_casts_at: Option<SimTime>,
109    pub(crate) instant_off_gcd_spells: LocalSpellSet,
110    pub(crate) active_channel_target: Option<EnemyIdx>,
111    pub(crate) channel_generation: u64,
112    pub(crate) active_channel_damage_mult: f64,
113    pub(crate) active_channel: Option<ActiveChannelIdentity>,
114    pub(crate) active_channel_last_tick: SimTime,
115    pub(crate) active_channel_tick_interval_ms: u32,
116    pub(crate) active_channel_ticks_remaining: u8,
117    pub(crate) active_channel_on_last_tick: bool,
118    pub(crate) active_channel_pushback_count: u8,
119    pub(crate) free_action_dispatch_depth: u8,
120    pub(crate) channel_chain_spell: Option<LocalSpellIdx>,
121    pub(crate) active_casts: ActorTable<Option<ActiveCast>>,
122}
123
124#[derive(Debug, Default)]
125pub(crate) struct LocalSpellSet {
126    words: [u64; 4],
127}
128
129impl LocalSpellSet {
130    pub(crate) fn clear(&mut self) {
131        self.words.fill(0);
132    }
133
134    pub(crate) fn contains(&self, spell: LocalSpellIdx) -> bool {
135        let index = spell.as_usize();
136
137        self.words[index / u64::BITS as usize] & (1_u64 << (index % u64::BITS as usize)) != 0
138    }
139
140    pub(crate) fn insert(&mut self, spell: LocalSpellIdx) {
141        let index = spell.as_usize();
142
143        self.words[index / u64::BITS as usize] |= 1_u64 << (index % u64::BITS as usize);
144    }
145
146    #[cfg(feature = "rotation-optimizations")]
147    pub(super) fn is_empty(&self) -> bool {
148        self.words.iter().all(|word| *word == 0)
149    }
150}
151
152/// Direction in which an observed value crossed a rotation-relevant boundary.
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154#[cfg(feature = "rotation-optimizations")]
155pub(crate) enum RotationMutationDirection {
156    Rise,
157    Fall,
158}
159
160/// Coarse finalized state family observed by authored rotation conditions.
161#[derive(Clone, Copy, Debug, Eq, PartialEq)]
162#[cfg(feature = "rotation-optimizations")]
163pub(crate) enum RotationSemanticMutation {
164    AuraState,
165    TargetHealth,
166    Spatial,
167    EncounterPopulation,
168    PlayerHealth,
169    ActorState,
170    SpellState,
171    EquipmentState,
172    HistoryState,
173    AuraTiming,
174    TargetIdentity,
175}
176
177#[cfg(feature = "rotation-optimizations")]
178impl RotationSemanticMutation {
179    const fn bit(self) -> u16 {
180        match self {
181            Self::AuraState => 1 << 0,
182            Self::TargetHealth => 1 << 1,
183            Self::Spatial => 1 << 2,
184            Self::EncounterPopulation => 1 << 3,
185            Self::PlayerHealth => 1 << 5,
186            Self::ActorState => 1 << 6,
187            Self::SpellState => 1 << 7,
188            Self::EquipmentState => 1 << 8,
189            Self::HistoryState => 1 << 9,
190            Self::AuraTiming => 1 << 10,
191            Self::TargetIdentity => 1 << 11,
192        }
193    }
194}
195
196/// Allocation-free accumulator for mutations produced by one or more event callbacks.
197#[derive(Debug, Default)]
198#[cfg(feature = "rotation-optimizations")]
199pub(crate) struct RotationMutationAccumulator {
200    cooldown_rises: LocalSpellSet,
201    cooldown_falls: LocalSpellSet,
202    resource_rises: u32,
203    resource_falls: u32,
204    semantic_rises: u16,
205    semantic_falls: u16,
206    at: SimTime,
207}
208
209#[cfg(feature = "rotation-optimizations")]
210#[cfg_attr(
211    not(test),
212    expect(
213        dead_code,
214        reason = "the accumulator API is consumed by the gated event-wake integration"
215    )
216)]
217impl RotationMutationAccumulator {
218    pub(crate) fn record_cooldown(
219        &mut self,
220        spell: LocalSpellIdx,
221        direction: RotationMutationDirection,
222        at: SimTime,
223    ) {
224        match direction {
225            RotationMutationDirection::Rise => self.cooldown_rises.insert(spell),
226            RotationMutationDirection::Fall => self.cooldown_falls.insert(spell),
227        }
228
229        self.record_time(at);
230    }
231
232    pub(crate) fn record_resource(
233        &mut self,
234        resource: ResourceType,
235        direction: RotationMutationDirection,
236        at: SimTime,
237    ) {
238        let bit = 1_u32 << u8::from(resource);
239
240        match direction {
241            RotationMutationDirection::Rise => self.resource_rises |= bit,
242            RotationMutationDirection::Fall => self.resource_falls |= bit,
243        }
244
245        self.record_time(at);
246    }
247
248    pub(crate) fn contains_cooldown(
249        &self,
250        spell: LocalSpellIdx,
251        direction: RotationMutationDirection,
252    ) -> bool {
253        match direction {
254            RotationMutationDirection::Rise => self.cooldown_rises.contains(spell),
255            RotationMutationDirection::Fall => self.cooldown_falls.contains(spell),
256        }
257    }
258
259    pub(crate) fn contains_resource(
260        &self,
261        resource: ResourceType,
262        direction: RotationMutationDirection,
263    ) -> bool {
264        let bit = 1_u32 << u8::from(resource);
265
266        match direction {
267            RotationMutationDirection::Rise => self.resource_rises & bit != 0,
268            RotationMutationDirection::Fall => self.resource_falls & bit != 0,
269        }
270    }
271
272    pub(crate) fn record_semantic(
273        &mut self,
274        mutation: RotationSemanticMutation,
275        direction: RotationMutationDirection,
276        at: SimTime,
277    ) {
278        match direction {
279            RotationMutationDirection::Rise => self.semantic_rises |= mutation.bit(),
280            RotationMutationDirection::Fall => self.semantic_falls |= mutation.bit(),
281        }
282
283        self.record_time(at);
284    }
285
286    pub(crate) const fn contains_semantic(
287        &self,
288        mutation: RotationSemanticMutation,
289        direction: RotationMutationDirection,
290    ) -> bool {
291        let mutations = match direction {
292            RotationMutationDirection::Rise => self.semantic_rises,
293            RotationMutationDirection::Fall => self.semantic_falls,
294        };
295
296        mutations & mutation.bit() != 0
297    }
298
299    pub(crate) const fn at(&self) -> SimTime {
300        self.at
301    }
302
303    pub(crate) const fn cooldown_rises(&self) -> &[u64; 4] {
304        &self.cooldown_rises.words
305    }
306
307    pub(crate) const fn cooldown_falls(&self) -> &[u64; 4] {
308        &self.cooldown_falls.words
309    }
310
311    pub(crate) const fn resource_rises(&self) -> u32 {
312        self.resource_rises
313    }
314
315    pub(crate) const fn resource_falls(&self) -> u32 {
316        self.resource_falls
317    }
318
319    pub(crate) const fn semantic_rises(&self) -> u16 {
320        self.semantic_rises
321    }
322
323    pub(crate) const fn semantic_falls(&self) -> u16 {
324        self.semantic_falls
325    }
326
327    pub(crate) fn take(&mut self) -> Option<Self> {
328        if self.is_empty() {
329            return None;
330        }
331
332        Some(std::mem::take(self))
333    }
334
335    pub(crate) fn clear(&mut self) {
336        *self = Self::default();
337    }
338
339    fn record_time(&mut self, at: SimTime) {
340        self.at = self.at.max(at);
341    }
342
343    fn is_empty(&self) -> bool {
344        self.cooldown_rises.is_empty()
345            && self.cooldown_falls.is_empty()
346            && self.resource_rises == 0
347            && self.resource_falls == 0
348            && self.semantic_rises == 0
349            && self.semantic_falls == 0
350    }
351}
352
353#[cfg(all(test, feature = "rotation-optimizations"))]
354mod rotation_mutation_tests {
355    use googletest::prelude::*;
356
357    use super::*;
358
359    const FIRST: LocalSpellIdx = LocalSpellIdx::new(1);
360    const SECOND: LocalSpellIdx = LocalSpellIdx::new(65);
361
362    #[gtest]
363    fn repeated_mutations_coalesce_by_identity_and_direction() -> Result<()> {
364        let mut mutations = RotationMutationAccumulator::default();
365
366        mutations.record_cooldown(
367            FIRST,
368            RotationMutationDirection::Rise,
369            SimTime::from_millis(10),
370        );
371        mutations.record_cooldown(
372            FIRST,
373            RotationMutationDirection::Rise,
374            SimTime::from_millis(10),
375        );
376        mutations.record_cooldown(
377            SECOND,
378            RotationMutationDirection::Fall,
379            SimTime::from_millis(10),
380        );
381        mutations.record_resource(
382            ResourceType::Energy,
383            RotationMutationDirection::Rise,
384            SimTime::from_millis(10),
385        );
386        mutations.record_resource(
387            ResourceType::Energy,
388            RotationMutationDirection::Fall,
389            SimTime::from_millis(10),
390        );
391        mutations.record_semantic(
392            RotationSemanticMutation::AuraState,
393            RotationMutationDirection::Rise,
394            SimTime::from_millis(10),
395        );
396
397        verify_that!(
398            mutations.contains_cooldown(FIRST, RotationMutationDirection::Rise),
399            is_true()
400        )?;
401        verify_that!(
402            mutations.contains_cooldown(FIRST, RotationMutationDirection::Fall),
403            is_false()
404        )?;
405        verify_that!(
406            mutations.contains_cooldown(SECOND, RotationMutationDirection::Fall),
407            is_true()
408        )?;
409        verify_that!(
410            mutations.contains_resource(ResourceType::Energy, RotationMutationDirection::Rise),
411            is_true()
412        )?;
413
414        verify_that!(
415            mutations.contains_resource(ResourceType::Energy, RotationMutationDirection::Fall),
416            is_true()
417        )?;
418        verify_that!(
419            mutations.contains_semantic(
420                RotationSemanticMutation::AuraState,
421                RotationMutationDirection::Rise,
422            ),
423            is_true()
424        )?;
425
426        verify_that!(
427            mutations.contains_semantic(
428                RotationSemanticMutation::AuraState,
429                RotationMutationDirection::Fall,
430            ),
431            is_false()
432        )
433    }
434
435    #[gtest]
436    fn mutation_time_is_the_latest_recorded_time() -> Result<()> {
437        let mut mutations = RotationMutationAccumulator::default();
438
439        for at in [90, 40, 120, 110] {
440            mutations.record_resource(
441                ResourceType::ComboPoints,
442                RotationMutationDirection::Rise,
443                SimTime::from_millis(at),
444            );
445        }
446
447        verify_that!(mutations.at(), eq(SimTime::from_millis(120)))
448    }
449
450    #[gtest]
451    fn take_and_clear_restore_the_empty_state() -> Result<()> {
452        let mut mutations = RotationMutationAccumulator::default();
453
454        mutations.record_cooldown(
455            FIRST,
456            RotationMutationDirection::Rise,
457            SimTime::from_millis(15),
458        );
459        let taken = mutations.take().or_fail()?;
460
461        verify_that!(
462            taken.contains_cooldown(FIRST, RotationMutationDirection::Rise),
463            is_true()
464        )?;
465        verify_that!(mutations.take(), none())?;
466        verify_that!(mutations.at(), eq(SimTime::ZERO))?;
467
468        mutations.record_resource(
469            ResourceType::Mana,
470            RotationMutationDirection::Fall,
471            SimTime::from_millis(20),
472        );
473        mutations.clear();
474
475        verify_that!(mutations.take(), none())?;
476
477        verify_that!(mutations.at(), eq(SimTime::ZERO))
478    }
479
480    #[gtest]
481    fn record_take_and_clear_allocate_nothing() -> Result<()> {
482        let mut mutations = RotationMutationAccumulator::default();
483        let (_, allocations) = crate::test_alloc::allocations_during(|| {
484            mutations.record_cooldown(
485                LocalSpellIdx::new(u8::MAX),
486                RotationMutationDirection::Fall,
487                SimTime::from_millis(50),
488            );
489            mutations.record_resource(
490                ResourceType::AlternateMount,
491                RotationMutationDirection::Rise,
492                SimTime::from_millis(60),
493            );
494            mutations.record_semantic(
495                RotationSemanticMutation::EncounterPopulation,
496                RotationMutationDirection::Rise,
497                SimTime::from_millis(70),
498            );
499            let _ = mutations.take();
500
501            mutations.clear();
502        });
503
504        verify_that!(allocations.count, eq(0))?;
505
506        verify_that!(allocations.bytes, eq(0))
507    }
508}
509
510#[derive(Debug)]
511pub(crate) struct RuntimeControl {
512    pub(crate) school_lockouts: ActorTable<Option<SchoolLockout>>,
513    pub(crate) movement: ActorTable<MovementState>,
514    pub(crate) movement_generation: ActorTable<u64>,
515    pub(crate) crowd_control: CrowdControlTable,
516    pub(crate) diminishing_returns: DiminishingTable,
517}
518
519// #t(rust_magic_numbers) two non-indexed actor families precede pet and enemy ordinals.
520const ACTOR_TABLE_LEN: usize = 2 + PetIdx::MAX + EnemyIdx::MAX;
521
522const fn actor_ordinal(actor: ActorId) -> usize {
523    match actor {
524        ActorId::Player => 0,
525        ActorId::External => 1,
526        ActorId::Pet(pet) => 2 + pet.as_usize(),
527        ActorId::Enemy(enemy) => 2 + PetIdx::MAX + enemy.as_usize(),
528    }
529}
530
531#[derive(Debug)]
532pub(crate) struct ActorTable<T> {
533    values: Box<[T]>,
534    reset: T,
535}
536
537// #t(rust_where_clauses) the private one-bound dense table is clearest inline.
538// #t(rust_multiple_inherent_impl) specialized tables expose only valid operations for their value family.
539impl<T: Clone> ActorTable<T> {
540    fn new(reset: T) -> Self {
541        Self {
542            values: vec![reset.clone(); ACTOR_TABLE_LEN].into_boxed_slice(),
543            reset,
544        }
545    }
546
547    pub(crate) fn clear(&mut self) {
548        self.values.fill(self.reset.clone());
549    }
550}
551
552impl<T> ActorTable<Option<T>> {
553    pub(crate) fn contains_key(&self, actor: ActorId) -> bool {
554        self.get(actor).is_some()
555    }
556    pub(crate) fn get(&self, actor: ActorId) -> Option<&T> {
557        self.values.get(actor_ordinal(actor))?.as_ref()
558    }
559
560    pub(crate) fn get_mut(&mut self, actor: ActorId) -> Option<&mut T> {
561        self.values.get_mut(actor_ordinal(actor))?.as_mut()
562    }
563
564    pub(crate) fn insert(&mut self, actor: ActorId, value: T) -> Option<T> {
565        self.values
566            .get_mut(actor_ordinal(actor))
567            .and_then(|slot| slot.replace(value))
568    }
569
570    pub(crate) fn remove(&mut self, actor: ActorId) -> Option<T> {
571        self.values.get_mut(actor_ordinal(actor))?.take()
572    }
573}
574
575// #t(rust_unchecked_indexing) actor_ordinal bounds every representable ActorId.
576impl ActorTable<MovementState> {
577    pub(crate) fn value(&self, actor: ActorId) -> MovementState {
578        self.values[actor_ordinal(actor)]
579    }
580
581    pub(crate) fn set(&mut self, actor: ActorId, value: MovementState) {
582        self.values[actor_ordinal(actor)] = value;
583    }
584}
585
586// #t(rust_unchecked_indexing) actor_ordinal bounds every representable ActorId.
587impl ActorTable<u64> {
588    pub(crate) fn value(&self, actor: ActorId) -> u64 {
589        self.values[actor_ordinal(actor)]
590    }
591
592    pub(crate) fn value_mut(&mut self, actor: ActorId) -> &mut u64 {
593        &mut self.values[actor_ordinal(actor)]
594    }
595}
596
597#[derive(Debug)]
598pub(crate) struct CrowdControlTable {
599    values: Box<[ControlDeadline]>,
600}
601
602// #t(rust_assoc_fn_no_self) ordinal is colocated with its sole flattened table consumer.
603// #t(rust_unchecked_indexing) flattened coordinates are bounded by actor and control-kind counts.
604impl CrowdControlTable {
605    fn new() -> Self {
606        Self {
607            values: vec![ControlDeadline::default(); ACTOR_TABLE_LEN * CrowdControlKind::COUNT]
608                .into_boxed_slice(),
609        }
610    }
611
612    pub(crate) fn get(&self, key: (ActorId, CrowdControlKind)) -> Option<&ControlDeadline> {
613        self.values.get(Self::ordinal(key))
614    }
615
616    pub(crate) fn insert(
617        &mut self,
618        key: (ActorId, CrowdControlKind),
619        value: ControlDeadline,
620    ) -> Option<ControlDeadline> {
621        self.values
622            .get_mut(Self::ordinal(key))
623            .map(|slot| std::mem::replace(slot, value))
624    }
625
626    pub(crate) fn remove(&mut self, key: (ActorId, CrowdControlKind)) -> Option<ControlDeadline> {
627        self.values.get_mut(Self::ordinal(key)).map(std::mem::take)
628    }
629
630    pub(crate) fn clear(&mut self) {
631        self.values.fill(ControlDeadline::default());
632    }
633
634    fn ordinal(key: (ActorId, CrowdControlKind)) -> usize {
635        actor_ordinal(key.0) * CrowdControlKind::COUNT + key.1.ordinal()
636    }
637}
638
639#[derive(Debug)]
640pub(crate) struct DiminishingTable {
641    values: Box<[DiminishingState]>,
642}
643
644// #t(rust_assoc_fn_no_self) ordinal is colocated with its sole flattened table consumer.
645// #t(rust_unchecked_indexing) flattened coordinates are bounded by actor and diminishing-group counts.
646impl DiminishingTable {
647    fn new() -> Self {
648        Self {
649            values: vec![DiminishingState::default(); ACTOR_TABLE_LEN * DiminishingGroup::COUNT]
650                .into_boxed_slice(),
651        }
652    }
653
654    #[cfg(test)]
655    pub(crate) fn get(&self, key: (ActorId, DiminishingGroup)) -> Option<&DiminishingState> {
656        self.values.get(Self::ordinal(key))
657    }
658
659    pub(crate) fn get_mut(
660        &mut self,
661        key: (ActorId, DiminishingGroup),
662    ) -> Option<&mut DiminishingState> {
663        self.values.get_mut(Self::ordinal(key))
664    }
665
666    pub(crate) fn state_mut(
667        &mut self,
668        actor: ActorId,
669        group: DiminishingGroup,
670    ) -> &mut DiminishingState {
671        &mut self.values[Self::ordinal((actor, group))]
672    }
673
674    pub(crate) fn clear(&mut self) {
675        self.values.fill(DiminishingState::default());
676    }
677
678    fn ordinal(key: (ActorId, DiminishingGroup)) -> usize {
679        actor_ordinal(key.0) * DiminishingGroup::COUNT + key.1.ordinal()
680    }
681}
682
683#[derive(Debug)]
684#[expect(
685    clippy::struct_excessive_bools,
686    reason = "independent proc-family reentrancy guards prevent recursive dispatch per family"
687)]
688pub(crate) struct RuntimeProcs {
689    pub(crate) player_cast_hooks_dispatching: bool,
690    pub(crate) impact_procs_dispatching: bool,
691    pub(crate) landed_impact_procs_dispatching: bool,
692    pub(crate) accumulating_impact_procs_dispatching: bool,
693    pub(crate) impact_effect_procs_dispatching: bool,
694    pub(crate) resource_gain_procs_dispatching: bool,
695    pub(crate) rppm_trackers: Vec<RppmTracker>,
696    pub(crate) threshold_trackers: Vec<ThresholdTracker>,
697    pub(crate) accumulating_impact_accumulators: Vec<f64>,
698    pub(crate) resource_gain_attempts: Vec<u32>,
699    pub(crate) impact_effect_ready_at: Vec<f64>,
700    pub(crate) impact_effect_target_ready_at: Vec<SimTime>,
701    impact_effect_target_count: usize,
702    pub(crate) impact_effect_charges: Vec<u8>,
703    pub(crate) impact_effect_rng: Vec<ImpactEffectRngState>,
704    pub(crate) system_rppm_ready_at: Vec<SimTime>,
705    pub(crate) proc_category_ready_at_dense: Vec<SimTime>,
706    pub(crate) proc_counters: Vec<u32>,
707    pub(crate) proc_heartbeat_scheduled_at: Option<SimTime>,
708}
709
710#[derive(Debug)]
711pub(crate) struct RuntimePools {
712    pub(crate) residual_damage_pools: DenseDamagePool,
713    pub(crate) accumulated_damage_pools: DenseDamagePool,
714    pub(crate) rolling_tick_mult: DenseRollingTickPool,
715    pub(crate) async_stack_expiries: DenseExpiryPool,
716    pub(crate) overkill_absorb_ready_at: DenseAuraCooldownPool,
717    pub(crate) cooldown_ready_at: DenseCooldownPool,
718    pub(crate) start_recovery_ready_at: DenseCooldownPool,
719    pub(crate) cooldown_category_ready_at: DenseCooldownPool,
720    pub(crate) charge_category_ready_at: DenseCooldownPool,
721    pub(crate) spell_gates: DenseSpellGatePool,
722}
723
724impl RuntimePools {
725    pub(crate) fn resize_finalized(&mut self, aura_count: usize, spell_count: usize) {
726        self.residual_damage_pools.resize(aura_count);
727        self.accumulated_damage_pools.resize(aura_count);
728        self.rolling_tick_mult.resize(aura_count);
729        self.async_stack_expiries.resize(aura_count);
730        self.overkill_absorb_ready_at.resize(aura_count);
731        self.spell_gates.resize(spell_count);
732    }
733}
734
735impl RuntimeProcs {
736    pub(crate) fn resize_target_icds(&mut self, proc_count: usize, target_count: usize) {
737        self.impact_effect_target_count = target_count;
738        self.impact_effect_target_ready_at
739            .resize(proc_count.saturating_mul(target_count), SimTime::ZERO);
740        self.impact_effect_target_ready_at.fill(SimTime::ZERO);
741    }
742
743    pub(crate) fn resize_target_icds_for_known_targets(&mut self, proc_count: usize) {
744        self.resize_target_icds(proc_count, self.impact_effect_target_count);
745    }
746
747    pub(crate) fn target_icd(&self, proc_index: usize, target: EnemyIdx) -> Option<SimTime> {
748        let index = proc_index
749            .checked_mul(self.impact_effect_target_count)?
750            .checked_add(target.as_usize())?;
751
752        self.impact_effect_target_ready_at.get(index).copied()
753    }
754
755    pub(crate) fn set_target_icd(
756        &mut self,
757        proc_index: usize,
758        target: EnemyIdx,
759        ready_at: SimTime,
760    ) {
761        let Some(index) = proc_index
762            .checked_mul(self.impact_effect_target_count)
763            .and_then(|base| base.checked_add(target.as_usize()))
764        else {
765            return;
766        };
767
768        if let Some(slot) = self.impact_effect_target_ready_at.get_mut(index) {
769            *slot = ready_at;
770        }
771    }
772}
773
774#[derive(Debug, Default)]
775pub(crate) struct DenseCooldownPool {
776    ready_at: Vec<SimTime>,
777}
778
779impl DenseCooldownPool {
780    pub(crate) fn resize(&mut self, len: usize) {
781        self.ready_at.resize(len, SimTime::ZERO);
782    }
783
784    pub(crate) fn ready_at(&self, local: LocalSpellIdx) -> SimTime {
785        self.ready_at
786            .get(local.as_usize())
787            .copied()
788            .unwrap_or(SimTime::ZERO)
789    }
790
791    pub(crate) fn is_ready(&self, local: LocalSpellIdx, now: SimTime) -> bool {
792        self.ready_at(local) <= now
793    }
794
795    pub(crate) fn start(&mut self, local: LocalSpellIdx, ready_at: SimTime) -> SimTime {
796        if let Some(slot) = self.ready_at.get_mut(local.as_usize()) {
797            *slot = ready_at;
798        }
799
800        ready_at
801    }
802
803    pub(crate) fn start_for(
804        &mut self,
805        local: LocalSpellIdx,
806        now: SimTime,
807        duration: SimTime,
808    ) -> SimTime {
809        self.start(local, now.saturating_add(duration))
810    }
811
812    pub(crate) fn reduce(
813        &mut self,
814        local: LocalSpellIdx,
815        amount: SimTime,
816        floor: SimTime,
817    ) -> SimTime {
818        let reduced = self.ready_at(local).saturating_sub(amount).max(floor);
819
820        self.start(local, reduced)
821    }
822
823    pub(crate) fn reset(&mut self, local: LocalSpellIdx) {
824        self.start(local, SimTime::ZERO);
825    }
826
827    pub(crate) fn clear(&mut self) {
828        self.ready_at.fill(SimTime::ZERO);
829    }
830}
831
832#[derive(Debug)]
833pub(crate) struct RuntimePending {
834    pub(crate) pending_hook_timers: Vec<Option<PendingHookTimer>>,
835    pub(crate) hook_timer_generations: Vec<u32>,
836    pub(crate) pending_spell_impacts: Vec<Option<PendingSpellImpact>>,
837    pub(crate) spell_impact_generations: Vec<u32>,
838}
839
840#[derive(Debug)]
841pub(crate) struct RuntimeCompanions {
842    pub(crate) guardians: Vec<Option<GuardianInstance>>,
843    pub(crate) guardian_generations: Vec<u32>,
844    pub(crate) active_guardian_slots: Vec<u16>,
845    pub(crate) free_guardian_slots: Vec<u16>,
846    pub(crate) pet_actions: Vec<Option<PetActionState>>,
847}
848
849impl CombatRuntime {
850    pub(crate) fn new(enemies: Vec<EnemyState>, init: CombatRuntimeInit) -> Self {
851        let enemy_count = enemies.len();
852        let CombatRuntimeInit {
853            encounter,
854            preferred_target,
855            player_transform,
856            player_max_health,
857            has_pet,
858        } = init;
859        let current_target = enemies
860            .get(preferred_target.as_usize())
861            .filter(|enemy| enemy.id() == preferred_target && enemy.is_active() && enemy.is_alive())
862            .map(EnemyState::id)
863            .or_else(|| {
864                enemies
865                    .iter()
866                    .find(|enemy| enemy.is_active() && enemy.is_alive())
867                    .map(EnemyState::id)
868            });
869
870        Self {
871            spec_runtime: Box::new(()),
872            enemies,
873            encounter,
874            player_transform,
875            attack_position: AttackPosition::Behind,
876            current_target,
877            health: RuntimeHealth {
878                player_health: HealthPool::full(player_max_health),
879                pet_health: companion_health_slots(has_pet, player_max_health),
880                immunity_windows: Vec::new(),
881                active_absorbs: crate::state::BoundedVec::default(),
882                next_absorb_application_order: 0,
883                enemy_swing_deadlines: vec![None; enemy_count],
884                enemy_invulnerable_until: vec![SimTime::ZERO; enemy_count],
885            },
886            runtime_error: None,
887            total_damage: 0.0,
888            pending_events: Vec::new(),
889            #[cfg(feature = "rotation-optimizations")]
890            rotation_mutations: RotationMutationAccumulator::default(),
891            resources: RuntimeResources {
892                last_primary_spent: 0.0,
893                last_optional_primary_spent: 0.0,
894                last_secondary_spent: 0.0,
895                primary_base_regen_override: None,
896                runes: RuneState::default(),
897            },
898            casting: RuntimeCasting {
899                selected_candidate: None,
900                active_player_candidate: None,
901                instant_cast_pending_at: None,
902                instant_off_gcd_casts_at: None,
903                instant_off_gcd_spells: LocalSpellSet::default(),
904                active_channel_target: None,
905                channel_generation: 0,
906                active_channel_damage_mult: 1.0,
907                active_channel: None,
908                active_channel_last_tick: SimTime::ZERO,
909                active_channel_tick_interval_ms: 0,
910                active_channel_ticks_remaining: 0,
911                active_channel_on_last_tick: false,
912                active_channel_pushback_count: 0,
913                free_action_dispatch_depth: 0,
914                channel_chain_spell: None,
915                active_casts: ActorTable::new(None),
916            },
917            control: RuntimeControl {
918                school_lockouts: ActorTable::new(None),
919                movement: ActorTable::new(MovementState::Stationary),
920                movement_generation: ActorTable::new(0),
921                crowd_control: CrowdControlTable::new(),
922                diminishing_returns: DiminishingTable::new(),
923            },
924            scratch: ScratchBuffers::default(),
925            deferred_work: crate::state::DeferredWorkQueue::default(),
926            procs: RuntimeProcs {
927                player_cast_hooks_dispatching: false,
928                impact_procs_dispatching: false,
929                landed_impact_procs_dispatching: false,
930                accumulating_impact_procs_dispatching: false,
931                impact_effect_procs_dispatching: false,
932                resource_gain_procs_dispatching: false,
933                rppm_trackers: Vec::new(),
934                threshold_trackers: Vec::new(),
935                accumulating_impact_accumulators: Vec::new(),
936                resource_gain_attempts: Vec::new(),
937                impact_effect_ready_at: Vec::new(),
938                impact_effect_target_ready_at: Vec::new(),
939                impact_effect_target_count: enemy_count,
940                impact_effect_charges: Vec::new(),
941                impact_effect_rng: Vec::new(),
942                system_rppm_ready_at: Vec::new(),
943                proc_category_ready_at_dense: Vec::new(),
944                proc_counters: vec![0; crate::generated_identities::proc_counter::COUNT],
945                proc_heartbeat_scheduled_at: None,
946            },
947            mask_reasons: None,
948            #[cfg(test)]
949            rotation_spells_scanned: 0,
950            recharge_rate_mults: Vec::new(),
951            #[cfg(feature = "rotation-optimizations")]
952            cooldown_duration_mults: Vec::new(),
953            #[cfg(feature = "rotation-optimizations")]
954            initial_recharge_rate_mults: Vec::new(),
955            #[cfg(feature = "rotation-optimizations")]
956            initial_cooldown_duration_mults: Vec::new(),
957            pools: RuntimePools {
958                residual_damage_pools: DenseDamagePool::default(),
959                accumulated_damage_pools: DenseDamagePool::default(),
960                rolling_tick_mult: DenseRollingTickPool::default(),
961                async_stack_expiries: DenseExpiryPool::default(),
962                overkill_absorb_ready_at: DenseAuraCooldownPool::default(),
963                cooldown_ready_at: DenseCooldownPool::default(),
964                start_recovery_ready_at: DenseCooldownPool::default(),
965                cooldown_category_ready_at: DenseCooldownPool::default(),
966                charge_category_ready_at: DenseCooldownPool::default(),
967                spell_gates: DenseSpellGatePool::default(),
968            },
969            pending: RuntimePending {
970                pending_hook_timers: Vec::new(),
971                hook_timer_generations: Vec::new(),
972                pending_spell_impacts: Vec::new(),
973                spell_impact_generations: Vec::new(),
974            },
975            companions: RuntimeCompanions {
976                guardians: vec![None; PetIdx::MAX - 1],
977                guardian_generations: vec![0; PetIdx::MAX - 1],
978                active_guardian_slots: Vec::with_capacity(PetIdx::MAX - 1),
979                free_guardian_slots: (0..u16::try_from(PetIdx::MAX - 1)
980                    .expect("guardian arena fits u16"))
981                    .rev()
982                    .collect(),
983                pet_actions: Vec::new(),
984            },
985        }
986    }
987
988    pub(crate) fn set_spec_runtime(&mut self, runtime: Box<dyn SpecRuntime>) {
989        self.spec_runtime = runtime;
990    }
991
992    #[inline]
993    pub(crate) fn reset_spec_runtime(&mut self) {
994        self.spec_runtime.reset();
995    }
996}
997
998fn companion_health_slots(has_pet: bool, player_max_health: f64) -> Vec<Option<HealthPool>> {
999    let mut health = vec![None; PetIdx::MAX];
1000
1001    if has_pet && let Some(primary) = health.get_mut(PetIdx::PRIMARY.as_usize()) {
1002        *primary = Some(HealthPool::full(player_max_health));
1003    }
1004
1005    health
1006}
1007
1008#[derive(Debug)]
1009pub(crate) enum ImpactEffectRngState {
1010    Stateless,
1011    Shuffled(ShuffledRng),
1012    Accumulated(AccumulatedRng),
1013}
1014
1015impl ImpactEffectRngState {
1016    pub(crate) fn from_proc(proc: &ImpactEffectProc) -> Self {
1017        match proc.chance_scale {
1018            ImpactChanceScale::Shuffled {
1019                success_entries,
1020                total_entries,
1021            } => Self::Shuffled(ShuffledRng::from_valid_counts(
1022                success_entries,
1023                total_entries,
1024            )),
1025            ImpactChanceScale::Accumulated { cap, initial_count } => {
1026                Self::Accumulated(AccumulatedRng::new(proc.chance, cap, initial_count))
1027            }
1028            _ => Self::Stateless,
1029        }
1030    }
1031
1032    pub(crate) fn reset(&mut self) {
1033        match self {
1034            Self::Stateless => {}
1035            Self::Shuffled(tracker) => tracker.reset(),
1036            Self::Accumulated(tracker) => tracker.reset(),
1037        }
1038    }
1039}
1040
1041pub(crate) struct CombatRuntimeInit {
1042    pub(crate) encounter: EncounterRuntime,
1043    pub(crate) preferred_target: EnemyIdx,
1044    pub(crate) player_transform: SpatialTransform,
1045    pub(crate) player_max_health: f64,
1046    pub(crate) has_pet: bool,
1047}