From 668f6214853bdcbfbbaaa9f3ad666a6ac280efcc Mon Sep 17 00:00:00 2001 From: Nikki Date: Thu, 7 Nov 2024 16:11:58 +0100 Subject: [PATCH] Added economy leaderboard command --- .../commands/economy/leaderboard.ts | 70 ++++ src/structure/classes/balance-leaderboard.ts | 386 ++++++++++++++++++ src/structure/locales/de_commands.json | 10 + src/structure/locales/en_commands.json | 10 + src/structure/types/canvacord.ts | 41 +- 5 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 src/interactions/commands/economy/leaderboard.ts create mode 100644 src/structure/classes/balance-leaderboard.ts diff --git a/src/interactions/commands/economy/leaderboard.ts b/src/interactions/commands/economy/leaderboard.ts new file mode 100644 index 00000000..7a833141 --- /dev/null +++ b/src/interactions/commands/economy/leaderboard.ts @@ -0,0 +1,70 @@ +import { ApplicationIntegrationType, AttachmentBuilder, EmbedBuilder, InteractionContextType, SlashCommandBuilder } from 'discord.js'; +import { t } from 'i18next'; + +import { LeaderboardBuilder } from 'classes/balance-leaderboard'; +import { Command } from 'classes/command'; + +import { computeLeaderboard, getLeaderboard } from 'db/user'; + +import { chunk } from 'utils/common'; +import { pagination } from 'utils/pagination'; + +import { ModuleType } from 'types/interactions'; + +export default new Command({ + module: ModuleType.Economy, + botPermissions: ['SendMessages'], + data: new SlashCommandBuilder() + .setName('leaderboard') + .setDescription('Shows the richest users') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) + .setContexts(InteractionContextType.BotDM, InteractionContextType.Guild, InteractionContextType.PrivateChannel) + .addBooleanOption((option) => option.setName('ephemeral').setDescription('When set to false will show the message to everyone').setRequired(false)), + async execute({ interaction, client, lng }) { + if (!interaction.inCachedGuild()) { + return; + } + + const { options } = interaction; + + const ephemeral = options.getBoolean('ephemeral', false) ?? true; + await interaction.deferReply({ ephemeral }); + + const leaderboard = await getLeaderboard(); + const computedLeaderboard = await computeLeaderboard(leaderboard, client); + + if (!computedLeaderboard.length) { + await interaction.editReply({ embeds: [new EmbedBuilder().setColor(client.colors.error).setDescription(t('level.none', { lng }))] }); + return; + } + + const chunkedLeaderboard = chunk(computedLeaderboard, 5); + + const embeds: EmbedBuilder[] = []; + + const attachments: AttachmentBuilder[] = []; + + for (let i = 0; i < chunkedLeaderboard.length; i++) { + const leaderboardBuilder = new LeaderboardBuilder({ + players: chunkedLeaderboard[i].map((user) => ({ + avatar: user.avatar, + username: user.username ?? user.userId, + displayName: user.displayName ?? 'Unknown User', + rank: user.position, + bank: user.bank, + wallet: user.wallet + })) + }); + if (i === 0) { + leaderboardBuilder.setVariant('default'); + } else { + leaderboardBuilder.setVariant('horizontal'); + } + const image = await leaderboardBuilder.build({ format: 'png' }); + attachments.push(new AttachmentBuilder(image, { name: `leaderboard_${i}.png` })); + embeds.push(new EmbedBuilder().setColor(client.colors.level).setImage(`attachment://leaderboard_${i}.png`)); + } + + await pagination({ interaction, embeds, attachments }); + } +}); diff --git a/src/structure/classes/balance-leaderboard.ts b/src/structure/classes/balance-leaderboard.ts new file mode 100644 index 00000000..efbace18 --- /dev/null +++ b/src/structure/classes/balance-leaderboard.ts @@ -0,0 +1,386 @@ +/** @jsx JSX.createElement */ +/** @jsxFrag JSX.Fragment */ + +import { Builder, Font, FontFactory, JSX, loadImage, StyleSheet, type ImageSource } from 'canvacord'; + +import { leaderboardColors, type LeaderboardVariants } from 'constants/canvacord'; + +import { chunk } from 'utils/common'; + +import type { EconomyLeaderboardProps, EconomyLeaderboardPropsType } from 'types/canvacord'; + +const Crown = () => { + return JSX.createElement( + 'svg', + { width: '20', height: '20', viewBox: '0 0 20 20', fill: 'none', xmlns: 'http://www.w3.org/2000/svg' }, + JSX.createElement('path', { + d: 'M16.5 17.5H3.5C3.225 17.5 3 17.7813 3 18.125V19.375C3 19.7188 3.225 20 3.5 20H16.5C16.775 20 17 19.7188 17 19.375V18.125C17 17.7813 16.775 17.5 16.5 17.5ZM18.5 5C17.6719 5 17 5.83984 17 6.875C17 7.15234 17.05 7.41016 17.1375 7.64844L14.875 9.34375C14.3937 9.70313 13.7719 9.5 13.4937 8.89063L10.9469 3.32031C11.2812 2.97656 11.5 2.46094 11.5 1.875C11.5 0.839844 10.8281 0 10 0C9.17188 0 8.5 0.839844 8.5 1.875C8.5 2.46094 8.71875 2.97656 9.05313 3.32031L6.50625 8.89063C6.22812 9.5 5.60312 9.70313 5.125 9.34375L2.86562 7.64844C2.95 7.41406 3.00312 7.15234 3.00312 6.875C3.00312 5.83984 2.33125 5 1.50312 5C0.675 5 0 5.83984 0 6.875C0 7.91016 0.671875 8.75 1.5 8.75C1.58125 8.75 1.6625 8.73438 1.74063 8.71875L4 16.25H16L18.2594 8.71875C18.3375 8.73438 18.4188 8.75 18.5 8.75C19.3281 8.75 20 7.91016 20 6.875C20 5.83984 19.3281 5 18.5 5Z', + fill: '#FFAA00' + }) + ); +}; + +const fixed = (v: number, r: boolean) => { + if (!r) return v; + const formatter = new Intl.NumberFormat('en-US', { currency: 'USD', style: 'currency', notation: 'compact' }); + return formatter.format(v); +}; + +export class LeaderboardBuilder extends Builder { + public variant?: LeaderboardVariants; + + /** + * Create a new leaderboard ui builder + */ + public constructor(props: EconomyLeaderboardPropsType) { + super(600, 650); + + this.bootstrap({ + ...props, + background: props.background ?? null, + backgroundColor: props.backgroundColor ?? '#2b2f35', + abbreviate: props.abbreviate ?? true, + text: props.text ?? { bank: 'BANK: ', wallet: 'WALLET: ', rank: 'RANK: ' } + }); + + this.setStyle({ + borderRadius: '1.5rem' + }); + + if (!FontFactory.size) { + Font.loadDefault(); + } + } + + /** + * Set the ui variant for this leaderboard + * @param variant ui type + */ + public setVariant(variant: LeaderboardVariants) { + this.variant = variant; + return this; + } + + /** + * Set background for this leaderboard ui + * @param background background image + */ + public setBackground(background: ImageSource) { + this.options.set('background', background); + return this; + } + + /** + * Set background color for this leaderboard ui + * @param color background color + */ + public setBackgroundColor(color: string) { + this.options.set('backgroundColor', color); + return this; + } + + /** + * Set header for this leaderboard ui + * @param data header data + */ + public setHeader(data: EconomyLeaderboardProps['header'] & {}) { + this.options.set('header', data); + return this; + } + + /** + * Set players for this leaderboard ui. The canvas size will be adjusted automatically based on the number of players. + * @param players players data + */ + public setPlayers(players: EconomyLeaderboardProps['players']) { + const items = players.slice(0, 10); + this.options.set('players', items); + + return this; + } + + /** + * Configures the text renderer for this leaderboard. + * @param config The configuration for this leaderboard. + */ + public setTextStyles(config: Partial) { + this.options.merge('text', config); + return this; + } + + /** + * Render this leaderboard ui on the canvas + */ + public async render() { + if (!this.variant || this.variant === 'default') { + return this.renderDefaultVariant(); + } + + return this.renderHorizontalVariant(); + } + + /** + * Render players ui on the canvas + */ + public renderDefaultPlayers(players: JSX.Element[]) { + return JSX.createElement('div', { className: 'mt-4 flex flex-col items-center justify-center w-[95%]' }, players as unknown as JSX.Element); + } + + /** + * Render top players ui on the canvas + */ + public async renderDefaultTop({ avatar, displayName, bank, rank, username, wallet }: EconomyLeaderboardProps['players'][number]) { + const currentColor = leaderboardColors[rank === 1 ? 'gold' : rank === 2 ? 'silver' : 'bronze']; + const crown = rank === 1; + + return JSX.createElement( + 'div', + { + className: StyleSheet.cn( + `relative flex flex-col items-center justify-center p-4 bg-[${this.options.get('backgroundColor')}B3] w-[35%] rounded-md`, + crown ? `-mt-4 bg-[${this.options.get('backgroundColor')}] rounded-b-none h-[113%]` : '', + rank === 2 ? 'rounded-br-none' : rank === 3 ? 'rounded-bl-none' : '' + ) + }, + crown + ? JSX.createElement('div', { className: 'absolute flex -top-16' }, Crown() as unknown as JSX.Element) + : JSX.createElement('div', { className: 'hidden' }), + JSX.createElement( + 'div', + { className: 'flex items-center justify-center flex-col absolute -top-10' }, + avatar + ? JSX.createElement('img', { + src: (await loadImage(avatar)).toDataURL(), + className: StyleSheet.cn(`border-[3px] border-[${currentColor}] rounded-full h-18 w-18`), + alt: 'avatar' + }) + : JSX.createElement('div', { className: StyleSheet.cn(`border-[3px] border-[${currentColor}] rounded-full h-18 w-18`) }), + JSX.createElement( + 'div', + { + className: `flex items-center justify-center text-xs p-2 text-center font-bold h-3 w-3 rounded-full text-white absolute bg-[${currentColor}] -bottom-[0.4rem]` + }, + rank as unknown as JSX.Element + ) + ), + JSX.createElement( + 'div', + { className: 'flex flex-col items-center justify-center mt-5 text-center' }, + JSX.createElement('h1', { className: 'text-white text-base font-extrabold m-0' }, displayName as unknown as JSX.Element), + JSX.createElement('h2', { className: 'text-[#9ca0a5] text-xs font-thin m-0 mb-2' }, `@${username}` as unknown as JSX.Element), + JSX.createElement( + 'h4', + { className: `text-sm text-[${currentColor}] m-0` }, + `${this.options.get('text').bank} ${fixed(bank, this.options.get('abbreviate'))}` as unknown as JSX.Element + ), + JSX.createElement( + 'h4', + { className: `text-sm text-[${currentColor}] m-0` }, + `${this.options.get('text').wallet} ${fixed(wallet, this.options.get('abbreviate'))}` as unknown as JSX.Element + ) + ) + ) as JSX.Element; + } + + /** + * Render player ui on the canvas + */ + public async renderDefaultPlayer({ avatar, displayName, bank, rank, username, wallet }: EconomyLeaderboardProps['players'][number]) { + return JSX.createElement( + 'div', + { + className: `bg-[${this.options.get('backgroundColor')}B3] p-4 rounded-md flex flex-row justify-between items-center w-full mb-2` + }, + JSX.createElement( + 'div', + { className: 'flex flex-row' }, + avatar + ? JSX.createElement('img', { src: (await loadImage(avatar)).toDataURL(), className: 'rounded-full h-14 w-14 mr-2', alt: 'avatar' }) + : JSX.createElement('div', { className: 'rounded-full h-14 w-14 mr-2' }), + JSX.createElement( + 'div', + { className: 'flex flex-col items-start justify-center' }, + JSX.createElement('h1', { className: 'text-white font-extrabold text-lg m-0' }, displayName as unknown as JSX.Element), + JSX.createElement('h4', { className: 'text-[#9ca0a5] font-medium text-base m-0' }, `@${username}` as unknown as JSX.Element) + ) + ), + JSX.createElement( + 'div', + { className: 'flex flex-col items-center justify-center' }, + JSX.createElement( + 'h4', + { className: 'text-[#9ca0a5] text-sm m-0' }, + this.options.get('text').rank as unknown as JSX.Element, + JSX.createElement('span', { className: 'text-white ml-1' }, `#${rank}` as unknown as JSX.Element) + ), + JSX.createElement( + 'h4', + { className: 'text-[#9ca0a5] text-sm m-0' }, + this.options.get('text').bank as unknown as JSX.Element, + JSX.createElement('span', { className: 'text-white ml-1' }, fixed(bank, this.options.get('abbreviate')) as unknown as JSX.Element) + ), + JSX.createElement( + 'h4', + { className: 'text-[#9ca0a5] text-sm m-0' }, + this.options.get('text').wallet as unknown as JSX.Element, + JSX.createElement('span', { className: 'text-white ml-1' }, fixed(wallet, this.options.get('abbreviate')) as unknown as JSX.Element) + ) + ) + ) as JSX.Element; + } + + public async renderDefaultVariant() { + const options = this.options.getOptions(); + const total = options.players.length; + + if (!total) { + throw new RangeError('Number of players must be greater than 0'); + } + + let background, headerImg; + + if (options.background) { + background = await loadImage(options.background); + } + + if (options.header) { + headerImg = await loadImage(options.header.image); + } + + const winners = [options.players[1], options.players[0], options.players[2]].filter(Boolean); + + return JSX.createElement( + 'div', + { className: 'h-full w-full flex relative' }, + background + ? JSX.createElement('img', { src: background.toDataURL(), className: 'absolute top-0 left-0 h-full w-full', alt: 'background' }) + : JSX.createElement('div', { className: 'hidden' }), + JSX.createElement( + 'div', + { className: 'py-[30px] flex flex-col items-center w-full' }, + options.header && headerImg + ? JSX.createElement( + 'div', + { className: 'flex items-center justify-center flex-col w-full' }, + JSX.createElement('img', { src: headerImg.toDataURL(), className: 'rounded-full w-16 h-w-16', alt: 'header' }), + JSX.createElement('h1', { className: 'text-white text-xl font-extrabold m-0 mt-2' }, options.header.title as unknown as JSX.Element), + JSX.createElement('h2', { className: 'text-white text-sm font-medium m-0' }, options.header.subtitle as unknown as JSX.Element) + ) + : JSX.createElement('div', { className: 'hidden' }), + JSX.createElement( + 'div', + { className: StyleSheet.cn('flex flex-row w-[90%] justify-center items-center mt-16', winners.length ? 'mt-24' : '') }, + ...(await Promise.all(winners.map((winner) => this.renderDefaultTop(winner)))) + ), + this.renderDefaultPlayers(await Promise.all(options.players.filter((f) => !winners.includes(f)).map((m) => this.renderDefaultPlayer(m)))) + ) + ) as JSX.Element; + } + + public async renderHorizontalPlayer({ avatar, displayName, bank, rank, username, wallet }: EconomyLeaderboardProps['players'][number]) { + return JSX.createElement( + 'div', + { + className: `flex items-center bg-[${this.options.get('backgroundColor')}] rounded-xl p-2 px-3 justify-between` + }, + JSX.createElement( + 'div', + { className: 'flex justify-between items-center' }, + JSX.createElement( + 'div', + { className: `flex text-2xl w-[25px]`, style: { marginRight: `${Math.floor((`#${rank}`.length + 2) / 2)}rem` } }, + `#${rank}` as unknown as JSX.Element + ), + avatar + ? JSX.createElement('img', { src: (await loadImage(avatar)).toDataURL(), width: 50, height: 50, className: 'rounded-full flex', alt: 'avatar' }) + : JSX.createElement('div', { width: 50, height: 50, className: 'rounded-full flex', alt: 'avatar' }), + JSX.createElement( + 'div', + { className: 'flex flex-col justify-center ml-3' }, + JSX.createElement('h1', { className: 'text-white text-lg font-extrabold m-0' }, displayName as unknown as JSX.Element), + JSX.createElement('h2', { className: 'text-[#9ca0a5] text-base font-medium m-0 mb-2' }, `@${username}` as unknown as JSX.Element) + ) + ), + JSX.createElement( + 'div', + { className: 'flex flex-col items-end' }, + JSX.createElement( + 'h4', + { className: 'text-[#9ca0a5] text-sm m-0' }, + this.options.get('text').bank as unknown as JSX.Element, + JSX.createElement('span', { className: 'text-white ml-1' }, fixed(bank, this.options.get('abbreviate')) as unknown as JSX.Element) + ), + JSX.createElement( + 'h4', + { className: 'text-[#9ca0a5] text-sm m-0' }, + this.options.get('text').wallet as unknown as JSX.Element, + JSX.createElement('span', { className: 'text-white ml-1' }, fixed(wallet, this.options.get('abbreviate')) as unknown as JSX.Element) + ) + ) + ) as JSX.Element; + } + + public async renderHorizontalVariant() { + const options = this.options.getOptions(); + const total = options.players.length; + + if (!total) { + throw new RangeError('Number of players must be greater than 0'); + } + + this.height = 500; + + this.adjustCanvas(); + + let background, headerImg; + + if (options.background) { + background = await loadImage(options.background); + } + + if (options.header) { + headerImg = await loadImage(options.header.image); + } + + const playerGroupChunks = chunk(options.players, 5) as Array; + + const processedPlayerGroups = await Promise.all( + playerGroupChunks.map(async (playerGroup) => { + const renderedPlayers = await Promise.all(playerGroup.map((player) => this.renderHorizontalPlayer(player))); + return renderedPlayers; + }) + ); + + return JSX.createElement( + 'div', + { className: 'flex relative w-full flex-col' }, + background + ? JSX.createElement('img', { src: background.toDataURL(), className: 'flex absolute top-0 left-0', alt: 'background' }) + : JSX.createElement('div', { className: 'hidden' }), + JSX.createElement( + 'div', + { className: 'flex justify-center w-full m-0 my-5' }, + options.header && headerImg + ? JSX.createElement('img', { src: headerImg.toDataURL(), width: 49.53, height: 50.44, className: 'flex rounded-full mr-3', alt: 'header' }) + : JSX.createElement('div', { className: 'hidden' }), + JSX.createElement( + 'div', + { className: 'flex flex-col items-center justify-center' }, + options.header?.title + ? JSX.createElement('div', { className: 'text-white font-semibold text-2xl flex' }, options.header.title as unknown as JSX.Element) + : JSX.createElement('div', { className: 'hidden' }), + options.header?.subtitle + ? JSX.createElement('div', { className: 'text-gray-300 font-medium flex' }, options.header.subtitle as unknown as JSX.Element) + : JSX.createElement('div', { className: 'hidden' }) + ) + ), + JSX.createElement( + 'div', + { className: 'flex text-white p-2 px-3', style: { gap: '6' } }, + ...processedPlayerGroups.map((renderedPlayers) => + JSX.createElement('div', { className: 'flex flex-col flex-1', style: { gap: '6' } }, ...renderedPlayers) + ) + ) + ) as JSX.Element; + } +} diff --git a/src/structure/locales/de_commands.json b/src/structure/locales/de_commands.json index 8029071b..c402ec90 100644 --- a/src/structure/locales/de_commands.json +++ b/src/structure/locales/de_commands.json @@ -1,4 +1,14 @@ { + "leaderboard": { + "name": "rangliste", + "description": "Zeigt die reichesten User an", + "options": [ + { + "name": "geheim", + "description": "Wenn *True*, kann die Nachricht nur von dir gesehen werden" + } + ] + }, "divorce": { "name": "scheiden", "description": "Scheide dich von deinem Partner" diff --git a/src/structure/locales/en_commands.json b/src/structure/locales/en_commands.json index 1d6a1e08..2ae99292 100644 --- a/src/structure/locales/en_commands.json +++ b/src/structure/locales/en_commands.json @@ -1,4 +1,14 @@ { + "leaderboard": { + "name": "leaderboard", + "description": "Shows the richest users", + "options": [ + { + "name": "ephemeral", + "description": "If *True*, message can only be seen by you" + } + ] + }, "divorce": { "name": "divorce", "description": "Divorce your partner" diff --git a/src/structure/types/canvacord.ts b/src/structure/types/canvacord.ts index 3885176f..eb5677a2 100644 --- a/src/structure/types/canvacord.ts +++ b/src/structure/types/canvacord.ts @@ -74,6 +74,12 @@ export type LeaderboardTexts = { rank: string; }; +export type EconomyLeaderboardTexts = { + bank: string; + wallet: string; + rank: string; +}; + export type LeaderboardPropsType = { variant?: 'horizontal' | 'default'; background?: ImageSource | null; @@ -95,4 +101,37 @@ export type LeaderboardPropsType = { abbreviate?: boolean; }; -export type LeaderboardProps = LeaderboardPropsType & { text: LeaderboardTexts; background: ImageSource | null; backgroundColor: string; abbreviate: boolean }; +export type EconomyLeaderboardPropsType = { + variant?: 'horizontal' | 'default'; + background?: ImageSource | null; + backgroundColor?: string; + header?: { + title: string; + subtitle: string; + image: ImageSource; + }; + players: { + displayName: string; + username: string; + bank: number; + wallet: number; + rank: number; + avatar?: ImageSource; + }[]; + text?: EconomyLeaderboardTexts; + abbreviate?: boolean; +}; + +export type LeaderboardProps = LeaderboardPropsType & { + text: LeaderboardTexts; + background: ImageSource | null; + backgroundColor: string; + abbreviate: boolean; +}; + +export type EconomyLeaderboardProps = EconomyLeaderboardPropsType & { + text: EconomyLeaderboardTexts; + background: ImageSource | null; + backgroundColor: string; + abbreviate: boolean; +};