diff --git a/src/lib/glicko2-utils.ts b/src/lib/glicko2-utils.ts new file mode 100644 index 0000000..bf29a65 --- /dev/null +++ b/src/lib/glicko2-utils.ts @@ -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 + */ +export async function updateGlicko2Ratings( + prisma: PrismaClient, + matchData: { + team1Players: number[]; + team2Players: number[]; + team1Won: boolean; + team2Won: boolean; + isTie: boolean; + } +): Promise { + const { team1Players, team2Players, team1Won, team2Won, isTie } = matchData; + + // Get current ratings for all players + const playerRecords = new Map(); + + 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(); + + // 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, + }; +}