From cfd36d375c5c4007b526fffe4e9806520f48a5b4 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Tue, 31 Mar 2026 03:38:12 -0700 Subject: [PATCH] feat: add OpenSkill rating system support --- src/lib/openskill-utils.ts | 318 +++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 src/lib/openskill-utils.ts diff --git a/src/lib/openskill-utils.ts b/src/lib/openskill-utils.ts new file mode 100644 index 0000000..2c7997c --- /dev/null +++ b/src/lib/openskill-utils.ts @@ -0,0 +1,318 @@ +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: { + team1P1: true, + team1P2: true, + team2P1: true, + team2P2: 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 { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score } = match; + + // Get current ratings + const p1Rating = playerRatings.get(team1P1.id) ?? { mu: 25.0, sigma: 8.33 }; + const p2Rating = playerRatings.get(team1P2.id) ?? { mu: 25.0, sigma: 8.33 }; + const p3Rating = playerRatings.get(team2P1.id) ?? { mu: 25.0, sigma: 8.33 }; + const p4Rating = playerRatings.get(team2P2.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(team1P1.id, newRatings[0][0]); + playerRatings.set(team1P2.id, newRatings[0][1]); + playerRatings.set(team2P1.id, newRatings[1][0]); + playerRatings.set(team2P2.id, newRatings[1][1]); + + // Update database records + await (prisma as any).openSkillRating.upsert({ + where: { playerId: team1P1.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: team1P1.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: team1P2.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: team1P2.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: team2P1.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: team2P1.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: team2P2.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: team2P2.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, + }; +}