Skip to main content

wowlab_engine_combat/state/program/
damage.rs

1// #t(file: rust_inline_test_module_size) private dense-index invariants require direct access to finalized plan internals.
2
3//! Finalized damage, affect-list, and `AoE` execution metadata.
4
5use wowlab_engine_domain::{
6    dbc::{MechanicMask, ResolvedGameDataSemanticExt as _, SpellSchoolMask},
7    targeting::{
8        ExecutableTargetPlan, TargetPlanInput, compile_target_plan, lower_target_plan,
9        registered_target_geometry_overlay,
10    },
11};
12use wowlab_engine_gamedata::{AoeProperties, PassiveSpellModifiers};
13use wowlab_types::sim::{EffectRef, SpellIdx};
14
15use super::{EffectPlan, ProgramConfig};
16use crate::state::{
17    AuraData, DriverSpellModifier, EffectFamily, LandedImpactProc, PassiveDriverEffect,
18    ResourcePool, SpellData, SpellEffectData,
19};
20
21/// Generated/content-local coordinate for one finalized damage action.
22#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
23pub struct DamageActionId {
24    brand: u32,
25    spell_id: u32,
26}
27
28impl DamageActionId {
29    pub(crate) const fn new(brand: u32, spell_id: u32) -> Self {
30        Self { brand, spell_id }
31    }
32
33    pub(crate) const fn spell_id(self) -> u32 {
34        self.spell_id
35    }
36}
37
38/// Generated/content-local coordinate for one finalized damage effect.
39#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
40pub struct DamageEffectId {
41    action: DamageActionId,
42    effect_index: u8,
43}
44
45impl DamageEffectId {
46    pub(crate) const fn new(action: DamageActionId, effect_index: u8) -> Self {
47        Self {
48            action,
49            effect_index,
50        }
51    }
52
53    /// Returns the opaque branded action that owns this generated effect coordinate.
54    #[must_use]
55    pub const fn action(self) -> DamageActionId {
56        self.action
57    }
58
59    pub(crate) const fn effect_index(self) -> u8 {
60        self.effect_index
61    }
62}
63
64/// Stable dense identity for one action in the finalized combat-local damage universe.
65#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
66pub(crate) struct LocalDamageActionIdx(u16);
67
68impl LocalDamageActionIdx {
69    // #t(fn: rust_string_error) the private cold constructor adds concrete capacity context before the typed outer construction error.
70    fn new(index: usize) -> Result<Self, String> {
71        u16::try_from(index)
72            .map(Self)
73            .map_err(|error| format!("damage action registry exceeds u16 capacity: {error}"))
74    }
75
76    #[cfg(any(test, feature = "test-support"))]
77    pub(crate) const fn __test(index: u16) -> Self {
78        Self(index)
79    }
80
81    pub(crate) const fn index(self) -> usize {
82        self.0 as usize
83    }
84}
85
86/// Stable dense identity for one effect in the finalized combat-local damage universe.
87#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
88pub(crate) struct LocalDamageEffectIdx(u32);
89
90impl LocalDamageEffectIdx {
91    // #t(fn: rust_string_error) the private cold constructor adds concrete capacity context before the typed outer construction error.
92    fn new(index: usize) -> Result<Self, String> {
93        u32::try_from(index)
94            .map(Self)
95            .map_err(|error| format!("damage effect registry exceeds u32 capacity: {error}"))
96    }
97
98    pub(crate) const fn index(self) -> usize {
99        self.0 as usize
100    }
101}
102
103/// Direct local bindings carried by every executable damage request.
104#[derive(Clone, Copy, Debug)]
105pub(crate) struct DamageRequestBinding {
106    pub(crate) effect: LocalDamageEffectIdx,
107    effect_action: LocalDamageActionIdx,
108    pub(crate) geometry_effect: LocalDamageEffectIdx,
109    pub(crate) profile_action: LocalDamageActionIdx,
110    pub(crate) target: FinalizedTargetExecution,
111}
112
113impl DamageRequestBinding {
114    #[must_use]
115    pub(crate) fn effect_action(self) -> LocalDamageActionIdx {
116        self.effect_action
117    }
118
119    #[must_use]
120    pub(crate) fn geometry_effect_action(self, plan: &DamagePlan) -> LocalDamageActionIdx {
121        plan.effect_at(self.geometry_effect).action
122    }
123}
124
125/// Damage coordinates frozen for one registered spell definition.
126#[derive(Clone, Copy, Debug)]
127pub(crate) struct SpellDamageBinding {
128    pub(crate) request: DamageRequestBinding,
129}
130
131/// Damage coordinates frozen for one registered periodic aura definition.
132#[derive(Clone, Copy, Debug)]
133pub(crate) struct PeriodicDamageBinding {
134    pub(crate) request: DamageRequestBinding,
135    pub(crate) snapshot_effect: LocalDamageEffectIdx,
136}
137
138/// Runtime-ready damage replication with all source and impact identities localized.
139#[derive(Clone, Debug)]
140pub(crate) struct FinalizedDamageReplication {
141    pub(crate) definition: crate::state::DamageReplication,
142    pub(crate) request: DamageRequestBinding,
143    source_actions: Box<[u64]>,
144}
145
146/// Dense coordinates attached to one finalized aura/passive effect slot.
147#[derive(Clone, Debug)]
148pub(crate) struct FinalizedBuffDamageBinding {
149    pub(crate) source_effect: Option<LocalDamageEffectIdx>,
150    pub(crate) replacement_request: Option<DamageRequestBinding>,
151    listed_actions: Box<[u64]>,
152}
153
154impl FinalizedBuffDamageBinding {
155    #[must_use]
156    pub(crate) fn lists_action(&self, action: LocalDamageActionIdx) -> bool {
157        let bit = action.index();
158
159        self.listed_actions
160            .get(bit / AFFECT_WORD_BITS)
161            .is_some_and(|word| word & (1_u64 << (bit % AFFECT_WORD_BITS)) != 0)
162    }
163}
164
165impl FinalizedDamageReplication {
166    #[must_use]
167    pub(crate) fn accepts_source(&self, source: LocalDamageActionIdx) -> bool {
168        let bit = source.index();
169
170        self.source_actions
171            .get(bit / AFFECT_WORD_BITS)
172            .is_some_and(|word| word & (1_u64 << (bit % AFFECT_WORD_BITS)) != 0)
173    }
174}
175
176/// Target bytecode and static geometry frozen together during combat finalization.
177#[derive(Clone, Copy, Debug)]
178// #t(rust_similar_structs) frozen execution geometry and mutable runtime shape inputs belong to different lifecycle stages
179pub(crate) struct FinalizedTargetExecution {
180    pub(crate) executable: ExecutableTargetPlan,
181    pub(crate) is_multi_target: bool,
182    pub(crate) radius: f64,
183    pub(crate) range: f64,
184    pub(crate) cone_half_angle: f64,
185    pub(crate) face_explicit_target: bool,
186}
187
188/// Static profile-level area behavior copied from a registered spell definition.
189#[derive(Clone, Copy, Debug)]
190pub(crate) struct DamageProfileAoe {
191    pub(crate) is_aoe: bool,
192    pub(crate) split: bool,
193    pub(crate) chain_targeting: bool,
194    pub(crate) chain_multiplier: f64,
195    pub(crate) base_mult: f64,
196    pub(crate) reduced_targets: u8,
197    pub(crate) full_targets: u8,
198    pub(crate) max_targets: u8,
199}
200
201impl Default for DamageProfileAoe {
202    fn default() -> Self {
203        Self {
204            is_aoe: false,
205            split: false,
206            chain_targeting: false,
207            chain_multiplier: 0.0,
208            base_mult: 1.0,
209            reduced_targets: 0,
210            full_targets: 0,
211            max_targets: 0,
212        }
213    }
214}
215
216/// Scalar and flag data shared by every hit of one spell identity.
217#[derive(Clone, Debug)]
218#[expect(
219    clippy::struct_excessive_bools,
220    reason = "the finalized action record stores independent immutable spell flags"
221)]
222pub(crate) struct DamageActionPlan {
223    pub(crate) spell: SpellIdx,
224    effect_start: LocalDamageEffectIdx,
225    effect_count: u16,
226    pub(crate) passive: PassiveSpellModifiers,
227    pub(crate) proc_chance: f64,
228    pub(crate) gcd_ms: u32,
229    pub(crate) aura_duration_ms: u32,
230    pub(crate) aura_tick_ms: u32,
231    pub(crate) duration_hasted: bool,
232    pub(crate) hasted_ticks: bool,
233    pub(crate) secondary_gain: f64,
234    pub(crate) primary_resource_gain: f64,
235    pub(crate) hook_flat_damage: Option<f64>,
236    pub(crate) hook_scalar: Option<f64>,
237    pub(crate) profile_base_points: f64,
238    pub(crate) profile_aoe: DamageProfileAoe,
239    registered_damage: Option<(f64, crate::state::RuntimeDamageDef)>,
240    pub(crate) school: wowlab_types::combat::DamageSchool,
241    pub(crate) school_mask: SpellSchoolMask,
242    pub(crate) mechanic_mask: MechanicMask,
243    pub(crate) attributes: Box<[i32]>,
244    pub(crate) labels: Box<[i32]>,
245    pub(crate) proc_policy: crate::ProcPolicy,
246    pub(crate) primary_cost_entry: u8,
247    pub(crate) secondary_cost_entry: u8,
248    pub(crate) has_attack_effect: bool,
249    pub(crate) has_direct_damage: bool,
250    pub(crate) guaranteed_crit: bool,
251    pub(crate) casting_disabled_exception: bool,
252    pub(crate) reflection_eligible: bool,
253    pub(crate) defense_type: i32,
254    pub(crate) hit_chance_flat: f64,
255    pub(crate) hit_chance_multiplier: f64,
256    pub(crate) target_resistance_flat: f64,
257    pub(crate) target_resistance_multiplier: f64,
258    pub(crate) treat_as_periodic: bool,
259    pub(crate) mastery_affects_points: bool,
260    pub(crate) damage_attribute_flags: crate::DamageFlags,
261    pub(crate) chain_target_range: f64,
262    pub(crate) ignores_line_of_sight: bool,
263}
264
265/// Effect-local coordinates and precompiled relationships used by damage setup.
266#[derive(Clone, Debug)]
267pub(crate) struct DamageEffectPlan {
268    pub(crate) action: LocalDamageActionIdx,
269    pub(crate) effect_index: u8,
270    pub(crate) aoe: AoeProperties,
271    pub(crate) target: Option<FinalizedTargetExecution>,
272    pub(crate) mechanic_mask: MechanicMask,
273    pub(crate) base_points: f64,
274    pub(crate) ap_coefficient: f64,
275    pub(crate) sp_coefficient: f64,
276    pub(crate) amplitude: f64,
277    pub(crate) period: f64,
278    pub(crate) power_coefficient_mastery_add: f64,
279    pub(crate) aura_subtype: i32,
280    pub(crate) modifier_property: i32,
281    pub(crate) attributes: i32,
282    pub(crate) geometry: EffectRef,
283    geometry_local: LocalDamageEffectIdx,
284    pub(crate) resolved_damage: Option<crate::state::RuntimeDamageDef>,
285    pub(crate) resolved_damage_base_points: f64,
286    affected_actions: Box<[u64]>,
287}
288
289/// Complete static weapon and companion inputs for one finalized swing definition.
290#[derive(Clone, Copy, Debug)]
291pub(crate) struct FinalizedAutoAttackProfile {
292    pub(crate) request: DamageRequestBinding,
293    pub(crate) weapon: Option<wowlab_types::data::WeaponStats>,
294    pub(crate) hand: crate::state::SwingHand,
295    pub(crate) dual_wield: bool,
296    pub(crate) weapon_multiplier: f64,
297    pub(crate) normalized: bool,
298    pub(crate) is_physical: bool,
299    pub(crate) stat_inheritance: crate::state::PetStatInheritance,
300}
301
302impl DamageEffectPlan {
303    #[must_use]
304    pub(crate) fn affects_action(&self, action: LocalDamageActionIdx) -> bool {
305        let bit = action.index();
306
307        self.affected_actions
308            .get(bit / u64::BITS as usize)
309            .is_some_and(|word| word & (1_u64 << (bit % u64::BITS as usize)) != 0)
310    }
311}
312
313/// Immutable program-local damage catalog compiled during finalization.
314#[derive(Debug)]
315#[expect(
316    clippy::struct_excessive_bools,
317    reason = "the finalized catalog stores independent capability flags for hot-path dispatch"
318)]
319pub(crate) struct DamagePlan {
320    actions: Box<[DamageActionPlan]>,
321    effects: Box<[DamageEffectPlan]>,
322    generated_action_bindings: wowlab_types::sim::FastMap<DamageActionId, LocalDamageActionIdx>,
323    #[cfg(not(feature = "rotation-optimizations"))]
324    folded_passive_effects: Box<[LocalDamageEffectIdx]>,
325    #[cfg(feature = "rotation-optimizations")]
326    folded_passive_effects_by_action: Box<[Box<[LocalDamageEffectIdx]>]>,
327    crit_damage_per_crit_chance: Box<[(SpellSchoolMask, f64)]>,
328    spell_requests: Box<[Option<SpellDamageBinding>]>,
329    spell_actions: Box<[LocalDamageActionIdx]>,
330    periodic_requests: Box<[Option<PeriodicDamageBinding>]>,
331    aura_actions: Box<[LocalDamageActionIdx]>,
332    aura_apply_profile_actions: Box<[LocalDamageActionIdx]>,
333    aura_tick_profile_actions: Box<[LocalDamageActionIdx]>,
334    effect_requests: Box<[Option<DamageRequestBinding>]>,
335    spell_effect_sources: Box<[Option<LocalDamageEffectIdx>]>,
336    delayed_profile_actions: Box<[Option<LocalDamageActionIdx>]>,
337    mastery_action: Option<LocalDamageActionIdx>,
338    auto_attacks: Box<[FinalizedAutoAttackProfile]>,
339    channel_requests: Box<[[Option<DamageRequestBinding>; CHANNEL_DAMAGE_VARIANTS]]>,
340    replications: Box<[FinalizedDamageReplication]>,
341    aura_replications: Box<[[Option<u16>; crate::state::MAX_AURA_BUFF_EFFECTS]]>,
342    passive_replications: Box<[Option<u16>]>,
343    aura_buff_bindings:
344        Box<[[Option<FinalizedBuffDamageBinding>; crate::state::MAX_AURA_BUFF_EFFECTS]]>,
345    passive_buff_bindings: Box<[Option<FinalizedBuffDamageBinding>]>,
346    #[cfg(not(feature = "rotation-optimizations"))]
347    driver_modifier_actions: Box<[Box<[u64]>]>,
348    #[cfg(feature = "rotation-optimizations")]
349    driver_modifiers_by_action: Box<[Box<[usize]>]>,
350    impact_effect_driver_actions: Box<[Option<LocalDamageActionIdx>]>,
351    impact_effect_profile_actions: Box<[Option<LocalDamageActionIdx>]>,
352    periodic_driver_actions: Box<[LocalDamageActionIdx]>,
353    pub(crate) auto_attack_damage_mult: f64,
354    pub(crate) pet_damage_mult: f64,
355    pub(crate) guardian_damage_mult: f64,
356    pub(crate) target_armor_mult: f64,
357    pub(crate) player_level: i32,
358    pub(crate) main_hand: wowlab_types::data::WeaponStats,
359    pub(crate) off_hand: Option<wowlab_types::data::WeaponStats>,
360    primary_power_display_divisor: f64,
361    secondary_power_display_divisor: f64,
362    primary_power_cost_percent: f64,
363    secondary_power_cost_percent: f64,
364    rage_auto_attack_gain_multiplier: f64,
365    pub(crate) health_per_stamina: Option<f64>,
366    pub(crate) has_spell_modifiers: bool,
367    pub(crate) has_armor_bypass: bool,
368    pub(crate) has_health_critical: bool,
369    pub(crate) has_replication: bool,
370    pub(crate) has_reflection: bool,
371    pub(crate) has_deflection: bool,
372    pub(crate) retains_landed_impacts: bool,
373}
374
375const AFFECT_WORD_BITS: usize = u64::BITS as usize;
376const CHANNEL_DAMAGE_VARIANTS: usize = 2;
377
378#[path = "damage/compile.rs"]
379mod compile;
380
381impl DamagePlan {
382    pub(crate) fn action_locals(&self) -> impl Iterator<Item = LocalDamageActionIdx> + '_ {
383        (0..=u16::MAX)
384            .map(LocalDamageActionIdx)
385            .take(self.actions.len())
386    }
387
388    pub(crate) fn finalize_action_for_spell(&self, spell_id: u32) -> Option<LocalDamageActionIdx> {
389        self.actions
390            .iter()
391            .position(|action| action.spell.as_u32() == spell_id)
392            .and_then(|index| u16::try_from(index).ok())
393            .map(LocalDamageActionIdx)
394    }
395
396    pub(crate) fn action_request(
397        &self,
398        action: LocalDamageActionIdx,
399    ) -> Option<DamageRequestBinding> {
400        let plan = self.actions.get(action.index())?;
401        let effect = LocalDamageEffectIdx(plan.effect_start.0 + 1);
402
403        Some(DamageRequestBinding {
404            effect,
405            effect_action: action,
406            geometry_effect: effect,
407            profile_action: action,
408            target: self.effect_at(effect).target?,
409        })
410    }
411
412    #[cfg(any(test, feature = "test-support"))]
413    pub(crate) fn diagnostic_action_ordinal(&self, spell_id: u32) -> Option<usize> {
414        self.actions
415            .iter()
416            .position(|action| action.spell.as_u32() == spell_id)
417    }
418    pub(crate) fn dense_talent_ranks(
419        &self,
420        ranks: &wowlab_types::sim::IntMap<u32, u8>,
421    ) -> Box<[u8]> {
422        self.actions
423            .iter()
424            .map(|action| ranks.get(&action.spell.as_u32()).copied().unwrap_or(0))
425            .collect()
426    }
427
428    #[must_use]
429    pub(crate) fn generated_action(&self, action: DamageActionId) -> Option<LocalDamageActionIdx> {
430        self.generated_action_bindings.get(&action).copied()
431    }
432
433    #[must_use]
434    pub(crate) fn action_at(&self, local: LocalDamageActionIdx) -> &DamageActionPlan {
435        // BOUNDS: typed local indices are assigned while pushing this exact dense array.
436        &self.actions[local.index()]
437    }
438
439    #[must_use]
440    pub(crate) fn spell_request(
441        &self,
442        local: crate::state::LocalSpellIdx,
443    ) -> Option<SpellDamageBinding> {
444        self.spell_requests.get(local.as_usize()).copied().flatten()
445    }
446
447    #[must_use]
448    pub(crate) fn spell_action(&self, local: crate::state::LocalSpellIdx) -> LocalDamageActionIdx {
449        // BOUNDS: spell actions are compiled one-for-one in finalized local spell order.
450        self.spell_actions[local.as_usize()]
451    }
452
453    #[must_use]
454    pub(crate) fn periodic_request(
455        &self,
456        local: crate::state::LocalAuraIdx,
457    ) -> Option<PeriodicDamageBinding> {
458        self.periodic_requests
459            .get(local.as_usize())
460            .copied()
461            .flatten()
462    }
463
464    #[must_use]
465    pub(crate) fn aura_action(&self, local: crate::state::LocalAuraIdx) -> LocalDamageActionIdx {
466        // BOUNDS: aura actions are compiled one-for-one in finalized local aura order.
467        self.aura_actions[local.as_usize()]
468    }
469
470    #[must_use]
471    pub(crate) fn aura_apply_profile_action(
472        &self,
473        local: crate::state::LocalAuraIdx,
474    ) -> LocalDamageActionIdx {
475        // BOUNDS: apply profiles are compiled one-for-one in finalized local aura order.
476        self.aura_apply_profile_actions[local.as_usize()]
477    }
478
479    #[must_use]
480    pub(crate) fn aura_tick_profile_action(
481        &self,
482        local: crate::state::LocalAuraIdx,
483    ) -> LocalDamageActionIdx {
484        // BOUNDS: tick profiles are compiled one-for-one in finalized local aura order.
485        self.aura_tick_profile_actions[local.as_usize()]
486    }
487
488    #[must_use]
489    pub(crate) fn action_effect(
490        &self,
491        action: LocalDamageActionIdx,
492        effect_index: u8,
493    ) -> Option<LocalDamageEffectIdx> {
494        let plan = self.action_at(action);
495
496        (u16::from(effect_index) < plan.effect_count).then_some(LocalDamageEffectIdx(
497            plan.effect_start.0 + u32::from(effect_index),
498        ))
499    }
500
501    pub(crate) fn action_effects(
502        &self,
503        action: LocalDamageActionIdx,
504    ) -> impl Iterator<Item = (LocalDamageEffectIdx, &DamageEffectPlan)> {
505        let plan = self.action_at(action);
506
507        (0..plan.effect_count).map(|offset| {
508            let local = LocalDamageEffectIdx(plan.effect_start.0 + u32::from(offset));
509
510            (local, self.effect_at(local))
511        })
512    }
513
514    #[must_use]
515    pub(crate) fn effect_request(&self, index: usize) -> Option<DamageRequestBinding> {
516        self.effect_requests.get(index).copied().flatten()
517    }
518
519    #[must_use]
520    pub(crate) fn spell_effect_source(&self, index: usize) -> Option<LocalDamageEffectIdx> {
521        self.spell_effect_sources.get(index).copied().flatten()
522    }
523
524    #[must_use]
525    pub(crate) fn delayed_profile_action(&self, index: usize) -> Option<LocalDamageActionIdx> {
526        self.delayed_profile_actions.get(index).copied().flatten()
527    }
528
529    #[must_use]
530    pub(crate) fn mastery_action(&self) -> Option<LocalDamageActionIdx> {
531        self.mastery_action
532    }
533
534    #[must_use]
535    pub(crate) fn auto_attack_profile(&self, index: usize) -> Option<FinalizedAutoAttackProfile> {
536        self.auto_attacks.get(index).copied()
537    }
538
539    #[must_use]
540    pub(crate) fn channel_request(
541        &self,
542        local: crate::state::LocalSpellIdx,
543        alternate: bool,
544    ) -> Option<DamageRequestBinding> {
545        self.channel_requests
546            .get(local.as_usize())
547            .and_then(|requests| requests.get(usize::from(alternate)).copied().flatten())
548    }
549
550    #[must_use]
551    pub(crate) fn aura_replication(
552        &self,
553        local: crate::state::LocalAuraIdx,
554        position: u8,
555    ) -> Option<&FinalizedDamageReplication> {
556        let index = self
557            .aura_replications
558            .get(local.as_usize())?
559            .get(usize::from(position))?
560            .as_ref()?;
561
562        self.replications.get(usize::from(*index))
563    }
564
565    #[must_use]
566    pub(crate) fn passive_replication(&self, index: usize) -> Option<&FinalizedDamageReplication> {
567        let replication = self.passive_replications.get(index)?.as_ref()?;
568
569        self.replications.get(usize::from(*replication))
570    }
571
572    #[must_use]
573    pub(crate) fn aura_buff_binding(
574        &self,
575        local: crate::state::LocalAuraIdx,
576        position: u8,
577    ) -> Option<&FinalizedBuffDamageBinding> {
578        self.aura_buff_bindings
579            .get(local.as_usize())?
580            .get(usize::from(position))?
581            .as_ref()
582    }
583
584    #[must_use]
585    pub(crate) fn passive_buff_binding(&self, index: usize) -> Option<&FinalizedBuffDamageBinding> {
586        self.passive_buff_bindings.get(index)?.as_ref()
587    }
588
589    #[must_use]
590    #[cfg(not(feature = "rotation-optimizations"))]
591    pub(crate) fn driver_modifier_lists_action(
592        &self,
593        modifier: usize,
594        action: LocalDamageActionIdx,
595    ) -> bool {
596        let bit = action.index();
597
598        self.driver_modifier_actions
599            .get(modifier)
600            .and_then(|actions| actions.get(bit / AFFECT_WORD_BITS))
601            .is_some_and(|word| word & (1_u64 << (bit % AFFECT_WORD_BITS)) != 0)
602    }
603
604    #[must_use]
605    #[cfg(feature = "rotation-optimizations")]
606    pub(crate) fn driver_modifiers_for_action(&self, action: LocalDamageActionIdx) -> &[usize] {
607        self.driver_modifiers_by_action
608            .get(action.index())
609            .map(Box::as_ref)
610            .expect("finalized damage action has a driver-modifier adjacency")
611    }
612
613    #[must_use]
614    pub(crate) fn impact_effect_driver_action(&self, index: usize) -> Option<LocalDamageActionIdx> {
615        self.impact_effect_driver_actions
616            .get(index)
617            .copied()
618            .flatten()
619    }
620
621    #[must_use]
622    pub(crate) fn impact_effect_profile_action(
623        &self,
624        index: usize,
625    ) -> Option<LocalDamageActionIdx> {
626        self.impact_effect_profile_actions
627            .get(index)
628            .copied()
629            .flatten()
630    }
631
632    #[must_use]
633    pub(crate) fn periodic_driver_action(&self, index: usize) -> LocalDamageActionIdx {
634        // BOUNDS: callers use an index from the finalized periodic-driver registry.
635        self.periodic_driver_actions[index]
636    }
637
638    #[must_use]
639    pub(crate) fn effect_at(&self, local: LocalDamageEffectIdx) -> &DamageEffectPlan {
640        // BOUNDS: typed local indices are assigned while pushing this exact dense array.
641        &self.effects[local.index()]
642    }
643
644    pub(crate) fn effect_ref(&self, local: LocalDamageEffectIdx) -> crate::state::DamageEffectRef {
645        let effect = self.effect_at(local);
646        let action = self.action_at(effect.action);
647
648        crate::state::DamageEffectRef::new(action.spell.as_u32(), effect.effect_index)
649    }
650
651    #[must_use]
652    pub(crate) fn action_id_request(&self, action: DamageActionId) -> Option<DamageRequestBinding> {
653        let local = self.generated_action(action)?;
654        let plan = self.action_id_plan(action)?;
655
656        let effect = LocalDamageEffectIdx(plan.effect_start.0 + 1);
657
658        Some(DamageRequestBinding {
659            effect,
660            effect_action: local,
661            geometry_effect: effect,
662            profile_action: local,
663            target: self.effect_at(effect).target?,
664        })
665    }
666
667    #[must_use]
668    pub(crate) fn action_id_plan(&self, action: DamageActionId) -> Option<&DamageActionPlan> {
669        let local = self.generated_action(action)?;
670
671        self.actions.get(local.index())
672    }
673
674    #[must_use]
675    pub(crate) fn validated_action_id(
676        &self,
677        action: DamageActionId,
678    ) -> Option<LocalDamageActionIdx> {
679        self.generated_action(action)
680    }
681
682    #[must_use]
683    pub(crate) fn profiled_action_id_request(
684        &self,
685        action: DamageActionId,
686        profile: DamageActionId,
687    ) -> Option<DamageRequestBinding> {
688        let mut request = self.action_id_request(action)?;
689
690        request.profile_action = self.generated_action(profile)?;
691
692        Some(request)
693    }
694
695    #[must_use]
696    pub(crate) fn effect_id_plan(
697        &self,
698        coordinate: DamageEffectId,
699    ) -> Option<(LocalDamageEffectIdx, &DamageEffectPlan)> {
700        let action_local = self.generated_action(coordinate.action)?;
701        let action = self.actions.get(action_local.index())?;
702
703        if u16::from(coordinate.effect_index) >= action.effect_count {
704            return None;
705        }
706
707        let local =
708            LocalDamageEffectIdx(action.effect_start.0 + u32::from(coordinate.effect_index));
709
710        Some((local, self.effect_at(local)))
711    }
712
713    #[must_use]
714    pub(crate) fn triggered_effect_id_request(
715        &self,
716        source: DamageEffectId,
717    ) -> Option<(DamageRequestBinding, &DamageEffectPlan)> {
718        let (_, planned) = self.effect_id_plan(source)?;
719        let source_action = self.generated_action(source.action)?;
720        let geometry = self.effect_at(planned.geometry_local);
721
722        Some((
723            DamageRequestBinding {
724                effect: planned.geometry_local,
725                effect_action: geometry.action,
726                geometry_effect: planned.geometry_local,
727                profile_action: source_action,
728                target: geometry.target?,
729            },
730            planned,
731        ))
732    }
733
734    #[must_use]
735    pub(crate) fn effect_id_request(
736        &self,
737        effect: DamageEffectId,
738        geometry: DamageEffectId,
739        profile: DamageActionId,
740    ) -> Option<DamageRequestBinding> {
741        let (effect_local, _) = self.effect_id_plan(effect)?;
742        let (geometry_local, _) = self.effect_id_plan(geometry)?;
743        let effect_action = self.generated_action(effect.action)?;
744        let profile_action = self.generated_action(profile)?;
745
746        Some(DamageRequestBinding {
747            effect: effect_local,
748            effect_action,
749            geometry_effect: geometry_local,
750            profile_action,
751            target: self.effect_at(geometry_local).target?,
752        })
753    }
754
755    #[must_use]
756    pub(crate) fn effect_affects_action(
757        &self,
758        source: LocalDamageEffectIdx,
759        target: LocalDamageActionIdx,
760    ) -> bool {
761        self.effect_at(source).affects_action(target)
762    }
763
764    #[cfg(test)]
765    pub(crate) fn diagnostic_bind_action(&self, spell_id: u32) -> Option<LocalDamageActionIdx> {
766        wowlab_types::bench_metrics::record_damage_plan_keyed_lookup();
767
768        self.finalize_action_for_spell(spell_id)
769    }
770
771    /// Constructs a generated-style action token for a fixture identity after exercising the
772    /// explicit cold diagnostic lookup path.
773    #[cfg(test)]
774    #[must_use]
775    pub(crate) fn diagnostic_action_id(&self, spell_id: u32) -> Option<DamageActionId> {
776        self.diagnostic_bind_action(spell_id)?;
777
778        self.generated_action_bindings
779            .iter()
780            .find_map(|(action, _)| (action.spell_id == spell_id).then_some(*action))
781    }
782
783    /// Constructs a generated-style effect token for a fixture identity after exercising the
784    /// explicit cold diagnostic lookup path.
785    #[cfg(test)]
786    #[must_use]
787    pub(crate) fn diagnostic_effect_id(
788        &self,
789        effect: crate::state::DamageEffectRef,
790    ) -> Option<DamageEffectId> {
791        let action = self.diagnostic_action_id(effect.spell_id)?;
792
793        self.diagnostic_bind_effect(effect)?;
794
795        Some(DamageEffectId {
796            action,
797            effect_index: effect.effect_index,
798        })
799    }
800
801    pub(crate) fn proc_provenance_local(
802        &self,
803        action: LocalDamageActionIdx,
804        kind: crate::state::ProcSourceKind,
805    ) -> crate::state::ProcProvenance {
806        let plan = self.action_at(action);
807
808        crate::state::ProcProvenance {
809            trigger_spell_id: plan.spell.as_u32(),
810            kind,
811            policy: plan.proc_policy,
812        }
813    }
814
815    pub(crate) fn resolved_action_damage(
816        &self,
817        local: LocalDamageActionIdx,
818    ) -> Option<(f64, crate::state::RuntimeDamageDef)> {
819        let action = self.action_at(local);
820
821        if let Some(damage) = action.registered_damage {
822            return Some(damage);
823        }
824
825        (0..action.effect_count).find_map(|offset| {
826            let effect = LocalDamageEffectIdx(action.effect_start.0 + u32::from(offset));
827            let effect = self.effect_at(effect);
828
829            effect
830                .resolved_damage
831                .map(|damage| (effect.resolved_damage_base_points, damage))
832        })
833    }
834
835    pub(crate) fn cost_entry_local(&self, action: LocalDamageActionIdx, pool: ResourcePool) -> u8 {
836        let action = self.action_at(action);
837
838        match pool {
839            ResourcePool::Primary => action.primary_cost_entry,
840            ResourcePool::Secondary => action.secondary_cost_entry,
841        }
842    }
843
844    pub(crate) const fn power_display_divisor(&self, pool: ResourcePool) -> f64 {
845        match pool {
846            ResourcePool::Primary => self.primary_power_display_divisor,
847            ResourcePool::Secondary => self.secondary_power_display_divisor,
848        }
849    }
850
851    pub(crate) const fn power_cost_percent(&self, pool: ResourcePool) -> f64 {
852        match pool {
853            ResourcePool::Primary => self.primary_power_cost_percent,
854            ResourcePool::Secondary => self.secondary_power_cost_percent,
855        }
856    }
857
858    pub(crate) const fn rage_auto_attack_gain_multiplier(&self) -> f64 {
859        self.rage_auto_attack_gain_multiplier
860    }
861
862    pub(crate) fn live_effect_points_local(
863        &self,
864        effect: LocalDamageEffectIdx,
865        mastery_points: f64,
866    ) -> f64 {
867        let effect = self.effect_at(effect);
868        let action = self.action_at(effect.action);
869
870        effect.base_points
871            + if action.mastery_affects_points {
872                mastery_points * effect.sp_coefficient
873            } else {
874                0.0
875            }
876    }
877
878    #[must_use]
879    #[cfg(test)]
880    pub(crate) fn diagnostic_bind_effect(
881        &self,
882        effect: crate::state::DamageEffectRef,
883    ) -> Option<LocalDamageEffectIdx> {
884        wowlab_types::bench_metrics::record_damage_plan_raw_binary_search();
885        let action = self.diagnostic_bind_action(effect.spell_id)?;
886        let plan = self.action_at(action);
887
888        if u16::from(effect.effect_index) >= plan.effect_count {
889            return None;
890        }
891
892        Some(LocalDamageEffectIdx(
893            plan.effect_start.0 + u32::from(effect.effect_index),
894        ))
895    }
896
897    #[must_use]
898    #[cfg(test)]
899    pub(crate) fn diagnostic_bind_request(
900        &self,
901        effect: crate::state::DamageEffectRef,
902        geometry_effect: crate::state::DamageEffectRef,
903        profile_spell_id: u32,
904    ) -> Option<DamageRequestBinding> {
905        let effect = self.diagnostic_bind_effect(effect)?;
906        let geometry_effect = self.diagnostic_bind_effect(geometry_effect)?;
907
908        Some(DamageRequestBinding {
909            effect,
910            effect_action: self.effect_at(effect).action,
911            geometry_effect,
912            profile_action: self.diagnostic_bind_action(profile_spell_id)?,
913            target: self.effect_at(geometry_effect).target?,
914        })
915    }
916
917    pub(crate) fn crit_damage_per_crit_chance(
918        &self,
919    ) -> impl Iterator<Item = (SpellSchoolMask, f64)> + '_ {
920        self.crit_damage_per_crit_chance.iter().copied()
921    }
922
923    #[cfg(not(feature = "rotation-optimizations"))]
924    pub(crate) fn folded_passive_effects(
925        &self,
926    ) -> impl Iterator<Item = (LocalDamageEffectIdx, &DamageEffectPlan)> {
927        self.folded_passive_effects.iter().copied().map(|local| {
928            let effect = self.effect_at(local);
929
930            (local, effect)
931        })
932    }
933
934    #[cfg(feature = "rotation-optimizations")]
935    pub(crate) fn folded_passive_effects_for_action(
936        &self,
937        action: LocalDamageActionIdx,
938    ) -> &[LocalDamageEffectIdx] {
939        self.folded_passive_effects_by_action
940            .get(action.index())
941            .map(Box::as_ref)
942            .expect("finalized damage action has a folded-passive adjacency")
943    }
944}
945
946fn reflection_eligible(attributes: &[i32], defense_type: i32) -> bool {
947    let has = |kind| wowlab_engine_domain::dbc::spell_attribute_is(attributes, kind);
948    let explicitly_allowed =
949        has(wowlab_engine_domain::dbc::SpellAttributeKind::AllowSpellReflection);
950    let eligible_class = (wowlab_engine_domain::dbc::DefenseType::try_from(defense_type).ok()
951        == Some(wowlab_engine_domain::dbc::DefenseType::Magic)
952        && !has(wowlab_engine_domain::dbc::SpellAttributeKind::Ability))
953        || explicitly_allowed;
954
955    eligible_class
956        && !has(wowlab_engine_domain::dbc::SpellAttributeKind::NoReflection)
957        && !has(wowlab_engine_domain::dbc::SpellAttributeKind::NoImmunities)
958        && !has(wowlab_engine_domain::dbc::SpellAttributeKind::Passive)
959}
960
961#[cfg(test)]
962mod tests {
963    use googletest::gtest;
964    use wowlab_engine_gamedata::{PassiveSpellModifiers, ResolvedGameData, SpellProps};
965    use wowlab_engine_ports::CombatStats;
966    use wowlab_types::sim::{EffectRef, SpellIdx};
967
968    use crate::{
969        CombatSystemBuilder, DamageActionId, HookCtx, ImpactActorFilter, LandedImpactEvent,
970        LandedImpactProc, ProcDriver,
971    };
972
973    fn rotation() -> wowlab_types::sim::Rotation {
974        crate::test_support::rotation_from_json(
975            r#"{"version":1,"name":"damage-plan","variables":{},"actions":[{"type":"wait","seconds":1.0}],"lists":{}}"#,
976        )
977    }
978
979    fn noop_landed_proc(_ctx: &mut HookCtx<'_>, _event: LandedImpactEvent) {}
980
981    #[gtest]
982    fn finalized_damage_plan_compiles_scalars_aoe_and_affect_relationships() {
983        const SOURCE: u32 = 91_001;
984        const TARGET: u32 = 91_002;
985        let source = SpellIdx::from_raw(SOURCE);
986        let target = SpellIdx::from_raw(TARGET);
987        let coordinate = EffectRef::new(source, 1);
988        let passive = PassiveSpellModifiers {
989            direct_damage_mult: 1.25,
990            crit_chance_bonus: 7.0,
991            ..PassiveSpellModifiers::default()
992        };
993        let mut data = ResolvedGameData::builder();
994
995        data.insert_passive_spell_modifiers(target, passive);
996        data.insert_school_mask(target, 0x04);
997        data.insert_spell_props(
998            source,
999            SpellProps {
1000                max_affected_targets: 5,
1001                treat_as_area_effect: true,
1002                ..SpellProps::default()
1003            },
1004        );
1005        data.insert_spell_class_flags(source, 8, [0, 0, 0, 0]);
1006        data.insert_spell_class_flags(target, 8, [0x20, 0, 0, 0]);
1007        data.insert_effect_class_mask(source, 1, [0x20, 0, 0, 0]);
1008        data.insert_effect_type(coordinate, 2);
1009        data.insert_implicit_targets(coordinate, 22, 24);
1010        data.insert_effect_radius(source, 1, 12.0);
1011        let built = CombatSystemBuilder::new_for_test(CombatStats::default())
1012            .game_data(data.build())
1013            .spell("source", SOURCE, Ok)
1014            .spell("target", TARGET, Ok)
1015            .into_draft()
1016            .expect("valid damage-plan draft")
1017            .finalize(&rotation())
1018            .expect("valid damage plan");
1019        let target_local = built
1020            .program
1021            .damage
1022            .diagnostic_bind_action(TARGET)
1023            .expect("planned target");
1024        let effect_local = built
1025            .program
1026            .damage
1027            .diagnostic_bind_effect(crate::state::DamageEffectRef::new(SOURCE, 1))
1028            .expect("planned source effect");
1029        let action = built.program.damage.action_at(target_local);
1030        let effect = built.program.damage.effect_at(effect_local);
1031
1032        assert_eq!(action.passive, passive);
1033        assert_eq!(action.school, wowlab_types::combat::DamageSchool::Fire);
1034        googletest::expect_that!(
1035            effect.aoe.radius,
1036            googletest::matchers::near(12.0, f64::EPSILON)
1037        );
1038        assert_eq!(effect.aoe.max_affected_targets, 5);
1039        assert!(effect.aoe.treat_as_area_effect);
1040        assert!(effect.affects_action(target_local));
1041    }
1042
1043    #[gtest]
1044    fn local_damage_indices_are_stable_and_include_unregistered_trigger_children() {
1045        const FIRST: u32 = 91_101;
1046        const SECOND: u32 = 91_102;
1047        const CHILD: u32 = 91_103;
1048        let first = SpellIdx::from_raw(FIRST);
1049        let child = SpellIdx::from_raw(CHILD);
1050        let mut data = ResolvedGameData::builder();
1051
1052        data.insert_effect_type(
1053            EffectRef::new(first, 1),
1054            wowlab_engine_domain::dbc::SpellEffectKind::TriggerSpell as i32,
1055        );
1056        data.insert_trigger_spell(EffectRef::new(first, 1), CHILD.cast_signed());
1057        data.insert_effect_type(
1058            EffectRef::new(child, 1),
1059            wowlab_engine_domain::dbc::SpellEffectKind::SchoolDamage as i32,
1060        );
1061        data.insert_ap_coef(child, 1, 1.0);
1062        let built = CombatSystemBuilder::new_for_test(CombatStats::default())
1063            .game_data(data.build())
1064            .spell("second", SECOND, Ok)
1065            .spell("first", FIRST, Ok)
1066            .into_draft()
1067            .expect("valid damage-index draft")
1068            .finalize(&rotation())
1069            .expect("valid damage-index plan");
1070        let second_local = built
1071            .program
1072            .damage
1073            .diagnostic_bind_action(SECOND)
1074            .expect("second action");
1075        let first_local = built
1076            .program
1077            .damage
1078            .diagnostic_bind_action(FIRST)
1079            .expect("first action");
1080        let child_local = built
1081            .program
1082            .damage
1083            .diagnostic_bind_action(CHILD)
1084            .expect("trigger child");
1085
1086        assert_eq!(
1087            (
1088                second_local.index(),
1089                first_local.index(),
1090                child_local.index()
1091            ),
1092            (1, 0, 2)
1093        );
1094        assert_eq!(built.program.damage.action_at(child_local).spell, child);
1095    }
1096
1097    #[cfg(feature = "bench-metrics")]
1098    #[gtest]
1099    fn typed_damage_and_target_access_do_not_record_legacy_runtime_work() {
1100        let built = CombatSystemBuilder::new_for_test(CombatStats::default())
1101            .spell("local", 91_201, Ok)
1102            .into_draft()
1103            .expect("valid counter draft")
1104            .finalize(&rotation())
1105            .expect("valid counter plan");
1106        let local = built
1107            .program
1108            .damage
1109            .diagnostic_bind_action(91_201)
1110            .expect("local action");
1111
1112        wowlab_types::bench_metrics::reset();
1113        let _ = built.program.damage.action_at(local);
1114        let counters = wowlab_types::bench_metrics::snapshot();
1115
1116        assert_eq!(
1117            (
1118                counters.damage_plan_keyed_lookups,
1119                counters.damage_plan_raw_binary_searches,
1120                counters.target_plan_runtime_lowerings,
1121                counters.target_plan_runtime_fallbacks,
1122            ),
1123            (0, 0, 0, 0),
1124        );
1125    }
1126
1127    #[cfg(feature = "bench-metrics")]
1128    #[gtest]
1129    fn cold_diagnostic_damage_binding_records_real_lookup_work() {
1130        let built = CombatSystemBuilder::new_for_test(CombatStats::default())
1131            .spell("local", 91_202, Ok)
1132            .into_draft()
1133            .expect("valid diagnostic counter draft")
1134            .finalize(&rotation())
1135            .expect("valid diagnostic counter plan");
1136
1137        wowlab_types::bench_metrics::reset();
1138        let effect = crate::state::DamageEffectRef::new(91_202, 0);
1139        let _ = built.program.damage.diagnostic_bind_action(91_202);
1140        let _ = built.program.damage.diagnostic_bind_effect(effect);
1141        let counters = wowlab_types::bench_metrics::snapshot();
1142
1143        assert_eq!(counters.damage_plan_keyed_lookups, 2);
1144        assert_eq!(counters.damage_plan_raw_binary_searches, 1);
1145    }
1146
1147    #[gtest]
1148    fn optional_damage_work_and_landed_storage_follow_finalized_capabilities() {
1149        let neutral = CombatSystemBuilder::new_for_test(CombatStats::default())
1150            .into_draft()
1151            .expect("valid neutral draft")
1152            .finalize(&rotation())
1153            .expect("valid neutral plan");
1154        let plan = &neutral.program.damage;
1155
1156        assert!(!plan.has_spell_modifiers);
1157        assert!(!plan.has_armor_bypass);
1158        assert!(!plan.has_health_critical);
1159        assert!(!plan.has_replication);
1160        assert!(!plan.has_reflection);
1161        assert!(!plan.has_deflection);
1162        assert!(!plan.retains_landed_impacts);
1163
1164        let with_consumer = CombatSystemBuilder::new_for_test(CombatStats::default())
1165            .register_landed_impact_proc(LandedImpactProc {
1166                driver: ProcDriver::default(),
1167                actor_filter: ImpactActorFilter::Player,
1168                chance: 1.0,
1169                fire: noop_landed_proc,
1170                spell_filter: None,
1171                periodic_only: false,
1172                skip_periodic: false,
1173                crit_only: false,
1174            })
1175            .into_draft()
1176            .expect("valid landed-consumer draft")
1177            .finalize(&rotation())
1178            .expect("valid landed-consumer plan");
1179
1180        assert!(with_consumer.program.damage.retains_landed_impacts);
1181    }
1182
1183    #[gtest]
1184    fn unsupported_target_program_fails_combat_finalization() {
1185        const SPELL_ID: u32 = 91_301;
1186        let spell = SpellIdx::from_raw(SPELL_ID);
1187        let mut data = ResolvedGameData::builder();
1188
1189        data.insert_effect_type(EffectRef::new(spell, 1), 2);
1190        data.insert_implicit_targets(EffectRef::new(spell, 1), 57, 0);
1191        let error = CombatSystemBuilder::new_for_test(CombatStats::default())
1192            .game_data(data.build())
1193            .spell("unsupported_target", SPELL_ID, Ok)
1194            .into_draft()
1195            .expect("valid pre-finalization draft")
1196            .finalize(&rotation())
1197            .expect_err("unsupported target program must fail finalization");
1198
1199        assert!(error.to_string().contains("failed to lower target plan"));
1200    }
1201
1202    #[googletest::gtest]
1203    fn finalized_damage_catalog_contains_only_declared_program_identities() {
1204        const OWN_IDS: [u32; 3] = [91_401, 91_402, 91_403];
1205        const OWN: [DamageActionId; 3] = [
1206            DamageActionId::new(41, 91_401),
1207            DamageActionId::new(41, 91_402),
1208            DamageActionId::new(41, 91_403),
1209        ];
1210        const UNRELATED: u32 = 1_303_048;
1211        let built = CombatSystemBuilder::new_for_test(CombatStats::default())
1212            .effect_identities(&OWN)
1213            .into_draft()
1214            .expect("valid finite damage draft")
1215            .finalize(&rotation())
1216            .expect("valid finite damage program");
1217
1218        assert_eq!(built.program.damage.actions.len(), OWN.len());
1219
1220        for (spell_id, identity) in OWN_IDS.into_iter().zip(OWN) {
1221            assert!(built.program.damage.generated_action(identity).is_some());
1222
1223            assert!(
1224                built
1225                    .program
1226                    .damage
1227                    .generated_action(DamageActionId::new(42, spell_id))
1228                    .is_none(),
1229                "foreign namespace token with the same game-data ID must be rejected"
1230            );
1231        }
1232
1233        assert!(
1234            built
1235                .program
1236                .damage
1237                .generated_action(DamageActionId::new(0, UNRELATED))
1238                .is_none()
1239        );
1240    }
1241}