UNPKG

rg-stats

Version:

A library for calculating various rhythm game stats.

135 lines (114 loc) 4.88 kB
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; }