import type { PrismaClient } from '@prisma/client'; /** * Elo Rating Utilities * * Provides functions for calculating Elo ratings and changes * Based on the standard Elo rating system */ /** * Calculate the expected score for a player based on rating difference * @param ratingA Player A's rating * @param ratingB Player B's rating * @returns Expected score (0-1) for Player A */ export function calculateExpectedScore(ratingA: number, ratingB: number): number { const ratingDiff = ratingA - ratingB; return 1 / (1 + Math.pow(10, -ratingDiff / 400)); } /** * Calculate Elo rating change for a single game * @param rating Player's current rating * @param opponentRating Opponent's rating * @param actualScore Actual score (1 = win, 0.5 = draw, 0 = loss) * @param kFactor K-factor (how much ratings change per game) * @returns Elo rating change (positive or negative) */ export function calculateEloChange( rating: number, opponentRating: number, actualScore: number, kFactor: number = 32 ): number { const expectedScore = calculateExpectedScore(rating, opponentRating); return kFactor * (actualScore - expectedScore); } /** * Calculate team Elo rating as average of individual ratings * @param rating1 Rating of player 1 * @param rating2 Rating of player 2 * @returns Team rating */ export function calculateTeamElo(rating1: number, rating2: number): number { return (rating1 + rating2) / 2; } /** * Update player statistics after a match * @param currentRating Current player rating * @param currentGamesPlayed Current games played count * @param currentWins Current wins count * @param currentLosses Current losses count * @param eloChange Elo rating change * @param won Whether the player won * @param isTie Whether the match was a tie * @returns Updated statistics */ export function updatePlayerStats( currentRating: number, currentGamesPlayed: number, currentWins: number, currentLosses: number, eloChange: number, won: boolean, isTie: boolean = false ): { newRating: number; newGamesPlayed: number; newWins: number; newLosses: number; winRate: number; } { const newRating = currentRating + Math.round(eloChange); const newGamesPlayed = currentGamesPlayed + 1; let newWins = currentWins; let newLosses = currentLosses; if (isTie) { // For ties, don't increment wins or losses } else if (won) { newWins += 1; } else { newLosses += 1; } const winRate = newGamesPlayed > 0 ? newWins / newGamesPlayed : 0; return { newRating, newGamesPlayed, newWins, newLosses, winRate, }; } /** * Calculate new rating after a match * @param currentRating Current rating * @param opponentRating Opponent's rating * @param won Whether the player won * @param kFactor K-factor (default 32) * @returns New rating */ export function calculateNewRating( currentRating: number, opponentRating: number, won: boolean, kFactor: number = 32 ): number { const actualScore = won ? 1 : 0; const eloChange = calculateEloChange(currentRating, opponentRating, actualScore, kFactor); return Math.round(currentRating + eloChange); } /** * Validate rating is within reasonable bounds * @param rating Rating to validate * @returns Whether the rating is valid */ export function isValidRating(rating: number): boolean { return rating >= 100 && rating <= 3000; } /** * Get rating tier name based on rating * @param rating Player rating * @returns Tier name */ export function getRatingTier(rating: number): string { if (rating < 1100) return 'Beginner'; if (rating < 1300) return 'Novice'; if (rating < 1500) return 'Intermediate'; if (rating < 1700) return 'Advanced'; if (rating < 1900) return 'Expert'; if (rating < 2100) return 'Master'; return 'Grandmaster'; } /** * Calculate expected score for a team match * @param team1Rating Average rating of team 1 * @param team2Rating Average rating of team 2 * @returns Expected score for team 1 */ export function calculateExpectedTeamScore(team1Rating: number, team2Rating: number): number { return calculateExpectedScore(team1Rating, team2Rating); } /** * Calculate Elo change for team match * @param team1Rating Average rating of team 1 * @param team2Rating Average rating of team 2 * @param team1Score Actual score for team 1 (1 = win, 0.5 = draw, 0 = loss) * @param kFactor K-factor (default 32) * @returns Elo change for team 1 */ export function calculateTeamEloChange( team1Rating: number, team2Rating: number, team1Score: number, kFactor: number = 32 ): number { return calculateEloChange(team1Rating, team2Rating, team1Score, kFactor); } /** * Recalculate all player and partnership statistics from match history * This function is idempotent - running it multiple times produces the same result * @param prisma Prisma client instance * @returns Object with counts of affected records */ export async function recalculateAllElo(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 player stats to zero await prisma.player.updateMany({ data: { currentElo: 1000, gamesPlayed: 0, wins: 0, losses: 0, }, }); // Delete all existing elo snapshots await prisma.eloSnapshot.deleteMany({}); // Delete all existing partnership stats (will be rebuilt) await prisma.partnershipStat.deleteMany({}); // Initialize in-memory tracking for all players const playerStats = new Map(); // Initialize partnership tracking const partnershipStats = new Map(); // Helper to get/create player stats const getPlayerStats = (playerId: number) => { if (!playerStats.has(playerId)) { playerStats.set(playerId, { rating: 1000, gamesPlayed: 0, wins: 0, losses: 0 }); } return playerStats.get(playerId)!; }; // Helper to get partnership key (sorted player IDs) const getPartnershipKey = (p1Id: number, p2Id: number) => { return [Math.min(p1Id, p2Id), Math.max(p1Id, p2Id)].join('-'); }; // Helper to get/update partnership stats const getPartnershipStats = (p1Id: number, p2Id: number) => { const key = getPartnershipKey(p1Id, p2Id); if (!partnershipStats.has(key)) { partnershipStats.set(key, { gamesPlayed: 0, wins: 0, losses: 0, totalEloChange: 0, lastPlayed: null }); } return partnershipStats.get(key)!; }; // Process each match in chronological order for (const match of matches) { const { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score, id: matchId, playedAt } = match; // Get current ratings for all players const p1Rating = getPlayerStats(team1P1.id).rating; const p2Rating = getPlayerStats(team1P2.id).rating; const p3Rating = getPlayerStats(team2P1.id).rating; const p4Rating = getPlayerStats(team2P2.id).rating; // Calculate team ratings const team1Rating = calculateTeamElo(p1Rating, p2Rating); const team2Rating = calculateTeamElo(p3Rating, p4Rating); // Determine match result (team1 score based on points) const team1ScoreNormalized = team1Score > team2Score ? 1 : team1Score === team2Score ? 0.5 : 0; // Calculate team ELO changes const team1Change = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreNormalized); const team2Change = -team1Change; // Equal and opposite // Split changes evenly between partners const p1Change = Math.round(team1Change / 2); const p2Change = Math.round(team1Change / 2); const p3Change = Math.round(team2Change / 2); const p4Change = Math.round(team2Change / 2); // Update player stats const team1Won = team1Score > team2Score; const team2Won = team2Score > team1Score; const isTie = team1Score === team2Score; // Update Player 1 (team 1, player 1) const stats1 = getPlayerStats(team1P1.id); stats1.rating += p1Change; stats1.gamesPlayed += 1; if (isTie) { // For ties, don't increment wins or losses (gamesPlayed is already incremented) } else if (team1Won) { stats1.wins += 1; } else { stats1.losses += 1; } // Update Player 2 (team 1, player 2) const stats2 = getPlayerStats(team1P2.id); stats2.rating += p2Change; stats2.gamesPlayed += 1; if (isTie) { // For ties, don't increment wins or losses (gamesPlayed is already incremented) } else if (team1Won) { stats2.wins += 1; } else { stats2.losses += 1; } // Update Player 3 (team 2, player 1) const stats3 = getPlayerStats(team2P1.id); stats3.rating += p3Change; stats3.gamesPlayed += 1; if (isTie) { // For ties, don't increment wins or losses (gamesPlayed is already incremented) } else if (team2Won) { stats3.wins += 1; } else { stats3.losses += 1; } // Update Player 4 (team 2, player 2) const stats4 = getPlayerStats(team2P2.id); stats4.rating += p4Change; stats4.gamesPlayed += 1; if (isTie) { // For ties, don't increment wins or losses (gamesPlayed is already incremented) } else if (team2Won) { stats4.wins += 1; } else { stats4.losses += 1; } // Update partnership stats for team 1 const partnership1 = getPartnershipStats(team1P1.id, team1P2.id); partnership1.gamesPlayed += 1; if (isTie) { // For ties, don't increment wins or losses (gamesPlayed is already incremented) } else if (team1Won) { partnership1.wins += 1; } else { partnership1.losses += 1; } partnership1.totalEloChange += p1Change + p2Change; if (playedAt && (!partnership1.lastPlayed || playedAt > partnership1.lastPlayed)) { partnership1.lastPlayed = playedAt; } // Update partnership stats for team 2 const partnership2 = getPartnershipStats(team2P1.id, team2P2.id); partnership2.gamesPlayed += 1; if (isTie) { // For ties, don't increment wins or losses (gamesPlayed is already incremented) } else if (team2Won) { partnership2.wins += 1; } else { partnership2.losses += 1; } partnership2.totalEloChange += p3Change + p4Change; if (playedAt && (!partnership2.lastPlayed || playedAt > partnership2.lastPlayed)) { partnership2.lastPlayed = playedAt; } // Create elo snapshots for all players const snapshotData = [ { playerId: team1P1.id, ratingBefore: p1Rating, ratingChange: p1Change }, { playerId: team1P2.id, ratingBefore: p2Rating, ratingChange: p2Change }, { playerId: team2P1.id, ratingBefore: p3Rating, ratingChange: p3Change }, { playerId: team2P2.id, ratingBefore: p4Rating, ratingChange: p4Change }, ]; for (const snapshot of snapshotData) { await prisma.eloSnapshot.create({ data: { playerId: snapshot.playerId, matchId: matchId, ratingBefore: snapshot.ratingBefore, ratingAfter: snapshot.ratingBefore + snapshot.ratingChange, ratingChange: snapshot.ratingChange, }, }); } } // Write all updated player stats to database const playerUpdatePromises = Array.from(playerStats.entries()).map( async ([playerId, stats]) => await prisma.player.update({ where: { id: playerId }, data: { currentElo: stats.rating, gamesPlayed: stats.gamesPlayed, wins: stats.wins, losses: stats.losses, }, }) ); await Promise.all(playerUpdatePromises); // Write all partnership stats to database const partnershipUpdatePromises = Array.from(partnershipStats.entries()).map(async ([key, stats]) => { const [player1Id, player2Id] = key.split('-').map(Number); // Check if the partnership stat already exists const existingStat = await prisma.partnershipStat.findFirst({ where: { player1Id, player2Id, }, }); if (existingStat) { // Update existing record return await prisma.partnershipStat.update({ where: { id: existingStat.id, }, data: { gamesPlayed: stats.gamesPlayed, wins: stats.wins, losses: stats.losses, totalEloChange: stats.totalEloChange, lastPlayed: stats.lastPlayed, }, }); } else { // Create new record return await prisma.partnershipStat.create({ data: { player1Id, player2Id, gamesPlayed: stats.gamesPlayed, wins: stats.wins, losses: stats.losses, totalEloChange: stats.totalEloChange, lastPlayed: stats.lastPlayed, }, }); } }); await Promise.all(partnershipUpdatePromises); return { matchesProcessed: matches.length, playersUpdated: playerStats.size, partnershipsUpdated: partnershipStats.size, }; }