feat: Add /sell command, enhance inventory service, and refactor student ID card generation with new constellation graphics and dynamic backgrounds.

This commit is contained in:
syntaxbullet
2025-12-12 13:41:13 +01:00
parent 209340c06e
commit 8262eb8f02
14 changed files with 179 additions and 106 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,123 @@
import { createCommand } from "@/lib/utils";
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
type BaseGuildTextChannel,
type ButtonInteraction,
PermissionFlagsBits
} from "discord.js";
import { userService } from "@/modules/user/user.service";
import { inventoryService } from "@/modules/inventory/inventory.service";
import type { items } from "@db/schema";
export const sell = createCommand({
data: new SlashCommandBuilder()
.setName("sell")
.setDescription("Post an item for sale in the current channel so regular users can buy it")
.addNumberOption(option =>
option.setName("itemid")
.setDescription("The ID of the item to sell")
.setRequired(true)
)
.addChannelOption(option =>
option.setName("channel")
.setDescription("The channel to post the item in")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true });
const itemId = interaction.options.getNumber("itemid", true);
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
if (!targetChannel || !targetChannel.isSendable()) {
await interaction.editReply({ content: "Target channel is invalid or not sendable." });
return;
}
const item = await inventoryService.getItem(itemId);
if (!item) {
await interaction.editReply({ content: `Item with ID ${itemId} not found.` });
return;
}
if (!item.price) {
await interaction.editReply({ content: `Item "${item.name}" is not for sale (no price set).` });
return;
}
const embed = new EmbedBuilder()
.setTitle(`Item for sale: ${item.name}`)
.setDescription(item.description || "No description available.")
.addFields({ name: "Price", value: `${item.price} 🪙`, inline: true })
.setColor("Yellow")
.setThumbnail(item.iconUrl || null)
.setImage(item.imageUrl || null);
const buyButton = new ButtonBuilder()
.setCustomId("buy")
.setLabel("Buy")
.setStyle(ButtonStyle.Success);
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
try {
const message = await targetChannel.send({ embeds: [embed], components: [actionRow] });
await interaction.editReply({ content: `Item posted in ${targetChannel}.` });
// Create a collector on the specific message
const collector = message.createMessageComponentCollector({
componentType: ComponentType.Button,
filter: (i) => i.customId === "buy",
});
collector.on("collect", async (i) => {
await handleBuyInteraction(i, item);
});
} catch (error) {
console.error("Failed to send sell message:", error);
await interaction.editReply({ content: "Failed to post the item for sale." });
}
}
});
async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof items.$inferSelect) {
try {
await interaction.deferReply({ ephemeral: true });
const userId = interaction.user.id;
const user = await userService.getUserById(userId);
if (!user) {
await interaction.editReply({ content: "User profile not found." });
return;
}
if ((user.balance ?? 0n) < (item.price ?? 0n)) {
await interaction.editReply({ content: `You don't have enough money! You need ${item.price} 🪙.` });
return;
}
const result = await inventoryService.buyItem(userId, item.id, 1n);
if (!result.success) {
await interaction.editReply({ content: "Transaction failed. Please try again." });
return;
}
await interaction.editReply({ content: `Successfully bought **${item.name}** for ${item.price} 🪙!` });
} catch (error) {
console.error("Error processing purchase:", error);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "An error occurred while processing your purchase." });
} else {
await interaction.reply({ content: "An error occurred while processing your purchase.", ephemeral: true });
}
}
}

View File

@@ -1,7 +1,6 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder, AttachmentBuilder } from "discord.js";
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service";
import { classService } from "@/modules/class/class.service";
import { generateStudentIdCard } from "@/graphics/studentID";
export const profile = createCommand({
@@ -17,10 +16,6 @@ export const profile = createCommand({
await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user;
const targetMember = await interaction.guild?.members.fetch(targetUser.id).catch(() => null);
const targetUserClass = await userService.getUserClass(targetUser.id);
const classBalance = await classService.getClassBalance(targetUserClass?.id || BigInt(0));
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const cardBuffer = await generateStudentIdCard({
@@ -30,7 +25,6 @@ export const profile = createCommand({
level: user!.level || 1,
xp: user!.xp || 0n,
au: user!.balance || 0n,
cu: classBalance || 0n,
className: user!.class?.name || "D"
});

View File

@@ -7,35 +7,50 @@ const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
const textColor = '#FFE7C5';
const secondaryTextColor = '#9C7D53';
const accentColor = '#3F2923';
interface StudentCardData {
username: string;
avatarUrl: string;
id: string;
level: number;
au: bigint; // Astral Units
cu: bigint; // Constellation Units
xp: bigint; // Experience
xp: bigint;
className?: string | null;
}
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'student-id-template.png');
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', 'template.png');
const classTemplatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
const template = await loadImage(templatePath);
const classTemplate = await loadImage(classTemplatePath);
const canvas = createCanvas(template.width, template.height);
const ctx = canvas.getContext('2d');
// Draw Background Gradient with random hue
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
const hue = Math.random() * 360;
const saturation = 40 + Math.random() * 20; // 40-60%
const lightness = 20 + Math.random() * 20; // 20-40%
const color2 = `hsl(${Math.random() * 360}, ${saturation}%, ${lightness}%)`;
const color = `hsl(${Math.random() * 360}, ${saturation}%, ${lightness - 20}%)`;
gradient.addColorStop(0, color);
gradient.addColorStop(1, color2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw Template
ctx.drawImage(template, 0, 0);
// Draw Class Template
ctx.drawImage(classTemplate, 0, 0);
// Draw Avatar
const avatarSize = 111;
const avatarX = 18;
const avatarY = 64;
const avatarSize = 140;
const avatarX = 19;
const avatarY = 76;
try {
const avatar = await loadImage(data.avatarUrl);
@@ -53,106 +68,43 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
// Draw ID
ctx.save();
ctx.font = '12px IBMPlexMono-Bold';
ctx.fillStyle = secondaryTextColor;
ctx.fillStyle = '#DAC7A1';
ctx.textAlign = 'left';
ctx.fillText(`ID: ${data.id}`, 302, 30);
ctx.fillText(`ID: ${data.id}`, 314, 30);
ctx.restore();
// Draw Username
ctx.save();
ctx.font = '32px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = textColor;
ctx.font = '24px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'left';
ctx.fillText(data.username, 140, 107);
ctx.fillText(data.username, 181, 122);
ctx.restore();
// Draw Class
try {
const classBadge = await loadImage(path.join(process.cwd(), 'src', 'assets', 'student-id-class-badge.png'));
ctx.save();
ctx.beginPath();
ctx.drawImage(classBadge, 100, 126, 58, 58);
ctx.font = '32px IBMPlexMono-Bold';
ctx.fillStyle = textColor;
ctx.textAlign = 'left';
ctx.fillText(data.className || 'Unknown', 120, 162);
ctx.restore();
} catch (e) {
console.error("Failed to load class badge", e);
}
// Draw Astral Units
// Draw AU
ctx.save();
ctx.font = '32px IBMPlexMono-Bold';
ctx.fillStyle = textColor;
ctx.textAlign = 'left';
ctx.fillText(data.au.toString(), 163, 218);
ctx.restore();
// Draw Constellation Units
ctx.save();
ctx.font = '32px IBMPlexMono-Bold';
ctx.fillStyle = textColor;
ctx.textAlign = 'left';
ctx.fillText(data.cu.toString(), 163, 153);
ctx.font = '24px IBMPlexMono-Bold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'right';
ctx.fillText(`${data.au}`, 270, 183);
ctx.restore();
// Draw Level
ctx.save();
// check how many digits the level has and adjust the position accordingly
const levelDigits = data.level.toString().length;
ctx.font = '12px IBMPlexMono-Bold';
ctx.fillStyle = secondaryTextColor;
ctx.textAlign = 'left';
if (levelDigits === 1) {
ctx.fillText(data.level.toString(), 406, 265);
} else {
ctx.fillText(data.level.toString(), 400, 265);
}
ctx.restore();
// Draw Next Level
ctx.save();
// check how many digits the next level has and adjust the position accordingly
const nextLevelDigits = (data.level + 1).toString().length;
ctx.font = '24px IBMPlexMono-Bold';
ctx.fillStyle = textColor;
ctx.textAlign = 'left';
if (nextLevelDigits === 1) {
ctx.fillText((data.level + 1).toString(), 438, 255);
} else {
ctx.fillText((data.level + 1).toString(), 431, 255);
}
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.fillText(`${data.level}`, 445, 255);
ctx.restore();
// Draw xp bar
const xpbarX = 2;
const xpbarY = 268;
const xpbarMaxWidth = 400; // in pixels
const xpbarHeight = 4;
const xp = data.xp;
const requiredXp = BigInt(levelingService.getXpForLevel(data.level));
// Check to avoid division by zero, though requiredXp should be >= 100
let percentage = 0;
if (requiredXp > 0n) {
percentage = Number(xp) / Number(requiredXp);
}
const xpFilledWidth = Math.min(Math.max(percentage * xpbarMaxWidth, 0), xpbarMaxWidth);
const gradient = ctx.createLinearGradient(xpbarX, xpbarY, xpbarX + xpFilledWidth, xpbarY);
gradient.addColorStop(0, '#FFFFFF');
gradient.addColorStop(1, '#D9B178');
// Draw XP Bar
const xpForNextLevel = levelingService.getXpForLevel(data.level);
const xpBarMaxWidth = 382;
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel);
const xpBarHeight = 3;
ctx.save();
ctx.fillStyle = accentColor;
ctx.fillRect(xpbarX, xpbarY, xpbarMaxWidth, xpbarHeight);
if (xpFilledWidth > 0) {
ctx.fillStyle = gradient;
ctx.fillRect(xpbarX, xpbarY, xpFilledWidth, xpbarHeight);
}
ctx.fillStyle = '#B3AD93';
ctx.fillRect(32, 244, xpBarWidth, xpBarHeight);
ctx.restore();
return canvas.toBuffer('image/png');

View File

@@ -120,5 +120,11 @@ export const inventoryService = {
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}
},
getItem: async (itemId: number) => {
return await DrizzleClient.query.items.findFirst({
where: eq(items.id, itemId),
});
},
};

View File

@@ -2,7 +2,6 @@
import { DrizzleClient } from "@/lib/DrizzleClient";
import { eq } from "drizzle-orm";
import { userService } from "@/modules/user/user.service";
import { classService } from "@/modules/class/class.service";
import { generateStudentIdCard } from "@/graphics/studentID";
import fs from 'fs';
import path from 'path';
@@ -25,9 +24,9 @@ async function main() {
const className = userClass?.name || "Unknown";
console.log(`User Class: ${className}`);
// Get class balance
const classBalance = await classService.getClassBalance(userClass?.id || BigInt(0));
console.log(`Class Balance: ${classBalance}`);
console.log(`User Balance: ${user.balance}`);
console.log(`User XP: ${user.xp}`);
console.log(`User Level: ${user.level}`);
// Placeholder avatar (default discord avatar)
const avatarUrl = "https://cdn.discordapp.com/embed/avatars/0.png";
@@ -42,7 +41,6 @@ async function main() {
level: user.level || 1,
xp: user.xp || 0n,
au: user.balance || 0n,
cu: classBalance || 0n,
className: 'D'
});

BIN
test-student-id.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB