Skip to main content

wowlab_engine_domain/
stats.rs

1//! Stat and rating recomputation. Secondary/tertiary stats on [`CombatStats`] are stored as percent points (`25.0` = 25%), never as a fraction.
2
3use wowlab_engine_gamedata::{
4    RatingMultiplierSlot, ResolvedCurves, ResolvedGameData, ResolvedGameTables,
5};
6use wowlab_types::{
7    constants::HUNDRED,
8    game::{Attribute, RatingType, SpecId},
9    numeric::interpolate_sorted,
10};
11
12use crate::{constants::BASE_CRIT_CHANCE, dbc::PassiveStatMods};
13
14#[rustfmt::skip]
15pub use wowlab_engine_ports::CombatStats;
16
17/// Baseline mastery points every spec starts with.
18pub const BASE_MASTERY_POINTS: f64 = 8.0;
19
20/// DR curve for Crit/Haste/Mastery/Versatility.
21pub const SECONDARY_DR_CURVE_ID: i32 = 21_024;
22/// DR curve for Leech/Speed/Avoidance.
23pub const TERTIARY_DR_CURVE_ID: i32 = 21_025;
24const BASE_CRIT_MULTIPLIER: f64 = 2.0;
25
26/// Converts haste percent points into a cadence multiplier.
27#[must_use]
28pub const fn haste_multiplier(stats: &CombatStats) -> f64 {
29    1.0 + stats.haste / HUNDRED
30}
31
32/// Converts critical-damage bonus percent points into the full critical multiplier.
33#[must_use]
34pub const fn crit_multiplier(stats: &CombatStats) -> f64 {
35    BASE_CRIT_MULTIPLIER + stats.crit_damage_bonus / HUNDRED
36}
37
38/// Converts versatility percent points into a damage/healing multiplier.
39#[must_use]
40pub const fn versatility_multiplier(stats: &CombatStats) -> f64 {
41    1.0 + stats.versatility / HUNDRED
42}
43
44/// Selects the combat-rating divisor for `rating_type` from the resolved level row.
45#[must_use]
46pub fn combat_rating_divisor(
47    tables: &ResolvedGameTables,
48    rating_type: RatingType,
49    level: u32,
50) -> Option<f64> {
51    let row = tables.combat_ratings_row(level)?;
52
53    Some(match rating_type {
54        RatingType::Crit => row.crit_spell,
55        RatingType::Haste => row.haste_spell,
56        RatingType::Mastery => row.mastery,
57        RatingType::Versatility => row.versatility_damage_done,
58        RatingType::Leech => row.lifesteal,
59        RatingType::Avoidance => row.avoidance,
60        RatingType::Speed => row.speed,
61    })
62}
63
64/// Selects the item-level rating multiplier for a gear budget slot.
65#[must_use]
66pub fn rating_multiplier_at_ilvl(
67    tables: &ResolvedGameTables,
68    slot: RatingMultiplierSlot,
69    item_level: i32,
70) -> f64 {
71    tables
72        .combat_ratings_multiplier_row(item_level)
73        .map_or(1.0, |row| match slot {
74            RatingMultiplierSlot::Armor => row.armor_multiplier,
75            RatingMultiplierSlot::Weapon => row.weapon_multiplier,
76            RatingMultiplierSlot::Trinket => row.trinket_multiplier,
77            RatingMultiplierSlot::Jewelry => row.jewelry_multiplier,
78            _ => 1.0,
79        })
80}
81
82wowlab_engine_macros::define_error! {
83    /// Error raised while converting raw ratings into effective stats.
84    #[derive(Debug)]
85    pub struct StatsError {
86        kind: StatsErrorKind,
87    }
88
89    #[derive(Debug, thiserror::Error)]
90    enum StatsErrorKind {
91        #[error("diminishing-returns curve {curve_id} missing for {rating_type:?}")]
92        MissingCurve {
93            curve_id: i32,
94            rating_type: RatingType,
95        },
96        #[error("combat-rating divisor missing for {rating_type:?} at level {level}")]
97        MissingCombatRating { rating_type: RatingType, level: u32 },
98    }
99}
100
101impl StatsError {
102    fn missing_curve(curve_id: i32, rating_type: RatingType) -> Self {
103        Self {
104            kind: StatsErrorKind::MissingCurve {
105                curve_id,
106                rating_type,
107            },
108        }
109    }
110
111    fn missing_combat_rating(rating_type: RatingType, level: u32) -> Self {
112        Self {
113            kind: StatsErrorKind::MissingCombatRating { rating_type, level },
114        }
115    }
116
117    /// Tests whether this reports a specific missing diminishing-returns curve.
118    #[must_use]
119    pub fn is_missing_curve(&self, curve_id: i32, rating_type: RatingType) -> bool {
120        matches!(
121            self.kind,
122            StatsErrorKind::MissingCurve {
123                curve_id: found_curve,
124                rating_type: found_rating,
125            } if found_curve == curve_id && found_rating == rating_type
126        )
127    }
128}
129
130macro_rules! stat_accessors {
131    (
132        $struct:ident, $enum_ty:ty, [ $( $variant:ident => $field:ident ),+ $(,)? ]
133    ) => {
134        impl $struct {
135            pub fn get(&self, key: $enum_ty) -> f64 {
136                match key {
137                    $( <$enum_ty>::$variant => self.$field, )+
138                }
139            }
140
141            pub fn set(&mut self, key: $enum_ty, value: f64) {
142                match key {
143                    $( <$enum_ty>::$variant => self.$field = value, )+
144                }
145            }
146
147            pub fn add(&mut self, key: $enum_ty, value: f64) {
148                match key {
149                    $( <$enum_ty>::$variant => self.$field += value, )+
150                }
151            }
152        }
153    };
154}
155
156/// Player primary attributes.
157#[derive(Clone, Debug, Default)]
158pub struct PrimaryStats {
159    pub strength: f64,
160    pub agility: f64,
161    pub intellect: f64,
162    pub stamina: f64,
163}
164
165stat_accessors!(PrimaryStats, Attribute, [
166    Strength => strength,
167    Agility => agility,
168    Intellect => intellect,
169    Stamina => stamina,
170]);
171
172/// Raw secondary and tertiary stat ratings before DR conversion.
173#[derive(Clone, Debug, Default)]
174pub struct Ratings {
175    pub crit: f64,
176    pub haste: f64,
177    pub mastery: f64,
178    pub versatility: f64,
179    pub leech: f64,
180    pub avoidance: f64,
181    pub speed: f64,
182}
183
184stat_accessors!(Ratings, RatingType, [
185    Crit => crit,
186    Haste => haste,
187    Mastery => mastery,
188    Versatility => versatility,
189    Leech => leech,
190    Avoidance => avoidance,
191    Speed => speed,
192]);
193
194/// Convert a raw rating to effective percent points via the DBC DR curve.
195///
196/// # Errors
197///
198/// Returns an error when the required diminishing-returns curve is unavailable.
199// docref:start stats-rating-to-percent
200pub fn rating_to_percent(
201    rating: f64,
202    rating_type: RatingType,
203    divisor: f64,
204    curves: &ResolvedCurves,
205) -> Result<f64, StatsError> {
206    let raw_pct = rating / divisor;
207    let curve_id = dr_curve_id(rating_type);
208    curves
209        .curve_points(curve_id)
210        .and_then(|points| interpolate_sorted(points, raw_pct))
211        .ok_or_else(|| StatsError::missing_curve(curve_id, rating_type))
212}
213// docref:end stats-rating-to-percent
214
215/// Convert raw rating from resolved game data into effective percent points.
216///
217/// Empty introspection data produces a neutral zero instead of requiring rating tables.
218///
219/// # Errors
220///
221/// Errors when the level divisor or diminishing-returns curve is unavailable.
222pub fn rating_percent_points(
223    data: &ResolvedGameData,
224    rating_type: RatingType,
225    amount: f64,
226) -> Result<f64, StatsError> {
227    if data.is_empty() {
228        return Ok(0.0);
229    }
230
231    let divisor = divisor_for(data.game_tables(), rating_type, data.level())?;
232
233    rating_to_percent(amount, rating_type, divisor, data.curves())
234}
235
236fn divisor_for(
237    tables: &ResolvedGameTables,
238    rating_type: RatingType,
239    level: u32,
240) -> Result<f64, StatsError> {
241    combat_rating_divisor(tables, rating_type, level)
242        .ok_or_else(|| StatsError::missing_combat_rating(rating_type, level))
243}
244
245const fn dr_curve_id(rating_type: RatingType) -> i32 {
246    match rating_type {
247        RatingType::Crit | RatingType::Haste | RatingType::Versatility | RatingType::Mastery => {
248            SECONDARY_DR_CURVE_ID
249        }
250        RatingType::Leech | RatingType::Avoidance | RatingType::Speed => TERTIARY_DR_CURVE_ID,
251    }
252}
253
254/// Returns the primary attribute used by a spec.
255#[must_use]
256pub fn primary_stat_for_spec(spec: SpecId) -> Attribute {
257    use SpecId::{
258        Affliction, Arcane, Arms, Assassination, Augmentation, Balance, BeastMastery, Blood,
259        Brewmaster, Demonology, Destruction, Devastation, Devourer, Discipline, Elemental,
260        Enhancement, Feral, Fire, FrostDK, FrostMage, Fury, Guardian, Havoc, HolyPaladin,
261        HolyPriest, Marksmanship, Mistweaver, Outlaw, Preservation, ProtPaladin, ProtWarrior,
262        RestoDruid, RestoShaman, Retribution, Shadow, Subtlety, Survival, Unholy, Vengeance,
263        Windwalker,
264    };
265
266    match spec {
267        BeastMastery | Marksmanship | Survival | Assassination | Outlaw | Subtlety | Feral
268        | Guardian | Brewmaster | Windwalker | Havoc | Vengeance | Enhancement => {
269            Attribute::Agility
270        }
271
272        Arms | Fury | ProtWarrior | HolyPaladin | ProtPaladin | Retribution | Blood | FrostDK
273        | Unholy => Attribute::Strength,
274
275        Discipline | HolyPriest | Shadow | Elemental | RestoShaman | Arcane | Fire | FrostMage
276        | Affliction | Demonology | Destruction | Mistweaver | Balance | RestoDruid | Devourer
277        | Devastation | Preservation | Augmentation => Attribute::Intellect,
278    }
279}
280
281// Attack and spell power intentionally share a 1:1 primary-stat model.
282const fn attack_and_spell_power(primary_value: f64) -> (f64, f64) {
283    (primary_value, primary_value)
284}
285
286/// Produces [`CombatStats`] from primaries, ratings, passives, and spec data.
287///
288/// # Errors
289///
290/// Returns an error when required game-table rows or rating curves are unavailable.
291// #t(fn: rust_large_fn_params) recompute threads every resolved stat input verbatim.
292// docref:start stats-recompute
293pub fn recompute(
294    primary: &PrimaryStats,
295    ratings: &Ratings,
296    passives: &PassiveStatMods,
297    spec: SpecId,
298    tables: &ResolvedGameTables,
299    level: u32,
300    curves: &ResolvedCurves,
301) -> Result<CombatStats, StatsError> {
302    // docref:end stats-recompute
303    let strength = primary.strength * passives.strength_mult;
304    let agility = primary.agility * passives.agility_mult;
305    let stamina = primary.stamina * passives.stamina_mult;
306    let intellect = primary.intellect * passives.intellect_mult;
307
308    let (primary_value, primary_stat_mult) = match primary_stat_for_spec(spec) {
309        Attribute::Strength => (strength, passives.strength_mult),
310        Attribute::Agility => (agility, passives.agility_mult),
311        Attribute::Stamina => (stamina, passives.stamina_mult),
312        Attribute::Intellect => (intellect, passives.intellect_mult),
313    };
314
315    let (attack_power, spell_power) = attack_and_spell_power(primary_value);
316
317    let crit_divisor = divisor_for(tables, RatingType::Crit, level)?;
318    let crit_rating = ratings.crit * passives.crit_rating_mult;
319    let crit_pct = rating_to_percent(crit_rating, RatingType::Crit, crit_divisor, curves)?;
320    let crit_chance = BASE_CRIT_CHANCE * HUNDRED + crit_pct + passives.crit_chance;
321
322    let haste_divisor = divisor_for(tables, RatingType::Haste, level)?;
323    let haste_rating = ratings.haste * passives.haste_rating_mult;
324    let haste_pct = rating_to_percent(haste_rating, RatingType::Haste, haste_divisor, curves)?;
325    let haste = ((1.0 + haste_pct / HUNDRED) * passives.haste_mult - 1.0) * HUNDRED;
326
327    let mastery_divisor = divisor_for(tables, RatingType::Mastery, level)?;
328    // Raw mastery in points (base plus rating); the per-effect coefficient is applied by hooks.
329    let mastery_rating = ratings.mastery * passives.mastery_rating_mult;
330    let mastery = BASE_MASTERY_POINTS
331        + passives.mastery_points
332        + rating_to_percent(mastery_rating, RatingType::Mastery, mastery_divisor, curves)?;
333
334    let vers_divisor = divisor_for(tables, RatingType::Versatility, level)?;
335    let vers_rating = ratings.versatility * passives.versatility_rating_mult;
336    let versatility =
337        rating_to_percent(vers_rating, RatingType::Versatility, vers_divisor, curves)?
338            + passives.versatility_pct;
339    let leech_divisor = divisor_for(tables, RatingType::Leech, level)?;
340    let leech = rating_to_percent(ratings.leech, RatingType::Leech, leech_divisor, curves)?;
341
342    // The single place every formula input is known; without it a rating gap and a
343    // rating->percent conversion gap are indistinguishable downstream.
344
345    tracing::debug!(
346        name: "resolved_combat_stats",
347        level,
348        attack_power,
349        crit_rating = crit_rating,
350        crit_divisor,
351        crit_chance,
352        haste_rating = haste_rating,
353        haste_divisor,
354        haste,
355        mastery_rating = mastery_rating,
356        mastery_divisor,
357        mastery_base_points = BASE_MASTERY_POINTS + passives.mastery_points,
358        mastery,
359        versatility_rating = vers_rating,
360        vers_divisor,
361        versatility,
362        "resolved combat stats"
363    );
364
365    Ok(CombatStats {
366        attack_power,
367        spell_power,
368        crit_chance,
369        haste,
370        mastery,
371        versatility,
372        leech,
373        crit_damage_bonus: 0.0,
374        armor: 0.0,
375        stamina,
376        intellect,
377        agility,
378        strength,
379        positive_strength_bonus: 0.0,
380        positive_agility_bonus: 0.0,
381        positive_stamina_bonus: 0.0,
382        positive_intellect_bonus: 0.0,
383        primary_stat_mult,
384    })
385}
386
387// Hand-picked round numbers, not derived from real ratings (no curves available in those contexts).
388const PLACEHOLDER_POWER: f64 = 15_000.0;
389const PLACEHOLDER_CRIT_PCT: f64 = 25.0;
390const PLACEHOLDER_HASTE_PCT: f64 = 15.0;
391const PLACEHOLDER_MASTERY_PCT: f64 = 40.0;
392const PLACEHOLDER_VERSATILITY_PCT: f64 = 5.0;
393
394/// Placeholder gearless [`CombatStats`]; see the module note on units (percent points).
395#[must_use]
396pub const fn default_stats() -> CombatStats {
397    CombatStats {
398        attack_power: PLACEHOLDER_POWER,
399        spell_power: PLACEHOLDER_POWER,
400        crit_chance: PLACEHOLDER_CRIT_PCT,
401        haste: PLACEHOLDER_HASTE_PCT,
402        mastery: PLACEHOLDER_MASTERY_PCT,
403        versatility: PLACEHOLDER_VERSATILITY_PCT,
404        leech: 0.0,
405        crit_damage_bonus: 0.0,
406        armor: 0.0,
407        stamina: 0.0,
408        intellect: 0.0,
409        agility: 0.0,
410        strength: 0.0,
411        positive_strength_bonus: 0.0,
412        positive_agility_bonus: 0.0,
413        positive_stamina_bonus: 0.0,
414        positive_intellect_bonus: 0.0,
415        primary_stat_mult: 1.0,
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use googletest::prelude::*;
422    use rstest::rstest;
423    use wowlab_types::sim::IntMap;
424
425    use super::*;
426
427    const LEVEL_90_CRIT_DIVISOR: f64 = 46.0;
428
429    fn secondary_dr_curves() -> ResolvedCurves {
430        let mut points: IntMap<i32, Vec<(f64, f64)>> = IntMap::default();
431
432        points.insert(
433            SECONDARY_DR_CURVE_ID,
434            vec![(0.0, 0.0), (30.0, 30.0), (40.0, 39.0), (50.0, 47.0)],
435        );
436
437        ResolvedCurves::from_points(points)
438    }
439
440    #[gtest]
441    #[rstest]
442    #[case::zero_rating(0.0, 0.0)]
443    #[case::ret_mid1_crit_below_first_breakpoint(1052.0, 1052.0 / LEVEL_90_CRIT_DIVISOR)]
444    #[case::dr_interpolates_past_30(1610.0, 34.5)]
445    fn rating_to_percent_level_90_crit_arms(
446        #[case] rating: f64,
447        #[case] expected_pct: f64,
448    ) -> Result<()> {
449        let curves = secondary_dr_curves();
450        let pct = rating_to_percent(rating, RatingType::Crit, LEVEL_90_CRIT_DIVISOR, &curves)
451            .expect("secondary DR curve is seeded");
452
453        verify_that!(pct, near(expected_pct, 1e-9))
454    }
455
456    #[gtest]
457    fn enhancement_uses_agility_as_its_primary_stat() {
458        expect_that!(
459            primary_stat_for_spec(SpecId::Enhancement),
460            eq(Attribute::Agility)
461        );
462    }
463}