Files
euchre_camp/src/lib/elo-utils.ts
T

428 lines
13 KiB
TypeScript

import type { PrismaClient } from '@prisma/client';
/**
* Elo Rating Utilities
*
* Provides functions for calculating Elo ratings and changes
* Based on the standard Elo rating system
*/
/**
* Calculate the expected score for a player based on rating difference
* @param ratingA Player A's rating
* @param ratingB Player B's rating
* @returns Expected score (0-1) for Player A
*/
export function calculateExpectedScore(ratingA: number, ratingB: number): number {
const ratingDiff = ratingA - ratingB;
return 1 / (1 + Math.pow(10, -ratingDiff / 400));
}
/**
* Calculate Elo rating change for a single game
* @param rating Player's current rating
* @param opponentRating Opponent's rating
* @param actualScore Actual score (1 = win, 0.5 = draw, 0 = loss)
* @param kFactor K-factor (how much ratings change per game)
* @returns Elo rating change (positive or negative)
*/
export function calculateEloChange(
rating: number,
opponentRating: number,
actualScore: number,
kFactor: number = 32
): number {
const expectedScore = calculateExpectedScore(rating, opponentRating);
return kFactor * (actualScore - expectedScore);
}
/**
* Calculate team Elo rating as average of individual ratings
* @param rating1 Rating of player 1
* @param rating2 Rating of player 2
* @returns Team rating
*/
export function calculateTeamElo(rating1: number, rating2: number): number {
return (rating1 + rating2) / 2;
}
/**
* Update player statistics after a match
* @param currentRating Current player rating
* @param currentGamesPlayed Current games played count
* @param currentWins Current wins count
* @param currentLosses Current losses count
* @param eloChange Elo rating change
* @param won Whether the player won
* @param isTie Whether the match was a tie
* @returns Updated statistics
*/
export function updatePlayerStats(
currentRating: number,
currentGamesPlayed: number,
currentWins: number,
currentLosses: number,
eloChange: number,
won: boolean,
isTie: boolean = false
): {
newRating: number;
newGamesPlayed: number;
newWins: number;
newLosses: number;
winRate: number;
} {
const newRating = currentRating + Math.round(eloChange);
const newGamesPlayed = currentGamesPlayed + 1;
let newWins = currentWins;
let newLosses = currentLosses;
if (isTie) {
// For ties, don't increment wins or losses
} else if (won) {
newWins += 1;
} else {
newLosses += 1;
}
const winRate = newGamesPlayed > 0 ? newWins / newGamesPlayed : 0;
return {
newRating,
newGamesPlayed,
newWins,
newLosses,
winRate,
};
}
/**
* Calculate new rating after a match
* @param currentRating Current rating
* @param opponentRating Opponent's rating
* @param won Whether the player won
* @param kFactor K-factor (default 32)
* @returns New rating
*/
export function calculateNewRating(
currentRating: number,
opponentRating: number,
won: boolean,
kFactor: number = 32
): number {
const actualScore = won ? 1 : 0;
const eloChange = calculateEloChange(currentRating, opponentRating, actualScore, kFactor);
return Math.round(currentRating + eloChange);
}
/**
* Validate rating is within reasonable bounds
* @param rating Rating to validate
* @returns Whether the rating is valid
*/
export function isValidRating(rating: number): boolean {
return rating >= 100 && rating <= 3000;
}
/**
* Get rating tier name based on rating
* @param rating Player rating
* @returns Tier name
*/
export function getRatingTier(rating: number): string {
if (rating < 1100) return 'Beginner';
if (rating < 1300) return 'Novice';
if (rating < 1500) return 'Intermediate';
if (rating < 1700) return 'Advanced';
if (rating < 1900) return 'Expert';
if (rating < 2100) return 'Master';
return 'Grandmaster';
}
/**
* Calculate expected score for a team match
* @param team1Rating Average rating of team 1
* @param team2Rating Average rating of team 2
* @returns Expected score for team 1
*/
export function calculateExpectedTeamScore(team1Rating: number, team2Rating: number): number {
return calculateExpectedScore(team1Rating, team2Rating);
}
/**
* Calculate Elo change for team match
* @param team1Rating Average rating of team 1
* @param team2Rating Average rating of team 2
* @param team1Score Actual score for team 1 (1 = win, 0.5 = draw, 0 = loss)
* @param kFactor K-factor (default 32)
* @returns Elo change for team 1
*/
export function calculateTeamEloChange(
team1Rating: number,
team2Rating: number,
team1Score: number,
kFactor: number = 32
): 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;
const isTie = team1Score === team2Score;
// Update Player 1 (team 1, player 1)
const stats1 = getPlayerStats(team1P1.id);
stats1.rating += p1Change;
stats1.gamesPlayed += 1;
if (isTie) {
// For ties, don't increment wins or losses (gamesPlayed is already incremented)
} else if (team1Won) {
stats1.wins += 1;
} else {
stats1.losses += 1;
}
// Update Player 2 (team 1, player 2)
const stats2 = getPlayerStats(team1P2.id);
stats2.rating += p2Change;
stats2.gamesPlayed += 1;
if (isTie) {
// For ties, don't increment wins or losses (gamesPlayed is already incremented)
} else if (team1Won) {
stats2.wins += 1;
} else {
stats2.losses += 1;
}
// Update Player 3 (team 2, player 1)
const stats3 = getPlayerStats(team2P1.id);
stats3.rating += p3Change;
stats3.gamesPlayed += 1;
if (isTie) {
// For ties, don't increment wins or losses (gamesPlayed is already incremented)
} else if (team2Won) {
stats3.wins += 1;
} else {
stats3.losses += 1;
}
// Update Player 4 (team 2, player 2)
const stats4 = getPlayerStats(team2P2.id);
stats4.rating += p4Change;
stats4.gamesPlayed += 1;
if (isTie) {
// For ties, don't increment wins or losses (gamesPlayed is already incremented)
} else if (team2Won) {
stats4.wins += 1;
} else {
stats4.losses += 1;
}
// Update partnership stats for team 1
const partnership1 = getPartnershipStats(team1P1.id, team1P2.id);
partnership1.gamesPlayed += 1;
if (isTie) {
// For ties, don't increment wins or losses (gamesPlayed is already incremented)
} else if (team1Won) {
partnership1.wins += 1;
} else {
partnership1.losses += 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;
if (isTie) {
// For ties, don't increment wins or losses (gamesPlayed is already incremented)
} else if (team2Won) {
partnership2.wins += 1;
} else {
partnership2.losses += 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,
};
}