mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-05-10 02:33:06 +00:00
feat: add achievement system
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
This commit is contained in:
parent
830838a6a1
commit
2f5c3499e7
15 changed files with 1966 additions and 37 deletions
BIN
assets/images/trophy.png
Normal file
BIN
assets/images/trophy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"token": "DISCORD_BOT_API_KEY",
|
"token": "DISCORD_BOT_TOKEN",
|
||||||
"clientId": "DISCORD_BOT_ID",
|
"clientId": "DISCORD_BOT_ID",
|
||||||
"guildId": "DISCORD_SERVER_ID",
|
"guildId": "DISCORD_SERVER_ID",
|
||||||
"database": {
|
"database": {
|
||||||
|
|
925
src/commands/fun/achievement.ts
Normal file
925
src/commands/fun/achievement.ts
Normal file
|
@ -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<StringSelectMenuBuilder>().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<StringSelectMenuBuilder>().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<any>,
|
||||||
|
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<any>,
|
||||||
|
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<string, typeof achievements> = {
|
||||||
|
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<any>,
|
||||||
|
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**: <t:${Math.floor(earnedDate.getTime() / 1000)}:R>`;
|
||||||
|
} 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;
|
423
src/db/db.ts
423
src/db/db.ts
|
@ -430,31 +430,36 @@ export async function getUserLevel(
|
||||||
|
|
||||||
const cacheKey = `level-${discordId}`;
|
const cacheKey = `level-${discordId}`;
|
||||||
|
|
||||||
return await withCache<schema.levelTableTypes>(cacheKey, async () => {
|
return await withCache<schema.levelTableTypes>(
|
||||||
const level = await db
|
cacheKey,
|
||||||
.select()
|
async () => {
|
||||||
.from(schema.levelTable)
|
const level = await db
|
||||||
.where(eq(schema.levelTable.discordId, discordId))
|
.select()
|
||||||
.then((rows) => rows[0]);
|
.from(schema.levelTable)
|
||||||
|
.where(eq(schema.levelTable.discordId, discordId))
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
if (level) {
|
if (level) {
|
||||||
return {
|
return {
|
||||||
...level,
|
...level,
|
||||||
lastMessageTimestamp: level.lastMessageTimestamp ?? undefined,
|
lastMessageTimestamp: level.lastMessageTimestamp ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLevel: schema.levelTableTypes = {
|
||||||
|
discordId,
|
||||||
|
xp: 0,
|
||||||
|
level: 0,
|
||||||
|
lastMessageTimestamp: new Date(),
|
||||||
|
messagesSent: 0,
|
||||||
|
reactionCount: 0,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const newLevel: schema.levelTableTypes = {
|
await db.insert(schema.levelTable).values(newLevel);
|
||||||
discordId,
|
return newLevel;
|
||||||
xp: 0,
|
},
|
||||||
level: 0,
|
300,
|
||||||
lastMessageTimestamp: new Date(),
|
);
|
||||||
messagesSent: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.insert(schema.levelTable).values(newLevel);
|
|
||||||
return newLevel;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleDbError('Error getting user level', error as Error);
|
return handleDbError('Error getting user level', error as Error);
|
||||||
}
|
}
|
||||||
|
@ -484,8 +489,11 @@ export async function addXpToUser(
|
||||||
const cacheKey = `level-${discordId}`;
|
const cacheKey = `level-${discordId}`;
|
||||||
const userData = await getUserLevel(discordId);
|
const userData = await getUserLevel(discordId);
|
||||||
const currentLevel = userData.level;
|
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.lastMessageTimestamp = new Date();
|
||||||
userData.level = calculateLevelFromXp(userData.xp);
|
userData.level = calculateLevelFromXp(userData.xp);
|
||||||
userData.messagesSent += 1;
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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
|
* Get the XP leaderboard
|
||||||
* @param limit - Number of entries to return
|
* @param limit - Number of entries to return
|
||||||
|
@ -1191,3 +1286,285 @@ export async function rerollGiveaway(
|
||||||
return handleDbError('Failed to reroll giveaway', error as Error);
|
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<schema.userAchievementsTableTypes[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<schema.achievementDefinitionsTableTypes | undefined> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
json,
|
||||||
jsonb,
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
@ -32,6 +33,7 @@ export interface levelTableTypes {
|
||||||
xp: number;
|
xp: number;
|
||||||
level: number;
|
level: number;
|
||||||
messagesSent: number;
|
messagesSent: number;
|
||||||
|
reactionCount: number;
|
||||||
lastMessageTimestamp?: Date;
|
lastMessageTimestamp?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,6 +45,7 @@ export const levelTable = pgTable('levels', {
|
||||||
xp: integer('xp').notNull().default(0),
|
xp: integer('xp').notNull().default(0),
|
||||||
level: integer('level').notNull().default(0),
|
level: integer('level').notNull().default(0),
|
||||||
messagesSent: integer('messages_sent').notNull().default(0),
|
messagesSent: integer('messages_sent').notNull().default(0),
|
||||||
|
reactionCount: integer('reaction_count').notNull().default(0),
|
||||||
lastMessageTimestamp: timestamp('last_message_timestamp'),
|
lastMessageTimestamp: timestamp('last_message_timestamp'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -143,3 +146,36 @@ export const giveawayTable = pgTable('giveaways', {
|
||||||
requireAllCriteria: boolean('require_all_criteria').default(true),
|
requireAllCriteria: boolean('require_all_criteria').default(true),
|
||||||
bonusEntries: jsonb('bonus_entries').default({}),
|
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(),
|
||||||
|
});
|
||||||
|
|
|
@ -13,6 +13,7 @@ async function startBot() {
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.GuildMessageReactions,
|
||||||
GatewayIntentBits.GuildModeration,
|
GatewayIntentBits.GuildModeration,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { approveFact, deleteFact } from '@/db/db.js';
|
||||||
import * as GiveawayManager from '@/util/giveaways/giveawayManager.js';
|
import * as GiveawayManager from '@/util/giveaways/giveawayManager.js';
|
||||||
import { ExtendedClient } from '@/structures/ExtendedClient.js';
|
import { ExtendedClient } from '@/structures/ExtendedClient.js';
|
||||||
import { safelyRespond, validateInteraction } from '@/util/helpers.js';
|
import { safelyRespond, validateInteraction } from '@/util/helpers.js';
|
||||||
|
import { processCommandAchievements } from '@/util/achievementManager.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
|
@ -48,12 +49,22 @@ async function handleCommand(interaction: Interaction) {
|
||||||
|
|
||||||
if (interaction.isChatInputCommand()) {
|
if (interaction.isChatInputCommand()) {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
|
await processCommandAchievements(
|
||||||
|
interaction.user.id,
|
||||||
|
command.data.name,
|
||||||
|
interaction.guild!,
|
||||||
|
);
|
||||||
} else if (
|
} else if (
|
||||||
interaction.isUserContextMenuCommand() ||
|
interaction.isUserContextMenuCommand() ||
|
||||||
interaction.isMessageContextMenuCommand()
|
interaction.isMessageContextMenuCommand()
|
||||||
) {
|
) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
|
await processCommandAchievements(
|
||||||
|
interaction.user.id,
|
||||||
|
command.data.name,
|
||||||
|
interaction.guild!,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
checkAndAssignLevelRoles,
|
checkAndAssignLevelRoles,
|
||||||
processMessage,
|
processMessage,
|
||||||
} from '@/util/levelingSystem.js';
|
} from '@/util/levelingSystem.js';
|
||||||
|
import { processLevelUpAchievements } from '@/util/achievementManager.js';
|
||||||
|
|
||||||
export const messageDelete: Event<typeof Events.MessageDelete> = {
|
export const messageDelete: Event<typeof Events.MessageDelete> = {
|
||||||
name: Events.MessageDelete,
|
name: Events.MessageDelete,
|
||||||
|
@ -102,6 +103,12 @@ export const messageCreate: Event<typeof Events.MessageCreate> = {
|
||||||
levelResult.newLevel,
|
levelResult.newLevel,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await processLevelUpAchievements(
|
||||||
|
message.author.id,
|
||||||
|
levelResult.newLevel,
|
||||||
|
message.guild,
|
||||||
|
);
|
||||||
|
|
||||||
if (assignedRole) {
|
if (assignedRole) {
|
||||||
await advancementsChannel.send(
|
await advancementsChannel.send(
|
||||||
`<@${message.author.id}> You've earned the <@&${assignedRole}> role!`,
|
`<@${message.author.id}> You've earned the <@&${assignedRole}> role!`,
|
||||||
|
|
52
src/events/reactionEvents.ts
Normal file
52
src/events/reactionEvents.ts
Normal file
|
@ -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<typeof Events.MessageReactionAdd> = {
|
||||||
|
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<typeof Events.MessageReactionRemove> = {
|
||||||
|
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];
|
115
src/util/achievementCardGenerator.ts
Normal file
115
src/util/achievementCardGenerator.ts
Normal file
|
@ -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<AttachmentBuilder> {
|
||||||
|
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' });
|
||||||
|
}
|
303
src/util/achievementManager.ts
Normal file
303
src/util/achievementManager.ts
Normal file
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,9 @@ import {
|
||||||
GuildMember,
|
GuildMember,
|
||||||
Guild,
|
Guild,
|
||||||
Interaction,
|
Interaction,
|
||||||
|
ButtonStyle,
|
||||||
|
ButtonBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { and, eq } from 'drizzle-orm';
|
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
|
* Checks if an interaction is valid
|
||||||
* @param interaction - The interaction to check
|
* @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);
|
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<ButtonBuilder> {
|
||||||
|
return new ActionRowBuilder<ButtonBuilder>().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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import * as schema from '@/db/schema.js';
|
import * as schema from '@/db/schema.js';
|
||||||
import { loadConfig } from './configLoader.js';
|
import { loadConfig } from './configLoader.js';
|
||||||
import { roundRect } from './helpers.js';
|
import { roundRect } from './helpers.js';
|
||||||
|
import { processMessageAchievements } from './achievementManager.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
|
@ -39,12 +40,24 @@ export const calculateXpForLevel = (level: number): number => {
|
||||||
export const calculateLevelFromXp = (xp: number): number => {
|
export const calculateLevelFromXp = (xp: number): number => {
|
||||||
if (xp < calculateXpForLevel(1)) return 0;
|
if (xp < calculateXpForLevel(1)) return 0;
|
||||||
|
|
||||||
let level = 0;
|
let low = 1;
|
||||||
while (calculateXpForLevel(level + 1) <= xp) {
|
let high = 200;
|
||||||
level++;
|
|
||||||
|
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 {
|
try {
|
||||||
const userId = message.author.id;
|
const userId = message.author.id;
|
||||||
const userData = await getUserLevel(userId);
|
const userData = await getUserLevel(userId);
|
||||||
|
const oldXp = userData.xp;
|
||||||
|
|
||||||
if (userData.lastMessageTimestamp) {
|
if (userData.lastMessageTimestamp) {
|
||||||
const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime();
|
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 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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing message for XP:', error);
|
console.error('Error processing message for XP:', error);
|
||||||
|
@ -263,17 +293,15 @@ export async function checkAndAssignLevelRoles(
|
||||||
|
|
||||||
if (rolesToAdd.length === 0) return;
|
if (rolesToAdd.length === 0) return;
|
||||||
|
|
||||||
const existingLevelRoles = config.roles.levelRoles.map((r) => r.roleId);
|
const newRolesToAdd = rolesToAdd.filter(
|
||||||
const rolesToRemove = member.roles.cache.filter((role) =>
|
(roleId) => !member.roles.cache.has(roleId),
|
||||||
existingLevelRoles.includes(role.id),
|
|
||||||
);
|
);
|
||||||
if (rolesToRemove.size > 0) {
|
|
||||||
await member.roles.remove(rolesToRemove);
|
if (newRolesToAdd.length > 0) {
|
||||||
|
await member.roles.add(newRolesToAdd);
|
||||||
}
|
}
|
||||||
|
|
||||||
const highestRole = rolesToAdd[rolesToAdd.length - 1];
|
const highestRole = rolesToAdd[rolesToAdd.length - 1];
|
||||||
await member.roles.add(highestRole);
|
|
||||||
|
|
||||||
return highestRole;
|
return highestRole;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error assigning level roles:', error);
|
console.error('Error assigning level roles:', error);
|
||||||
|
|
|
@ -4478,7 +4478,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"semver@npm:^7.6.2, semver@npm:^7.6.3":
|
"semver@npm:^7.6.3":
|
||||||
version: 7.7.1
|
version: 7.7.1
|
||||||
resolution: "semver@npm:7.7.1"
|
resolution: "semver@npm:7.7.1"
|
||||||
bin:
|
bin:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue