feat: add OpenSkill rating system support
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user