import { rate, rating } from 'openskill'; import type { PrismaClient } from '@prisma/client'; /** * OpenSkill Rating Utilities * * Provides functions for calculating OpenSkill ratings (Plackett-Luce model) * OpenSkill is a rating system for multiple-player games (not just 1v1) */ /** * Convert a numeric rating to OpenSkill Rating object * @param numericRating Numeric rating value * @returns OpenSkill Rating object with mu and sigma */ export function toOpenSkillRating(numericRating: number): { mu: number; sigma: number } { // OpenSkill default: mu=25, sigma=8.33 // We'll use a simplified mapping where the numeric rating maps to mu // and a fixed sigma represents uncertainty return { mu: numericRating, sigma: 8.33, // Standard deviation }; } /** * Convert OpenSkill Rating object to numeric rating * @param openSkillRating OpenSkill Rating object * @returns Numeric rating value (mu) */ export function fromOpenSkillRating(openSkillRating: { mu: number; sigma: number }): number { return openSkillRating.mu; } /** * Calculate OpenSkill ratings for a team match * @param teams Array of teams, each team is an array of player ratings (mu, sigma) * @param rankings Array of team rankings (0 = first place, 1 = second place, etc.) * @returns Updated ratings for all players */ export function calculateOpenSkillRatings( teams: { mu: number; sigma: number }[][], rankings: number[] ): { mu: number; sigma: number }[][] { // Rate the match using Plackett-Luce model (default) const newRatings = rate(teams, { rank: rankings }); // Return the updated ratings return newRatings; } /** * Calculate expected probability of team 1 beating team 2 * @param team1Ratings Array of ratings for team 1 players (mu values) * @param team2Ratings Array of ratings for team 2 players (mu values) * @returns Probability that team 1 wins (0-1) */ export function calculateOpenSkillWinProbability( team1Ratings: number[], team2Ratings: number[] ): number { // Calculate average mu for each team const team1Mu = team1Ratings.reduce((sum, r) => sum + r, 0) / team1Ratings.length; const team2Mu = team2Ratings.reduce((sum, r) => sum + r, 0) / team2Ratings.length; // Using a simplified logistic function const muDiff = team1Mu - team2Mu; return 1 / (1 + Math.exp(-muDiff / 25)); // 25 is the scale factor } /** * Update player OpenSkill statistics after a match * @param prisma Prisma client instance * @param matchData Match data with player IDs and results * @returns Promise */ export async function updateOpenSkillRatings( 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 playerRatings = new Map(); for (const playerId of [...team1Players, ...team2Players]) { // Note: This will fail until Prisma client is regenerated // For now, we'll handle the error gracefully try { const ratingRecord = await (prisma as any).openSkillRating.findUnique({ where: { playerId }, }); if (ratingRecord) { playerRatings.set(playerId, { mu: ratingRecord.rating, sigma: 8.33 }); } else { playerRatings.set(playerId, { mu: 25.0, sigma: 8.33 }); } } catch (error) { // If the model doesn't exist yet, use default playerRatings.set(playerId, { mu: 25.0, sigma: 8.33 }); } } // Convert to arrays for OpenSkill calculation const team1Ratings = team1Players.map(id => playerRatings.get(id)!); const team2Ratings = team2Players.map(id => playerRatings.get(id)!); // Determine rankings (0 = first place, 1 = second place) let rankings: number[]; if (isTie) { // In case of tie, both teams share first place (OpenSkill handles this) rankings = [0, 0]; } else if (team1Won) { rankings = [0, 1]; // Team 1 wins } else { rankings = [1, 0]; // Team 2 wins } // Calculate new ratings const teams = [team1Ratings, team2Ratings]; const newTeamRatings = calculateOpenSkillRatings(teams, rankings); // Update database for (let i = 0; i < team1Players.length; i++) { const newRating = fromOpenSkillRating(newTeamRatings[0][i]); await (prisma as any).openSkillRating.upsert({ where: { playerId: team1Players[i] }, update: { rating: newRating, gamesPlayed: { increment: 1 }, wins: team1Won ? { increment: 1 } : undefined, losses: !team1Won && !isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined, lastUpdated: new Date(), }, create: { playerId: team1Players[i], rating: newRating, gamesPlayed: 1, wins: team1Won ? 1 : 0, losses: !team1Won && !isTie ? 1 : 0, draws: isTie ? 1 : 0, lastUpdated: new Date(), }, }); } for (let i = 0; i < team2Players.length; i++) { const newRating = fromOpenSkillRating(newTeamRatings[1][i]); await (prisma as any).openSkillRating.upsert({ where: { playerId: team2Players[i] }, update: { rating: newRating, gamesPlayed: { increment: 1 }, wins: team2Won ? { increment: 1 } : undefined, losses: !team2Won && !isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined, lastUpdated: new Date(), }, create: { playerId: team2Players[i], rating: newRating, gamesPlayed: 1, wins: team2Won ? 1 : 0, losses: !team2Won && !isTie ? 1 : 0, draws: isTie ? 1 : 0, lastUpdated: new Date(), }, }); } } /** * Recalculate all OpenSkill ratings from match history * @param prisma Prisma client instance * @returns Object with counts of affected records */ export async function recalculateAllOpenSkill(prisma: PrismaClient) { // Get all matches in chronological order const matches = await prisma.match.findMany({ orderBy: { playedAt: 'asc' }, include: { player1P1: true, player1P2: true, player2P1: true, player2P2: true, }, }); // Reset all OpenSkill ratings await (prisma as any).openSkillRating.updateMany({ data: { rating: 25.0, gamesPlayed: 0, wins: 0, losses: 0, draws: 0, }, }); // Initialize in-memory tracking const playerRatings = new Map(); // Process each match for (const match of matches) { const { player1P1, player1P2, player2P1, player2P2, team1Score, team2Score } = match; // Skip matches with missing players if (!player1P1 || !player1P2 || !player2P1 || !player2P2) { continue; } // Get current ratings const p1Rating = playerRatings.get(player1P1.id) ?? { mu: 25.0, sigma: 8.33 }; const p2Rating = playerRatings.get(player1P2.id) ?? { mu: 25.0, sigma: 8.33 }; const p3Rating = playerRatings.get(player2P1.id) ?? { mu: 25.0, sigma: 8.33 }; const p4Rating = playerRatings.get(player2P2.id) ?? { mu: 25.0, sigma: 8.33 }; const team1Won = team1Score > team2Score; const team2Won = team2Score > team1Score; const isTie = team1Score === team2Score; const rankings = isTie ? [0, 0] : team1Won ? [0, 1] : [1, 0]; const teams = [[p1Rating, p2Rating], [p3Rating, p4Rating]]; const newRatings = calculateOpenSkillRatings(teams, rankings); // Update in-memory ratings playerRatings.set(player1P1.id, newRatings[0][0]); playerRatings.set(player1P2.id, newRatings[0][1]); playerRatings.set(player2P1.id, newRatings[1][0]); playerRatings.set(player2P2.id, newRatings[1][1]); // Update database records await (prisma as any).openSkillRating.upsert({ where: { playerId: player1P1.id }, update: { rating: fromOpenSkillRating(newRatings[0][0]), gamesPlayed: { increment: 1 }, wins: team1Won ? { increment: 1 } : undefined, losses: !team1Won && !isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined, }, create: { playerId: player1P1.id, rating: fromOpenSkillRating(newRatings[0][0]), gamesPlayed: 1, wins: team1Won ? 1 : 0, losses: !team1Won && !isTie ? 1 : 0, draws: isTie ? 1 : 0, }, }); await (prisma as any).openSkillRating.upsert({ where: { playerId: player1P2.id }, update: { rating: fromOpenSkillRating(newRatings[0][1]), gamesPlayed: { increment: 1 }, wins: team1Won ? { increment: 1 } : undefined, losses: !team1Won && !isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined, }, create: { playerId: player1P2.id, rating: fromOpenSkillRating(newRatings[0][1]), gamesPlayed: 1, wins: team1Won ? 1 : 0, losses: !team1Won && !isTie ? 1 : 0, draws: isTie ? 1 : 0, }, }); await (prisma as any).openSkillRating.upsert({ where: { playerId: player2P1.id }, update: { rating: fromOpenSkillRating(newRatings[1][0]), gamesPlayed: { increment: 1 }, wins: team2Won ? { increment: 1 } : undefined, losses: !team2Won && !isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined, }, create: { playerId: player2P1.id, rating: fromOpenSkillRating(newRatings[1][0]), gamesPlayed: 1, wins: team2Won ? 1 : 0, losses: !team2Won && !isTie ? 1 : 0, draws: isTie ? 1 : 0, }, }); await (prisma as any).openSkillRating.upsert({ where: { playerId: player2P2.id }, update: { rating: fromOpenSkillRating(newRatings[1][1]), gamesPlayed: { increment: 1 }, wins: team2Won ? { increment: 1 } : undefined, losses: !team2Won && !isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined, }, create: { playerId: player2P2.id, rating: fromOpenSkillRating(newRatings[1][1]), gamesPlayed: 1, wins: team2Won ? 1 : 0, losses: !team2Won && !isTie ? 1 : 0, draws: isTie ? 1 : 0, }, }); } return { matchesProcessed: matches.length, playersUpdated: playerRatings.size, }; }