1use 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
17pub const BASE_MASTERY_POINTS: f64 = 8.0;
19
20pub const SECONDARY_DR_CURVE_ID: i32 = 21_024;
22pub const TERTIARY_DR_CURVE_ID: i32 = 21_025;
24const BASE_CRIT_MULTIPLIER: f64 = 2.0;
25
26#[must_use]
28pub const fn haste_multiplier(stats: &CombatStats) -> f64 {
29 1.0 + stats.haste / HUNDRED
30}
31
32#[must_use]
34pub const fn crit_multiplier(stats: &CombatStats) -> f64 {
35 BASE_CRIT_MULTIPLIER + stats.crit_damage_bonus / HUNDRED
36}
37
38#[must_use]
40pub const fn versatility_multiplier(stats: &CombatStats) -> f64 {
41 1.0 + stats.versatility / HUNDRED
42}
43
44#[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#[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 #[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 #[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#[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#[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
194pub 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}
213pub 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#[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
281const fn attack_and_spell_power(primary_value: f64) -> (f64, f64) {
283 (primary_value, primary_value)
284}
285
286pub 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 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 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 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
387const 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#[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}