Skip to main content

wowlab_engine_combat/state/runtime/
mod.rs

1use wowlab_engine_domain::{
2    encounter::{BecameDead, EnemyActorDefinition, EnemyState, HealthModel},
3    rotation::BoundDenseBuffer,
4};
5use wowlab_engine_ports::{
6    EnemyDespawnOutcome, EngineError, Event, SpatialActor, SpatialQuery, SpatialQueryError,
7    SpecRuntime, SpecRuntimeError,
8};
9use wowlab_engine_rng::{AccumulatedRng, ShuffledRng};
10#[cfg(test)]
11use wowlab_types::sim::SpellIdx;
12use wowlab_types::sim::{
13    ActorId, EnemyIdx, IntMap, PositionedActorRef, PullId, SimTime, SpatialTransform,
14};
15
16use super::{
17    EncounterRuntime, GuardianInstance, HealthPool, ImpactEffectProc, IncomingImmunityWindow,
18    LocalSpellIdx, PendingHookTimer, PendingSpellImpact, PetActionState, RppmTracker, RuneState,
19    ScratchBuffers, ThresholdTracker,
20};
21
22mod auras;
23mod buff_cache;
24mod config_access;
25mod dense_pools;
26mod enemies;
27mod events;
28mod health;
29mod projection;
30mod spatial;
31mod state;
32
33pub(crate) use auras::{
34    AuraActivation, AuraMutation, AuraRefresh, AuraSchedule, AuraSnapshot, AuraTransferState,
35};
36#[cfg(all(
37    feature = "rotation-optimizations",
38    any(test, feature = "test-support")
39))]
40pub(crate) use buff_cache::DamageProjectionSheetStats;
41#[cfg(feature = "rotation-optimizations")]
42pub(crate) use buff_cache::{DamageBuffProjection, DamageProjectionKey};
43pub(crate) use dense_pools::{
44    DenseAuraCooldownPool, DenseDamagePool, DenseExpiryPool, DenseRollingTickPool,
45    DenseSpellGatePool,
46};
47pub(crate) use projection::ProjectionDirty;
48pub(crate) use state::{
49    ActiveChannelIdentity, CombatRuntime, CombatRuntimeInit, ImpactEffectRngState,
50};
51#[cfg(feature = "rotation-optimizations")]
52pub(crate) use state::{
53    RotationMutationAccumulator, RotationMutationDirection, RotationSemanticMutation,
54};
55
56/// Mutable state owned by one combat handler and reset between iterations.
57#[derive(Debug)]
58// #t(rust_similar_structs) explicit test-support visibility mirrors the same fields without changing the production surface.
59pub struct CombatInstance {
60    #[cfg(feature = "test-support")]
61    #[doc(hidden)]
62    pub runtime: CombatRuntime,
63    #[cfg(not(feature = "test-support"))]
64    pub(crate) runtime: CombatRuntime,
65    #[cfg(feature = "test-support")]
66    #[doc(hidden)]
67    pub buffer: BoundDenseBuffer,
68    #[cfg(not(feature = "test-support"))]
69    pub(crate) buffer: BoundDenseBuffer,
70    spatial: SpatialQueryHandle,
71    aura_totals_revisions: auras::AuraTotalsRevisions,
72    #[cfg(feature = "rotation-optimizations")]
73    active_aura_bindings: auras::ActiveAuraBindings,
74    pub(super) buff_totals_caches: buff_cache::BuffTotalsCaches,
75    projection_dirty: ProjectionDirty,
76    projection_dirty_at: SimTime,
77    aura_projection_dirty: projection::AuraProjectionDirty,
78    #[cfg(test)]
79    projection_flush_count: u32,
80}
81
82#[derive(Debug)]
83struct SpatialQueryHandle(Box<dyn SpatialQuery>);
84
85impl std::ops::Deref for SpatialQueryHandle {
86    type Target = dyn SpatialQuery;
87
88    fn deref(&self) -> &Self::Target {
89        &*self.0
90    }
91}
92
93impl std::ops::DerefMut for SpatialQueryHandle {
94    fn deref_mut(&mut self) -> &mut Self::Target {
95        &mut *self.0
96    }
97}
98
99fn parry_hasted_deadline(base_ms: u32, current: SimTime, now: SimTime) -> Option<SimTime> {
100    const MINIMUM_REMAINING_FRACTION: f64 = 0.2;
101    const HASTE_REDUCTION_FRACTION: f64 = 0.4;
102    const FULL_REDUCTION_THRESHOLD: f64 = 0.6;
103
104    let base = SimTime::from_millis(base_ms);
105    let remaining = current.saturating_sub(now);
106    let scaled = |fraction| {
107        SimTime::from_millis(wowlab_types::numeric::f64_to_u32_saturating_ceil(
108            f64::from(base_ms) * fraction,
109        ))
110    };
111    let minimum = scaled(MINIMUM_REMAINING_FRACTION);
112    let accelerated_remaining = if remaining > scaled(FULL_REDUCTION_THRESHOLD) {
113        remaining.saturating_sub(scaled(HASTE_REDUCTION_FRACTION))
114    } else if remaining > minimum {
115        minimum
116    } else {
117        return None;
118    };
119
120    Some(now.saturating_add(accelerated_remaining.min(base)))
121}
122
123impl CombatInstance {
124    pub(crate) fn new(
125        program: &crate::state::CombatProgram,
126        runtime: CombatRuntime,
127        buffer: BoundDenseBuffer,
128    ) -> Result<Self, EngineError> {
129        if program.enemies.len() != runtime.enemies.len() {
130            return Err(EngineError::spec_construction(format!(
131                "enemy definition/runtime length mismatch: definitions={}, runtime={}",
132                program.enemies.len(),
133                runtime.enemies.len()
134            )));
135        }
136
137        for (raw_index, (definition, state)) in
138            program.enemies.iter().zip(&runtime.enemies).enumerate()
139        {
140            let index = u16::try_from(raw_index).map_err(|source| {
141                // #t(rust_alloc_in_loop) only allocates while rejecting invalid construction
142                EngineError::spec_construction(format!(
143                    "enemy index {raw_index} exceeds EnemyIdx range: {source}"
144                ))
145            })?;
146            let expected = EnemyIdx(index);
147
148            if definition.id() != expected || state.id() != expected {
149                // #t(rust_alloc_in_loop) only allocates while rejecting invalid construction
150                return Err(EngineError::spec_construction(format!(
151                    "enemy identity mismatch at dense index {raw_index}: definition={:?}, runtime={:?}",
152                    definition.id(),
153                    state.id()
154                )));
155            }
156        }
157
158        let initial_actors: Vec<_> = runtime
159            .enemies
160            .iter()
161            .filter(|enemy| enemy.is_active() && enemy.is_alive())
162            .map(|enemy| SpatialActor {
163                id: enemy.id(),
164                transform: enemy.current_transform(),
165            })
166            .collect();
167        let spatial = wowlab_engine_spatial::build_spatial_query(
168            &program.config.encounter.definition().spatial_scene,
169            &initial_actors,
170        )
171        .map_err(|error| {
172            EngineError::spec_construction(format!("failed to build spatial index: {error}"))
173        })?;
174
175        Ok(Self {
176            runtime,
177            buffer,
178            spatial: SpatialQueryHandle(Box::new(spatial)),
179            aura_totals_revisions: auras::AuraTotalsRevisions::new(program.enemies.len()),
180            #[cfg(feature = "rotation-optimizations")]
181            active_aura_bindings: auras::ActiveAuraBindings::new(
182                &program.buffer_bindings,
183                program.effects.static_total_aura_plan(),
184                program.enemies.len(),
185            ),
186            buff_totals_caches: buff_cache::BuffTotalsCaches::new(program.enemies.len()),
187            projection_dirty: ProjectionDirty::empty(),
188            projection_dirty_at: SimTime::ZERO,
189            aura_projection_dirty: projection::AuraProjectionDirty::new(
190                program.aura_dependencies.projection_count(),
191            ),
192            #[cfg(test)]
193            projection_flush_count: 0,
194        })
195    }
196
197    /// Current player critical-strike chance in percentage points.
198    #[must_use]
199    pub fn player_crit(&self) -> f64 {
200        self.buffer.player().crit
201    }
202
203    pub fn rppm_tracker_count(&self) -> usize {
204        self.runtime.procs.rppm_trackers.len()
205    }
206    pub fn rppm_tracker(&self, tracker: super::LocalRppmIdx) -> Option<&RppmTracker> {
207        self.runtime.procs.rppm_trackers.get(tracker.as_usize())
208    }
209}
210
211#[cfg(test)]
212#[path = "tests.rs"]
213mod tests;