feat: add OpenSkill rating system support

This commit is contained in:
2026-03-31 03:38:12 -07:00
parent 2932d40ff7
commit cfd36d375c
+318
View File
@@ -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<void>
*/
export async function updateOpenSkillRatings(
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 playerRatings = new Map<number, { mu: number; sigma: number }>();
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<number, { mu: number; sigma: number }>();
// 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,
};
}