feat: add recalculateAllElo function for idempotent ELO recalculation
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Elo Rating Utilities
|
||||
*
|
||||
@@ -150,3 +152,232 @@ export function calculateTeamEloChange(
|
||||
): number {
|
||||
return calculateEloChange(team1Rating, team2Rating, team1Score, kFactor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate all player and partnership statistics from match history
|
||||
* This function is idempotent - running it multiple times produces the same result
|
||||
* @param prisma Prisma client instance
|
||||
* @returns Object with counts of affected records
|
||||
*/
|
||||
export async function recalculateAllElo(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 player stats to zero
|
||||
await prisma.player.updateMany({
|
||||
data: {
|
||||
currentElo: 1000,
|
||||
gamesPlayed: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Delete all existing elo snapshots
|
||||
await prisma.eloSnapshot.deleteMany({});
|
||||
|
||||
// Delete all existing partnership stats (will be rebuilt)
|
||||
await prisma.partnershipStat.deleteMany({});
|
||||
|
||||
// Initialize in-memory tracking for all players
|
||||
const playerStats = new Map<number, { rating: number; gamesPlayed: number; wins: number; losses: number }>();
|
||||
|
||||
// Initialize partnership tracking
|
||||
const partnershipStats = new Map<string, { gamesPlayed: number; wins: number; losses: number; totalEloChange: number; lastPlayed: Date | null }>();
|
||||
|
||||
// Helper to get/create player stats
|
||||
const getPlayerStats = (playerId: number) => {
|
||||
if (!playerStats.has(playerId)) {
|
||||
playerStats.set(playerId, { rating: 1000, gamesPlayed: 0, wins: 0, losses: 0 });
|
||||
}
|
||||
return playerStats.get(playerId)!;
|
||||
};
|
||||
|
||||
// Helper to get partnership key (sorted player IDs)
|
||||
const getPartnershipKey = (p1Id: number, p2Id: number) => {
|
||||
return [Math.min(p1Id, p2Id), Math.max(p1Id, p2Id)].join('-');
|
||||
};
|
||||
|
||||
// Helper to get/update partnership stats
|
||||
const getPartnershipStats = (p1Id: number, p2Id: number) => {
|
||||
const key = getPartnershipKey(p1Id, p2Id);
|
||||
if (!partnershipStats.has(key)) {
|
||||
partnershipStats.set(key, { gamesPlayed: 0, wins: 0, losses: 0, totalEloChange: 0, lastPlayed: null });
|
||||
}
|
||||
return partnershipStats.get(key)!;
|
||||
};
|
||||
|
||||
// Process each match in chronological order
|
||||
for (const match of matches) {
|
||||
const { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score, id: matchId, playedAt } = match;
|
||||
|
||||
// Get current ratings for all players
|
||||
const p1Rating = getPlayerStats(team1P1.id).rating;
|
||||
const p2Rating = getPlayerStats(team1P2.id).rating;
|
||||
const p3Rating = getPlayerStats(team2P1.id).rating;
|
||||
const p4Rating = getPlayerStats(team2P2.id).rating;
|
||||
|
||||
// Calculate team ratings
|
||||
const team1Rating = calculateTeamElo(p1Rating, p2Rating);
|
||||
const team2Rating = calculateTeamElo(p3Rating, p4Rating);
|
||||
|
||||
// Determine match result (team1 score based on points)
|
||||
const team1ScoreNormalized = team1Score > team2Score ? 1 : team1Score === team2Score ? 0.5 : 0;
|
||||
|
||||
// Calculate team ELO changes
|
||||
const team1Change = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreNormalized);
|
||||
const team2Change = -team1Change; // Equal and opposite
|
||||
|
||||
// Split changes evenly between partners
|
||||
const p1Change = Math.round(team1Change / 2);
|
||||
const p2Change = Math.round(team1Change / 2);
|
||||
const p3Change = Math.round(team2Change / 2);
|
||||
const p4Change = Math.round(team2Change / 2);
|
||||
|
||||
// Update player stats
|
||||
const team1Won = team1Score > team2Score;
|
||||
const team2Won = team2Score > team1Score;
|
||||
|
||||
// Update Player 1 (team 1, player 1)
|
||||
const stats1 = getPlayerStats(team1P1.id);
|
||||
stats1.rating += p1Change;
|
||||
stats1.gamesPlayed += 1;
|
||||
stats1.wins += team1Won ? 1 : 0;
|
||||
stats1.losses += team1Won ? 0 : 1;
|
||||
|
||||
// Update Player 2 (team 1, player 2)
|
||||
const stats2 = getPlayerStats(team1P2.id);
|
||||
stats2.rating += p2Change;
|
||||
stats2.gamesPlayed += 1;
|
||||
stats2.wins += team1Won ? 1 : 0;
|
||||
stats2.losses += team1Won ? 0 : 1;
|
||||
|
||||
// Update Player 3 (team 2, player 1)
|
||||
const stats3 = getPlayerStats(team2P1.id);
|
||||
stats3.rating += p3Change;
|
||||
stats3.gamesPlayed += 1;
|
||||
stats3.wins += team2Won ? 1 : 0;
|
||||
stats3.losses += team2Won ? 0 : 1;
|
||||
|
||||
// Update Player 4 (team 2, player 2)
|
||||
const stats4 = getPlayerStats(team2P2.id);
|
||||
stats4.rating += p4Change;
|
||||
stats4.gamesPlayed += 1;
|
||||
stats4.wins += team2Won ? 1 : 0;
|
||||
stats4.losses += team2Won ? 0 : 1;
|
||||
|
||||
// Update partnership stats for team 1
|
||||
const partnership1 = getPartnershipStats(team1P1.id, team1P2.id);
|
||||
partnership1.gamesPlayed += 1;
|
||||
partnership1.wins += team1Won ? 1 : 0;
|
||||
partnership1.losses += team1Won ? 0 : 1;
|
||||
partnership1.totalEloChange += p1Change + p2Change;
|
||||
if (playedAt && (!partnership1.lastPlayed || playedAt > partnership1.lastPlayed)) {
|
||||
partnership1.lastPlayed = playedAt;
|
||||
}
|
||||
|
||||
// Update partnership stats for team 2
|
||||
const partnership2 = getPartnershipStats(team2P1.id, team2P2.id);
|
||||
partnership2.gamesPlayed += 1;
|
||||
partnership2.wins += team2Won ? 1 : 0;
|
||||
partnership2.losses += team2Won ? 0 : 1;
|
||||
partnership2.totalEloChange += p3Change + p4Change;
|
||||
if (playedAt && (!partnership2.lastPlayed || playedAt > partnership2.lastPlayed)) {
|
||||
partnership2.lastPlayed = playedAt;
|
||||
}
|
||||
|
||||
// Create elo snapshots for all players
|
||||
const snapshotData = [
|
||||
{ playerId: team1P1.id, ratingBefore: p1Rating, ratingChange: p1Change },
|
||||
{ playerId: team1P2.id, ratingBefore: p2Rating, ratingChange: p2Change },
|
||||
{ playerId: team2P1.id, ratingBefore: p3Rating, ratingChange: p3Change },
|
||||
{ playerId: team2P2.id, ratingBefore: p4Rating, ratingChange: p4Change },
|
||||
];
|
||||
|
||||
for (const snapshot of snapshotData) {
|
||||
await prisma.eloSnapshot.create({
|
||||
data: {
|
||||
playerId: snapshot.playerId,
|
||||
matchId: matchId,
|
||||
ratingBefore: snapshot.ratingBefore,
|
||||
ratingAfter: snapshot.ratingBefore + snapshot.ratingChange,
|
||||
ratingChange: snapshot.ratingChange,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write all updated player stats to database
|
||||
const playerUpdatePromises = Array.from(playerStats.entries()).map(
|
||||
async ([playerId, stats]) =>
|
||||
await prisma.player.update({
|
||||
where: { id: playerId },
|
||||
data: {
|
||||
currentElo: stats.rating,
|
||||
gamesPlayed: stats.gamesPlayed,
|
||||
wins: stats.wins,
|
||||
losses: stats.losses,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(playerUpdatePromises);
|
||||
|
||||
// Write all partnership stats to database
|
||||
const partnershipUpdatePromises = Array.from(partnershipStats.entries()).map(async ([key, stats]) => {
|
||||
const [player1Id, player2Id] = key.split('-').map(Number);
|
||||
|
||||
// Check if the partnership stat already exists
|
||||
const existingStat = await prisma.partnershipStat.findFirst({
|
||||
where: {
|
||||
player1Id,
|
||||
player2Id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingStat) {
|
||||
// Update existing record
|
||||
return await prisma.partnershipStat.update({
|
||||
where: {
|
||||
id: existingStat.id,
|
||||
},
|
||||
data: {
|
||||
gamesPlayed: stats.gamesPlayed,
|
||||
wins: stats.wins,
|
||||
losses: stats.losses,
|
||||
totalEloChange: stats.totalEloChange,
|
||||
lastPlayed: stats.lastPlayed,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new record
|
||||
return await prisma.partnershipStat.create({
|
||||
data: {
|
||||
player1Id,
|
||||
player2Id,
|
||||
gamesPlayed: stats.gamesPlayed,
|
||||
wins: stats.wins,
|
||||
losses: stats.losses,
|
||||
totalEloChange: stats.totalEloChange,
|
||||
lastPlayed: stats.lastPlayed,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(partnershipUpdatePromises);
|
||||
|
||||
return {
|
||||
matchesProcessed: matches.length,
|
||||
playersUpdated: playerStats.size,
|
||||
partnershipsUpdated: partnershipStats.size,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user