feat: add Glicko2 rating system support

This commit is contained in:
2026-03-31 03:38:16 -07:00
parent cfd36d375c
commit e3137085c8
+452
View File
@@ -0,0 +1,452 @@
import type { PrismaClient } from '@prisma/client';
// Import Glicko2 using require to avoid TypeScript issues with the module declaration
const Glicko2 = require('glicko2').Glicko2;
/**
* Glicko2 Rating Utilities
*
* Provides functions for calculating Glicko2 ratings
* Glicko2 is a rating system that accounts for rating reliability (deviation)
*/
// Glicko2 settings
const settings = {
tau: 0.5, // constrains volatility
rating: 1500,
rd: 350,
vol: 0.06
};
/**
* Create a new Glicko2 instance
* @returns Glicko2 instance
*/
export function createGlicko2Instance() {
return new Glicko2(settings);
}
/**
* Create a new Glicko2 player
* @returns Glicko2 player object
*/
export function createGlicko2Player() {
const glicko = createGlicko2Instance();
return glicko.makePlayer();
}
/**
* Convert Prisma Glicko2 rating to Glicko2 player
* @param ratingRecord Prisma Glicko2Rating record
* @returns Glicko2 player object
*/
export function toGlicko2Player(ratingRecord: {
rating: number;
deviation: number;
volatility: number;
}) {
const glicko = new Glicko2(settings);
return glicko.makePlayer(
ratingRecord.rating,
ratingRecord.deviation,
ratingRecord.volatility
);
}
/**
* Convert Glicko2 player to Prisma Glicko2 rating format
* @param player Glicko2 player object
* @returns Object with rating, deviation, and volatility
*/
export function fromGlicko2Player(player: any): {
rating: number;
deviation: number;
volatility: number;
} {
return {
rating: player.getRating(),
deviation: player.getRd(),
volatility: player.getVol()
};
}
/**
* Calculate Glicko2 ratings for a match
* @param team1Players Array of Glicko2 players for team 1
* @param team2Players Array of Glicko2 players for team 2
* @param team1Won Whether team 1 won
* @param team2Won Whether team 2 won
* @param isTie Whether the match was a tie
* @returns Updated player ratings
*/
export function calculateGlicko2Ratings(
team1Players: any[],
team2Players: any[],
team1Won: boolean,
team2Won: boolean,
isTie: boolean
): { player: any; newRating: number; newDeviation: number; newVolatility: number }[] {
const glicko = new Glicko2(settings);
// Convert players to Glicko2 format if needed
const processedTeam1 = team1Players.map(p =>
typeof p.getRating === 'function' ? p : glicko.makePlayer(p.rating, p.deviation, p.volatility)
);
const processedTeam2 = team2Players.map(p =>
typeof p.getRating === 'function' ? p : glicko.makePlayer(p.rating, p.deviation, p.volatility)
);
// Create matches in Glicko2 format
// Glicko2 expects matches as [player1, player2, outcome]
// where outcome is 1 for player1 win, 0.5 for draw, 0 for player2 win
const matches: [any, any, number][] = [];
// For team matches, we create individual matchups between players
// This is a simplified approach - in practice, you might want a more sophisticated model
for (const p1 of processedTeam1) {
for (const p2 of processedTeam2) {
let outcome: number;
if (isTie) {
outcome = 0.5; // Draw
} else if (team1Won) {
outcome = 1; // Team 1 player wins
} else {
outcome = 0; // Team 2 player wins
}
matches.push([p1, p2, outcome]);
}
}
// Update ratings
glicko.updateRatings(matches);
// Return updated players
return [
...processedTeam1.map(p => ({
player: p,
newRating: p.getRating(),
newDeviation: p.getRd(),
newVolatility: p.getVol()
})),
...processedTeam2.map(p => ({
player: p,
newRating: p.getRating(),
newDeviation: p.getRd(),
newVolatility: p.getVol()
}))
];
}
/**
* Update player Glicko2 statistics after a match
* @param prisma Prisma client instance
* @param matchData Match data with player IDs and results
* @returns Promise<void>
*/
export async function updateGlicko2Ratings(
prisma: PrismaClient,
matchData: {
team1Players: number[];
team2Players: number[];
team1Won: boolean;
team2Won: boolean;
isTie: boolean;
}
): Promise<void> {
const { team1Players, team2Players, team1Won, team2Won, isTie } = matchData;
// Get current ratings for all players
const playerRecords = new Map<number, {
rating: number;
deviation: number;
volatility: number;
}>();
for (const playerId of [...team1Players, ...team2Players]) {
try {
const ratingRecord = await (prisma as any).glicko2Rating.findUnique({
where: { playerId },
});
if (ratingRecord) {
playerRecords.set(playerId, {
rating: ratingRecord.rating,
deviation: ratingRecord.deviation,
volatility: ratingRecord.volatility
});
} else {
playerRecords.set(playerId, {
rating: 1500,
deviation: 350,
volatility: 0.06
});
}
} catch (error) {
// If the model doesn't exist yet, use default
playerRecords.set(playerId, {
rating: 1500,
deviation: 350,
volatility: 0.06
});
}
}
// Convert to Glicko2 players
const glicko = new Glicko2(settings);
const team1GlickoPlayers = team1Players.map(id => {
const record = playerRecords.get(id)!;
return glicko.makePlayer(record.rating, record.deviation, record.volatility);
});
const team2GlickoPlayers = team2Players.map(id => {
const record = playerRecords.get(id)!;
return glicko.makePlayer(record.rating, record.deviation, record.volatility);
});
// Calculate new ratings
const updatedPlayers = calculateGlicko2Ratings(
team1GlickoPlayers,
team2GlickoPlayers,
team1Won,
team2Won,
isTie
);
// Update database
// The updatedPlayers array contains both teams, so we need to match them back to player IDs
let playerIndex = 0;
for (const playerId of [...team1Players, ...team2Players]) {
const updated = updatedPlayers[playerIndex];
await (prisma as any).glicko2Rating.upsert({
where: { playerId },
update: {
rating: updated.newRating,
deviation: updated.newDeviation,
volatility: updated.newVolatility,
gamesPlayed: { increment: 1 },
wins: team1Players.includes(playerId) && team1Won ? { increment: 1 } :
team2Players.includes(playerId) && team2Won ? { increment: 1 } : undefined,
losses: (team1Players.includes(playerId) && team2Won && !isTie) ||
(team2Players.includes(playerId) && team1Won && !isTie) ? { increment: 1 } : undefined,
draws: isTie ? { increment: 1 } : undefined,
lastUpdated: new Date(),
},
create: {
playerId,
rating: updated.newRating,
deviation: updated.newDeviation,
volatility: updated.newVolatility,
gamesPlayed: 1,
wins: team1Players.includes(playerId) && team1Won ? 1 :
team2Players.includes(playerId) && team2Won ? 1 : 0,
losses: (team1Players.includes(playerId) && team2Won && !isTie) ||
(team2Players.includes(playerId) && team1Won && !isTie) ? 1 : 0,
draws: isTie ? 1 : 0,
lastUpdated: new Date(),
},
});
playerIndex++;
}
}
/**
* Recalculate all Glicko2 ratings from match history
* @param prisma Prisma client instance
* @returns Object with counts of affected records
*/
export async function recalculateAllGlicko2(prisma: PrismaClient) {
// Get all matches in chronological order
const matches = await prisma.match.findMany({
orderBy: { playedAt: 'asc' },
include: {
team1P1: true,
team1P2: true,
team2P1: true,
team2P2: true,
},
});
// Reset all Glicko2 ratings
await (prisma as any).glicko2Rating.updateMany({
data: {
rating: 1500,
deviation: 350,
volatility: 0.06,
gamesPlayed: 0,
wins: 0,
losses: 0,
draws: 0,
},
});
// Initialize in-memory tracking
const playerRatings = new Map<number, {
rating: number;
deviation: number;
volatility: number;
}>();
// Initialize Glicko2 system
const glicko = new Glicko2(settings);
// Process each match
for (const match of matches) {
const { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score } = match;
// Get current ratings
const getOrCreatePlayer = (playerId: number) => {
if (!playerRatings.has(playerId)) {
playerRatings.set(playerId, {
rating: 1500,
deviation: 350,
volatility: 0.06
});
}
const record = playerRatings.get(playerId)!;
return glicko.makePlayer(record.rating, record.deviation, record.volatility);
};
const p1 = getOrCreatePlayer(team1P1.id);
const p2 = getOrCreatePlayer(team1P2.id);
const p3 = getOrCreatePlayer(team2P1.id);
const p4 = getOrCreatePlayer(team2P2.id);
const team1Won = team1Score > team2Score;
const team2Won = team2Score > team1Score;
const isTie = team1Score === team2Score;
// Create matches
const matchesToUpdate: [any, any, number][] = [];
const outcome = isTie ? 0.5 : team1Won ? 1 : 0;
// Create matchups between all players
[p1, p2].forEach(p1Player => {
[p3, p4].forEach(p2Player => {
matchesToUpdate.push([p1Player, p2Player, outcome]);
});
});
// Update ratings
glicko.updateRatings(matchesToUpdate);
// Update in-memory ratings
playerRatings.set(team1P1.id, {
rating: p1.getRating(),
deviation: p1.getRd(),
volatility: p1.getVol()
});
playerRatings.set(team1P2.id, {
rating: p2.getRating(),
deviation: p2.getRd(),
volatility: p2.getVol()
});
playerRatings.set(team2P1.id, {
rating: p3.getRating(),
deviation: p3.getRd(),
volatility: p3.getVol()
});
playerRatings.set(team2P2.id, {
rating: p4.getRating(),
deviation: p4.getRd(),
volatility: p4.getVol()
});
// Update database records
await (prisma as any).glicko2Rating.upsert({
where: { playerId: team1P1.id },
update: {
rating: p1.getRating(),
deviation: p1.getRd(),
volatility: p1.getVol(),
gamesPlayed: { increment: 1 },
wins: team1Won ? { increment: 1 } : undefined,
losses: !team1Won && !isTie ? { increment: 1 } : undefined,
draws: isTie ? { increment: 1 } : undefined,
},
create: {
playerId: team1P1.id,
rating: p1.getRating(),
deviation: p1.getRd(),
volatility: p1.getVol(),
gamesPlayed: 1,
wins: team1Won ? 1 : 0,
losses: !team1Won && !isTie ? 1 : 0,
draws: isTie ? 1 : 0,
},
});
await (prisma as any).glicko2Rating.upsert({
where: { playerId: team1P2.id },
update: {
rating: p2.getRating(),
deviation: p2.getRd(),
volatility: p2.getVol(),
gamesPlayed: { increment: 1 },
wins: team1Won ? { increment: 1 } : undefined,
losses: !team1Won && !isTie ? { increment: 1 } : undefined,
draws: isTie ? { increment: 1 } : undefined,
},
create: {
playerId: team1P2.id,
rating: p2.getRating(),
deviation: p2.getRd(),
volatility: p2.getVol(),
gamesPlayed: 1,
wins: team1Won ? 1 : 0,
losses: !team1Won && !isTie ? 1 : 0,
draws: isTie ? 1 : 0,
},
});
await (prisma as any).glicko2Rating.upsert({
where: { playerId: team2P1.id },
update: {
rating: p3.getRating(),
deviation: p3.getRd(),
volatility: p3.getVol(),
gamesPlayed: { increment: 1 },
wins: team2Won ? { increment: 1 } : undefined,
losses: !team2Won && !isTie ? { increment: 1 } : undefined,
draws: isTie ? { increment: 1 } : undefined,
},
create: {
playerId: team2P1.id,
rating: p3.getRating(),
deviation: p3.getRd(),
volatility: p3.getVol(),
gamesPlayed: 1,
wins: team2Won ? 1 : 0,
losses: !team2Won && !isTie ? 1 : 0,
draws: isTie ? 1 : 0,
},
});
await (prisma as any).glicko2Rating.upsert({
where: { playerId: team2P2.id },
update: {
rating: p4.getRating(),
deviation: p4.getRd(),
volatility: p4.getVol(),
gamesPlayed: { increment: 1 },
wins: team2Won ? { increment: 1 } : undefined,
losses: !team2Won && !isTie ? { increment: 1 } : undefined,
draws: isTie ? { increment: 1 } : undefined,
},
create: {
playerId: team2P2.id,
rating: p4.getRating(),
deviation: p4.getRd(),
volatility: p4.getVol(),
gamesPlayed: 1,
wins: team2Won ? 1 : 0,
losses: !team2Won && !isTie ? 1 : 0,
draws: isTie ? 1 : 0,
},
});
}
return {
matchesProcessed: matches.length,
playersUpdated: playerRatings.size,
};
}