diff --git a/migrations/1760733866474_queue-log-things.js b/migrations/1760733866474_queue-log-things.js new file mode 100644 index 00000000..322e0eb6 --- /dev/null +++ b/migrations/1760733866474_queue-log-things.js @@ -0,0 +1,24 @@ +/** + * @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} + */ +export const up = (pgm) => { + pgm.addColumn('matches', { results_msg_id: { type: 'varchar(255)', notNull: false, default: null }}) + pgm.addColumn('matches', { queue_log_msg_id: { type: 'varchar(255)', notNull: false, default: null }}) +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => { + pgm.dropColumn('matches', 'results_msg_id'); + pgm.dropColumn('matches', 'queue_log_msg_id'); +}; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 3e17ea44..2d45a025 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -1,7 +1,7 @@ // Types for the database stuff declare module 'psqlDB' { - import { EmbedField } from 'discord.js' + import { ColorResolvable, EmbedField } from 'discord.js' export interface Queues { id: number @@ -188,7 +188,7 @@ declare module 'psqlDB' { export type EmbedType = { title: string | null description: string | null - color: number | null + color: ColorResolvable | null fields: EmbedField[] | null footer: { text: string } | null blame: string | null diff --git a/src/api/routers/commands/stats.router.ts b/src/api/routers/commands/stats.router.ts index e0c0ce90..9d81cb9a 100644 --- a/src/api/routers/commands/stats.router.ts +++ b/src/api/routers/commands/stats.router.ts @@ -6,17 +6,10 @@ const statsRouter = new OpenAPIHono() statsRouter.openapi( createRoute({ method: 'get', - path: '/{user_id}/{queue_id}', - description: 'Get player statistics for a specific queue.', + path: '/leaderboard/{queue_id}', + description: 'Get leaderboard for a specific queue.', request: { params: z.object({ - user_id: z.string().openapi({ - param: { - name: 'user_id', - in: 'path', - }, - example: '123456789012345678', - }), queue_id: z .string() .regex(/^\d+$/) @@ -29,37 +22,39 @@ statsRouter.openapi( example: '1', }), }), + query: z.object({ + limit: z + .string() + .regex(/^\d+$/) + .transform(Number) + .optional() + .openapi({ + param: { + name: 'limit', + in: 'query', + }, + example: '100', + }), + }), }, responses: { 200: { content: { 'application/json': { schema: z.object({ - mmr: z.number(), - wins: z.number(), - losses: z.number(), - streak: z.number(), - totalgames: z.number(), - decay: z.number(), - ign: z.any().nullable(), - peak_mmr: z.number(), - peak_streak: z.number(), - rank: z.number(), - winrate: z.number(), - }), - }, - }, - description: 'Player statistics retrieved successfully.', - }, - 404: { - content: { - 'application/json': { - schema: z.object({ - error: z.string(), + leaderboard: z.array( + z.object({ + rank: z.number(), + user_id: z.string(), + mmr: z.number(), + wins: z.number(), + losses: z.number(), + }), + ), }), }, }, - description: 'Player not found in this queue.', + description: 'Leaderboard retrieved successfully.', }, 500: { content: { @@ -74,26 +69,24 @@ statsRouter.openapi( }, }), async (c) => { - const { user_id, queue_id } = c.req.valid('param') + const { queue_id } = c.req.valid('param') + const { limit } = c.req.valid('query') + const finalLimit = limit || 100 try { - const stats = await COMMAND_HANDLERS.STATS.GET_PLAYER_STATS( - user_id, + const leaderboard = await COMMAND_HANDLERS.STATS.GET_LEADERBOARD( queue_id, + finalLimit, ) - if (!stats) { - return c.json( - { - error: 'Player not found in this queue.', - }, - 404, - ) - } - - return c.json(stats, 200) + return c.json( + { + leaderboard, + }, + 200, + ) } catch (error) { - console.error('Error fetching player stats:', error) + console.error('Error fetching leaderboard:', error) return c.json( { error: 'Internal server error', @@ -211,4 +204,105 @@ statsRouter.openapi( }, ) +statsRouter.openapi( + createRoute({ + method: 'get', + path: '/{user_id}/{queue_id}', + description: 'Get player statistics for a specific queue.', + request: { + params: z.object({ + user_id: z.string().openapi({ + param: { + name: 'user_id', + in: 'path', + }, + example: '123456789012345678', + }), + queue_id: z + .string() + .regex(/^\d+$/) + .transform(Number) + .openapi({ + param: { + name: 'queue_id', + in: 'path', + }, + example: '1', + }), + }), + }, + responses: { + 200: { + content: { + 'application/json': { + schema: z.object({ + mmr: z.number(), + wins: z.number(), + losses: z.number(), + streak: z.number(), + totalgames: z.number(), + decay: z.number(), + ign: z.any().nullable(), + peak_mmr: z.number(), + peak_streak: z.number(), + rank: z.number(), + winrate: z.number(), + }), + }, + }, + description: 'Player statistics retrieved successfully.', + }, + 404: { + content: { + 'application/json': { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: 'Player not found in this queue.', + }, + 500: { + content: { + 'application/json': { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: 'Internal server error.', + }, + }, + }), + async (c) => { + const { user_id, queue_id } = c.req.valid('param') + + try { + const stats = await COMMAND_HANDLERS.STATS.GET_PLAYER_STATS( + user_id, + queue_id, + ) + + if (!stats) { + return c.json( + { + error: 'Player not found in this queue.', + }, + 404, + ) + } + + return c.json(stats, 200) + } catch (error) { + console.error('Error fetching player stats:', error) + return c.json( + { + error: 'Internal server error', + }, + 500, + ) + } + }, +) + export { statsRouter } diff --git a/src/command-handlers/stats/getLeaderboard.ts b/src/command-handlers/stats/getLeaderboard.ts new file mode 100644 index 00000000..3f6905f3 --- /dev/null +++ b/src/command-handlers/stats/getLeaderboard.ts @@ -0,0 +1,28 @@ +import { getQueueLeaderboard } from '../../utils/queryDB' + +export type LeaderboardEntry = { + rank: number + user_id: string + mmr: number + wins: number + losses: number +} + +/** + * Gets leaderboard data for a specific queue. + * + * @param {number} queueId - The queue ID to fetch leaderboard for. + * @param {number} limit - Maximum number of entries to return. + * @return {Promise} A promise that resolves to the leaderboard data. + */ +export async function getLeaderboard( + queueId: number, + limit: number = 100, +): Promise { + try { + return await getQueueLeaderboard(queueId, limit) + } catch (error) { + console.error('Error fetching leaderboard:', error) + throw error + } +} diff --git a/src/command-handlers/stats/index.ts b/src/command-handlers/stats/index.ts index c620160b..f94f20e3 100644 --- a/src/command-handlers/stats/index.ts +++ b/src/command-handlers/stats/index.ts @@ -1,7 +1,9 @@ import { getPlayerStats } from './getPlayerStats' import { getMatchHistory } from './getMatchHistory' +import { getLeaderboard } from './getLeaderboard' export const STATS_COMMAND_HANDLERS = { GET_PLAYER_STATS: getPlayerStats, GET_MATCH_HISTORY: getMatchHistory, + GET_LEADERBOARD: getLeaderboard, } diff --git a/src/commands/moderation/cancelMatch.ts b/src/commands/moderation/cancelMatch.ts index 7d4a0610..3f9145d6 100644 --- a/src/commands/moderation/cancelMatch.ts +++ b/src/commands/moderation/cancelMatch.ts @@ -6,11 +6,8 @@ import { PermissionFlagsBits, } from 'discord.js' import { COMMAND_HANDLERS } from '../../command-handlers' -import { - getActiveMatches, - getQueueIdFromMatch, - getQueueSettings, -} from '../../utils/queryDB' +import { getQueueIdFromMatch, getQueueSettings } from '../../utils/queryDB' +import { getMatchesForAutocomplete } from '../../utils/Autocompletions' export default { data: new SlashCommandBuilder() @@ -71,11 +68,11 @@ export default { async autocomplete(interaction: AutocompleteInteraction) { try { - const focusedValue = interaction.options.getFocused().toLowerCase() - const activeMatches = await getActiveMatches() + const focusedValue = interaction.options.getFocused() + const matches = await getMatchesForAutocomplete(focusedValue) const choices = await Promise.all( - activeMatches.map(async (match) => { + matches.map(async (match) => { const queueId = await getQueueIdFromMatch(match.id) const queueSettings = await getQueueSettings(queueId, ['queue_name']) return { @@ -85,11 +82,7 @@ export default { }), ) - const filtered = choices.filter((choice) => - choice.name.toLowerCase().includes(focusedValue), - ) - - await interaction.respond(filtered.slice(0, 25)) + await interaction.respond(choices) } catch (err) { console.error('Error in cancel-match autocomplete:', err) await interaction.respond([]) diff --git a/src/commands/moderation/giveWin.ts b/src/commands/moderation/giveWin.ts index 9d7bed86..a2452d57 100644 --- a/src/commands/moderation/giveWin.ts +++ b/src/commands/moderation/giveWin.ts @@ -6,8 +6,9 @@ import { SlashCommandBuilder, } from 'discord.js' import { getUsersInMatch, getUserTeam } from '../../utils/queryDB' -import { pool } from '../../db' import { endMatch } from '../../utils/matchHelpers' +import { getMatchesForAutocomplete } from '../../utils/Autocompletions' +import { pool } from '../../db' export default { data: new SlashCommandBuilder() @@ -33,13 +34,14 @@ export default { .setAutocomplete(true), ), async execute(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }) + try { const matchId = interaction.options.getInteger('match-id') const userId = interaction.options.getString('user', true) if (!matchId) { - await interaction.reply({ + await interaction.editReply({ content: 'No match found.', - flags: MessageFlags.Ephemeral, }) return } @@ -50,23 +52,18 @@ export default { matchId, ]) - await endMatch(matchId) + // End match + await endMatch(matchId, false) - await interaction.reply( - `Assigned win to <@${interaction.options.getString('user', true)}>.`, - ) + await interaction.editReply({ + content: `Successfully assigned win to <@${userId}> (Team ${winningTeam}) for Match #${matchId}.`, + }) } catch (err: any) { - if (interaction.deferred || interaction.replied) { - await interaction.editReply({ - content: `Failed to assign win. Reason: ${err}`, - }) - } else { - await interaction.reply({ - content: `Failed to assign win. Reason: ${err}`, - flags: MessageFlags.Ephemeral, - }) - } - console.error(err) + console.error('Error assigning win:', err) + const errorMessage = err instanceof Error ? err.message : String(err) + await interaction.editReply({ + content: `Failed to assign win.\nError: ${errorMessage}`, + }) } }, @@ -96,22 +93,20 @@ export default { ) await interaction.respond(filtered.slice(0, 25)) } else if (currentValue.name === 'match-id') { - const input = currentValue.value - const matches = await pool.query( - 'SELECT id FROM matches WHERE open = true', - ) - if (!matches.rows || matches.rows.length === 0) { + const input = currentValue.value ?? '' + const matches = await getMatchesForAutocomplete(input) + + if (!matches || matches.length === 0) { await interaction.respond([]) return } - const matchIds = matches.rows.map((match: any) => ({ - name: (interaction.channelId = match.id.toString()) - ? `${match.id.toString()} (current channel)` - : match.id.toString(), + + const matchIds = matches.map((match) => ({ + name: match.id.toString(), value: match.id.toString(), })) - const filtered = matchIds.filter((match) => match.name.includes(input)) - await interaction.respond(filtered.slice(0, 25)) + + await interaction.respond(matchIds) } }, } diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index a14cf257..ffb108ce 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -252,7 +252,9 @@ export default { // Check if match is already being processed if (processingMatchEnds.has(matchId)) { - console.log(`Match ${matchId} already being processed, skipping`) + console.log( + `Match ${matchId} already being processed, skipping`, + ) return } @@ -410,6 +412,32 @@ export default { components: [], }) } + + if (interaction.customId.startsWith('change-match-winner-')) { + const matchId = parseInt(interaction.customId.split('-')[3]) + const newWinningTeam = parseInt(interaction.values[0]) + await interaction.deferUpdate() + + try { + // Set the new winning team + await setWinningTeam(matchId, newWinningTeam) + + // Re-run endMatch logic to recalculate MMR and update message + await endMatch(matchId, false) + + await interaction.editReply({}) + await interaction.followUp({ + content: `Successfully changed winner to Team ${newWinningTeam} for Match #${matchId}. MMR has been recalculated.`, + }) + } catch (err) { + console.error('Error changing match winner:', err) + const errorMessage = + err instanceof Error ? err.message : String(err) + await interaction.editReply({ + content: `Failed to change match winner.\nError: ${errorMessage}`, + }) + } + } } catch (err) { console.error('Error in select menu interaction:', err) try { @@ -624,9 +652,13 @@ export default { t.players.map((u) => u.user_id), ) - async function cancel(interaction: any, matchId: number) { + async function cancel( + interaction: any, + matchId: number, + log: boolean = false, + ) { try { - if (interaction.message) { + if (interaction.message && !log) { await interaction .update({ content: 'The match has been cancelled.', @@ -653,6 +685,11 @@ export default { await cancel(interaction, matchId) } + // Check if log channel is the channel + if (interaction.channel!.id == botSettings.queue_logs_channel_id) { + await cancel(interaction, matchId, true) + } + // Otherwise do normal vote try { await handleVoting(interaction, { diff --git a/src/utils/Autocompletions.ts b/src/utils/Autocompletions.ts index 193a04fb..f1455371 100644 --- a/src/utils/Autocompletions.ts +++ b/src/utils/Autocompletions.ts @@ -1,6 +1,7 @@ import { AutocompleteInteraction, User } from 'discord.js' import { getAllOpenRooms, strikeUtils } from './queryDB' import { client } from '../client' +import { pool } from '../db' const userCache = new Map() @@ -186,3 +187,34 @@ export async function roomDeleteAutoCompletion( await interaction.respond(choices) return } + +// Show all active matches for autocomplete using prefix +export async function getMatchesForAutocomplete( + input: string, +): Promise<{ id: number }[]> { + let query: string + let params: any[] + + if (input.length > 0) { + query = ` + SELECT id FROM matches + WHERE open = true + AND id::text ILIKE $1 + ORDER BY id DESC + LIMIT 25 + ` + params = [`${input}%`] + } else { + // Show 25 matches with no input + query = ` + SELECT id FROM matches + WHERE open = true + ORDER BY id DESC + LIMIT 25 + ` + params = [] + } + + const result = await pool.query(query, params) + return result.rows +} diff --git a/src/utils/logCommandUse.ts b/src/utils/logCommandUse.ts index 94935524..3ace652f 100644 --- a/src/utils/logCommandUse.ts +++ b/src/utils/logCommandUse.ts @@ -1,4 +1,4 @@ -import { EmbedBuilder, EmbedField } from 'discord.js' +import { ColorResolvable, EmbedBuilder, EmbedField } from 'discord.js' import { pool } from '../db' import { client } from '../client' import { EmbedType } from 'psqlDB' @@ -9,7 +9,7 @@ export abstract class Embed { channel: any = null embed: any = null embedFields: EmbedField[] = [] - color: number = 10070709 // grey + color: ColorResolvable = '#99A5A5' // grey title: string = 'TITLE' description: string = ' ' blame: string = ' ' @@ -48,7 +48,7 @@ export abstract class Embed { public setEmbedFields(fields: any[]) { this.embedFields = fields } - public setColor(color: number) { + public setColor(color: ColorResolvable) { this.color = color } public setTitle(title: string) { @@ -124,18 +124,20 @@ export class CommandFactory extends Embed { return new General() case 'room': return new Room(id) + case 'match_created': + return new MatchCreated() } } } export class General extends CommandFactory { - color: number = 10070709 // grey + color: ColorResolvable = '#99A5A5' // grey title: string = 'COMMAND LOGGED' logType: string = 'command' } export class Room extends CommandFactory { - color: number = 16776960 // yellow + color: ColorResolvable = '#FFFF00' title: string = 'Room created' logType: string = 'room' @@ -146,20 +148,26 @@ export class Room extends CommandFactory { } export class RemoveStrike extends CommandFactory { - color: number = 65280 // green + color: ColorResolvable = '#00FF00' // green title: string = 'REMOVE STRIKE' logType: string = 'command' } export class AddStrike extends CommandFactory { - color: number = 16711680 // red + color: ColorResolvable = '#FF0000' // red title: string = 'ADD STRIKE' logType: string = 'command' } +export class MatchCreated extends CommandFactory { + color: ColorResolvable = '#fff203' // yellow, for in progress + title: string = 'Match Created' + logType: string = 'queue' +} + // distilled process to log an EmbedType object // @parameter -// type - choose from a list of embed types ['add_strike', 'remove_strike', 'general', 'room'] +// type - choose from a list of embed types ['add_strike', 'remove_strike', 'general', 'room', 'match_created'] export async function logStrike( type: string, embed: EmbedType, @@ -183,7 +191,7 @@ export async function logStrike( export function createEmbedType( title: string | null = null, description: string | null = null, - color: number | null = null, + color: ColorResolvable | null = null, fields: EmbedField[] | null = null, footer: { text: string } | null = null, blame: string | null = null, diff --git a/src/utils/matchHelpers.ts b/src/utils/matchHelpers.ts index 52252b73..b1312fc6 100644 --- a/src/utils/matchHelpers.ts +++ b/src/utils/matchHelpers.ts @@ -20,15 +20,18 @@ import { getDecksInQueue, getMatchChannel, getMatchData, + getMatchQueueLogMessageId, getMatchResultsChannel, - getMatchStatus, + getMatchResultsMessageId, getQueueIdFromMatch, getQueueSettings, + getSettings, getStakeByName, getStakeList, getUserDefaultDeckBans, getUserQueueRole, getWinningTeamFromMatch, + setMatchResultsMessageId, setMatchStakeVoteTeam, setMatchVoiceChannel, setPickedMatchDeck, @@ -563,6 +566,121 @@ export async function resendMatchWinVote( return sentMessage.id } +// Updates the queue log message when a match is complete with match info +export async function updateQueueLogMessage( + matchId: number, + queueId: number, + teamResults: teamResults, + cancelled: boolean = false, +): Promise { + try { + // Get the queue log message ID + const queueLogMsgId = await getMatchQueueLogMessageId(matchId) + if (!queueLogMsgId) { + console.log(`No queue log message found for match ${matchId}`) + return + } + + const settings = await getSettings() + const queueLogsChannelId = settings.queue_logs_channel_id + if (!queueLogsChannelId) { + console.log('No queue logs channel configured') + return + } + + const queueLogsChannel = await client.channels.fetch(queueLogsChannelId) + if (!queueLogsChannel || !queueLogsChannel.isTextBased()) { + console.log('Queue logs channel not found or not text-based') + return + } + + const queueLogMsg = await queueLogsChannel.messages.fetch(queueLogMsgId) + if (!queueLogMsg) { + console.log(`Queue log message ${queueLogMsgId} not found`) + return + } + + // Get queue settings + const queueSettings = await getQueueSettings(queueId) + const winningTeamId = await getWinningTeamFromMatch(matchId) + + const logFields = [] + + // Match ID field + logFields.push({ + name: 'Match ID', + value: `#${matchId}`, + inline: true, + }) + + // Queue field + logFields.push({ + name: 'Queue', + value: queueSettings.queue_name, + inline: true, + }) + + // Status field + logFields.push({ + name: 'Status', + value: cancelled ? 'Cancelled' : 'Finished', + inline: true, + }) + + // Winner field + if (!cancelled) { + const winningTeam = teamResults.teams.find((t) => t.id === winningTeamId) + if (winningTeam) { + let winnerLabel = `Team ${winningTeam.id}` + if (winningTeam.players.length === 1) { + try { + winnerLabel = `<@${winningTeam.players[0].user_id}>` + } catch (err) { + // Do nothing + } + } + logFields.push({ + name: 'Winner', + value: winnerLabel, + inline: true, + }) + } + } + + // Players and MMR changes field + const playerLines: string[] = [] + for (const team of teamResults.teams) { + for (const player of team.players) { + const eloChange = player.elo_change ?? 0 + const changeStr = eloChange > 0 ? `+${eloChange}` : `${eloChange}` + const winnerEmoji = team.id === winningTeamId ? '🏆 ' : '' + playerLines.push(`${winnerEmoji}<@${player.user_id}>: ${changeStr} MMR`) + } + } + + logFields.push({ + name: 'Results', + value: playerLines.join('\n'), + inline: false, + }) + + const updatedEmbed = EmbedBuilder.from(queueLogMsg.embeds[0]) + .setFields(logFields) + .setColor(cancelled ? '#ff0000' : '#2ECD71') + + await queueLogMsg.edit({ + embeds: [updatedEmbed], + }) + + console.log(`Updated queue log message for match ${matchId}`) + } catch (err) { + console.error( + `Failed to update queue log message for match ${matchId}:`, + err, + ) + } +} + export async function endMatch( matchId: number, cancelled = false, @@ -578,12 +696,27 @@ export async function endMatch( await closeMatch(matchId) console.log(`Ending match ${matchId}, cancelled: ${cancelled}`) + // Get teams early so we can use for both cancelled and completed matches + const matchTeams = await getTeamsInMatch(matchId) + const queueId = await getQueueIdFromMatch(matchId) + if (cancelled) { console.log(`Match ${matchId} cancelled.`) const wasSuccessfullyDeleted = await deleteMatchChannel(matchId) if (!wasSuccessfullyDeleted) { console.log(`Channel id not found / failed to delete match ${matchId}`) } + + // Update queue log message for cancelled match + try { + await updateQueueLogMessage(matchId, queueId, matchTeams, true) + } catch (err) { + console.error( + `Failed to update queue log message for match ${matchId}:`, + err, + ) + } + return true } @@ -601,15 +734,12 @@ export async function endMatch( .setEmoji('📩') .setStyle(ButtonStyle.Secondary), ) - - const matchTeams = await getTeamsInMatch(matchId) const winningTeamId = await getWinningTeamFromMatch(matchId) if (!winningTeamId) { console.error(`No winning team found for match ${matchId}`) return false } - const queueId = await getQueueIdFromMatch(matchId) console.log(`Queue ID for match ${matchId}: ${queueId}`) const queueSettings = await getQueueSettings(queueId) console.log(`Queue settings for match ${matchId}:`, queueSettings) @@ -628,7 +758,13 @@ export async function endMatch( console.log(`match ${matchId} team results made`) - teamResults = await calculateNewMMR(queueId, matchData, queueSettings, teamResultsData, winningTeamId) + teamResults = await calculateNewMMR( + queueId, + matchData, + queueSettings, + teamResultsData, + winningTeamId, + ) console.log(`match ${matchId} results: ${teamResults.teams}`) @@ -681,12 +817,15 @@ export async function endMatch( // } // delete match channel - const wasSuccessfullyDeleted = await deleteMatchChannel(matchId) - if (!wasSuccessfullyDeleted) { - console.log(`Channel id not found / failed to delete match ${matchId}`) + try { + const wasSuccessfullyDeleted = await deleteMatchChannel(matchId) + if (!wasSuccessfullyDeleted) { + console.log(`Channel id not found / failed to delete match ${matchId}`) + } + } catch (err) { + console.error(`Failed to delete match channel for match ${matchId}:`, err) + // Continue execution even if channel deletion fails } - - if (cancelled) return true } catch (err) { console.error( `Error in file formatting or channel deletion for match ${matchId}:`, @@ -694,122 +833,140 @@ export async function endMatch( ) } - // build results embed - const resultsEmbed = new EmbedBuilder() - .setTitle(`🏆 ${queueSettings.queue_name} Match #${matchId} 🏆`) - .setColor(queueSettings.color as any) - - const guild = - client.guilds.cache.get(process.env.GUILD_ID!) ?? - (await client.guilds.fetch(process.env.GUILD_ID!)) - - // running for every team then combining at the end - const embedFields = await Promise.all( - (teamResults?.teams ?? []).map(async (team) => { - const playerList = await Promise.all( - team.players.map((player) => guild.members.fetch(player.user_id)), - ) - const playerNameList = playerList.map((user) => user.displayName) - - // show name for every player in team - let label = - team.score === 1 - ? `__${playerNameList.join('\n')}__` - : `${playerNameList.join('\n')}` - - // show id, elo change, new elo, and queue role for every player in team - const description = await Promise.all( - team.players.map(async (player) => { - // Get the player's queue role - const queueRole = await getUserQueueRole(queueId, player.user_id) - let emoteText = '' - - if (queueRole) { - // Fetch the role from Discord to get the name - const guild = - client.guilds.cache.get(process.env.GUILD_ID!) ?? - (await client.guilds.fetch(process.env.GUILD_ID!)) - const role = await guild.roles.fetch(queueRole.role_id) - if (role) { - // Include emote if it exists - emoteText = queueRole.emote ? `${queueRole.emote} ` : '' - if (playerNameList.length == 1) { - label = `${label} ${emoteText}` + try { + // build results embed + const resultsEmbed = new EmbedBuilder() + .setTitle(`🏆 ${queueSettings.queue_name} Match #${matchId} 🏆`) + .setColor(queueSettings.color as any) + + const guild = + client.guilds.cache.get(process.env.GUILD_ID!) ?? + (await client.guilds.fetch(process.env.GUILD_ID!)) + + // running for every team then combining at the end + const embedFields = await Promise.all( + (teamResults?.teams ?? []).map(async (team) => { + const playerList = await Promise.all( + team.players.map((player) => guild.members.fetch(player.user_id)), + ) + const playerNameList = playerList.map((user) => user.displayName) + + // show name for every player in team + let label = + team.score === 1 + ? `__${playerNameList.join('\n')}__` + : `${playerNameList.join('\n')}` + + // show id, elo change, new elo, and queue role for every player in team + const description = await Promise.all( + team.players.map(async (player) => { + // Get the player's queue role + const queueRole = await getUserQueueRole(queueId, player.user_id) + let emoteText = '' + + if (queueRole) { + // Fetch the role from Discord to get the name + const guild = + client.guilds.cache.get(process.env.GUILD_ID!) ?? + (await client.guilds.fetch(process.env.GUILD_ID!)) + const role = await guild.roles.fetch(queueRole.role_id) + if (role) { + // Include emote if it exists + emoteText = queueRole.emote ? `${queueRole.emote} ` : '' + if (playerNameList.length == 1) { + label = `${label} ${emoteText}` + } } } - } - return `<@${player.user_id}> *${player.elo_change && player.elo_change > 0 ? `+` : ``}${player.elo_change}* **(${player.elo})**` - }), - ) + return `<@${player.user_id}> *${player.elo_change && player.elo_change > 0 ? `+` : ``}${player.elo_change}* **(${player.elo})**` + }), + ) - // return array of objects to embedFields - return { - isWinningTeam: team.score === 1, - label, - description: description.join('\n'), - } - }), - ) + // return array of objects to embedFields + return { + isWinningTeam: team.score === 1, + label, + description: description.join('\n'), + } + }), + ) - // initialize arrays to hold fields - const winUserLabels: string[] = [] - const winUserDescs: string[] = [] - const lossUserLabels: string[] = [] - const lossUserDescs: string[] = [] - - // separate winning and losing teams - for (const field of embedFields) { - if (field.isWinningTeam) { - winUserLabels.push(field.label) - winUserDescs.push(field.description) - } else { - lossUserLabels.push(field.label) - lossUserDescs.push(field.description) + // initialize arrays to hold fields + const winUserLabels: string[] = [] + const winUserDescs: string[] = [] + const lossUserLabels: string[] = [] + const lossUserDescs: string[] = [] + + // separate winning and losing teams + for (const field of embedFields) { + if (field.isWinningTeam) { + winUserLabels.push(field.label) + winUserDescs.push(field.description) + } else { + lossUserLabels.push(field.label) + lossUserDescs.push(field.description) + } } - } - resultsEmbed.addFields( - { - name: winUserLabels.join(' / '), - value: winUserDescs.join('\n'), - inline: true, - }, - { - name: lossUserLabels.join(' / '), - value: lossUserDescs.join('\n'), - inline: true, - }, - ) + resultsEmbed.addFields( + { + name: winUserLabels.join(' / '), + value: winUserDescs.join('\n'), + inline: true, + }, + { + name: lossUserLabels.join(' / '), + value: lossUserDescs.join('\n'), + inline: true, + }, + ) - // Add deck and stake information if available - if (matchData.deck || matchData.stake) { - const matchInfoParts: string[] = [] - if (matchData.deck) { - const deckData = await getDeckByName(matchData.deck) - if (deckData) matchInfoParts.push(`${deckData.deck_emote}`) + // Add deck and stake information if available + if (matchData.deck || matchData.stake) { + const matchInfoParts: string[] = [] + if (matchData.deck) { + const deckData = await getDeckByName(matchData.deck) + if (deckData) matchInfoParts.push(`${deckData.deck_emote}`) + } + if (matchData.stake) { + const stakeData = await getStakeByName(matchData.stake) + if (stakeData) matchInfoParts.push(`${stakeData.stake_emote}`) + } + resultsEmbed.setTitle( + `${queueSettings.queue_name} Match #${matchId} ${matchInfoParts.join('')}`, + ) } - if (matchData.stake) { - const stakeData = await getStakeByName(matchData.stake) - if (stakeData) matchInfoParts.push(`${stakeData.stake_emote}`) + + const resultsChannel = await getMatchResultsChannel() + if (!resultsChannel) { + console.error(`No results channel found for match ${matchId}`) + return false } - resultsEmbed.setTitle( - `${queueSettings.queue_name} Match #${matchId} ${matchInfoParts.join('')}`, - ) - } - const resultsChannel = await getMatchResultsChannel() - if (!resultsChannel) { - console.error(`No results channel found for match ${matchId}`) - return false + console.log(`Sending results to ${resultsChannel.id} on match ${matchId}`) + const existingResultsMsgId = await getMatchResultsMessageId(matchId) + if (existingResultsMsgId) { + const existingResultsMsg = + await resultsChannel.messages.fetch(existingResultsMsgId) + if (existingResultsMsg) { + await existingResultsMsg.edit({ embeds: [resultsEmbed] }) + } + } else { + const resultsMsg = await resultsChannel.send({ + embeds: [resultsEmbed], + components: [resultsButtonRow], + }) + await setMatchResultsMessageId(matchId, resultsMsg.id) + } + } catch (err) { + console.error(`Failed to send match results for match ${matchId}:`, err) + // Continue execution even if sending results fails } - console.log(`Sending results to ${resultsChannel.id} on match ${matchId}`) + // Update queue log message after everything else is done + await updateQueueLogMessage(matchId, queueId, teamResults, false) - await resultsChannel.send({ - embeds: [resultsEmbed], - components: [resultsButtonRow], - }) return true } diff --git a/src/utils/queryDB.ts b/src/utils/queryDB.ts index fd277cfc..8853d4cd 100644 --- a/src/utils/queryDB.ts +++ b/src/utils/queryDB.ts @@ -317,12 +317,12 @@ export async function getUsersNeedingRoleUpdates( [queueId], ) - const thresholds = roles.rows.map(r => r.mmr_threshold) + const thresholds = roles.rows.map((r) => r.mmr_threshold) const usersToUpdate: string[] = [] for (const player of players) { - const oldRole = thresholds.find(t => t <= player.oldMMR) - const newRole = thresholds.find(t => t <= player.newMMR) + const oldRole = thresholds.find((t) => t <= player.oldMMR) + const newRole = thresholds.find((t) => t <= player.newMMR) if (oldRole !== newRole) { usersToUpdate.push(player.user_id) @@ -593,6 +593,56 @@ export async function setWinningTeam(matchId: number, winningTeam: number) { ]) } +// Get match results message id +export async function getMatchResultsMessageId( + matchId: number, +): Promise { + const res = await pool.query( + ` + SELECT results_msg_id FROM matches WHERE id = $1 + `, + [matchId], + ) + + return res.rowCount === 0 ? null : res.rows[0].results_msg_id +} + +// Set match results message id +export async function setMatchResultsMessageId( + matchId: number, + messageId: string, +): Promise { + await pool.query(`UPDATE matches SET results_msg_id = $2 WHERE id = $1`, [ + matchId, + messageId, + ]) +} + +// Get match queue log message id +export async function getMatchQueueLogMessageId( + matchId: number, +): Promise { + const res = await pool.query( + ` + SELECT queue_log_msg_id FROM matches WHERE id = $1 + `, + [matchId], + ) + + return res.rowCount === 0 ? null : res.rows[0].queue_log_msg_id +} + +// Set match queue log message id +export async function setMatchQueueLogMessageId( + matchId: number, + messageId: string, +): Promise { + await pool.query(`UPDATE matches SET queue_log_msg_id = $2 WHERE id = $1`, [ + matchId, + messageId, + ]) +} + // Set match win data export async function setMatchWinData( interaction: any, @@ -1133,36 +1183,47 @@ export async function updatePlayerWinStreak( queueId: number, won: boolean, ): Promise { - if (won) { - // Increment win streak and update peak if necessary - await pool.query( - `UPDATE queue_users - SET win_streak = win_streak + 1, - peak_win_streak = GREATEST(peak_win_streak, win_streak + 1) - WHERE user_id = $1 AND queue_id = $2`, - [userId, queueId], - ) - } else { - // If they lost, check current win_streak - const currentStreak = await pool.query( - `SELECT win_streak FROM queue_users WHERE user_id = $1 AND queue_id = $2`, - [userId, queueId], - ) + // Get current streak to determine if we need to reset + const currentStreak = await pool.query( + `SELECT win_streak FROM queue_users WHERE user_id = $1 AND queue_id = $2`, + [userId, queueId], + ) - if (currentStreak.rowCount === 0) return + if (currentStreak.rowCount === 0) return - const streak = currentStreak.rows[0].win_streak + const streak = currentStreak.rows[0].win_streak + if (won) { + if (streak < 0) { + // Had a loss streak, reset to 1 (first win) + await pool.query( + `UPDATE queue_users + SET win_streak = 1, + peak_win_streak = GREATEST(peak_win_streak, 1) + WHERE user_id = $1 AND queue_id = $2`, + [userId, queueId], + ) + } else { + // At 0 or already in a win streak, increment + await pool.query( + `UPDATE queue_users + SET win_streak = win_streak + 1, + peak_win_streak = GREATEST(peak_win_streak, win_streak + 1) + WHERE user_id = $1 AND queue_id = $2`, + [userId, queueId], + ) + } + } else { if (streak > 0) { - // Had a win streak, reset to 0 + // Had a win streak, reset to -1 (first loss) await pool.query( `UPDATE queue_users - SET win_streak = 0 + SET win_streak = -1 WHERE user_id = $1 AND queue_id = $2`, [userId, queueId], ) } else { - // At 0 or already in a loss streak, decrement (continue/start loss streak) + // At 0 or already in a loss streak, decrement await pool.query( `UPDATE queue_users SET win_streak = win_streak - 1 @@ -1939,3 +2000,42 @@ export async function getAllOpenRooms(): Promise { `) return res.rows } + +// Get leaderboard data for a queue +export async function getQueueLeaderboard( + queueId: number, + limit: number = 100, +): Promise< + Array<{ + rank: number + user_id: string + mmr: number + wins: number + losses: number + }> +> { + const res = await pool.query( + ` + SELECT + qu.user_id, + qu.elo, + COUNT(CASE WHEN m.winning_team = mu.team THEN 1 END)::integer as wins, + COUNT(CASE WHEN m.winning_team IS NOT NULL AND m.winning_team != mu.team THEN 1 END)::integer as losses + FROM queue_users qu + LEFT JOIN match_users mu ON mu.user_id = qu.user_id + LEFT JOIN matches m ON m.id = mu.match_id AND m.queue_id = $1 + WHERE qu.queue_id = $1 + GROUP BY qu.user_id, qu.elo + ORDER BY qu.elo DESC + LIMIT $2`, + [queueId, limit], + ) + + return res.rows.map((row, index) => ({ + rank: index + 1, + user_id: row.user_id, + mmr: row.elo, + wins: row.wins || 0, + losses: row.losses || 0, + })) +} diff --git a/src/utils/queueHelpers.ts b/src/utils/queueHelpers.ts index 92f13733..3062ab01 100644 --- a/src/utils/queueHelpers.ts +++ b/src/utils/queueHelpers.ts @@ -8,7 +8,6 @@ import { CommandInteraction, EmbedBuilder, Message, - MessageFlags, OverwriteType, PermissionFlagsBits, StringSelectMenuBuilder, @@ -16,14 +15,16 @@ import { StringSelectMenuOptionBuilder, TextChannel, } from 'discord.js' -import { sendMatchInitMessages } from './matchHelpers' +import { getTeamsInMatch, sendMatchInitMessages } from './matchHelpers' import { createQueueUser, getAllQueueRoles, getLeaderboardQueueRole, + getQueueSettings, getSettings, getUserQueueRole, getUsersInQueue, + setMatchQueueLogMessageId, userInMatch, userInQueue, } from './queryDB' @@ -551,6 +552,9 @@ export async function createMatch( // Send queue start messages await sendMatchInitMessages(queueId, matchId, channel) + // Log match creation + await sendQueueLog(matchId, queueId, userIds) + return channel } @@ -652,8 +656,111 @@ export function setupViewStatsButtons( ) } -// TODO: ADD +// Logs match creation with buttons to retroactively change the winner export async function sendQueueLog( - queueId: number, matchId: number, -): Promise {} + queueId: number, + userIds: string[], +): Promise { + const { CommandFactory } = await import('./logCommandUse') + + // Get queue details + const queueSettings = await getQueueSettings(queueId) + if (!queueSettings) return + + const queueName = queueSettings.queue_name + const numberOfTeams = queueSettings.number_of_teams + + const matchLog = CommandFactory.build('match_created') + if (!matchLog) return + + matchLog.setBlame('System') + + const fields = [ + { + name: 'Match ID', + value: `#${matchId}`, + inline: true, + }, + { + name: 'Queue', + value: queueName, + inline: true, + }, + { + name: 'Players', + value: userIds.map((id) => `<@${id}>`).join('\n'), + inline: false, + }, + ] + + matchLog.setFields(fields) + matchLog.createEmbed() + matchLog.addFields() + + // Add select menu for changing winner + const membersPerTeam = queueSettings.members_per_team + const options: StringSelectMenuOptionBuilder[] = [] + + // Get team assignments using teamResults + const teamResults = await getTeamsInMatch(matchId) + + // Build select menu options + for (const team of teamResults.teams) { + let label = `Team ${team.id}` + let description = `Set Team ${team.id} as the winner` + + // If team size is 1, use player name instead + if (membersPerTeam === 1 && team.players.length > 0) { + try { + const guild = await getGuild() + const member = await guild.members.fetch(team.players[0].user_id) + label = member.displayName + description = `Set ${member.displayName} as the winner` + } catch (err) { + // If fetching fails, fall back to Team X + console.error('Failed to fetch member for team label:', err) + } + } + + options.push( + new StringSelectMenuOptionBuilder() + .setLabel(label) + .setDescription(description) + .setValue(`${team.id}`), + ) + } + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`change-match-winner-${matchId}`) + .setPlaceholder('Change Match Winner') + .addOptions(options) + + const selectRow = + new ActionRowBuilder().addComponents(selectMenu) + + // Add cancel match button + const cancelButton = new ButtonBuilder() + .setCustomId(`cancel-${matchId}`) + .setLabel('Cancel Match') + .setStyle(ButtonStyle.Danger) + + const buttonRow = new ActionRowBuilder().addComponents( + cancelButton, + ) + + // Send to logging channel + try { + await matchLog.setLogChannel() + if (matchLog.channel) { + const matchLogMsg = await matchLog.channel.send({ + embeds: [matchLog.embed], + components: [selectRow, buttonRow], + }) + await setMatchQueueLogMessageId(matchId, matchLogMsg.id) + } + } catch (err) { + console.error('Failed to send queue log:', err) + // Continue execution even if logging fails - don't block match creation + } +}