319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|