Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions migrations/1760500003000_db_votes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
pgm.createTable('votes', {
id: { type: 'serial', primaryKey: true },
match_id: { type: 'integer', notNull: true, references: '"matches"', onDelete: 'CASCADE' },
user_id: { type: 'varchar(255)', notNull: true, references: '"users"(user_id)', onDelete: 'CASCADE', onUpdate: 'CASCADE' },
vote_type: { type: 'varchar(50)', notNull: true }, // 'win', 'cancel', 'rematch', 'bo3', 'bo5'
vote_value: { type: 'integer' }, // For win votes: team number (1, 2, etc), for others: null (presence = yes vote)
created_at: { type: 'timestamp with time zone', notNull: true, default: pgm.func('NOW()') },
});

// Index for faster lookups
pgm.addIndex('votes', ['match_id', 'user_id']);
pgm.addIndex('votes', ['match_id', 'vote_type']);

// Constraint: A user can only have one active vote per match (they can change their vote)
pgm.addConstraint('votes', 'unique_user_vote_per_match', {
unique: ['match_id', 'user_id']
});
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
pgm.dropTable('votes', { ifExists: true, cascade: true });
};
83 changes: 45 additions & 38 deletions src/events/interactionCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,55 +210,58 @@ export default {
await setWinningTeam(matchId, winner)
await endMatch(matchId)
return
}
} else {
const embed = interaction.message.embeds[0]
const fields = embed.data.fields || []

const embed = interaction.message.embeds[0]
const fields = embed.data.fields || []
// Update Best of scores in the embed (for display only)
const winnerIndex = winner === 1 ? 0 : 1
for (let i = 0; i < Math.min(2, fields.length); i++) {
const val = fields[i].value || ''
const lines = val.split('\n')

// Update Best of scores and check for match winner
const winnerIndex = winner === 1 ? 0 : 1
for (let i = 0; i < Math.min(2, fields.length); i++) {
const val = fields[i].value || ''
const lines = val.split('\n')
const cleaned = lines.filter(
(l) => !l.includes('Win Votes') && !l.includes('<@'),
)

const cleaned = lines.filter(
(l) => !l.includes('Win Votes') && !l.includes('<@'),
)
const mmrIdx = cleaned.findIndex((l) => l.includes('MMR'))
if (mmrIdx !== -1) {
const mmrLine = cleaned[mmrIdx]
const m = mmrLine.match(/Score:\s*(\d+)/i)
let score = m ? parseInt(m[1], 10) || 0 : 0

const mmrIdx = cleaned.findIndex((l) => l.includes('MMR'))
if (mmrIdx !== -1) {
const mmrLine = cleaned[mmrIdx]
const m = mmrLine.match(/Score:\s*(\d+)/i)
let score = m ? parseInt(m[1], 10) || 0 : 0
if (i === winnerIndex) score += 1

if (i === winnerIndex) score += 1
cleaned[mmrIdx] =
mmrLine.replace(/\s*-?\s*Score:\s*\d+/i, '').trimEnd() +
` - Score: ${score}`
}

cleaned[mmrIdx] =
mmrLine.replace(/\s*-?\s*Score:\s*\d+/i, '').trimEnd() +
` - Score: ${score}`
fields[i].value = cleaned.join('\n')
}

fields[i].value = cleaned.join('\n')
}

const scores = getBestOfMatchScores(fields)
const requiredWins = isBo5 ? 3 : isBo3 ? 2 : 1
const [team1Wins, team2Wins] = scores
// Get updated scores from the embed (after incrementing)
let scores = getBestOfMatchScores(fields)
let requiredWins = isBo5 ? 3 : isBo3 ? 2 : 1
const [team1Wins, team2Wins] = scores

// Check if a team has won the Best of series
let winningTeam = 0
if (team1Wins >= requiredWins) {
winningTeam = 1
} else if (team2Wins >= requiredWins) {
winningTeam = 2
}

let winningTeam = 0
if (team1Wins >= requiredWins) {
winningTeam = 1
} else if (team2Wins >= requiredWins) {
winningTeam = 2
}
if (winningTeam) {
await setWinningTeam(matchId, winningTeam)
await endMatch(matchId)
return
}

if (winningTeam) {
await endMatch(matchId)
return
interaction.message.embeds[0] = embed
await interaction.update({ embeds: interaction.message.embeds })
}

interaction.message.embeds[0] = embed
await interaction.update({ embeds: interaction.message.embeds })
},
})
}
Expand Down Expand Up @@ -542,6 +545,7 @@ export default {
voteType: 'Cancel Match Votes',
embedFieldIndex: 2,
participants: matchUsersArray,
matchId: matchId,
onComplete: async (interaction) => {
await cancel(interaction, matchId)
},
Expand Down Expand Up @@ -724,6 +728,8 @@ export default {
voteType: 'Rematch Votes',
embedFieldIndex: 2,
participants: matchUsersArray,
matchId: matchId,
resendMessage: false,
onComplete: async (interaction, { embed }) => {
await interaction.update({
content: 'A Rematch for this matchup has begun!',
Expand Down Expand Up @@ -796,6 +802,7 @@ export default {
voteType: voteFieldName,
embedFieldIndex: 3,
participants: matchUsersArray,
matchId: matchId,
onComplete: async (interaction, { embed }) => {
const rows = interaction.message.components.map((row) =>
ActionRowBuilder.from(row as any),
Expand Down
179 changes: 86 additions & 93 deletions src/utils/algorithms/calculateMMR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type { teamResults } from 'psqlDB'
import { setUserQueueRole } from 'utils/queueHelpers'
import { clamp } from 'lodash-es'

// MMR formula constants
const C_MMR_CHANGE = 25 // Base MMR change value
const V_VARIANCE = 1200 // MMR variance

// Function is from Owen, named Elowen, blame him if anything goes wrong
// - Jeff
function calculateRatingChange(
Expand All @@ -27,23 +31,26 @@ function calculateRatingChange(
return gMultiplier * (numerator / denominator)
}

// Calculate predicted MMR changes for each team without updating the database
export async function calculatePredictedMMR(
queueId: number,
// Helper function to calculate team statistics and MMR changes
async function calculateTeamStatsAndChanges(
teamResults: teamResults,
winningTeamId: number,
): Promise<Map<string, number>> {
const settings = await getQueueSettings(queueId)

// MMR formula constants
const C_MMR_CHANGE = 25
const V_VARIANCE = 1200

defaultElo: number,
): Promise<{
teamStats: Array<{
team: teamResults['teams'][0]
avgMMR: number
avgVolatility: number
isWinner: boolean
}>
ratingChange: number
loserCount: number
}> {
// Calculate average MMR and volatility for each team
const teamStats = teamResults.teams.map((team) => {
const players = team.players
const avgMMR =
players.reduce((sum, p) => sum + (p.elo ?? settings.default_elo), 0) /
players.reduce((sum, p) => sum + (p.elo ?? defaultElo), 0) /
players.length
const avgVolatility =
players.reduce((sum, p) => sum + (p.volatility ?? 0), 0) / players.length
Expand All @@ -60,7 +67,7 @@ export async function calculatePredictedMMR(
const loserStats = teamStats.filter((ts) => !ts.isWinner)

if (!winnerStats || loserStats.length === 0) {
return new Map()
throw new Error('Invalid team stats: no winner or losers found')
}

const avgLoserMMR =
Expand All @@ -80,21 +87,46 @@ export async function calculatePredictedMMR(
globalAvgVolatility,
)

// Build map of user_id -> predicted MMR change
const predictions = new Map<string, number>()
return {
teamStats,
ratingChange,
loserCount: loserStats.length,
}
}

// Calculate predicted MMR changes for each team without updating the database
export async function calculatePredictedMMR(
queueId: number,
teamResults: teamResults,
winningTeamId: number,
): Promise<Map<string, number>> {
const settings = await getQueueSettings(queueId)

try {
const { teamStats, ratingChange, loserCount } =
await calculateTeamStatsAndChanges(
teamResults,
winningTeamId,
settings.default_elo,
)

for (const ts of teamStats) {
const isWinner = ts.isWinner
const mmrChange = isWinner
? ratingChange
: -ratingChange / loserStats.length
// Build map of user_id -> predicted MMR change
const predictions = new Map<string, number>()

for (const player of ts.team.players) {
predictions.set(player.user_id, parseFloat(mmrChange.toFixed(1)))
for (const ts of teamStats) {
const isWinner = ts.isWinner
const mmrChange = isWinner ? ratingChange : -ratingChange / loserCount

for (const player of ts.team.players) {
predictions.set(player.user_id, parseFloat(mmrChange.toFixed(1)))
}
}
}

return predictions
return predictions
} catch (err) {
console.error('Error calculating predicted MMR:', err)
return new Map()
}
}

export async function calculateNewMMR(
Expand All @@ -106,84 +138,45 @@ export async function calculateNewMMR(
const settings = await getQueueSettings(matchData.queue_id)
const winningTeamId = await getWinningTeamFromMatch(matchId)

// MMR formula constants
const C_MMR_CHANGE = 25 // Base MMR change value
const V_VARIANCE = 1200 // MMR variance
try {
const { teamStats, ratingChange, loserCount } =
await calculateTeamStatsAndChanges(
teamResults,
winningTeamId ?? 0,
settings.default_elo,
)

// Calculate average MMR and volatility for each team
const teamStats = teamResults.teams.map((team) => {
const players = team.players
const avgMMR =
players.reduce((sum, p) => sum + (p.elo ?? settings.default_elo), 0) /
players.length
const avgVolatility =
players.reduce((sum, p) => sum + (p.volatility ?? 0), 0) / players.length
// Apply changes to all teams and players
for (const ts of teamStats) {
const isWinner = ts.isWinner
const mmrChange = isWinner ? ratingChange : -ratingChange / loserCount

return {
team,
avgMMR,
avgVolatility,
isWinner: team.id === winningTeamId,
}
})
for (const player of ts.team.players) {
const oldMMR = player.elo ?? settings.default_elo
const oldVolatility = player.volatility ?? 0

// Find winner and calculate average MMR of all losing teams
const winnerStats = teamStats.find((ts) => ts.isWinner)
const loserStats = teamStats.filter((ts) => !ts.isWinner)
const newMMR = parseFloat((oldMMR + mmrChange).toFixed(1))
const newVolatility = Math.min(oldVolatility + 1, 10)

if (!winnerStats || loserStats.length === 0) {
throw new Error('Unable to determine winner and loser teams')
}
// Update database
await updatePlayerMmrAll(queueId, player.user_id, newMMR, newVolatility)

// Average MMR and volatility of all losing teams
const avgLoserMMR =
loserStats.reduce((sum, ts) => sum + ts.avgMMR, 0) / loserStats.length
const avgLoserVolatility =
loserStats.reduce((sum, ts) => sum + ts.avgVolatility, 0) /
loserStats.length
// Update teamResults object
player.elo = clamp(newMMR, 0, 9999)
player.elo_change = parseFloat(mmrChange.toFixed(1))
player.volatility = newVolatility

// Use overall average volatility for g factor
const globalAvgVolatility =
(winnerStats.avgVolatility + avgLoserVolatility) / 2
// Set user queue role
await setUserQueueRole(queueId, player.user_id)
}

// Calculate rating change using the formula
const ratingChange = calculateRatingChange(
C_MMR_CHANGE,
V_VARIANCE,
avgLoserMMR,
winnerStats.avgMMR,
globalAvgVolatility,
)

// Apply changes to all teams and players
for (const ts of teamStats) {
const isWinner = ts.isWinner
const mmrChange = isWinner
? ratingChange
: -ratingChange / loserStats.length

for (const player of ts.team.players) {
const oldMMR = player.elo ?? settings.default_elo
const oldVolatility = player.volatility ?? 0

const newMMR = parseFloat((oldMMR + mmrChange).toFixed(1))
const newVolatility = Math.min(oldVolatility + 1, 10)

// Update database
await updatePlayerMmrAll(queueId, player.user_id, newMMR, newVolatility)

// Update teamResults object
player.elo = clamp(newMMR, 0, 9999)
player.elo_change = parseFloat(mmrChange.toFixed(1))
player.volatility = newVolatility

// Set user queue role
await setUserQueueRole(queueId, player.user_id)
// Set team score
ts.team.score = isWinner ? 1 : 0
}

// Set team score
ts.team.score = isWinner ? 1 : 0
return teamResults
} catch (err) {
console.error('Error calculating new MMR:', err)
return teamResults
}

return teamResults
}
Loading