glicko-ts
Version:
A TypeScript implementation of the Glicko-1 rating system.
248 lines (247 loc) • 13.5 kB
JavaScript
"use strict";
/**
* Glicko rating system implementation.
* This class provides methods for calculating and updating Glicko ratings for players based on match outcomes.
* The Glicko system accounts for rating deviation (RD), a measure of the uncertainty in a player's skill level.
* A lower RD indicates higher confidence in the rating, while a higher RD suggests more uncertainty.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Glicko = void 0;
const math_utils_1 = require("./utils/math-utils");
class Glicko {
/**
* Creates an instance of the Glicko rating system calculator.
* @param {Partial<GlickoConfig>} [config] Optional configuration settings.
*/
constructor(config) {
const defaultConfig = this.defaultConfig();
this.config = { ...defaultConfig, ...config };
this.config.q = defaultConfig.q;
if (this.config.initialRating < 0) {
throw new Error("initialRating must be non-negative.");
}
if (this.config.initialRD < 0) {
throw new Error("initialRD must be non-negative.");
}
if (this.config.inactivityConstant < 0) {
throw new Error("inactivityConstant must be non-negative.");
}
if (this.config.rdCeiling < 0) {
throw new Error("rdCeiling must be non-negative.");
}
if (this.config.daysPerRatingPeriod <= 0) {
throw new Error("daysPerRatingPeriod must be positive.");
}
if (this.config.roundingPrecision < 0 || !Number.isInteger(this.config.roundingPrecision)) {
throw new Error("roundingPrecision must be a non-negative integer.");
}
}
/**
* Provides the default configuration values for the Glicko system.
* @returns {GlickoConfig} The default configuration.
* @private
*/
defaultConfig() {
return {
initialRating: 1500,
initialRD: 350,
inactivityConstant: 0.5,
rdCeiling: 350,
q: Math.log(10) / 400,
daysPerRatingPeriod: 30,
roundingPrecision: 2, // Default rounding precision for ratings and RD
};
}
/**
* Creates a new Player object with initial or overridden rating/RD values.
* @param {Partial<Player>} [overrides] Optional values to override defaults.
* @returns {Player} A new Player object.
*/
initializeNewPlayer(overrides) {
return {
rating: math_utils_1.MathUtils.roundToDecimalPlaces(overrides?.rating ?? this.config.initialRating, this.config.roundingPrecision),
rd: math_utils_1.MathUtils.roundToDecimalPlaces(overrides?.rd ?? this.config.initialRD, this.config.roundingPrecision),
lastPlayedMatch: overrides?.lastPlayedMatch,
};
}
/**
* Updates a player's Rating Deviation (RD) based on inactivity.
* RD increases towards a ceiling over time if the player hasn't played.
* @param {Player} player The player's state *before* the inactivity period.
* @param {number} daysSinceLastActive The number of days since the player's last rated match.
* @returns {Player} A new Player object with the potentially updated RD.
* @throws {Error} If daysSinceLastActive is negative.
*/
updateRDForInactivity(player, daysSinceLastActive) {
if (daysSinceLastActive < 0) {
throw new Error("Days since last active cannot be negative.");
}
if (!player.lastPlayedMatch) {
return { ...player };
}
const periodsSinceLastActivity = daysSinceLastActive / this.config.daysPerRatingPeriod;
const newRd = Math.min(Math.sqrt(Math.pow(player.rd, 2) + Math.pow(this.config.inactivityConstant, 2) * periodsSinceLastActivity), this.config.rdCeiling);
return { ...player, rd: math_utils_1.MathUtils.roundToDecimalPlaces(newRd, this.config.roundingPrecision) };
}
/**
* Calculates the expected outcome (E) of a match for the player against an opponent.
* Represents the player's expected score (roughly, probability of winning) based on ratings and RDs.
* Formula: E = 1 / (1 + 10^(-g(RD_opp)*(r - r_opp)/400))
* @param {number} playerRating Player's rating (r).
* @param {number} opponentRating Opponent's rating (r_opp).
* @param {number} opponentRd Opponent's RD (RD_opp).
* @returns {number} Expected outcome for the player (0 to 1).
* @private
*/
calculateExpectedOutcome(playerRating, opponentRating, opponentRd) {
const g_opp = math_utils_1.MathUtils.g(opponentRd, this.config.q);
const exponent = -g_opp * (playerRating - opponentRating) * this.config.q;
return 1 / (1 + Math.exp(exponent));
}
/**
* Calculates the sum of factors related to the expected variance of match outcomes within a rating period.
* This sum quantifies the amount of information gained from the matches played, which influences the change in Rating Deviation (RD).
* Formula: sum[ g(opponent_RD)^2 * E * (1-E) ]
* A higher value indicates more information was gained, contributing to a larger RD decrease.
* @param {number} playerRating Rating of the player at the start of the period.
* @param {number} playerRd RD of the player at the start of the period.
* @param {Match[]} matchs Matches played during the rating period.
* @returns {number} The calculated sum. Returns 0 if matchs is empty/invalid.
* @throws {Error} If playerRd is not positive.
* @private
*/
sumMatchVarianceFactors(playerRating, playerRd, matchs) {
if (playerRd <= 0) {
throw new Error("Player RD must be positive for calculations.");
}
let sum = 0;
for (const match of matchs) {
const opponent = match.opponent;
if (opponent.rd <= 0) {
console.warn(`Skipping match in variance factor sum due to non-positive opponent RD: ${opponent.rd}`);
continue;
}
const E = this.calculateExpectedOutcome(playerRating, opponent.rating, opponent.rd);
const g_opp = math_utils_1.MathUtils.g(opponent.rd, this.config.q);
sum += Math.pow(g_opp, 2) * E * (1 - E);
}
return sum;
}
/**
* Calculates the sum of weighted differences between actual match scores and expected scores.
* This sum represents overall performance relative to expectation, adjusted for opponent RD.
* It's the primary factor determining the rating change direction and magnitude.
* Formula: sum[ g(opponent_RD) * (Actual_Score - Expected_Score) ]
* A positive sum indicates better-than-expected performance (rating increases); negative indicates worse (rating decreases).
* @param {number} playerRating Rating of the player at the start of the period.
* @param {number} playerRd RD of the player at the start of the period.
* @param {Match[]} matchs Matches played during the rating period.
* @returns {number} The calculated sum. Returns 0 if matchs is empty/invalid.
* @throws {Error} If playerRd is not positive.
* @private
*/
sumWeightedScorePerformance(playerRating, playerRd, matchs) {
if (playerRd <= 0) {
throw new Error("Player RD must be positive for calculations.");
}
let sum = 0;
for (const match of matchs) {
const opponent = match.opponent;
const score = match.score;
if (opponent.rd <= 0) {
console.warn(`Skipping match in weighted score sum due to non-positive opponent RD: ${opponent.rd}`);
continue;
}
const E = this.calculateExpectedOutcome(playerRating, opponent.rating, opponent.rd);
const g_opp = math_utils_1.MathUtils.g(opponent.rd, this.config.q);
sum += g_opp * (score - E);
}
return sum;
}
/**
* Calculates the player's new Rating (r') for the end of the rating period.
* Determined by initial rating adjusted by overall performance (weightedScorePerformanceSum),
* scaled by the system constant (q) and the square of the new RD'.
* Formula: r' = r + q * (RD')^2 * weightedScorePerformanceSum
* @param {number} initialRating Player's rating at the start of the period.
* @param {number} newRd The newly calculated unrounded RD' (output of calculateNewRD).
* @param {number} weightedScorePerformanceSum Sum representing performance vs expectation.
* @returns {number} The new rating (unrounded).
* @private
*/
calculateNewRating(initialRating, newRd, weightedScorePerformanceSum) {
const newRdSquared = Math.pow(newRd, 2);
const ratingChange = this.config.q * newRdSquared * weightedScorePerformanceSum;
return initialRating + ratingChange;
}
/**
* Calculates the player's new Rating Deviation (RD') for the end of the rating period.
* Determined by initial RD and information gained from matches (matchVarianceFactorSum). More information leads to a lower RD.
* Formula: RD' = 1 / sqrt( 1/RD^2 + q^2 * matchVarianceFactorSum )
* @param {number} initialRd Player's RD at the start of the period.
* @param {number} matchVarianceFactorSum Sum quantifying information gain from matches.
* @returns {number} The new rating deviation (unrounded). Returns initialRd if no info gained.
* @throws {Error} If initialRd is not positive.
* @private
*/
calculateNewRD(initialRd, matchVarianceFactorSum) {
if (initialRd <= 0) {
throw new Error("Initial RD must be positive.");
}
if (matchVarianceFactorSum <= 0) {
return initialRd;
}
const qSquared = Math.pow(this.config.q, 2);
const initialRdSquaredInverse = 1 / Math.pow(initialRd, 2);
const dSquaredInverse = qSquared * matchVarianceFactorSum;
const newRdSquared = 1 / (initialRdSquaredInverse + dSquaredInverse);
return Math.sqrt(newRdSquared);
}
/**
* Processes all matches for a player within a single rating period to update their Glicko-1 rating and RD.
* This is the main public method used to calculate rating updates after a set of games representing one period.
*
* The process involves these steps:
* 1. Optionally updates the player's RD based on inactivity since their last known activity.
* 2. Calculates intermediate factors based on the match outcomes during the period compared to expectations.
* 3. Computes the new RD' based on the RD at the start of the period and the information gained from matches.
* 4. Computes the new Rating' based on the rating at the start of the period, the performance during the period, and the new RD'.
* 5. Returns the updated player state, including the new rating, new RD (rounded), and an updated `lastPlayedMatch` timestamp.
*
* @param {Player} player The player's state (rating, RD, lastPlayedMatch) *before* the start of this rating period.
* @param {Match[]} matchs An array of all matches the player participated in *during* this rating period.
* Each match object should contain the opponent's state (at the time of the match, or start of period)
* and the score achieved by the player (e.g., 1=win, 0.5=draw, 0=loss).
* @param {number} [daysSinceLastActive] Optional: The number of days that elapsed between the `player.lastPlayedMatch` date
* (from the input `player` object) and the *start* of this rating period.
* If provided and greater than 0, the player's RD will be updated for
* inactivity *before* processing the matches in this period.
* @returns {Player} The updated state of the player after processing the rating period, with updated rating, RD,
* and `lastPlayedMatch` set to the current time of processing. If no matches are provided
* (matchs array is empty or null), it returns the player's state after only the potential
* inactivity update (and `lastPlayedMatch` is NOT updated in this case).
* @throws {Error} Can throw if internal calculations encounter issues (e.g., non-positive RD input to helpers)
* or if `daysSinceLastActive` is negative (via `updateRDForInactivity`).
*/
processGameResults(player, matchs, daysSinceLastActive) {
let playerAtPeriodStart = { ...player };
if (daysSinceLastActive !== undefined && daysSinceLastActive > 0) {
playerAtPeriodStart = this.updateRDForInactivity(player, daysSinceLastActive);
}
const initialRating = playerAtPeriodStart.rating;
const initialRd = playerAtPeriodStart.rd;
if (!matchs || matchs.length === 0) {
return playerAtPeriodStart;
}
const matchVarianceFactorSum = this.sumMatchVarianceFactors(initialRating, initialRd, matchs);
const weightedScorePerformanceSum = this.sumWeightedScorePerformance(initialRating, initialRd, matchs);
const newRdUnrounded = this.calculateNewRD(initialRd, matchVarianceFactorSum);
const newRatingUnrounded = this.calculateNewRating(initialRating, newRdUnrounded, weightedScorePerformanceSum);
return {
rating: math_utils_1.MathUtils.roundToDecimalPlaces(newRatingUnrounded, this.config.roundingPrecision),
rd: math_utils_1.MathUtils.roundToDecimalPlaces(newRdUnrounded, this.config.roundingPrecision),
lastPlayedMatch: new Date()
};
}
}
exports.Glicko = Glicko;