feat: Add /sell command, enhance inventory service, and refactor student ID card generation with new constellation graphics and dynamic backgrounds.
BIN
src/assets/graphics/studentID/Constellation-A.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/graphics/studentID/Constellation-B.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/graphics/studentID/Constellation-C.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
src/assets/graphics/studentID/Constellation-D.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/graphics/studentID/Constellation-S.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/graphics/studentID/template.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 80 KiB |
123
src/commands/economy/sell.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 44 KiB |