#!/usr/bin/env node /** * Recalculate all ELO ratings and partnership stats * * This script rebuilds all player stats and partnership stats from scratch * by processing all matches in chronological order. */ const { PrismaClient } = require('@prisma/client'); const { PrismaPg } = require('@prisma/adapter-pg'); // Load environment variables from .env file require('dotenv').config(); // Create PrismaClient with PostgreSQL adapter (matching src/lib/prisma.ts) const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter }); // Simple Elo calculation functions (copied from src/lib/elo-utils.ts) function calculateTeamElo(rating1, rating2) { return (rating1 + rating2) / 2; } function calculateExpectedScore(ratingA, ratingB) { const ratingDiff = ratingA - ratingB; return 1 / (1 + Math.pow(10, -ratingDiff / 400)); } function calculateEloChange(rating, opponentRating, actualScore, kFactor = 32) { const expectedScore = calculateExpectedScore(rating, opponentRating); return kFactor * (actualScore - expectedScore); } function calculateTeamEloChange(team1Rating, team2Rating, team1Score, kFactor = 32) { return calculateEloChange(team1Rating, team2Rating, team1Score, kFactor); } // Recalculate all Elo ratings async function recalculateAllElo(prisma) { // 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(); // Initialize partnership tracking const partnershipStats = new Map(); // Helper to get/create player stats const getPlayerStats = (playerId) => { 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, p2Id) => { return [Math.min(p1Id, p2Id), Math.max(p1Id, p2Id)].join('-'); }; // Helper to get/update partnership stats const getPartnershipStats = (p1Id, p2Id) => { 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, }; } async function main() { console.log('Starting ELO recalculation...'); console.log('This will delete all existing stats and rebuild them from match history.'); console.log(''); try { // Confirm before proceeding const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout }); const confirm = await new Promise(resolve => { readline.question('Are you sure you want to proceed? (yes/no): ', resolve); }); readline.close(); if (confirm.toLowerCase() !== 'yes') { console.log('Operation cancelled.'); return; } console.log('Recalculating stats...'); const result = await recalculateAllElo(prisma); console.log('\nRecalculation complete!'); console.log(`- Matches processed: ${result.matchesProcessed}`); console.log(`- Players updated: ${result.playersUpdated}`); console.log(`- Partnerships updated: ${result.partnershipsUpdated}`); } catch (error) { console.error('Error during recalculation:', error); process.exit(1); } finally { await prisma.$disconnect(); } } main();