rg-stats
Version:
A library for calculating various rhythm game stats.
135 lines (114 loc) • 4.88 kB
text/typescript
import { ThrowIf } from "../util/throw-if";
// [F, E, D, C, B, A, AA, AAA, S - 0.01, S, SS - 0.01, SS, SSS - 0.01, SSS, SSS+]
const RATE_TABLE: Record<number, number[]> = {
0: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
10: [0, 0, 10, 20, 40, 100, 150, 250, 400, 400, 500, 550, 650, 700, 800],
20: [0, 0, 20, 40, 80, 200, 250, 300, 450, 450, 550, 600, 700, 750, 850],
30: [0, 0, 30, 60, 120, 300, 350, 400, 500, 500, 600, 650, 750, 800, 900],
40: [0, 0, 40, 80, 160, 350, 400, 450, 550, 550, 650, 700, 800, 850, 950],
50: [0, 0, 50, 100, 200, 400, 500, 550, 600, 600, 700, 750, 850, 900, 1000],
60: [0, 0, 60, 120, 240, 450, 550, 600, 650, 650, 750, 800, 900, 950, 1050],
70: [0, 0, 70, 140, 280, 500, 600, 700, 725, 750, 875, 900, 975, 1000, 1100],
80: [0, 0, 80, 160, 320, 550, 650, 725, 750, 800, 925, 950, 1025, 1050, 1150],
90: [0, 0, 90, 180, 360, 600, 700, 750, 800, 900, 1025, 1050, 1125, 1150, 1250],
100: [0, 0, 100, 200, 400, 700, 800, 850, 900, 1000, 1125, 1150, 1225, 1250, 1350],
110: [0, 0, 110, 220, 440, 800, 900, 950, 1000, 1100, 1175, 1200, 1275, 1300, 1400],
120: [0, 0, 120, 240, 480, 900, 1000, 1050, 1100, 1200, 1275, 1300, 1375, 1400, 1500],
130: [0, 0, 130, 260, 520, 1000, 1100, 1150, 1250, 1350, 1475, 1500, 1575, 1600, 1700],
140: [0, 0, 140, 280, 560, 1100, 1200, 1250, 1400, 1500, 1675, 1700, 1775, 1800, 1900],
150: [0, 0, 150, 300, 600, 1200, 1300, 1350, 1550, 1650, 1875, 1900, 1975, 2000, 2100],
};
const RANK_BOUNDARIES = {
F: 0,
E: 1000,
D: 2000,
C: 4000,
B: 6000,
A: 8000,
AA: 9000,
AAA: 9400,
"S-": 9699,
S: 9700,
"SS-": 9899,
SS: 9900,
"SSS-": 9999,
SSS: 10000,
"SSS+": 20000,
} as const;
// @ts-expect-error getRank is called after the score is checked for validity
// (between 0 and 104), so not having an ending return statement is OK.
function getRank(score: number): keyof typeof RANK_BOUNDARIES {
for (const [rank, boundary] of Object.entries(RANK_BOUNDARIES).reverse()) {
if (score >= boundary) {
return rank as keyof typeof RANK_BOUNDARIES;
}
}
}
function calculateRatingCurve(iclInt: number): number[] {
let lowerIcl = Math.floor(iclInt / 10) * 10;
let upperIcl = Math.ceil(iclInt / 10) * 10;
// extrapolate for levels beyond 15
if (iclInt > 150) {
lowerIcl = 140;
upperIcl = 150;
}
if (lowerIcl === upperIcl) {
return RATE_TABLE[lowerIcl];
}
const lowerCurve = RATE_TABLE[lowerIcl];
const upperCurve = RATE_TABLE[upperIcl];
return lowerCurve.map((rateValue, index) => {
const upperRateValue = upperCurve[index];
return (
rateValue + ((upperRateValue - rateValue) * (iclInt - lowerIcl)) / (upperIcl - lowerIcl)
);
});
}
/**
* Calculates maimai rate for a score.
*
* @param score - The percentage score to calculate the rate for.
* @param maxScore - The max percentage score attainable of the chart the score was achieved on.
* @param internalChartLevel - The internal decimal level of the chart the score was achieved on.
*/
export function calculate(score: number, maxScore: number, internalChartLevel: number): number {
// Breaks are the most valuable note type, with a 2500 base score for PERFECTs and up to 100 bonus score.
// Assuming all notes are breaks, the max score is (2600 * n) / (2500 * n) * 100 = 104
ThrowIf(maxScore > 104, "Max score cannot be greater than 104%.", { maxScore });
ThrowIf(score > maxScore, "Score cannot be greater than max score.", { score, maxScore });
ThrowIf.negative(score, "Score cannot be negative.", { score });
ThrowIf.negative(internalChartLevel, "Internal chart level cannot be negative.", {
level: internalChartLevel,
});
let scoreInt = Math.round(score * 100);
const maxScoreInt = Math.round(maxScore * 100);
const iclInt = Math.round(internalChartLevel * 10);
if (scoreInt <= 1000) {
return 0;
}
const curve = calculateRatingCurve(iclInt);
if (scoreInt === maxScoreInt) {
return curve[curve.length - 1] / 100;
}
const rank = getRank(scoreInt);
const rankIndex = Object.keys(RANK_BOUNDARIES).indexOf(rank);
// For whatever reason, rating between SSS and SSS+ is calculated by scaling
// the score between 100% and 200%, with the base score taking up 100% and
// the break score being another 100%.
if (rank === "SSS") {
scoreInt = Math.floor(((scoreInt - 10000) / (maxScoreInt - 10000)) * 10000 + 10000);
}
const lowerRateValue = curve[rankIndex];
const lowerScoreBoundary = RANK_BOUNDARIES[rank];
// Fast path: Return the rate value as-is if the score is right on the border.
if (scoreInt === lowerScoreBoundary) {
return lowerRateValue / 100;
}
const upperRateValue = curve[rankIndex + 1];
const upperScoreBoundary = Object.values(RANK_BOUNDARIES)[rankIndex + 1];
const rate =
lowerRateValue +
(upperRateValue - lowerRateValue) *
((scoreInt - lowerScoreBoundary) / (upperScoreBoundary - lowerScoreBoundary));
return Math.floor(rate) / 100;
}