feat: add Glicko2 rating system support
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user