mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-06-07 15:39:30 +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
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,
|
||||
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<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 { 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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue