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 { 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 { userService } from "@/modules/user/user.service";
|
||||||
import { classService } from "@/modules/class/class.service";
|
|
||||||
import { generateStudentIdCard } from "@/graphics/studentID";
|
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||||
|
|
||||||
export const profile = createCommand({
|
export const profile = createCommand({
|
||||||
@@ -17,10 +16,6 @@ export const profile = createCommand({
|
|||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
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 user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
|
|
||||||
const cardBuffer = await generateStudentIdCard({
|
const cardBuffer = await generateStudentIdCard({
|
||||||
@@ -30,7 +25,6 @@ export const profile = createCommand({
|
|||||||
level: user!.level || 1,
|
level: user!.level || 1,
|
||||||
xp: user!.xp || 0n,
|
xp: user!.xp || 0n,
|
||||||
au: user!.balance || 0n,
|
au: user!.balance || 0n,
|
||||||
cu: classBalance || 0n,
|
|
||||||
className: user!.class?.name || "D"
|
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, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
const textColor = '#FFE7C5';
|
|
||||||
const secondaryTextColor = '#9C7D53';
|
|
||||||
const accentColor = '#3F2923';
|
|
||||||
|
|
||||||
interface StudentCardData {
|
interface StudentCardData {
|
||||||
username: string;
|
username: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
id: string;
|
id: string;
|
||||||
level: number;
|
level: number;
|
||||||
au: bigint; // Astral Units
|
au: bigint; // Astral Units
|
||||||
cu: bigint; // Constellation Units
|
xp: bigint;
|
||||||
xp: bigint; // Experience
|
|
||||||
className?: string | null;
|
className?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
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 template = await loadImage(templatePath);
|
||||||
|
const classTemplate = await loadImage(classTemplatePath);
|
||||||
|
|
||||||
const canvas = createCanvas(template.width, template.height);
|
const canvas = createCanvas(template.width, template.height);
|
||||||
const ctx = canvas.getContext('2d');
|
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
|
// Draw Template
|
||||||
ctx.drawImage(template, 0, 0);
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Draw Class Template
|
||||||
|
ctx.drawImage(classTemplate, 0, 0);
|
||||||
|
|
||||||
// Draw Avatar
|
// Draw Avatar
|
||||||
const avatarSize = 111;
|
const avatarSize = 140;
|
||||||
const avatarX = 18;
|
const avatarX = 19;
|
||||||
const avatarY = 64;
|
const avatarY = 76;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const avatar = await loadImage(data.avatarUrl);
|
const avatar = await loadImage(data.avatarUrl);
|
||||||
@@ -53,106 +68,43 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
|
|||||||
// Draw ID
|
// Draw ID
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.font = '12px IBMPlexMono-Bold';
|
ctx.font = '12px IBMPlexMono-Bold';
|
||||||
ctx.fillStyle = secondaryTextColor;
|
ctx.fillStyle = '#DAC7A1';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(`ID: ${data.id}`, 302, 30);
|
ctx.fillText(`ID: ${data.id}`, 314, 30);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Draw Username
|
// Draw Username
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.font = '32px IBMPlexSansCondensed-SemiBold';
|
ctx.font = '24px IBMPlexSansCondensed-SemiBold';
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = '#FFFFFF';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(data.username, 140, 107);
|
ctx.fillText(data.username, 181, 122);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Draw Class
|
// Draw AU
|
||||||
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
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.font = '32px IBMPlexMono-Bold';
|
ctx.font = '24px IBMPlexMono-Bold';
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = '#FFFFFF';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(data.au.toString(), 163, 218);
|
ctx.fillText(`${data.au}`, 270, 183);
|
||||||
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.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Draw Level
|
// Draw Level
|
||||||
ctx.save();
|
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.font = '24px IBMPlexMono-Bold';
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = '#FFFFFF';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'center';
|
||||||
if (nextLevelDigits === 1) {
|
ctx.fillText(`${data.level}`, 445, 255);
|
||||||
ctx.fillText((data.level + 1).toString(), 438, 255);
|
|
||||||
} else {
|
|
||||||
ctx.fillText((data.level + 1).toString(), 431, 255);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Draw xp bar
|
// Draw XP Bar
|
||||||
const xpbarX = 2;
|
const xpForNextLevel = levelingService.getXpForLevel(data.level);
|
||||||
const xpbarY = 268;
|
const xpBarMaxWidth = 382;
|
||||||
const xpbarMaxWidth = 400; // in pixels
|
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel);
|
||||||
const xpbarHeight = 4;
|
const xpBarHeight = 3;
|
||||||
|
|
||||||
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');
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = accentColor;
|
ctx.fillStyle = '#B3AD93';
|
||||||
ctx.fillRect(xpbarX, xpbarY, xpbarMaxWidth, xpbarHeight);
|
ctx.fillRect(32, 244, xpBarWidth, xpBarHeight);
|
||||||
|
|
||||||
if (xpFilledWidth > 0) {
|
|
||||||
ctx.fillStyle = gradient;
|
|
||||||
ctx.fillRect(xpbarX, xpbarY, xpFilledWidth, xpbarHeight);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
return canvas.toBuffer('image/png');
|
return canvas.toBuffer('image/png');
|
||||||
|
|||||||
@@ -120,5 +120,11 @@ export const inventoryService = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
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 { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { classService } from "@/modules/class/class.service";
|
|
||||||
import { generateStudentIdCard } from "@/graphics/studentID";
|
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -25,9 +24,9 @@ async function main() {
|
|||||||
const className = userClass?.name || "Unknown";
|
const className = userClass?.name || "Unknown";
|
||||||
console.log(`User Class: ${className}`);
|
console.log(`User Class: ${className}`);
|
||||||
|
|
||||||
// Get class balance
|
console.log(`User Balance: ${user.balance}`);
|
||||||
const classBalance = await classService.getClassBalance(userClass?.id || BigInt(0));
|
console.log(`User XP: ${user.xp}`);
|
||||||
console.log(`Class Balance: ${classBalance}`);
|
console.log(`User Level: ${user.level}`);
|
||||||
|
|
||||||
// Placeholder avatar (default discord avatar)
|
// Placeholder avatar (default discord avatar)
|
||||||
const avatarUrl = "https://cdn.discordapp.com/embed/avatars/0.png";
|
const avatarUrl = "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
@@ -42,7 +41,6 @@ async function main() {
|
|||||||
level: user.level || 1,
|
level: user.level || 1,
|
||||||
xp: user.xp || 0n,
|
xp: user.xp || 0n,
|
||||||
au: user.balance || 0n,
|
au: user.balance || 0n,
|
||||||
cu: classBalance || 0n,
|
|
||||||
className: 'D'
|
className: 'D'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
BIN
test-student-id.png
Normal file
|
After Width: | Height: | Size: 44 KiB |