1use 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#[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#[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 #[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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
66pub(crate) struct LocalDamageActionIdx(u16);
67
68impl LocalDamageActionIdx {
69 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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
88pub(crate) struct LocalDamageEffectIdx(u32);
89
90impl LocalDamageEffectIdx {
91 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#[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#[derive(Clone, Copy, Debug)]
127pub(crate) struct SpellDamageBinding {
128 pub(crate) request: DamageRequestBinding,
129}
130
131#[derive(Clone, Copy, Debug)]
133pub(crate) struct PeriodicDamageBinding {
134 pub(crate) request: DamageRequestBinding,
135 pub(crate) snapshot_effect: LocalDamageEffectIdx,
136}
137
138#[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#[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#[derive(Clone, Copy, Debug)]
178pub(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#[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#[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#[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#[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#[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 &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 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 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 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 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 self.periodic_driver_actions[index]
636 }
637
638 #[must_use]
639 pub(crate) fn effect_at(&self, local: LocalDamageEffectIdx) -> &DamageEffectPlan {
640 &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 #[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 #[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}