Skip to content
Open
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
211 changes: 152 additions & 59 deletions commands/leaderboard.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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<Discord.EmbedBuilder | null> => {
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<ButtonBuilder> => {
return new ActionRowBuilder<ButtonBuilder>()
.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<ButtonBuilder>()
.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
}
});
}
}

Expand Down
9 changes: 9 additions & 0 deletions models/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,15 @@ export default class StatsModel {
return top;
}

static async getLeaderboardPlayerCount(pickupId: number): Promise<number> {
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;
Expand Down