From 2f5c3499e7731bdc41c667345eb8d37bfff23f06 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:52:44 -0400 Subject: [PATCH] feat: add achievement system Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com> --- assets/images/trophy.png | Bin 0 -> 3526 bytes assets/{ => images}/welcome-bg.png | Bin config.example.json | 2 +- src/commands/fun/achievement.ts | 925 +++++++++++++++++++++++++++ src/db/db.ts | 423 +++++++++++- src/db/schema.ts | 36 ++ src/discord-bot.ts | 1 + src/events/interactionCreate.ts | 11 + src/events/messageEvents.ts | 7 + src/events/reactionEvents.ts | 52 ++ src/util/achievementCardGenerator.ts | 115 ++++ src/util/achievementManager.ts | 303 +++++++++ src/util/helpers.ts | 74 +++ src/util/levelingSystem.ts | 52 +- yarn.lock | 2 +- 15 files changed, 1966 insertions(+), 37 deletions(-) create mode 100644 assets/images/trophy.png rename assets/{ => images}/welcome-bg.png (100%) create mode 100644 src/commands/fun/achievement.ts create mode 100644 src/events/reactionEvents.ts create mode 100644 src/util/achievementCardGenerator.ts create mode 100644 src/util/achievementManager.ts diff --git a/assets/images/trophy.png b/assets/images/trophy.png new file mode 100644 index 0000000000000000000000000000000000000000..78b399bdf066e7b9d47cdf4ae6ca115cd9043e3d GIT binary patch literal 3526 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 literal 0 HcmV?d00001 diff --git a/assets/welcome-bg.png b/assets/images/welcome-bg.png similarity index 100% rename from assets/welcome-bg.png rename to assets/images/welcome-bg.png diff --git a/config.example.json b/config.example.json index 32d9014..25d2f48 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,5 @@ { - "token": "DISCORD_BOT_API_KEY", + "token": "DISCORD_BOT_TOKEN", "clientId": "DISCORD_BOT_ID", "guildId": "DISCORD_SERVER_ID", "database": { diff --git a/src/commands/fun/achievement.ts b/src/commands/fun/achievement.ts new file mode 100644 index 0000000..e328956 --- /dev/null +++ b/src/commands/fun/achievement.ts @@ -0,0 +1,925 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + PermissionFlagsBits, + ChatInputCommandInteraction, + StringSelectMenuInteraction, + ComponentType, + ButtonInteraction, +} from 'discord.js'; + +import { + getAllAchievements, + getUserAchievements, + awardAchievement, + createAchievement, + deleteAchievement, + removeUserAchievement, +} from '@/db/db.js'; +import { announceAchievement } from '@/util/achievementManager.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command = { + data: new SlashCommandBuilder() + .setName('achievement') + .setDescription('Manage server achievements') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((subcommand) => + subcommand + .setName('create') + .setDescription('Create a new achievement') + .addStringOption((option) => + option + .setName('name') + .setDescription('Name of the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('description') + .setDescription('Description of the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('requirement_type') + .setDescription('Type of requirement for this achievement') + .setRequired(true) + .addChoices( + { name: 'Message Count', value: 'message_count' }, + { name: 'Level', value: 'level' }, + { name: 'Reactions', value: 'reactions' }, + { name: 'Command Usage', value: 'command_usage' }, + ), + ) + .addIntegerOption((option) => + option + .setName('threshold') + .setDescription('Threshold value for completing the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('image_url') + .setDescription('URL for the achievement image (optional)') + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('command_name') + .setDescription('Command name (only for command_usage type)') + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('reward_type') + .setDescription('Type of reward (optional)') + .setRequired(false) + .addChoices( + { name: 'XP', value: 'xp' }, + { name: 'Role', value: 'role' }, + ), + ) + .addStringOption((option) => + option + .setName('reward_value') + .setDescription('Value of the reward (XP amount or role ID)') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('delete') + .setDescription('Delete an achievement') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('ID of the achievement to delete') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('award') + .setDescription('Award an achievement to a user') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to award the achievement to') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('achievement_id') + .setDescription('ID of the achievement to award') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('view') + .setDescription('View a users achievements') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to view achievements for') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('unaward') + .setDescription('Remove an achievement from a user') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to remove the achievement from') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('achievement_id') + .setDescription('ID of the achievement to remove') + .setRequired(true), + ), + ), + + async execute(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'create': + await handleCreateAchievement(interaction); + break; + case 'delete': + await handleDeleteAchievement(interaction); + break; + case 'award': + await handleAwardAchievement(interaction); + break; + case 'unaward': + await handleUnawardAchievement(interaction); + break; + case 'view': + await handleViewUserAchievements(interaction); + break; + } + }, +}; + +async function handleCreateAchievement( + interaction: ChatInputCommandInteraction, +) { + const name = interaction.options.getString('name')!; + const description = interaction.options.getString('description')!; + const imageUrl = interaction.options.getString('image_url'); + const requirementType = interaction.options.getString('requirement_type')!; + const threshold = interaction.options.getInteger('threshold')!; + const commandName = interaction.options.getString('command_name'); + const rewardType = interaction.options.getString('reward_type'); + const rewardValue = interaction.options.getString('reward_value'); + + if (requirementType === 'command_usage' && !commandName) { + await interaction.editReply( + 'Command name is required for command_usage type achievements.', + ); + return; + } + + if (rewardType && !rewardValue) { + await interaction.editReply( + `Reward value is required when setting a ${rewardType} reward.`, + ); + return; + } + + const requirement: any = {}; + if (requirementType === 'command_usage' && commandName) { + requirement.command = commandName; + } + + try { + const achievement = await createAchievement({ + name, + description, + imageUrl: imageUrl || undefined, + requirementType, + threshold, + requirement, + rewardType: rewardType || undefined, + rewardValue: rewardValue || undefined, + }); + + if (achievement) { + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Achievement Created') + .setDescription(`Successfully created achievement: **${name}**`) + .addFields( + { name: 'ID', value: `${achievement.id}`, inline: true }, + { name: 'Type', value: requirementType, inline: true }, + { name: 'Threshold', value: `${threshold}`, inline: true }, + { name: 'Description', value: description }, + ); + + if (rewardType && rewardValue) { + embed.addFields({ + name: 'Reward', + value: `${rewardType === 'xp' ? `${rewardValue} XP` : `<@&${rewardValue}>`}`, + }); + } + + await interaction.editReply({ embeds: [embed] }); + } else { + await interaction.editReply('Failed to create achievement.'); + } + } catch (error) { + console.error('Error creating achievement:', error); + await interaction.editReply( + 'An error occurred while creating the achievement.', + ); + } +} + +async function handleDeleteAchievement( + interaction: ChatInputCommandInteraction, +) { + const achievementId = interaction.options.getInteger('id')!; + + try { + const success = await deleteAchievement(achievementId); + + if (success) { + await interaction.editReply( + `Achievement with ID ${achievementId} has been deleted.`, + ); + } else { + await interaction.editReply( + `Failed to delete achievement with ID ${achievementId}.`, + ); + } + } catch (error) { + console.error('Error deleting achievement:', error); + await interaction.editReply( + 'An error occurred while deleting the achievement.', + ); + } +} + +async function handleAwardAchievement( + interaction: ChatInputCommandInteraction, +) { + const user = interaction.options.getUser('user')!; + const achievementId = interaction.options.getInteger('achievement_id')!; + + try { + const allAchievements = await getAllAchievements(); + const achievement = allAchievements.find((a) => a.id === achievementId); + + if (!achievement) { + await interaction.editReply( + `Achievement with ID ${achievementId} not found.`, + ); + return; + } + + const success = await awardAchievement(user.id, achievementId); + + if (success) { + await announceAchievement(interaction.guild!, user.id, achievement); + await interaction.editReply( + `Achievement "${achievement.name}" awarded to ${user}.`, + ); + } else { + await interaction.editReply( + 'Failed to award achievement or user already has this achievement.', + ); + } + } catch (error) { + console.error('Error awarding achievement:', error); + await interaction.editReply( + 'An error occurred while awarding the achievement.', + ); + } +} + +async function handleViewUserAchievements( + interaction: ChatInputCommandInteraction, +) { + const targetUser = interaction.options.getUser('user') || interaction.user; + + try { + const userAchievements = await getUserAchievements(targetUser.id); + const allAchievements = await getAllAchievements(); + + const totalAchievements = allAchievements.length; + const earnedCount = userAchievements.filter((ua) => ua.earnedAt).length; + const overallProgress = + totalAchievements > 0 + ? Math.round((earnedCount / totalAchievements) * 100) + : 0; + + if (totalAchievements === 0) { + await interaction.editReply( + 'No achievements have been created on this server yet.', + ); + return; + } + + const earnedAchievements = userAchievements + .filter((ua) => { + return ( + ua.earnedAt && + ua.earnedAt !== null && + ua.earnedAt !== undefined && + new Date(ua.earnedAt).getTime() > 0 + ); + }) + .map((ua) => { + const achievementDef = allAchievements.find( + (a) => a.id === ua.achievementId, + ); + return { + ...ua, + definition: achievementDef, + }; + }) + .filter((a) => a.definition); + + const inProgressAchievements = userAchievements + .filter((ua) => { + return ( + (!ua.earnedAt || + ua.earnedAt === null || + ua.earnedAt === undefined || + new Date(ua.earnedAt).getTime() <= 0) && + (ua.progress ?? 0) > 0 + ); + }) + .map((ua) => { + const achievementDef = allAchievements.find( + (a) => a.id === ua.achievementId, + ); + return { + ...ua, + definition: achievementDef, + }; + }) + .filter((a) => a.definition); + + const earnedAndInProgressIds = new Set( + userAchievements + .filter( + (ua) => + (ua.progress ?? 0) > 0 || + (ua.earnedAt && new Date(ua.earnedAt).getTime() > 0), + ) + .map((ua) => ua.achievementId), + ); + const availableAchievements = allAchievements + .filter((a) => !earnedAndInProgressIds.has(a.id)) + .map((definition) => { + const existingEntry = userAchievements.find( + (ua) => + ua.achievementId === definition.id && + (ua.progress === 0 || ua.progress === null), + ); + + return { + achievementId: definition.id, + progress: existingEntry?.progress || 0, + definition, + }; + }); + + interface AchievementViewOption { + label: string; + value: string; + count: number; + } + + const options: AchievementViewOption[] = []; + + if (earnedAchievements.length > 0) { + options.push({ + label: 'Earned Achievements', + value: 'earned', + count: earnedAchievements.length, + }); + } + + if (inProgressAchievements.length > 0) { + options.push({ + label: 'In Progress', + value: 'progress', + count: inProgressAchievements.length, + }); + } + + if (availableAchievements.length > 0) { + options.push({ + label: 'Available Achievements', + value: 'available', + count: availableAchievements.length, + }); + } + + if (options.length === 0) { + await interaction.editReply('No achievement data found.'); + return; + } + + let initialOption = options[0].value; + for (const preferredType of ['earned', 'progress', 'available']) { + const found = options.find((opt) => opt.value === preferredType); + if (found) { + initialOption = preferredType; + break; + } + } + + const initialEmbedData = + initialOption === 'earned' + ? { achievements: earnedAchievements, title: 'Earned Achievements' } + : initialOption === 'progress' + ? { + achievements: inProgressAchievements, + title: 'Achievements In Progress', + } + : { + achievements: availableAchievements, + title: 'Available Achievements', + }; + + const initialEmbed = createAchievementsEmbed( + initialEmbedData.achievements, + initialEmbedData.title, + targetUser, + overallProgress, + earnedCount, + totalAchievements, + ); + + // Define pagination variables + const achievementsPerPage = 5; + let currentPage = 0; + + const pages = splitAchievementsIntoPages( + initialEmbedData.achievements, + initialEmbedData.title, + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + + // Create achievements type selector + const selectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('achievement_view') + .setPlaceholder('Select achievement type') + .addOptions( + options.map((opt) => + new StringSelectMenuOptionBuilder() + .setLabel(`${opt.label} (${opt.count})`) + .setValue(opt.value) + .setDefault(opt.value === initialOption), + ), + ), + ); + + // Create pagination buttons + const paginationRow = createPaginationButtons(pages.length, currentPage); + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [selectMenu, ...(pages.length > 1 ? [paginationRow] : [])], + }); + + if (options.length <= 1 && pages.length <= 1) return; + + // Create collector for both select menu and button interactions + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60000, + }); + + const buttonCollector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 60000, + }); + + collector.on('collect', async (i: StringSelectMenuInteraction) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use this menu.', + ephemeral: true, + }); + return; + } + + await i.deferUpdate(); + + const selected = i.values[0]; + let categoryPages; + let selectedAchievements; + + if (selected === 'earned') { + selectedAchievements = earnedAchievements; + categoryPages = splitAchievementsIntoPages( + earnedAchievements, + 'Earned Achievements', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } else if (selected === 'progress') { + selectedAchievements = inProgressAchievements; + categoryPages = splitAchievementsIntoPages( + inProgressAchievements, + 'Achievements In Progress', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } else if (selected === 'available') { + selectedAchievements = availableAchievements; + categoryPages = splitAchievementsIntoPages( + availableAchievements, + 'Available Achievements', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } + + if (categoryPages && categoryPages.length > 0) { + currentPage = 0; + pages.splice(0, pages.length, ...categoryPages); + + const updatedSelectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('achievement_view') + .setPlaceholder('Select achievement type') + .addOptions( + options.map((opt) => + new StringSelectMenuOptionBuilder() + .setLabel(`${opt.label} (${opt.count})`) + .setValue(opt.value) + .setDefault(opt.value === selected), + ), + ), + ); + + const updatedPaginationRow = createPaginationButtons( + pages.length, + currentPage, + ); + + await i.editReply({ + embeds: [pages[currentPage]], + components: [ + updatedSelectMenu, + ...(pages.length > 1 ? [updatedPaginationRow] : []), + ], + }); + } + }); + + buttonCollector.on('collect', async (i: ButtonInteraction) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + ephemeral: true, + }); + return; + } + + await i.deferUpdate(); + + if (i.customId === 'first') { + currentPage = 0; + } else if (i.customId === 'prev') { + currentPage = Math.max(0, currentPage - 1); + } else if (i.customId === 'next') { + currentPage = Math.min(pages.length - 1, currentPage + 1); + } else if (i.customId === 'last') { + currentPage = pages.length - 1; + } + + const updatedPaginationRow = createPaginationButtons( + pages.length, + currentPage, + ); + + await i.editReply({ + embeds: [pages[currentPage]], + components: [selectMenu, updatedPaginationRow], + }); + }); + + collector.on('end', () => { + buttonCollector.stop(); + }); + + buttonCollector.on('end', () => { + interaction.editReply({ components: [] }).catch((err) => { + console.error('Failed to edit reply after collector ended.', err); + }); + }); + } catch (error) { + console.error('Error viewing user achievements:', error); + await interaction.editReply( + 'An error occurred while fetching user achievements.', + ); + } +} + +/** + * Handle removing an achievement from a user + */ +async function handleUnawardAchievement( + interaction: ChatInputCommandInteraction, +) { + const user = interaction.options.getUser('user')!; + const achievementId = interaction.options.getInteger('achievement_id')!; + + try { + const allAchievements = await getAllAchievements(); + const achievement = allAchievements.find((a) => a.id === achievementId); + + if (!achievement) { + await interaction.editReply( + `Achievement with ID ${achievementId} not found.`, + ); + return; + } + + const userAchievements = await getUserAchievements(user.id); + const earnedAchievement = userAchievements.find( + (ua) => ua.achievementId === achievementId && ua.earnedAt !== null, + ); + + if (!earnedAchievement) { + await interaction.editReply( + `${user.username} has not earned the achievement "${achievement.name}".`, + ); + return; + } + + const success = await removeUserAchievement(user.id, achievementId); + + if (success) { + await interaction.editReply( + `Achievement "${achievement.name}" has been removed from ${user.username}.`, + ); + + if (achievement.rewardType === 'role' && achievement.rewardValue) { + try { + const member = await interaction.guild!.members.fetch(user.id); + await member.roles.remove(achievement.rewardValue); + } catch (err) { + console.error( + `Failed to remove role ${achievement.rewardValue} from user ${user.id}`, + err, + ); + await interaction.followUp({ + content: + 'Note: Failed to remove the role reward. Please check permissions and remove it manually if needed.', + ephemeral: true, + }); + } + } + } else { + await interaction.editReply( + `Failed to remove achievement "${achievement.name}" from ${user.username}.`, + ); + } + } catch (error) { + console.error('Error removing achievement from user:', error); + await interaction.editReply( + 'An error occurred while removing the achievement.', + ); + } +} + +function createAchievementsEmbed( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, +) { + return createPageEmbed( + achievements, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + 1, + 1, + ); +} + +/** + * Creates a visual progress bar + * @param progress - Number between 0-100 + * @returns A string representing a progress bar + */ +function createProgressBar(progress: number): string { + const filledBars = Math.round(progress / 10); + const emptyBars = 10 - filledBars; + + const filled = '█'.repeat(filledBars); + const empty = '░'.repeat(emptyBars); + + return `[${filled}${empty}]`; +} + +function formatType(type: string): string { + return type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' '); +} + +/** + * Splits achievements into pages for pagination + */ +function splitAchievementsIntoPages( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, + achievementsPerPage: number = 5, +): EmbedBuilder[] { + if (achievements.length === 0) { + return [ + createAchievementsEmbed( + achievements, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + ), + ]; + } + + const groupedAchievements: Record = { + message_count: achievements.filter( + (a) => a.definition?.requirementType === 'message_count', + ), + level: achievements.filter( + (a) => a.definition?.requirementType === 'level', + ), + command_usage: achievements.filter( + (a) => a.definition?.requirementType === 'command_usage', + ), + reactions: achievements.filter( + (a) => a.definition?.requirementType === 'reactions', + ), + other: achievements.filter( + (a) => + !['message_count', 'level', 'command_usage', 'reactions'].includes( + a.definition?.requirementType, + ), + ), + }; + + let orderedAchievements: typeof achievements = []; + for (const [type, typeAchievements] of Object.entries(groupedAchievements)) { + if (typeAchievements.length > 0) { + orderedAchievements = orderedAchievements.concat( + typeAchievements.map((ach) => ({ + ...ach, + achievementType: type, + })), + ); + } + } + + const chunks: (typeof achievements)[] = []; + for (let i = 0; i < orderedAchievements.length; i += achievementsPerPage) { + chunks.push(orderedAchievements.slice(i, i + achievementsPerPage)); + } + + return chunks.map((chunk, index) => { + return createPageEmbed( + chunk, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + index + 1, + chunks.length, + ); + }); +} + +/** + * Creates an embed for a single page of achievements + */ +function createPageEmbed( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, + pageNumber: number = 1, + totalPages: number = 1, +): EmbedBuilder { + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle(`${user.username}'s ${title}`) + .setThumbnail(user.displayAvatarURL()) + .setFooter({ text: `Page ${pageNumber}/${totalPages}` }); + + if (achievements.length === 0) { + embed.setDescription('No achievements found.'); + return embed; + } + + let currentType: string | null = null; + + achievements.forEach((achievement) => { + const { definition, achievementType } = achievement; + if (!definition) return; + + if (achievementType && achievementType !== currentType) { + currentType = achievementType; + embed.addFields({ + name: `${formatType(currentType || '')} Achievements`, + value: '⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯', + }); + } + + let fieldValue = definition.description; + + if ( + achievement.earnedAt && + achievement.earnedAt !== null && + achievement.earnedAt !== undefined && + new Date(achievement.earnedAt).getTime() > 0 + ) { + const earnedDate = new Date(achievement.earnedAt); + fieldValue += `\n✅ **Completed**: `; + } else { + const progress = achievement.progress || 0; + const progressBar = createProgressBar(progress); + fieldValue += `\n${progressBar} **${progress}%**`; + + if (definition.requirementType === 'message_count') { + fieldValue += `\n📨 Send ${definition.threshold} messages`; + } else if (definition.requirementType === 'level') { + fieldValue += `\n🏆 Reach level ${definition.threshold}`; + } else if (definition.requirementType === 'command_usage') { + const cmdName = definition.requirement?.command || 'unknown'; + fieldValue += `\n🔧 Use /${cmdName} command`; + } else if (definition.requirementType === 'reactions') { + fieldValue += `\n😀 Add ${definition.threshold} reactions`; + } + } + + if (definition.rewardType && definition.rewardValue) { + fieldValue += `\n💰 **Reward**: ${ + definition.rewardType === 'xp' + ? `${definition.rewardValue} XP` + : `Role <@&${definition.rewardValue}>` + }`; + } + + embed.addFields({ + name: definition.name, + value: fieldValue, + }); + }); + + embed.addFields({ + name: '📊 Overall Achievement Progress', + value: + `${createProgressBar(overallProgress)} **${overallProgress}%**\n` + + `You've earned **${earnedCount}** of ${totalAchievements} achievements`, + }); + + return embed; +} + +export default command; diff --git a/src/db/db.ts b/src/db/db.ts index beeb793..0baacd5 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -430,31 +430,36 @@ export async function getUserLevel( const cacheKey = `level-${discordId}`; - return await withCache(cacheKey, async () => { - const level = await db - .select() - .from(schema.levelTable) - .where(eq(schema.levelTable.discordId, discordId)) - .then((rows) => rows[0]); + return await withCache( + cacheKey, + async () => { + const level = await db + .select() + .from(schema.levelTable) + .where(eq(schema.levelTable.discordId, discordId)) + .then((rows) => rows[0]); - if (level) { - return { - ...level, - lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + if (level) { + return { + ...level, + lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + }; + } + + const newLevel: schema.levelTableTypes = { + discordId, + xp: 0, + level: 0, + lastMessageTimestamp: new Date(), + messagesSent: 0, + reactionCount: 0, }; - } - const newLevel: schema.levelTableTypes = { - discordId, - xp: 0, - level: 0, - lastMessageTimestamp: new Date(), - messagesSent: 0, - }; - - await db.insert(schema.levelTable).values(newLevel); - return newLevel; - }); + await db.insert(schema.levelTable).values(newLevel); + return newLevel; + }, + 300, + ); } catch (error) { return handleDbError('Error getting user level', error as Error); } @@ -484,8 +489,11 @@ export async function addXpToUser( const cacheKey = `level-${discordId}`; const userData = await getUserLevel(discordId); const currentLevel = userData.level; + const currentXp = Number(userData.xp); + const xpToAdd = Number(amount); + + userData.xp = currentXp + xpToAdd; - userData.xp += amount; userData.lastMessageTimestamp = new Date(); userData.level = calculateLevelFromXp(userData.xp); userData.messagesSent += 1; @@ -596,6 +604,93 @@ async function getLeaderboardData(): Promise< } } +/** + * Increments the user's reaction count + * @param userId - Discord user ID + * @returns The updated reaction count + */ +export async function incrementUserReactionCount( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot increment reaction count', + ); + } + + const levelData = await getUserLevel(userId); + + const newCount = (levelData.reactionCount || 0) + 1; + await db + .update(schema.levelTable) + .set({ reactionCount: newCount }) + .where(eq(schema.levelTable.discordId, userId)); + await invalidateCache(`level-${userId}`); + + return newCount; + } catch (error) { + console.error('Error incrementing user reaction count:', error); + return 0; + } +} + +/** + * Decrements the user's reaction count (but not below zero) + * @param userId - Discord user ID + * @returns The updated reaction count + */ +export async function decrementUserReactionCount( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot increment reaction count', + ); + } + + const levelData = await getUserLevel(userId); + + const newCount = Math.max(0, levelData.reactionCount - 1); + await db + .update(schema.levelTable) + .set({ reactionCount: newCount < 0 ? 0 : newCount }) + .where(eq(schema.levelTable.discordId, userId)); + await invalidateCache(`level-${userId}`); + + return newCount; + } catch (error) { + console.error('Error decrementing user reaction count:', error); + return 0; + } +} + +/** + * Gets the user's reaction count + * @param userId - Discord user ID + * @returns The user's reaction count + */ +export async function getUserReactionCount(userId: string): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user reaction count'); + } + + const levelData = await getUserLevel(userId); + return levelData.reactionCount; + } catch (error) { + console.error('Error getting user reaction count:', error); + return 0; + } +} + /** * Get the XP leaderboard * @param limit - Number of entries to return @@ -1191,3 +1286,285 @@ export async function rerollGiveaway( return handleDbError('Failed to reroll giveaway', error as Error); } } + +// ======================== +// Achievement Functions +// ======================== + +/** + * Get all achievement definitions + * @returns Array of achievement definitions + */ +export async function getAllAchievements(): Promise< + schema.achievementDefinitionsTableTypes[] +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get achievements'); + return []; + } + + return await db + .select() + .from(schema.achievementDefinitionsTable) + .orderBy(schema.achievementDefinitionsTable.threshold); + } catch (error) { + return handleDbError('Failed to get all achievements', error as Error); + } +} + +/** + * Get achievements for a specific user + * @param userId - Discord ID of the user + * @returns Array of user achievements + */ +export async function getUserAchievements( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user achievements'); + return []; + } + + return await db + .select({ + id: schema.userAchievementsTable.id, + discordId: schema.userAchievementsTable.discordId, + achievementId: schema.userAchievementsTable.achievementId, + earnedAt: schema.userAchievementsTable.earnedAt, + progress: schema.userAchievementsTable.progress, + }) + .from(schema.userAchievementsTable) + .where(eq(schema.userAchievementsTable.discordId, userId)); + } catch (error) { + return handleDbError('Failed to get user achievements', error as Error); + } +} + +/** + * Award an achievement to a user + * @param userId - Discord ID of the user + * @param achievementId - ID of the achievement + * @returns Boolean indicating success + */ +export async function awardAchievement( + userId: string, + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot award achievement'); + return false; + } + + const existing = await db + .select() + .from(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, userId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ) + .then((rows) => rows[0]); + + if (existing) { + if (existing.earnedAt) { + return false; + } + + await db + .update(schema.userAchievementsTable) + .set({ + earnedAt: new Date(), + progress: 100, + }) + .where(eq(schema.userAchievementsTable.id, existing.id)); + } else { + await db.insert(schema.userAchievementsTable).values({ + discordId: userId, + achievementId: achievementId, + earnedAt: new Date(), + progress: 100, + }); + } + + return true; + } catch (error) { + handleDbError('Failed to award achievement', error as Error); + return false; + } +} + +/** + * Update achievement progress for a user + * @param userId - Discord ID of the user + * @param achievementId - ID of the achievement + * @param progress - Progress value (0-100) + * @returns Boolean indicating success + */ +export async function updateAchievementProgress( + userId: string, + achievementId: number, + progress: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot update achievement progress', + ); + return false; + } + + const existing = await db + .select() + .from(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, userId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ) + .then((rows) => rows[0]); + + if (existing) { + if (existing.earnedAt) { + return false; + } + + await db + .update(schema.userAchievementsTable) + .set({ + progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), + }) + .where(eq(schema.userAchievementsTable.id, existing.id)); + } else { + await db.insert(schema.userAchievementsTable).values({ + discordId: userId, + achievementId: achievementId, + progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), + }); + } + + return true; + } catch (error) { + handleDbError('Failed to update achievement progress', error as Error); + return false; + } +} + +/** + * Create a new achievement definition + * @param achievementData - Achievement definition data + * @returns Created achievement or undefined on failure + */ +export async function createAchievement(achievementData: { + name: string; + description: string; + imageUrl?: string; + requirementType: string; + threshold: number; + requirement?: any; + rewardType?: string; + rewardValue?: string; +}): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot create achievement'); + return undefined; + } + + const [achievement] = await db + .insert(schema.achievementDefinitionsTable) + .values({ + name: achievementData.name, + description: achievementData.description, + imageUrl: achievementData.imageUrl || null, + requirementType: achievementData.requirementType, + threshold: achievementData.threshold, + requirement: achievementData.requirement || {}, + rewardType: achievementData.rewardType || null, + rewardValue: achievementData.rewardValue || null, + }) + .returning(); + + return achievement; + } catch (error) { + return handleDbError('Failed to create achievement', error as Error); + } +} + +/** + * Delete an achievement definition + * @param achievementId - ID of the achievement to delete + * @returns Boolean indicating success + */ +export async function deleteAchievement( + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot delete achievement'); + return false; + } + + await db + .delete(schema.userAchievementsTable) + .where(eq(schema.userAchievementsTable.achievementId, achievementId)); + + await db + .delete(schema.achievementDefinitionsTable) + .where(eq(schema.achievementDefinitionsTable.id, achievementId)); + + return true; + } catch (error) { + handleDbError('Failed to delete achievement', error as Error); + return false; + } +} + +/** + * Removes an achievement from a user + * @param discordId - Discord user ID + * @param achievementId - Achievement ID to remove + * @returns boolean indicating success + */ +export async function removeUserAchievement( + discordId: string, + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot remove user achievement'); + return false; + } + + await db + .delete(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, discordId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ); + return true; + } catch (error) { + handleDbError('Failed to remove user achievement', error as Error); + return false; + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index cf50650..843decb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,6 +1,7 @@ import { boolean, integer, + json, jsonb, pgTable, timestamp, @@ -32,6 +33,7 @@ export interface levelTableTypes { xp: number; level: number; messagesSent: number; + reactionCount: number; lastMessageTimestamp?: Date; } @@ -43,6 +45,7 @@ export const levelTable = pgTable('levels', { xp: integer('xp').notNull().default(0), level: integer('level').notNull().default(0), messagesSent: integer('messages_sent').notNull().default(0), + reactionCount: integer('reaction_count').notNull().default(0), lastMessageTimestamp: timestamp('last_message_timestamp'), }); @@ -143,3 +146,36 @@ export const giveawayTable = pgTable('giveaways', { requireAllCriteria: boolean('require_all_criteria').default(true), bonusEntries: jsonb('bonus_entries').default({}), }); + +export type userAchievementsTableTypes = InferSelectModel< + typeof userAchievementsTable +>; + +export const userAchievementsTable = pgTable('user_achievements', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + discordId: varchar('user_id', { length: 50 }) + .notNull() + .references(() => memberTable.discordId), + achievementId: integer('achievement_id') + .notNull() + .references(() => achievementDefinitionsTable.id), + earnedAt: timestamp('earned_at'), + progress: integer().default(0), +}); + +export type achievementDefinitionsTableTypes = InferSelectModel< + typeof achievementDefinitionsTable +>; + +export const achievementDefinitionsTable = pgTable('achievement_definitions', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + name: varchar({ length: 100 }).notNull(), + description: varchar({ length: 255 }).notNull(), + imageUrl: varchar('image_url', { length: 255 }), + requirement: json().notNull(), + requirementType: varchar('requirement_type', { length: 50 }).notNull(), + threshold: integer().notNull(), + rewardType: varchar('reward_type', { length: 50 }), + rewardValue: varchar('reward_value', { length: 50 }), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); diff --git a/src/discord-bot.ts b/src/discord-bot.ts index 3f6c254..f754db7 100644 --- a/src/discord-bot.ts +++ b/src/discord-bot.ts @@ -13,6 +13,7 @@ async function startBot() { GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildModeration, ], }, diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index b50a17b..5792eb4 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -11,6 +11,7 @@ import { approveFact, deleteFact } from '@/db/db.js'; import * as GiveawayManager from '@/util/giveaways/giveawayManager.js'; import { ExtendedClient } from '@/structures/ExtendedClient.js'; import { safelyRespond, validateInteraction } from '@/util/helpers.js'; +import { processCommandAchievements } from '@/util/achievementManager.js'; export default { name: Events.InteractionCreate, @@ -48,12 +49,22 @@ async function handleCommand(interaction: Interaction) { if (interaction.isChatInputCommand()) { await command.execute(interaction); + await processCommandAchievements( + interaction.user.id, + command.data.name, + interaction.guild!, + ); } else if ( interaction.isUserContextMenuCommand() || interaction.isMessageContextMenuCommand() ) { // @ts-expect-error await command.execute(interaction); + await processCommandAchievements( + interaction.user.id, + command.data.name, + interaction.guild!, + ); } } diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts index 440b1f2..908de53 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -12,6 +12,7 @@ import { checkAndAssignLevelRoles, processMessage, } from '@/util/levelingSystem.js'; +import { processLevelUpAchievements } from '@/util/achievementManager.js'; export const messageDelete: Event = { name: Events.MessageDelete, @@ -102,6 +103,12 @@ export const messageCreate: Event = { levelResult.newLevel, ); + await processLevelUpAchievements( + message.author.id, + levelResult.newLevel, + message.guild, + ); + if (assignedRole) { await advancementsChannel.send( `<@${message.author.id}> You've earned the <@&${assignedRole}> role!`, diff --git a/src/events/reactionEvents.ts b/src/events/reactionEvents.ts new file mode 100644 index 0000000..0599409 --- /dev/null +++ b/src/events/reactionEvents.ts @@ -0,0 +1,52 @@ +import { + Events, + MessageReaction, + PartialMessageReaction, + User, + PartialUser, +} from 'discord.js'; + +import { Event } from '@/types/EventTypes.js'; +import { + decrementUserReactionCount, + incrementUserReactionCount, +} from '@/db/db.js'; +import { processReactionAchievements } from '@/util/achievementManager.js'; + +export const reactionAdd: Event = { + name: Events.MessageReactionAdd, + execute: async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + try { + if (user.bot || !reaction.message.guild) return; + + await incrementUserReactionCount(user.id); + + await processReactionAchievements(user.id, reaction.message.guild); + } catch (error) { + console.error('Error handling reaction add:', error); + } + }, +}; + +export const reactionRemove: Event = { + name: Events.MessageReactionRemove, + execute: async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + try { + if (user.bot || !reaction.message.guild) return; + + await decrementUserReactionCount(user.id); + + await processReactionAchievements(user.id, reaction.message.guild, true); + } catch (error) { + console.error('Error handling reaction remove:', error); + } + }, +}; + +export default [reactionAdd, reactionRemove]; diff --git a/src/util/achievementCardGenerator.ts b/src/util/achievementCardGenerator.ts new file mode 100644 index 0000000..955fc18 --- /dev/null +++ b/src/util/achievementCardGenerator.ts @@ -0,0 +1,115 @@ +import Canvas, { GlobalFonts } from '@napi-rs/canvas'; +import { AttachmentBuilder } from 'discord.js'; +import path from 'path'; + +import * as schema from '@/db/schema.js'; +import { drawMultilineText, roundRect } from './helpers.js'; + +const __dirname = path.resolve(); + +/** + * Generates an achievement card for a user + * TODO: Make this look better + * @param achievement - The achievement to generate a card for + * @returns - The generated card as an AttachmentBuilder + */ +export async function generateAchievementCard( + achievement: schema.achievementDefinitionsTableTypes, +): Promise { + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'), + 'Manrope Bold', + ); + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'), + 'Manrope', + ); + + const width = 600; + const height = 180; + const canvas = Canvas.createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + const gradient = ctx.createLinearGradient(0, 0, width, 0); + gradient.addColorStop(0, '#5865F2'); + gradient.addColorStop(1, '#EB459E'); + ctx.fillStyle = gradient; + roundRect({ ctx, x: 0, y: 0, width, height, radius: 16, fill: true }); + + ctx.lineWidth = 4; + ctx.strokeStyle = '#FFFFFF'; + roundRect({ + ctx, + x: 2, + y: 2, + width: width - 4, + height: height - 4, + radius: 16, + fill: false, + }); + + const padding = 40; + const iconSize = 72; + const iconX = padding; + const iconY = height / 2 - iconSize / 2; + + try { + const iconImage = await Canvas.loadImage( + achievement.imageUrl || + path.join(__dirname, 'assets', 'images', 'trophy.png'), + ); + + ctx.save(); + ctx.beginPath(); + ctx.arc( + iconX + iconSize / 2, + iconY + iconSize / 2, + iconSize / 2, + 0, + Math.PI * 2, + ); + ctx.clip(); + ctx.drawImage(iconImage, iconX, iconY, iconSize, iconSize); + ctx.restore(); + + ctx.beginPath(); + ctx.arc( + iconX + iconSize / 2, + iconY + iconSize / 2, + iconSize / 2 + 4, + 0, + Math.PI * 2, + ); + ctx.lineWidth = 3; + ctx.strokeStyle = '#FFFFFF'; + ctx.stroke(); + } catch (e) { + console.error('Error loading icon:', e); + } + + const textX = iconX + iconSize + 24; + const titleY = 60; + const nameY = titleY + 35; + const descY = nameY + 34; + + ctx.fillStyle = '#FFFFFF'; + + ctx.font = '22px "Manrope Bold"'; + ctx.fillText('Achievement Unlocked!', textX, titleY); + + ctx.font = '32px "Manrope Bold"'; + ctx.fillText(achievement.name, textX, nameY); + + ctx.font = '20px "Manrope"'; + drawMultilineText( + ctx, + achievement.description, + textX, + descY, + width - textX - 32, + 24, + ); + + const buffer = canvas.toBuffer('image/png'); + return new AttachmentBuilder(buffer, { name: 'achievement.png' }); +} diff --git a/src/util/achievementManager.ts b/src/util/achievementManager.ts new file mode 100644 index 0000000..adb78d9 --- /dev/null +++ b/src/util/achievementManager.ts @@ -0,0 +1,303 @@ +import { + Message, + Client, + EmbedBuilder, + GuildMember, + TextChannel, + Guild, +} from 'discord.js'; + +import { + addXpToUser, + awardAchievement, + getAllAchievements, + getUserAchievements, + getUserLevel, + getUserReactionCount, + updateAchievementProgress, +} from '@/db/db.js'; +import * as schema from '@/db/schema.js'; +import { loadConfig } from './configLoader.js'; +import { generateAchievementCard } from './achievementCardGenerator.js'; + +/** + * Check and process achievements for a user based on a message + * @param message - The message that triggered the check + */ +export async function processMessageAchievements( + message: Message, +): Promise { + if (message.author.bot) return; + + const userData = await getUserLevel(message.author.id); + const allAchievements = await getAllAchievements(); + + const messageAchievements = allAchievements.filter( + (a) => a.requirementType === 'message_count', + ); + + for (const achievement of messageAchievements) { + const progress = Math.min( + 100, + (userData.messagesSent / achievement.threshold) * 100, + ); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(message.author.id); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement( + message.author.id, + achievement.id, + ); + if (awarded) { + await announceAchievement( + message.guild!, + message.author.id, + achievement, + ); + } + } + } else { + await updateAchievementProgress( + message.author.id, + achievement.id, + progress, + ); + } + } + + const levelAchievements = allAchievements.filter( + (a) => a.requirementType === 'level', + ); + + for (const achievement of levelAchievements) { + const progress = Math.min( + 100, + (userData.level / achievement.threshold) * 100, + ); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(message.author.id); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement( + message.author.id, + achievement.id, + ); + if (awarded) { + await announceAchievement( + message.guild!, + message.author.id, + achievement, + ); + } + } + } else { + await updateAchievementProgress( + message.author.id, + achievement.id, + progress, + ); + } + } +} + +/** + * Check achievements for level-ups + * @param memberId - Member ID who leveled up + * @param newLevel - New level value + * @guild - Guild instance + */ +export async function processLevelUpAchievements( + memberId: string, + newLevel: number, + guild: Guild, +): Promise { + const allAchievements = await getAllAchievements(); + + const levelAchievements = allAchievements.filter( + (a) => a.requirementType === 'level', + ); + + for (const achievement of levelAchievements) { + const progress = Math.min(100, (newLevel / achievement.threshold) * 100); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(memberId); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(memberId, achievement.id); + if (awarded) { + await announceAchievement(guild, memberId, achievement); + } + } + } else { + await updateAchievementProgress(memberId, achievement.id, progress); + } + } +} + +/** + * Process achievements for command usage + * @param userId - User ID who used the command + * @param commandName - Name of the command + * @param client - Guild instance + */ +export async function processCommandAchievements( + userId: string, + commandName: string, + guild: Guild, +): Promise { + const allAchievements = await getAllAchievements(); + + const commandAchievements = allAchievements.filter( + (a) => + a.requirementType === 'command_usage' && + a.requirement && + (a.requirement as any).command === commandName, + ); + + for (const achievement of commandAchievements) { + const userAchievements = await getUserAchievements(userId); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(userId, achievement.id); + if (awarded) { + await announceAchievement(guild, userId, achievement); + } + } + } +} + +/** + * Process achievements for reaction events (add or remove) + * @param userId - User ID who added/removed the reaction + * @param guild - Guild instance + * @param isRemoval - Whether this is a reaction removal (true) or addition (false) + */ +export async function processReactionAchievements( + userId: string, + guild: Guild, + isRemoval: boolean = false, +): Promise { + try { + const member = await guild.members.fetch(userId); + if (member.user.bot) return; + + const allAchievements = await getAllAchievements(); + + const reactionAchievements = allAchievements.filter( + (a) => a.requirementType === 'reactions', + ); + + if (reactionAchievements.length === 0) return; + + const reactionCount = await getUserReactionCount(userId); + + for (const achievement of reactionAchievements) { + const progress = Math.max( + 0, + Math.min(100, (reactionCount / achievement.threshold) * 100), + ); + + if (progress >= 100 && !isRemoval) { + const userAchievements = await getUserAchievements(userId); + const existingAchievement = userAchievements.find( + (a) => + a.achievementId === achievement.id && + a.earnedAt !== null && + a.earnedAt !== undefined && + new Date(a.earnedAt).getTime() > 0, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(userId, achievement.id); + if (awarded) { + await announceAchievement(guild, userId, achievement); + } + } + } + + await updateAchievementProgress(userId, achievement.id, progress); + } + } catch (error) { + console.error('Error processing reaction achievements:', error); + } +} + +/** + * Announce a newly earned achievement + * @param guild - Guild instance + * @param userId - ID of the user who earned the achievement + * @param achievement - Achievement definition + */ +export async function announceAchievement( + guild: Guild, + userId: string, + achievement: schema.achievementDefinitionsTableTypes, +): Promise { + try { + const config = loadConfig(); + + if (!guild) { + console.error(`Guild ${guild} not found`); + return; + } + + const member = await guild.members.fetch(userId); + if (!member) { + console.warn(`Member ${userId} not found in guild`); + return; + } + + const achievementCard = await generateAchievementCard(achievement); + + const embed = new EmbedBuilder() + .setColor(0xffd700) + .setDescription( + `**${member.user.username}** just unlocked the achievement: **${achievement.name}**! 🎉`, + ) + .setImage('attachment://achievement.png') + .setTimestamp(); + + const advChannel = guild.channels.cache.get(config.channels.advancements); + if (advChannel?.isTextBased()) { + await (advChannel as TextChannel).send({ + content: `Congratulations <@${userId}>!`, + embeds: [embed], + files: [achievementCard], + }); + } + + if (achievement.rewardType === 'xp' && achievement.rewardValue) { + const xpAmount = parseInt(achievement.rewardValue); + if (!isNaN(xpAmount)) { + await addXpToUser(userId, xpAmount); + } + } else if (achievement.rewardType === 'role' && achievement.rewardValue) { + try { + await member.roles.add(achievement.rewardValue); + } catch (err) { + console.error( + `Failed to add role ${achievement.rewardValue} to user ${userId}`, + err, + ); + } + } + } catch (error) { + console.error('Error announcing achievement:', error); + } +} diff --git a/src/util/helpers.ts b/src/util/helpers.ts index bca579f..1cb210d 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -7,6 +7,9 @@ import { GuildMember, Guild, Interaction, + ButtonStyle, + ButtonBuilder, + ActionRowBuilder, } from 'discord.js'; import { and, eq } from 'drizzle-orm'; @@ -269,6 +272,38 @@ export function roundRect({ } } +/** + * Draw wrapped text in multiple lines + * @param ctx - The canvas context to use + * @param text - The text to draw + * @param x - The x position to draw the text + * @param y - The y position to draw the text + * @param maxWidth - The maximum width of the text + * @param lineHeight - The height of each line + */ +export function drawMultilineText( + ctx: Canvas.SKRSContext2D, + text: string, + x: number, + y: number, + maxWidth: number, + lineHeight: number, +) { + const words = text.split(' '); + let line = ''; + for (let i = 0; i < words.length; i++) { + const testLine = line + words[i] + ' '; + if (ctx.measureText(testLine).width > maxWidth && i > 0) { + ctx.fillText(line, x, y); + line = words[i] + ' '; + y += lineHeight; + } else { + line = testLine; + } + } + ctx.fillText(line, x, y); +} + /** * Checks if an interaction is valid * @param interaction - The interaction to check @@ -309,3 +344,42 @@ export async function safelyRespond(interaction: Interaction, content: string) { console.error('Failed to respond to interaction:', error); } } + +/** + * Creates pagination buttons for navigating through multiple pages + * @param totalPages - The total number of pages + * @param currentPage - The current page number + * @returns - The action row with pagination buttons + */ +export function createPaginationButtons( + totalPages: number, + currentPage: number, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('first') + .setLabel('⏮️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('prev') + .setLabel('◀️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('pageinfo') + .setLabel(`Page ${currentPage + 1}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId('next') + .setLabel('▶️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === totalPages - 1), + new ButtonBuilder() + .setCustomId('last') + .setLabel('⏭️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === totalPages - 1), + ); +} diff --git a/src/util/levelingSystem.ts b/src/util/levelingSystem.ts index 721dc81..e6bf4b9 100644 --- a/src/util/levelingSystem.ts +++ b/src/util/levelingSystem.ts @@ -12,6 +12,7 @@ import { import * as schema from '@/db/schema.js'; import { loadConfig } from './configLoader.js'; import { roundRect } from './helpers.js'; +import { processMessageAchievements } from './achievementManager.js'; const config = loadConfig(); @@ -39,12 +40,24 @@ export const calculateXpForLevel = (level: number): number => { export const calculateLevelFromXp = (xp: number): number => { if (xp < calculateXpForLevel(1)) return 0; - let level = 0; - while (calculateXpForLevel(level + 1) <= xp) { - level++; + let low = 1; + let high = 200; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const xpForMid = calculateXpForLevel(mid); + const xpForNext = calculateXpForLevel(mid + 1); + + if (xp >= xpForMid && xp < xpForNext) { + return mid; + } else if (xp < xpForMid) { + high = mid - 1; + } else { + low = mid + 1; + } } - return level; + return low - 1; }; /** @@ -86,6 +99,7 @@ export async function processMessage(message: Message) { try { const userId = message.author.id; const userData = await getUserLevel(userId); + const oldXp = userData.xp; if (userData.lastMessageTimestamp) { const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime(); @@ -96,9 +110,25 @@ export async function processMessage(message: Message) { } } - const xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP; + let xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP; + + if (xpToAdd > 100) { + console.error( + `Unusually large XP amount generated: ${xpToAdd}. Capping at 100.`, + ); + xpToAdd = 100; + } + const result = await addXpToUser(userId, xpToAdd); + const newUserData = await getUserLevel(userId); + if (newUserData.xp > oldXp + 100) { + console.error( + `Detected abnormal XP increase: ${oldXp} → ${newUserData.xp}`, + ); + } + + await processMessageAchievements(message); return result; } catch (error) { console.error('Error processing message for XP:', error); @@ -263,17 +293,15 @@ export async function checkAndAssignLevelRoles( if (rolesToAdd.length === 0) return; - const existingLevelRoles = config.roles.levelRoles.map((r) => r.roleId); - const rolesToRemove = member.roles.cache.filter((role) => - existingLevelRoles.includes(role.id), + const newRolesToAdd = rolesToAdd.filter( + (roleId) => !member.roles.cache.has(roleId), ); - if (rolesToRemove.size > 0) { - await member.roles.remove(rolesToRemove); + + if (newRolesToAdd.length > 0) { + await member.roles.add(newRolesToAdd); } const highestRole = rolesToAdd[rolesToAdd.length - 1]; - await member.roles.add(highestRole); - return highestRole; } catch (error) { console.error('Error assigning level roles:', error); diff --git a/yarn.lock b/yarn.lock index b9455de..4328c0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4478,7 +4478,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.6.2, semver@npm:^7.6.3": +"semver@npm:^7.6.3": version: 7.7.1 resolution: "semver@npm:7.7.1" bin: