Skip to main content

wowlab_engine_combat/systems/
mastery.rs

1//! Per-spec mastery: each spec's [`MasteryFn`] returns a hit's mastery damage multiplier.
2
3use wowlab_engine_domain::dbc::SpellSchoolMask;
4use wowlab_types::{
5    combat::DamageSchool,
6    constants::HUNDRED,
7    sim::{ActorId, EnemyIdx, SpellIdx},
8};
9
10use crate::state::{CombatInstance, CombatProgram};
11
12#[derive(Debug)]
13pub struct MasteryCtx<'a> {
14    pub program: &'a CombatProgram,
15    pub instance: &'a CombatInstance,
16    pub spell_id: u32,
17    pub(crate) spell_action: crate::state::program::LocalDamageActionIdx,
18    pub school: DamageSchool,
19    pub mastery: f64,
20    pub player_crit_pct: f64,
21    pub mastery_spell: SpellIdx,
22    pub(crate) mastery_action: Option<crate::state::program::LocalDamageActionIdx>,
23    pub is_periodic: bool,
24    pub is_pet: bool,
25    pub source: ActorId,
26    /// Hostile target used by target-specific mastery clauses.
27    /// Reflected hits re-executed against their caster have no hostile target.
28    pub target: Option<EnemyIdx>,
29    pub now: wowlab_types::sim::SimTime,
30}
31
32#[derive(Clone, Copy, Debug)]
33pub struct MasteryEvent {
34    pub action: crate::DamageActionId,
35    pub school: DamageSchool,
36    pub mastery: f64,
37    pub player_crit_pct: f64,
38    pub mastery_action: crate::DamageActionId,
39    pub is_periodic: bool,
40    pub is_pet: bool,
41    pub source: ActorId,
42    pub target: Option<EnemyIdx>,
43    pub now: wowlab_types::sim::SimTime,
44}
45
46impl MasteryCtx<'_> {
47    /// Builds a mastery hook context from finalized generated action coordinates.
48    #[must_use]
49    pub fn new<'a>(
50        program: &'a CombatProgram,
51        instance: &'a CombatInstance,
52        event: MasteryEvent,
53    ) -> Option<MasteryCtx<'a>> {
54        Some(MasteryCtx {
55            program,
56            instance,
57            spell_id: event.action.spell_id(),
58            spell_action: program.damage.validated_action_id(event.action)?,
59            school: event.school,
60            mastery: event.mastery,
61            player_crit_pct: event.player_crit_pct,
62            mastery_spell: SpellIdx::from_raw(event.mastery_action.spell_id()),
63            mastery_action: Some(program.damage.validated_action_id(event.mastery_action)?),
64            is_periodic: event.is_periodic,
65            is_pet: event.is_pet,
66            source: event.source,
67            target: event.target,
68            now: event.now,
69        })
70    }
71
72    /// Number of registered temporary weapon imbues currently active.
73    #[must_use]
74    pub fn active_weapon_imbue_count(&self) -> usize {
75        self.program
76            .weapon_imbue_bindings
77            .iter()
78            .filter(|binding| self.instance.buffer.weapon_imbue_at(binding.slot).is_active != 0)
79            .count()
80    }
81
82    /// Whether the exact source/target aura instance is active for this hit.
83    #[must_use]
84    pub fn is_aura_active(&self, local: crate::LocalAuraIdx) -> bool {
85        super::auras::is_aura_active(
86            &crate::context::CombatView::new(self.program, self.instance),
87            local,
88            self.source,
89            self.target,
90        )
91    }
92
93    /// Live effect points for the mastery spell's effect.
94    #[must_use]
95    pub fn points(&self, effect: u8) -> f64 {
96        self.mastery_action
97            .and_then(|action| self.program.damage.action_effect(action, effect))
98            .map_or(0.0, |effect| {
99                self.program
100                    .damage
101                    .live_effect_points_local(effect, self.mastery)
102            })
103    }
104
105    /// `1 + live_effect_points / 100`.
106    #[must_use]
107    pub fn bonus(&self, effect: u8) -> f64 {
108        1.0 + self.points(effect) / HUNDRED
109    }
110
111    /// Whether the hit's spell carries the given `SpellLabel` id.
112    #[must_use]
113    pub fn spell_has_label(&self, label: i32) -> bool {
114        self.program
115            .damage
116            .action_at(self.spell_action)
117            .labels
118            .contains(&label)
119    }
120
121    /// Whether the hit's DBC school mask contains `school`.
122    #[must_use]
123    pub fn has_school(&self, school: DamageSchool) -> bool {
124        let expected = SpellSchoolMask::from(school);
125
126        self.program
127            .damage
128            .action_at(self.spell_action)
129            .school_mask
130            .contains(expected)
131    }
132
133    /// Is the hit's spell inside the mastery effect's affect list? `effect` is 1-based.
134    #[must_use]
135    pub fn affects_spell(&self, effect: u8) -> bool {
136        self.mastery_action
137            .and_then(|mastery| self.program.damage.action_effect(mastery, effect))
138            .is_some_and(|source| {
139                self.program
140                    .damage
141                    .effect_affects_action(source, self.spell_action)
142            })
143    }
144
145    /// Whether a finalized authored effect's affect list includes this hit's action.
146    #[must_use]
147    pub fn effect_affects_spell(&self, effect: crate::DamageEffectId) -> bool {
148        self.program
149            .damage
150            .effect_id_plan(effect)
151            .is_some_and(|(source, _)| {
152                self.program
153                    .damage
154                    .effect_affects_action(source, self.spell_action)
155            })
156    }
157
158    /// [`Self::bonus`] only when the mastery effect's affect list includes this hit's spell.
159    #[must_use]
160    pub fn affected_spell_bonus(&self, effect: u8) -> f64 {
161        if self.affects_spell(effect) {
162            self.bonus(effect)
163        } else {
164            1.0
165        }
166    }
167
168    /// [`Self::bonus`] for the first listed effect whose affect list includes this hit's spell.
169    #[must_use]
170    pub fn affected_bonus_any(&self, effects: &[u8]) -> f64 {
171        effects
172            .iter()
173            .copied()
174            .find(|&effect| self.affects_spell(effect))
175            .map_or(1.0, |effect| self.bonus(effect))
176    }
177
178    /// Select the direct or periodic mastery effect list and apply the first matching bonus.
179    #[must_use]
180    pub fn direct_or_periodic_affected_bonus_any(
181        &self,
182        direct_effects: &[u8],
183        periodic_effects: &[u8],
184    ) -> f64 {
185        if self.is_periodic {
186            self.affected_bonus_any(periodic_effects)
187        } else {
188            self.affected_bonus_any(direct_effects)
189        }
190    }
191
192    /// [`Self::bonus`] only when the hit is dealt by a pet, else `1.0`.
193    #[must_use]
194    pub fn pet_bonus(&self, effect: u8) -> f64 {
195        if self.is_pet { self.bonus(effect) } else { 1.0 }
196    }
197
198    /// Exact target-health fraction (`1.0` = full) from canonical encounter state.
199    #[must_use]
200    pub fn target_health_pct(&self) -> f64 {
201        self.target
202            .and_then(|target| {
203                self.instance
204                    .enemy_health_fraction(self.program, target, self.now)
205            })
206            .unwrap_or(1.0)
207    }
208
209    /// Number of currently active temporary guardians.
210    #[must_use]
211    pub fn active_guardian_count(&self) -> usize {
212        self.instance
213            .runtime
214            .companions
215            .guardians
216            .iter()
217            .flatten()
218            .count()
219    }
220}
221
222pub type MasteryFn = fn(&MasteryCtx<'_>) -> f64;
223
224/// Mastery that does not modify direct damage (proc- or pet-based); the mechanic lives elsewhere.
225#[must_use]
226pub const fn no_damage_bonus(_ctx: &MasteryCtx<'_>) -> f64 {
227    1.0
228}
229
230/// Neutral `mastery_crit_damage` hook (no crit-damage bonus).
231#[must_use]
232pub const fn no_crit_damage(_ctx: &MasteryCtx<'_>) -> f64 {
233    0.0
234}