rosu_v2/model/
score.rs

1use rosu_mods::{serde::GameModsSeed, GameModIntermode, GameModsIntermode};
2use serde::{
3    de::{DeserializeSeed, IgnoredAny},
4    Deserialize, Deserializer,
5};
6use serde_json::value::RawValue;
7use time::OffsetDateTime;
8
9use crate::{error::OsuError, request::GetUser, Osu, OsuResult};
10
11use super::{
12    beatmap::{BeatmapExtended, Beatmapset},
13    mods::GameMods,
14    serde_util,
15    user::User,
16    CacheUserFn, ContainedUsers, GameMode, Grade,
17};
18
19#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
20#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
21pub struct BeatmapUserScore {
22    /// The position of the score within the requested beatmap ranking
23    #[serde(rename = "position")]
24    pub pos: usize,
25    /// The details of the score
26    pub score: Score,
27}
28
29impl BeatmapUserScore {
30    /// Request the [`UserExtended`](crate::model::user::UserExtended) of the score
31    #[inline]
32    pub fn get_user<'o>(&self, osu: &'o Osu) -> GetUser<'o> {
33        self.score.get_user(osu)
34    }
35}
36
37impl ContainedUsers for BeatmapUserScore {
38    fn apply_to_users(&self, f: impl CacheUserFn) {
39        self.score.apply_to_users(f);
40    }
41}
42
43#[derive(Clone, Debug, Deserialize)]
44#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
45pub struct ProcessedScores {
46    pub scores: Vec<Score>,
47    #[serde(default)]
48    pub(crate) mode: Option<GameMode>,
49    #[serde(rename = "cursor_string")]
50    pub(crate) cursor: Box<str>,
51}
52
53impl ProcessedScores {
54    /// Fetch the next batch of scores.
55    #[inline]
56    pub async fn get_next(&self, osu: &Osu) -> OsuResult<Self> {
57        let mut req = osu.scores().cursor(self.cursor.clone());
58
59        if let Some(mode) = self.mode {
60            req = req.mode(mode);
61        }
62
63        req.await
64    }
65}
66
67impl ContainedUsers for ProcessedScores {
68    fn apply_to_users(&self, f: impl CacheUserFn) {
69        self.scores.apply_to_users(f);
70    }
71}
72
73#[derive(Clone, Debug)]
74#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
75pub struct Score {
76    pub set_on_lazer: bool,
77    #[cfg_attr(feature = "serialize", serde(rename = "classic_total_score"))]
78    pub classic_score: u64,
79    pub ranked: Option<bool>,
80    pub preserve: Option<bool>,
81    pub processed: Option<bool>,
82    pub maximum_statistics: ScoreStatistics,
83    pub mods: GameMods,
84    pub statistics: ScoreStatistics,
85    #[cfg_attr(feature = "serialize", serde(rename = "beatmap_id"))]
86    pub map_id: u32,
87    pub best_id: Option<u64>,
88    pub id: u64,
89    #[cfg_attr(feature = "serialize", serde(rename = "rank"))]
90    pub grade: Grade,
91    #[cfg_attr(feature = "serialize", serde(rename = "type"))]
92    pub kind: Box<str>,
93    pub user_id: u32,
94    #[cfg_attr(feature = "serialize", serde(with = "serde_util::adjust_acc"))]
95    pub accuracy: f32,
96    pub build_id: Option<u32>,
97    #[cfg_attr(feature = "serialize", serde(with = "serde_util::datetime"))]
98    pub ended_at: OffsetDateTime,
99    pub has_replay: bool,
100    pub is_perfect_combo: bool,
101    pub legacy_perfect: Option<bool>,
102    pub legacy_score_id: Option<u64>,
103    #[cfg_attr(feature = "serialize", serde(rename = "legacy_total_score"))]
104    pub legacy_score: u32,
105    pub max_combo: u32,
106    pub passed: bool,
107    pub pp: Option<f32>,
108    #[cfg_attr(feature = "serialize", serde(rename = "ruleset_id"))]
109    pub mode: GameMode,
110    #[cfg_attr(feature = "serialize", serde(with = "serde_util::option_datetime"))]
111    pub started_at: Option<OffsetDateTime>,
112    #[cfg_attr(feature = "serialize", serde(rename = "total_score"))]
113    pub score: u32,
114    pub replay: bool,
115    pub current_user_attributes: UserAttributes,
116    pub total_score_without_mods: Option<u32>,
117    #[cfg_attr(feature = "serialize", serde(rename = "beatmap"))]
118    pub map: Option<Box<BeatmapExtended>>,
119    #[cfg_attr(feature = "serialize", serde(rename = "beatmapset"))]
120    pub mapset: Option<Box<Beatmapset>>,
121    pub rank_global: Option<u32>,
122    pub user: Option<Box<User>>,
123    pub weight: Option<ScoreWeight>,
124}
125
126impl ContainedUsers for Score {
127    fn apply_to_users(&self, f: impl CacheUserFn) {
128        self.user.apply_to_users(f);
129        self.map.apply_to_users(f);
130        self.mapset.apply_to_users(f);
131    }
132}
133
134impl<'de> Deserialize<'de> for Score {
135    #[allow(clippy::too_many_lines)]
136    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
137        #[derive(Deserialize)]
138        #[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
139        struct ScoreRawMods {
140            set_on_lazer: Option<bool>, // used for serialized score; not sent by osu!
141            #[serde(default, rename = "classic_total_score")]
142            classic_score: u64, // not available in legacy scores
143            ranked: Option<bool>,
144            preserve: Option<bool>,
145            processed: Option<bool>,
146            #[serde(default)]
147            maximum_statistics: ScoreStatistics,
148            mods: Box<RawValue>,
149            statistics: ScoreStatistics,
150            #[serde(rename = "beatmap_id")]
151            map_id: Option<u32>, // not available in legacy scores
152            best_id: Option<u64>,
153            id: u64,
154            #[serde(rename = "rank")]
155            grade: Grade,
156            #[serde(rename = "type")]
157            kind: Box<str>,
158            user_id: u32,
159            #[serde(with = "serde_util::adjust_acc")]
160            accuracy: f32,
161            build_id: Option<u32>,
162            #[serde(alias = "created_at", with = "serde_util::datetime")]
163            ended_at: OffsetDateTime,
164            has_replay: Option<bool>,       // not available in legacy scores
165            is_perfect_combo: Option<bool>, // not available in legacy scores
166            #[serde(alias = "perfect")]
167            legacy_perfect: Option<bool>,
168            legacy_score_id: Option<u64>,
169            #[serde(default, rename = "legacy_total_score")]
170            legacy_score: u32, // not available in legacy scores
171            max_combo: u32,
172            passed: bool,
173            pp: Option<f32>,
174            #[serde(rename = "mode")]
175            mode_: Option<IgnoredAny>, // only available in legacy scores
176            #[serde(rename = "ruleset_id", alias = "mode_int")]
177            mode: GameMode,
178            #[serde(default, with = "serde_util::option_datetime")]
179            started_at: Option<OffsetDateTime>,
180            #[serde(rename = "total_score", alias = "score")]
181            score: u32,
182            replay: bool,
183            current_user_attributes: UserAttributes,
184            total_score_without_mods: Option<u32>,
185            #[serde(rename = "beatmap")]
186            map: Option<Box<BeatmapExtended>>,
187            #[serde(rename = "beatmapset")]
188            mapset: Option<Box<Beatmapset>>,
189            rank_global: Option<u32>,
190            user: Option<Box<User>>,
191            // TODO: This is just a temporary fix for <https://github.com/ppy/osu-web/issues/10932>.
192            // Once the issue is resolved, `Option<ScoreWeight>` can be used again.
193            weight: Option<MaybeWeight>,
194        }
195
196        #[derive(Deserialize)]
197        struct MaybeWeight {
198            percentage: f32,
199            pp: Option<f32>,
200        }
201
202        let score_raw = <ScoreRawMods as serde::Deserialize>::deserialize(d)?;
203        let set_on_stable = score_raw.set_on_lazer.map_or(
204            score_raw.legacy_score > 0 || score_raw.mode_.is_some(),
205            <bool as std::ops::Not>::not,
206        );
207
208        Ok(Score {
209            set_on_lazer: !set_on_stable,
210            classic_score: score_raw.classic_score,
211            ranked: score_raw.ranked,
212            preserve: score_raw.preserve,
213            processed: score_raw.processed,
214            maximum_statistics: score_raw.maximum_statistics,
215            mods: GameModsSeed::Mode {
216                mode: score_raw.mode,
217                deny_unknown_fields: false,
218            }
219            .deserialize(&*score_raw.mods)
220            .map_err(|e| OsuError::invalid_mods(&score_raw.mods, &e))?,
221            statistics: score_raw.statistics,
222            map_id: score_raw
223                .map_id
224                .or_else(|| score_raw.map.as_ref().map(|map| map.map_id))
225                .unwrap_or(0),
226            best_id: score_raw.best_id,
227            id: score_raw.id,
228            grade: score_raw.grade,
229            kind: score_raw.kind,
230            user_id: score_raw.user_id,
231            accuracy: score_raw.accuracy,
232            build_id: score_raw.build_id,
233            ended_at: score_raw.ended_at,
234            has_replay: score_raw.has_replay.unwrap_or(score_raw.replay),
235            is_perfect_combo: score_raw
236                .is_perfect_combo
237                .or(score_raw.legacy_perfect)
238                .unwrap_or(false),
239            legacy_perfect: score_raw.legacy_perfect,
240            legacy_score_id: score_raw
241                .legacy_score_id
242                .or_else(|| set_on_stable.then_some(score_raw.id)),
243            legacy_score: if set_on_stable {
244                score_raw.score
245            } else {
246                score_raw.legacy_score
247            },
248            max_combo: score_raw.max_combo,
249            passed: score_raw.passed,
250            pp: score_raw.pp,
251            mode: score_raw.mode,
252            started_at: score_raw.started_at,
253            score: score_raw.score,
254            replay: score_raw.replay,
255            current_user_attributes: score_raw.current_user_attributes,
256            total_score_without_mods: score_raw.total_score_without_mods,
257            map: score_raw.map,
258            mapset: score_raw.mapset,
259            rank_global: score_raw.rank_global,
260            user: score_raw.user,
261            weight: score_raw.weight.and_then(|weight| {
262                Some(ScoreWeight {
263                    percentage: weight.percentage,
264                    pp: weight.pp?,
265                })
266            }),
267        })
268    }
269}
270
271impl Score {
272    #[inline]
273    pub fn get_user<'o>(&self, osu: &'o Osu) -> GetUser<'o> {
274        osu.user(self.user_id)
275    }
276
277    /// Count all hitobjects of the score i.e. for `GameMode::Osu` the amount 300s, 100s, 50s, and misses.
278    ///
279    /// Note: Includes tiny droplet (misses) for `GameMode::Catch`.
280    #[inline]
281    pub const fn total_hits(&self) -> u32 {
282        self.statistics.total_hits(self.mode)
283    }
284
285    /// Calculate the accuracy rounded to two decimal points i.e. `0 <= accuracy <= 100`.
286    #[inline]
287    pub fn accuracy(&self) -> f32 {
288        self.statistics
289            .accuracy(self.mode, &self.maximum_statistics)
290    }
291
292    /// Calculate the accuracy rounded to two decimal points i.e. `0 <= accuracy <= 100`.
293    ///
294    /// Slider hits and such will not be considered.
295    #[inline]
296    pub fn legacy_accuracy(&self) -> f32 {
297        self.statistics.legacy_accuracy(self.mode)
298    }
299
300    /// Calculate the grade of the score.
301    ///
302    /// The accuracy is calculated internally if not provided.
303    ///
304    /// This method assumes the score to be a pass i.e. the amount of passed
305    /// objects is equal to the beatmaps total amount of objects. Otherwise,
306    /// it may produce an incorrect grade.
307    pub fn grade(&self, accuracy: Option<f32>) -> Grade {
308        match self.mode {
309            GameMode::Osu => osu_grade(self, accuracy),
310            GameMode::Taiko => taiko_grade(self, accuracy),
311            GameMode::Catch => catch_grade(self, accuracy),
312            GameMode::Mania => mania_grade(self, accuracy),
313        }
314    }
315
316    /// Calculate the legacy grade of the score.
317    ///
318    /// The accuracy is calculated internally if not provided.
319    ///
320    /// This method assumes the score to be a pass i.e. the amount of passed
321    /// objects is equal to the beatmaps total amount of objects. Otherwise,
322    /// it may produce an incorrect grade.
323    pub fn legacy_grade(&self, accuracy: Option<f32>) -> Grade {
324        match self.mode {
325            GameMode::Osu => osu_grade_legacy(self),
326            GameMode::Taiko => taiko_grade_legacy(self),
327            GameMode::Catch => catch_grade_legacy(self, accuracy.ok_or(Score::legacy_accuracy)),
328            GameMode::Mania => mania_grade_legacy(self, accuracy.ok_or(Score::legacy_accuracy)),
329        }
330    }
331}
332
333impl PartialEq for Score {
334    #[inline]
335    fn eq(&self, other: &Self) -> bool {
336        self.user_id == other.user_id
337            && (self.ended_at.unix_timestamp() - other.ended_at.unix_timestamp()).abs() <= 2
338            && self.score == other.score
339    }
340}
341
342impl Eq for Score {}
343
344#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
345#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
346pub struct ScoreStatistics {
347    #[serde(
348        default,
349        alias = "count_miss",
350        deserialize_with = "serde_util::from_option::deserialize"
351    )]
352    pub miss: u32,
353    #[serde(
354        default,
355        alias = "count_50",
356        deserialize_with = "serde_util::from_option::deserialize"
357    )]
358    pub meh: u32,
359    #[serde(
360        default,
361        alias = "count_100",
362        deserialize_with = "serde_util::from_option::deserialize"
363    )]
364    pub ok: u32,
365    #[serde(
366        default,
367        alias = "count_katu",
368        deserialize_with = "serde_util::from_option::deserialize"
369    )]
370    pub good: u32,
371    #[serde(
372        default,
373        alias = "count_300",
374        deserialize_with = "serde_util::from_option::deserialize"
375    )]
376    pub great: u32,
377    #[serde(
378        default,
379        alias = "count_geki",
380        deserialize_with = "serde_util::from_option::deserialize"
381    )]
382    pub perfect: u32,
383    #[serde(default)]
384    pub large_tick_hit: u32,
385    #[serde(default)]
386    pub large_tick_miss: u32,
387    #[serde(default)]
388    pub small_tick_hit: u32,
389    #[serde(default)]
390    pub small_tick_miss: u32,
391    #[serde(default)]
392    pub ignore_hit: u32,
393    #[serde(default)]
394    pub ignore_miss: u32,
395    #[serde(default)]
396    pub large_bonus: u32,
397    #[serde(default)]
398    pub small_bonus: u32,
399    #[serde(default)]
400    pub slider_tail_hit: u32,
401    #[serde(default)]
402    pub combo_break: u32,
403    #[serde(default)]
404    pub legacy_combo_increase: u32,
405}
406
407impl ScoreStatistics {
408    /// Count all hitobjects of the score i.e. for `GameMode::Osu` the amount 300s, 100s, 50s, and misses.
409    ///
410    /// Note: Includes tiny droplet (misses) for `GameMode::Catch`.
411    pub const fn total_hits(&self, mode: GameMode) -> u32 {
412        match mode {
413            GameMode::Osu => self.ok + self.meh + self.great + self.miss,
414            GameMode::Taiko => self.ok + self.great + self.miss,
415            GameMode::Catch => self.miss + self.great + self.large_tick_hit + self.small_tick_hit,
416            GameMode::Mania => {
417                self.ok + self.meh + self.good + self.miss + self.great + self.perfect
418            }
419        }
420    }
421
422    /// Calculate the accuracy rounded to two decimal points i.e. `0 <= accuracy <= 100`
423    pub fn accuracy(&self, mode: GameMode, max_statistics: &ScoreStatistics) -> f32 {
424        let numerator = self.accuracy_value(mode) as f32;
425        let denominator = max_statistics.accuracy_value(mode) as f32;
426
427        (10_000.0 * numerator / denominator).round() / 100.0
428    }
429
430    const fn accuracy_value(&self, mode: GameMode) -> u32 {
431        let mut sum = 0;
432
433        sum += match mode {
434            GameMode::Osu | GameMode::Taiko | GameMode::Mania => self.small_tick_hit * 10,
435            GameMode::Catch => self.small_tick_hit * 300,
436        };
437
438        sum += match mode {
439            GameMode::Osu | GameMode::Taiko | GameMode::Mania => self.large_tick_hit * 30,
440            GameMode::Catch => self.large_tick_hit * 300,
441        };
442
443        sum += self.slider_tail_hit * 150;
444        sum += self.meh * 50;
445
446        sum += match mode {
447            GameMode::Osu | GameMode::Catch | GameMode::Mania => self.ok * 100,
448            GameMode::Taiko => self.ok * 150,
449        };
450
451        sum += self.good * 200;
452        sum += self.great * 300;
453
454        sum += match mode {
455            GameMode::Osu | GameMode::Taiko | GameMode::Catch => self.perfect * 300,
456            GameMode::Mania => self.perfect * 305,
457        };
458
459        sum
460    }
461
462    /// Calculate the accuracy rounded to two decimal points i.e. `0 <= accuracy <= 100`.
463    ///
464    /// Slider hits and such will not be considered.
465    pub fn legacy_accuracy(&self, mode: GameMode) -> f32 {
466        let numerator;
467        let denominator;
468
469        match mode {
470            GameMode::Osu => {
471                numerator = (self.meh * 50 + self.ok * 100 + self.great * 300) as f32;
472                denominator = (self.total_hits(mode) * 300) as f32;
473            }
474            GameMode::Taiko => {
475                numerator = (self.ok + self.great * 2) as f32;
476                denominator = (self.total_hits(mode) * 2) as f32;
477            }
478            GameMode::Catch => {
479                numerator = (self.large_tick_hit + self.great + self.small_tick_hit) as f32;
480                denominator = self.total_hits(mode) as f32;
481            }
482            GameMode::Mania => {
483                numerator = (self.meh * 50
484                    + self.ok * 100
485                    + self.good * 200
486                    + (self.great + self.perfect) * 300) as f32;
487
488                denominator = (self.total_hits(mode) * 300) as f32;
489            }
490        }
491
492        (10_000.0 * numerator / denominator).round() / 100.0
493    }
494
495    /// Turn [`ScoreStatistics`] into [`LegacyScoreStatistics`]
496    pub const fn as_legacy(&self, mode: GameMode) -> LegacyScoreStatistics {
497        let mut count_geki = 0;
498        let mut count_katu = 0;
499        let count_300 = self.great;
500        let count_100;
501        let mut count_50 = 0;
502        let count_miss = self.miss;
503
504        match mode {
505            GameMode::Osu => {
506                count_100 = self.ok;
507                count_50 = self.meh;
508            }
509            GameMode::Taiko => count_100 = self.ok,
510            GameMode::Catch => {
511                const fn max(a: u32, b: u32) -> u32 {
512                    if a > b {
513                        a
514                    } else {
515                        b
516                    }
517                }
518
519                count_100 = max(self.large_tick_hit, self.ok);
520                count_50 = max(self.small_tick_hit, self.meh);
521                count_katu = max(self.small_tick_miss, self.good);
522            }
523            GameMode::Mania => {
524                count_geki = self.perfect;
525                count_katu = self.good;
526                count_100 = self.ok;
527                count_50 = self.meh;
528            }
529        }
530
531        LegacyScoreStatistics {
532            count_geki,
533            count_katu,
534            count_300,
535            count_100,
536            count_50,
537            count_miss,
538        }
539    }
540}
541
542#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
543#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
544pub struct LegacyScoreStatistics {
545    pub count_geki: u32,
546    pub count_katu: u32,
547    pub count_300: u32,
548    pub count_100: u32,
549    pub count_50: u32,
550    pub count_miss: u32,
551}
552
553impl LegacyScoreStatistics {
554    /// Count all hitobjects of the score i.e. for `GameMode::Osu` the amount 300s, 100s, 50s, and misses.
555    ///
556    /// Note: Includes tiny droplet (misses) for `GameMode::Catch`
557    pub fn total_hits(&self, mode: GameMode) -> u32 {
558        let mut amount = self.count_300 + self.count_100 + self.count_miss;
559
560        if mode != GameMode::Taiko {
561            amount += self.count_50;
562
563            if mode != GameMode::Osu {
564                amount += self.count_katu;
565                amount += u32::from(mode != GameMode::Catch) * self.count_geki;
566            }
567        }
568
569        amount
570    }
571
572    /// Calculate the accuracy rounded to two decimal points i.e. `0 <= accuracy <= 100`
573    pub fn accuracy(&self, mode: GameMode) -> f32 {
574        let amount_objects = self.total_hits(mode) as f32;
575
576        let (numerator, denumerator) = match mode {
577            GameMode::Taiko => (
578                0.5 * self.count_100 as f32 + self.count_300 as f32,
579                amount_objects,
580            ),
581            GameMode::Catch => (
582                (self.count_300 + self.count_100 + self.count_50) as f32,
583                amount_objects,
584            ),
585            GameMode::Osu | GameMode::Mania => {
586                let mut n =
587                    (self.count_50 * 50 + self.count_100 * 100 + self.count_300 * 300) as f32;
588
589                n += (u32::from(mode == GameMode::Mania)
590                    * (self.count_katu * 200 + self.count_geki * 300)) as f32;
591
592                (n, amount_objects * 300.0)
593            }
594        };
595
596        (10_000.0 * numerator / denumerator).round() / 100.0
597    }
598}
599
600#[derive(Copy, Clone, Debug, Deserialize, PartialEq)]
601#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
602pub struct ScoreWeight {
603    /// Percentage of the score's pp that will be added to the user's total pp between 0 and 100
604    pub percentage: f32,
605    /// PP **after** taking the percentage of the score's raw pp
606    pub pp: f32,
607}
608
609#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq)]
610#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
611pub struct UserAttributes {
612    pub pin: Option<UserAttributesPin>,
613}
614
615#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq)]
616#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
617pub struct UserAttributesPin {
618    pub is_pinned: bool,
619    pub score_id: u64,
620}
621
622fn hdfl() -> GameModsIntermode {
623    [GameModIntermode::Hidden, GameModIntermode::Flashlight]
624        .into_iter()
625        .collect()
626}
627
628fn hdflfi() -> GameModsIntermode {
629    [
630        GameModIntermode::Hidden,
631        GameModIntermode::Flashlight,
632        GameModIntermode::FadeIn,
633    ]
634    .into_iter()
635    .collect()
636}
637
638fn osu_grade(score: &Score, accuracy: Option<f32>) -> Grade {
639    if score.statistics.great == score.maximum_statistics.great
640        && score.statistics.large_tick_hit == score.maximum_statistics.large_tick_hit
641        && score.statistics.slider_tail_hit == score.maximum_statistics.slider_tail_hit
642    {
643        return if score.mods.contains_any(hdfl()) {
644            Grade::XH
645        } else {
646            Grade::X
647        };
648    }
649
650    let accuracy = accuracy.unwrap_or_else(|| score.accuracy());
651
652    if accuracy >= 95.0
653        && score.statistics.miss == 0
654        && score.statistics.large_tick_hit == score.maximum_statistics.large_tick_hit
655    {
656        if score.mods.contains_any(hdflfi()) {
657            Grade::SH
658        } else {
659            Grade::S
660        }
661    } else if accuracy >= 90.0 {
662        Grade::A
663    } else if accuracy >= 80.0 {
664        Grade::B
665    } else if accuracy >= 70.0 {
666        Grade::C
667    } else {
668        Grade::D
669    }
670}
671
672fn taiko_grade(score: &Score, accuracy: Option<f32>) -> Grade {
673    osu_grade(score, accuracy)
674}
675
676fn catch_grade(score: &Score, accuracy: Option<f32>) -> Grade {
677    catch_grade_legacy(score, accuracy.ok_or(Score::accuracy))
678}
679
680fn mania_grade(score: &Score, accuracy: Option<f32>) -> Grade {
681    mania_grade_legacy(score, accuracy.ok_or(Score::accuracy))
682}
683
684fn osu_grade_legacy(score: &Score) -> Grade {
685    if score.statistics.great == score.maximum_statistics.great {
686        return if score.mods.contains_any(hdfl()) {
687            Grade::XH
688        } else {
689            Grade::X
690        };
691    }
692
693    let stats = &score.statistics;
694    let passed_objects = stats.total_hits(GameMode::Osu);
695
696    let ratio300 = stats.great as f32 / passed_objects as f32;
697    let ratio50 = stats.meh as f32 / passed_objects as f32;
698
699    if ratio300 > 0.9 && ratio50 < 0.01 && stats.miss == 0 {
700        if score.mods.contains_any(hdfl()) {
701            Grade::SH
702        } else {
703            Grade::S
704        }
705    } else if ratio300 > 0.9 || (ratio300 > 0.8 && stats.miss == 0) {
706        Grade::A
707    } else if ratio300 > 0.8 || (ratio300 > 0.7 && stats.miss == 0) {
708        Grade::B
709    } else if ratio300 > 0.6 {
710        Grade::C
711    } else {
712        Grade::D
713    }
714}
715
716fn taiko_grade_legacy(score: &Score) -> Grade {
717    if score.statistics.great == score.maximum_statistics.great {
718        return if score.mods.contains_any(hdfl()) {
719            Grade::XH
720        } else {
721            Grade::X
722        };
723    }
724
725    let stats = &score.statistics;
726    let passed_objects = stats.total_hits(GameMode::Taiko);
727    let ratio300 = stats.great as f32 / passed_objects as f32;
728
729    if ratio300 > 0.9 && stats.miss == 0 {
730        if score.mods.contains_any(hdfl()) {
731            Grade::SH
732        } else {
733            Grade::S
734        }
735    } else if ratio300 > 0.9 || (ratio300 > 0.8 && stats.miss == 0) {
736        Grade::A
737    } else if ratio300 > 0.8 || (ratio300 > 0.7 && stats.miss == 0) {
738        Grade::B
739    } else if ratio300 > 0.6 {
740        Grade::C
741    } else {
742        Grade::D
743    }
744}
745
746fn catch_grade_legacy(score: &Score, accuracy: Result<f32, fn(&Score) -> f32>) -> Grade {
747    let accuracy = accuracy.unwrap_or_else(|f| f(score));
748
749    if (100.0 - accuracy).abs() < f32::EPSILON {
750        if score.mods.contains_any(hdfl()) {
751            Grade::XH
752        } else {
753            Grade::X
754        }
755    } else if accuracy >= 98.0 {
756        if score.mods.contains_any(hdfl()) {
757            Grade::SH
758        } else {
759            Grade::S
760        }
761    } else if accuracy >= 94.0 {
762        Grade::A
763    } else if accuracy >= 90.0 {
764        Grade::B
765    } else if accuracy >= 85.0 {
766        Grade::C
767    } else {
768        Grade::D
769    }
770}
771
772fn mania_grade_legacy(score: &Score, accuracy: Result<f32, fn(&Score) -> f32>) -> Grade {
773    if score.statistics.perfect == score.maximum_statistics.perfect {
774        return if score.mods.contains_any(hdflfi()) {
775            Grade::XH
776        } else {
777            Grade::X
778        };
779    }
780
781    let accuracy = accuracy.unwrap_or_else(|f| f(score));
782
783    if accuracy >= 95.0 {
784        if score.mods.contains_any(hdflfi()) {
785            Grade::SH
786        } else {
787            Grade::S
788        }
789    } else if accuracy >= 90.0 {
790        Grade::A
791    } else if accuracy >= 80.0 {
792        Grade::B
793    } else if accuracy >= 70.0 {
794        Grade::C
795    } else {
796        Grade::D
797    }
798}