diff --git a/commands/leaderboard.ts b/commands/leaderboard.ts index 02c631a..92f28a7 100644 --- a/commands/leaderboard.ts +++ b/commands/leaderboard.ts @@ -1,4 +1,4 @@ -import Discord, { ApplicationCommandOptionData, ApplicationCommandOptionType } from 'discord.js'; +import Discord, { ApplicationCommandOptionData, ApplicationCommandOptionType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from 'discord.js'; import Bot from '../core/bot'; import ConfigTool from '../core/configTool'; import { Command } from '../core/types'; @@ -50,28 +50,19 @@ const command: Command = { global: false, perms: false, exec: async (bot, message, params, defaults, interaction) => { - const missingPermissions = Util.gotPermissions(message ? message : interaction, 'UseExternalEmojis'); - - if (missingPermissions) { - if (interaction) { - return interaction.reply({ embeds: [missingPermissions] }); - } else { - return message.channel.send({ embeds: [missingPermissions] }); - } - } - const guild = interaction ? interaction.guild : message.guild; - const guildSettings = Bot.getInstance().getGuild(guild.id); const emojis = ConfigTool.getConfig().emojis; - let page; + + const canUseExternalEmojis = !Util.gotPermissions(message ? message : interaction, 'UseExternalEmojis'); + + let page = 1; if (params.length > 1) { if (!/^\d+$/.test(params[1].toString())) { return Util.send(message ? message : interaction, 'error', 'leaderboard page has to be a number'); } - - page = +params[1] === 1 ? null : +params[1]; + page = Math.max(1, +params[1]); } const pickupSettings = await PickupModel.getPickupSettings(BigInt(guild.id), params[0], true); @@ -84,67 +75,169 @@ const command: Command = { return Util.send(message ? message : interaction, 'warn', 'given pickup is not rated, no leaderboard available'); } - const ratings = await StatsModel.getLeaderboardRatings(pickupSettings.id, page ? page : 1); + const totalPlayers = await StatsModel.getLeaderboardPlayerCount(pickupSettings.id); + const playersPerPage = 10; + const maxPages = Math.max(1, Math.ceil(totalPlayers / playersPerPage)); - if (!ratings) { - if (!page) { - return Util.send(message ? message : interaction, 'warn', 'there are no ratings stored for this pickup'); + page = Math.min(page, maxPages); - } else { - return Util.send(message ? message : interaction, 'warn', 'there are no ratings stored for this pickup in general or for this page'); + const makeEmbed = async (currentPage: number): Promise => { + const ratings = await StatsModel.getLeaderboardRatings(pickupSettings.id, currentPage); + + if (!ratings || !ratings.ratings || ratings.ratings.length === 0) { + return null; } - } - const playerNicks = []; - const playerGames = []; - const playerRatings = []; + const playerNicks: string[] = []; + const playerGames: string[] = []; + const playerRatings: string[] = []; - ratings.ratings.forEach(player => { - let rank = ''; + ratings.ratings.forEach(player => { + let rank: string; - switch (player.rank) { - case 1: rank = emojis.lb_leader; break; - case 2: rank = ':second_place:'; break; - case 3: rank = ':third_place:'; break; - default: rank = `#${player.rank}`; - } + switch (player.rank) { + case 1: rank = canUseExternalEmojis ? emojis.lb_leader : '🥇'; break; + case 2: rank = ':second_place:'; break; + case 3: rank = ':third_place:'; break; + default: rank = `#${player.rank}`; + } - const name = player.nick; - const amountOfGames = +player.wins + +player.draws + +player.losses; - const rankCap = ratings.rankRatingCap || guildSettings.maxRankRatingCap; + const name = player.nick; + const amountOfGames = +player.wins + +player.draws + +player.losses; + const rankCap = ratings.rankRatingCap || guildSettings.maxRankRatingCap; - let rankIcon; + let rankIcon: string; - if (amountOfGames < 10) { - rankIcon = emojis.unranked; - } else { - rankIcon = emojis[`rank_${Util.tsToRankIcon(player.rating, player.variance, rankCap)}`]; - } + if (amountOfGames < 10) { + rankIcon = canUseExternalEmojis ? emojis.unranked : '[UNRANKED]'; + } else { + const rankKey = Util.tsToRankIcon(player.rating, player.variance, rankCap); + rankIcon = canUseExternalEmojis ? emojis[`rank_${rankKey}`] : `[${rankKey.toUpperCase()}]`; + } - const winPercentage = Math.round((+player.wins / amountOfGames) * 100); - const rating = `${rankIcon} ${Util.tsToEloNumber(player.rating)} ± ${Util.tsToEloNumber(player.variance)}`; + const winPercentage = Math.round((+player.wins / amountOfGames) * 100); + const rating = `${rankIcon} ${Util.tsToEloNumber(player.rating)} ± ${Util.tsToEloNumber(player.variance)}`; - playerNicks.push(`**${rank}** ${Util.removeMarkdown(name)}`); - playerGames.push(`**${player.wins}** / **${player.draws}** / **${player.losses}** **(${winPercentage}%)**`); - playerRatings.push(`**${rating}**`); - }); + playerNicks.push(`**${rank}** ${Util.removeMarkdown(name)}`); + playerGames.push(`**${player.wins}** / **${player.draws}** / **${player.losses}** **(${winPercentage}%)**`); + playerRatings.push(`**${rating}**`); + }); - const botAvatarUrl = guild.client.user.avatarURL(); + const botAvatarUrl = guild.client.user.avatarURL(); + + return new Discord.EmbedBuilder() + .setColor('#126e82') + .setTitle(`Leaderboard - ${ratings.pickup}`) + .addFields( + { name: 'Player', value: playerNicks.join('\n'), inline: true }, + { name: 'W / D / L', value: playerGames.join('\n'), inline: true }, + { name: 'Rating', value: playerRatings.join('\n'), inline: true }, + ) + .setFooter({ + text: 'Player skill uncertainty taken into account for ranking.\nActive in last 14 days / 10 games required to be ranked', + iconURL: botAvatarUrl + }); + }; + + const makeButtons = (currentPage: number, maxPages: number): ActionRowBuilder => { + return new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('lb_prev') + .setLabel('◀') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage <= 1), + new ButtonBuilder() + .setCustomId('lb_page') + .setLabel(`${currentPage}/${maxPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId('lb_next') + .setLabel('▶') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage >= maxPages) + ); + }; + + const embed = await makeEmbed(page); + + if (!embed) { + return Util.send(message ? message : interaction, 'warn', 'there are no ratings stored for this pickup'); + } - const leaderboardEmbed = new Discord.EmbedBuilder() - .setColor('#126e82') - .setTitle(`Leaderboard - ${ratings.pickup}${page ? ` [Page ${page}]` : ''}`) - .addFields( - { name: 'Player', value: playerNicks.join('\n'), inline: true }, - { name: 'W / D / L', value: playerGames.join('\n'), inline: true }, - { name: 'Rating', value: playerRatings.join('\n'), inline: true }, - ).setFooter({ text: 'Player skill uncertainty taken into account for ranking.\nActive in last 14 days / 10 games required to be ranked', iconURL: botAvatarUrl }) + const components = maxPages > 1 ? [makeButtons(page, maxPages)] : []; + let reply: Discord.Message; if (interaction) { - interaction.reply({ embeds: [leaderboardEmbed] }); + reply = await interaction.reply({ embeds: [embed], components, fetchReply: true }); } else { - message.channel.send({ embeds: [leaderboardEmbed] }); + reply = await message.channel.send({ embeds: [embed], components }); } + + if (maxPages <= 1) return; + + const userId = interaction ? interaction.user.id : message.author.id; + + const collector = reply.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 120000 + }); + + let currentPage = page; + + collector.on('collect', async (interaction) => { + if (interaction.user.id !== userId) { + return; + } + + if (interaction.customId === 'lb_prev') { + currentPage = Math.max(1, currentPage - 1); + } else if (interaction.customId === 'lb_next') { + currentPage = Math.min(maxPages, currentPage + 1); + } + + const newEmbed = await makeEmbed(currentPage); + + if (newEmbed) { + await interaction.update({ + embeds: [newEmbed], + components: [makeButtons(currentPage, maxPages)] + }); + } else { + await interaction.reply({ + content: 'failed to load page data', + ephemeral: true + }); + } + }); + + collector.on('end', async () => { + const disabledButtons = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('lb_prev') + .setLabel('◀') + .setStyle(ButtonStyle.Primary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId('lb_page') + .setLabel(`${currentPage}/${maxPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId('lb_next') + .setLabel('▶') + .setStyle(ButtonStyle.Primary) + .setDisabled(true) + ); + + try { + await reply.edit({ components: [disabledButtons] }); + } catch (e) { + // message was probably deleted + } + }); } } diff --git a/models/stats.ts b/models/stats.ts index e1b7ae4..5cc3c76 100644 --- a/models/stats.ts +++ b/models/stats.ts @@ -388,6 +388,15 @@ export default class StatsModel { return top; } + static async getLeaderboardPlayerCount(pickupId: number): Promise { + const result = await db.query(` + SELECT COUNT(*) as count + FROM player_ratings + WHERE pickup_id = ? + `, [pickupId]); + return result[0]?.count || 0; + } + static async getLeaderboardRatings(pickupConfigId: number, page: number): Promise<{ pickupConfigId: number;