rg-stats
Version:
A library for calculating various rhythm game stats.
224 lines (190 loc) • 7.37 kB
text/typescript
import { LogToBase } from "../util/math";
import { ThrowIf } from "../util/throw-if";
import { integer } from "../util/types";
/**
* Calculates the "Beat Performance Index" of an IIDX score.
* this is a port of Poyashi's implementation.
* https://github.com/potakusan/iidx_score_manager/blob/f21ba6b85fcc0bf8b7ca888fa2239a3951a9c9c2/src/components/bpi/index.tsx#L120
*
* @param yourEx Your EX score.
* @param kaidenAverage The kaiden average EX score.
* @param worldRecord The world record's EX score.
* @param max The maximum amount of EX achievable on this chart.
* @param powCoef What power the BPI should be raised to. This is arbitrary, and assigned on a per-song basis. Defaults to 1.175.
* If powCoef is set to -1, it will be overrode to 1.175.
*
* @returns A number between -15 and 100. Unless your score is better than
* the world record, in which case returns can be above 100.
*
* @edgecase Even though any score better than the world record *is* now the world record,
* it takes around 6 months for the official data on the world record to be updated.
* Since changing the worldRecord affects all scores on the chart, poyashi's
* implementation just lets BPI exceed 100 until patched.
*
* @edgecase BPI is undefined for the case where the world record is equal to the
* kaiden average.
*/
export function calculate(
yourEx: integer,
kaidenAverage: integer,
worldRecord: integer,
max: integer,
powCoef: number | null
) {
let powCoefficient = powCoef ?? 1.175;
if (powCoefficient === -1) {
powCoefficient = 1.175;
}
AssertProvidedEXScores(kaidenAverage, worldRecord, max, powCoefficient);
ThrowIf.not(Number.isSafeInteger(yourEx), "Provided EX was not an integer.", { yourEx });
ThrowIf(yourEx > max, "Provided EX was greater than MAX.", { yourEx, max });
ThrowIf.negative(yourEx, "Provided EX was negative.", { yourEx });
ThrowIf(
worldRecord === kaidenAverage,
"BPI is undefined for the case where Kaiden Average equals the World Record",
{ kaidenAverage, worldRecord }
);
const yourPGF = PikaGreatFunction(yourEx, max);
const kaidenPGF = PikaGreatFunction(kaidenAverage, max);
const wrPGF = PikaGreatFunction(worldRecord, max);
// Work out the ratio of your PGF versus the kaiden PGF.
const yourScorePrime = yourPGF / kaidenPGF;
const wrScorePrime = wrPGF / kaidenPGF;
const isWorseThanKavg = yourEx < kaidenAverage;
const logExPrime = LogToBase(yourScorePrime, wrScorePrime);
if (isWorseThanKavg) {
// since logExPrime is negative (because yourScorePrime is between 0 and 1)
// We can't raise it to a power natively, as -0.5 to the power of something
// like 1.175 is imaginary.
// To fix this, the poyashi implementation negates logExPrime
// and raises it to the powCoef.
const negativeRaisedValue = (-1 * logExPrime) ** powCoefficient;
// and then negates it again when multiplying by 100.
const bpi = 100 * -1 * negativeRaisedValue;
if (bpi < -15) {
return -15;
}
return bpi;
} else {
return 100 * logExPrime ** powCoefficient;
}
}
/**
* Calculates the "PGF" of an IIDX score. This returns a number that indicates how
* many pgreats you are expected to get for every great.
*/
function PikaGreatFunction(score: integer, max: integer) {
// if score === max, then the subsequent lines will involve a divide by zero
// so we have to avoid that somehow.
if (score === max) {
// The poyashi implementation multiplies by 0.8 here for an unknown reason.
//
// This multiplication essentially asserts that on a perfect score on a chart with
// 1000 notes, it expects every 1600(!!) notes you make a mistake.
return max * 0.8;
}
const scorePercent = score / max;
return 0.5 / (1 - scorePercent);
}
/**
* Returns the EX Score necessary to achieve the provided BPI.
*
* @param bpi - The BPI you wish to find out how much ex is needed for.
* @param kaidenAverage The kaiden average EX score.
* @param worldRecord The world record's EX score.
* @param powCoef What power the BPI should be raised to. This is arbitrary, and assigned on a per-song basis. Defaults to 1.175.
* If powCoef is set to -1, it will be overrode to 1.175.
*
* @returns A number between 0 and `max`.
*
* @edgecase -15BPI is achievable by many possible EXs, since it is the lower bound
* for any given chart. Passing -15 into the function will return the largest
* possible exScore that results in -15BPI.
*/
export function inverse(
bpi: number,
kaidenAverage: integer,
worldRecord: integer,
max: integer,
powCoef: number | null
) {
let powCoefficient = powCoef ?? 1.175;
if (powCoefficient === -1) {
powCoefficient = 1.175;
}
AssertProvidedEXScores(kaidenAverage, worldRecord, max, powCoefficient);
ThrowIf(bpi < -15, "BPI must be greater than or equal to -15.", { bpi });
const isWorseThanKavg = bpi < 0;
let logExPrime;
if (isWorseThanKavg) {
logExPrime = -1 * (bpi / -100) ** (1 / powCoefficient);
} else {
logExPrime = (bpi / 100) ** (1 / powCoefficient);
}
const kaidenPGF = PikaGreatFunction(kaidenAverage, max);
const wrPGF = PikaGreatFunction(worldRecord, max);
const wrScorePrime = wrPGF / kaidenPGF;
const exScorePrime = wrScorePrime ** logExPrime;
const exScorePGF = exScorePrime * kaidenPGF;
const exScore = InversePikaGreatFunction(exScorePGF, max);
// This is because InversePGF can't correctly apply the 0.8x multiplier.
// If your exScore ceil's to MAX, then the ex score needed to get this score is max.
// note that this only applies to the case where bpi > 100.
if (bpi > 100 && Math.ceil(exScore) === max) {
return max;
}
return Math.round(exScore);
}
/**
* Inverts the PGF by returning the EXScore necessary to get a given PGF.
*
* @see {PikaGreatFunction}
*/
function InversePikaGreatFunction(pgf: number, max: number) {
return (pgf * max - 0.5 * max) / pgf;
}
/**
* Apply assertions and constraints into values that BPI recieves.
*/
function AssertProvidedEXScores(
kaidenAverage: integer,
worldRecord: integer,
max: integer,
powCoef: number
) {
// Is integer?
ThrowIf.not(Number.isSafeInteger(kaidenAverage), "Kaiden Average was not an integer.", {
kaidenAverage,
});
ThrowIf.not(Number.isSafeInteger(worldRecord), "World Record was not an integer.", {
worldRecord,
});
ThrowIf.not(Number.isSafeInteger(max), "MAX was not an integer.", { max });
// Is bounded correctly?
ThrowIf(kaidenAverage > max, "Kaiden Average was greater than MAX.", {
kaidenAverage,
max,
});
ThrowIf(worldRecord > max, "World Record was greater than MAX.", {
worldRecord,
max,
});
ThrowIf(kaidenAverage > worldRecord, "Kaiden Average was greater than WR.", {
kaidenAverage,
worldRecord,
});
ThrowIf.negativeOrZero(kaidenAverage, "Kaiden Average was negative or zero.", {
kaidenAverage,
});
ThrowIf.negativeOrZero(max, "MAX was negative or zero.", { max });
ThrowIf.negativeOrZero(powCoef, "Power Coefficient was not positive.", { powCoef });
// This assertion never triggers, because WR has to be greater than KAVG, and KAVG
// is non-negative.
// ThrowIf.negativeOrZero(worldRecord, "World Record was negative or zero.", { worldRecord });
// Other edge case -- WR == KAVG breaks BPI immediately.
ThrowIf(
worldRecord === kaidenAverage,
"BPI is undefined for the case where Kaiden Average equals the World Record",
{ kaidenAverage, worldRecord }
);
}