From 3d2cba78c78f0e3d6f76c577712d7eab41fe96ee Mon Sep 17 00:00:00 2001 From: yuhim Date: Sat, 4 Jul 2026 01:21:41 +0800 Subject: [PATCH 01/34] economy --- src/commands/fun/economy.ts | 285 ++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 src/commands/fun/economy.ts diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts new file mode 100644 index 0000000..6745171 --- /dev/null +++ b/src/commands/fun/economy.ts @@ -0,0 +1,285 @@ +import {EmbedBuilder, ChatInputCommandInteraction, GuildMember} from "discord.js"; +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, +} from "sequelize"; +import type { Cmd } from "~/util/base"; + +// 1. Define the Shop Items +const SHOP_ITEMS = [ + { id: "cookie", name: "šŸŖ Cookie", price: 10, description: "A delicious chocolate chip cookie." }, + { id: "bronze_medal", name: "šŸ„‰ Bronze Medal", price: 150, description: "A basic medal to show off your presence." }, + { id: "gold_shield", name: "šŸ›”ļø Gold Shield", price: 500, description: "The ultimate flex of wealth and protection." }, + { id: "super_role", name: "šŸ‘‘ VIP Custom Role", price: 2500, description: "Redeemable for a unique colored role!" } +]; + +// 2. Define the Database Models +export class EconomyProfile extends Model< + InferAttributes, + InferCreationAttributes +> { + declare guildId: string; + declare userId: string; + declare balance: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; +} + +export class Inventory extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare guildId: string; + declare userId: string; + declare itemKey: string; + declare quantity: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; +} + +export default { + data: { name: "economy" }, + + setup: async (ctx) => { + // Initialize Economy Profiles (Composite Primary Key of Guild + User) + EconomyProfile.init( + { + guildId: { type: DataTypes.STRING, primaryKey: true }, + userId: { type: DataTypes.STRING, primaryKey: true }, + balance: { type: DataTypes.INTEGER, defaultValue: 10 }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { sequelize: ctx.sql }, + ); + + // Initialize Inventory Tracking + Inventory.init( + { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + guildId: { type: DataTypes.STRING, allowNull: false }, + userId: { type: DataTypes.STRING, allowNull: false }, + itemKey: { type: DataTypes.STRING, allowNull: false }, + quantity: { type: DataTypes.INTEGER, defaultValue: 1 }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { sequelize: ctx.sql }, + ); + + // Establish relationships + EconomyProfile.hasMany(Inventory, { foreignKey: "userId", sourceKey: "userId", onDelete: "CASCADE" }); + Inventory.belongsTo(EconomyProfile, { foreignKey: "userId", targetKey: "userId" }); + + await ctx.sql.sync(); + }, + + slash: (builder) => { + return builder + .setName("economy") + .setDescription("Manage your pocket change and inventory") + .addSubcommand((sub) => + sub + .setName("balance") + .setDescription("Check your current balance or another user's balance") + .addUserOption((opt) => + opt.setName("user").setDescription("The user to check").setRequired(true), + ), + ) + .addSubcommand((sub) => + sub.setName("shop").setDescription("View available items for purchase"), + ) + .addSubcommand((sub) => + sub + .setName("buy") + .setDescription("Purchase an item from the shop") + .addStringOption((opt) => + opt + .setName("item") + .setDescription("The item you want to buy") + .setRequired(true) + .addChoices( + ...SHOP_ITEMS.map((item) => ({ + name: `${item.name} ($${item.price})`, + value: item.id, + })), + ), + ), + ) + .addSubcommand((sub) => + sub.setName("inventory").setDescription("View items you currently own"), + ) + .addSubcommand((sub) => + sub + .setName("add-money") + .setDescription("Add money to a user's balance (Admin/Staff Only)") + .addUserOption((opt) => + opt.setName("user").setDescription("The user receiving the money").setRequired(true), + ) + .addIntegerOption((opt) => + opt.setName("amount").setDescription("The amount of money to add").setRequired(true), + ), + ); + + }, + + onInteraction: async (ctx, interaction) => { + if (!interaction.isChatInputCommand()) return; + + const sub = interaction.options.getSubcommand(); + + if (sub === "balance") await handleBalance(interaction); + else if (sub === "shop") await handleShop(interaction); + else if (sub === "buy") await handleBuy(interaction); + else if (sub === "inventory") await handleInventory(interaction); + else if (sub === "add-money") await handleAddMoney(interaction); + }, +} as Cmd; + +// ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── + +async function handleBalance(interaction: ChatInputCommandInteraction) { + const targetUser = interaction.options.getUser("user") || interaction.user; + + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: targetUser.id }, + defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: 100 } + }); + + const embed = new EmbedBuilder() + .setTitle(`${targetUser.username}'s Vault`) + .setDescription(`šŸ’µ **Balance:** \`$${profile.balance}\``) + .setColor(0x00ae86) + .setThumbnail(targetUser.displayAvatarURL()); + + await interaction.reply({ embeds: [embed] }); +} + + +async function handleShop(interaction: ChatInputCommandInteraction) { + const embed = new EmbedBuilder() + .setTitle("šŸ›’ The Server Marketplace") + .setDescription("Use `/economy buy ` to purchase something!") + .setColor(0x00ae86); + + for (const item of SHOP_ITEMS) { + embed.addFields({ + name: `${item.name} — \`$${item.price}\``, + value: item.description, + inline: false, + }); + } + + await interaction.reply({ embeds: [embed] }); +} + +async function handleBuy(interaction: ChatInputCommandInteraction) { + const itemKey = interaction.options.getString("item", true); + const item = SHOP_ITEMS.find((i) => i.id === itemKey); + + if (!item) { + await interaction.reply({ content: "That item doesn't exist in our manifests.", ephemeral: true }); + return; + } + + // Fetch or create user profile + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: interaction.user.id }, + defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } + }); + + // Check if they have enough capital + if (profile.balance < item.price) { + await interaction.reply({ + content: `āŒ You can't afford that! **${item.name}** costs \`$${item.price}\`, but you only have \`$${profile.balance}\`.`, + ephemeral: true, + }); + return; + } + + // Deduct money from account + profile.balance -= item.price; + await profile.save(); + + // Add item to inventory (or increase quantity if they already own one) + const [invItem, created] = await Inventory.findOrCreate({ + where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, + defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: 1 } + }); + + if (!created) { + invItem.quantity += 1; + await invItem.save(); + } + + await interaction.reply({ + content: `šŸŽ‰ Success! You bought **${item.name}** for \`$${item.price}\`. Your remaining balance is \`$${profile.balance}\`.`, + }); +} + +async function handleInventory(interaction: ChatInputCommandInteraction) { + const items = await Inventory.findAll({ + where: { guildId: interaction.guildId!, userId: interaction.user.id } + }); + + if (items.length === 0) { + await interaction.reply({ content: "šŸŽ’ Your inventory is completely empty. Go buy something!", ephemeral: true }); + return; + } + + // Map database entries to their descriptive shop names + const itemManifest = Object.fromEntries(SHOP_ITEMS.map((i) => [i.id, i.name])); + + const inventoryList = items + .map((item) => { + const visualName = itemManifest[item.itemKey] || `āš™ļø Unknown Item (${item.itemKey})`; + return `${visualName} x\`${item.quantity}\``; + }) + .join("\n"); + + const embed = new EmbedBuilder() + .setTitle(`šŸŽ’ ${interaction.user.username}'s Inventory`) + .setDescription(inventoryList) + .setColor(0x00ae86); + + await interaction.reply({ embeds: [embed] }); +} + +async function handleAddMoney(interaction: ChatInputCommandInteraction) { + // šŸ‘‡ Your exact role-check logic (Replace "1234" with your real Staff role ID) + if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has("1262624821582364703")) { + return interaction.reply({ + content: "āŒ You do not have the required staff role to grant currency.", + ephemeral: true + }); + } + + const targetUser = interaction.options.getUser("user", true); + const amount = interaction.options.getInteger("amount", true); + + // Prevent staff from entering negative numbers to steal money + if (amount <= 0) { + return interaction.reply({ + content: "āŒ Please specify an amount greater than 0.", + ephemeral: true + }); + } + + // Fetch their profile, or create it if they've never interacted with the economy system + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: targetUser.id }, + defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: 100 } + }); + + // Credit the money and save back to the database + profile.balance += amount; + await profile.save(); + + await interaction.reply({ + content: `šŸŖ™ **Transaction Complete:** Successfully added \`$${amount}\` to ${targetUser.username}'s profile. Their new balance is \`$${profile.balance}\`.`, + }); +} \ No newline at end of file From 9ff815651f5cc36f458380b6d552c4006402ad23 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 02:31:54 +0800 Subject: [PATCH 02/34] added gambling and more --- src/commands/fun/economy.ts | 445 ++++++++++++++++++++++++++++++++++-- 1 file changed, 426 insertions(+), 19 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 6745171..1345479 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -1,4 +1,4 @@ -import {EmbedBuilder, ChatInputCommandInteraction, GuildMember} from "discord.js"; +import {EmbedBuilder, ChatInputCommandInteraction, GuildMember, Message, type TextChannel} from "discord.js"; import { DataTypes, Model, @@ -7,13 +7,13 @@ import { type InferCreationAttributes, } from "sequelize"; import type { Cmd } from "~/util/base"; - +import randomUtils from "~/util/rnd"; // 1. Define the Shop Items const SHOP_ITEMS = [ { id: "cookie", name: "šŸŖ Cookie", price: 10, description: "A delicious chocolate chip cookie." }, { id: "bronze_medal", name: "šŸ„‰ Bronze Medal", price: 150, description: "A basic medal to show off your presence." }, { id: "gold_shield", name: "šŸ›”ļø Gold Shield", price: 500, description: "The ultimate flex of wealth and protection." }, - { id: "super_role", name: "šŸ‘‘ VIP Custom Role", price: 2500, description: "Redeemable for a unique colored role!" } + { id: "super_role", name: "šŸ‘‘ VIP Custom Role", price: 2500, description: "Redeemable for a unique colored role!", roleId: "1510652320432521327" } ]; // 2. Define the Database Models @@ -41,6 +41,20 @@ export class Inventory extends Model< declare updatedAt: CreationOptional; } +export class ShopItem extends Model< + InferAttributes, + InferCreationAttributes +> { + declare guildId: string; + declare itemId: string; + declare name: string; + declare description: string; + declare price: number; + declare roleId: CreationOptional; + declare stock: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; +} export default { data: { name: "economy" }, @@ -71,6 +85,21 @@ export default { { sequelize: ctx.sql }, ); + ShopItem.init( + { + guildId: { type: DataTypes.STRING, primaryKey: true }, + itemId: { type: DataTypes.STRING, primaryKey: true }, // The ID users type to buy + name: { type: DataTypes.STRING, allowNull: false }, + description: { type: DataTypes.STRING, allowNull: false }, + price: { type: DataTypes.INTEGER, allowNull: false }, + roleId: { type: DataTypes.STRING, allowNull: true }, + stock: { type: DataTypes.INTEGER, defaultValue: -1 }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { sequelize: ctx.sql } + ); + // Establish relationships EconomyProfile.hasMany(Inventory, { foreignKey: "userId", sourceKey: "userId", onDelete: "CASCADE" }); Inventory.belongsTo(EconomyProfile, { foreignKey: "userId", targetKey: "userId" }); @@ -123,6 +152,54 @@ export default { .addIntegerOption((opt) => opt.setName("amount").setDescription("The amount of money to add").setRequired(true), ), + ) + .addSubcommand((sub) => + sub + .setName("set-balance") + .setDescription("Forcefully set a user's balance to a specific amount (Staff Only)") + .addUserOption((opt) => opt.setName("user").setDescription("The target user").setRequired(true)) + .addIntegerOption((opt) => opt.setName("amount").setDescription("The exact balance to set").setRequired(true)), + ) + .addSubcommand((sub) => + sub + .setName("add-item") + .setDescription("Create a new item in the server shop (Staff Only)") + .addStringOption((opt) => opt.setName("id").setDescription("A short ID for buying (e.g. 'cookie')").setRequired(true)) + .addStringOption((opt) => opt.setName("name").setDescription("The display name (e.g. 'šŸŖ Cookie')").setRequired(true)) + .addIntegerOption((opt) => opt.setName("price").setDescription("Cost of the item").setRequired(true)) + .addStringOption((opt) => opt.setName("description").setDescription("What the item does").setRequired(true)) + .addRoleOption((opt) => opt.setName("role").setDescription("Optional: A role to give upon purchase").setRequired(false)) + .addIntegerOption((opt) => opt.setName("stock").setDescription("Amount available (leave blank for infinite stock)").setRequired(false)) + ) + // šŸ‘‡ Admin Command: Remove Shop Item + .addSubcommand((sub) => + sub + .setName("remove-item") + .setDescription("Remove an item from the server shop (Staff Only)") + .addStringOption((opt) => opt.setName("id").setDescription("The ID of the item to delete").setRequired(true)) + ) + .addSubcommandGroup((group) => + group + .setName("gamble") + .setDescription("Risk your money on different casino games!") + .addSubcommand((sub) => + sub + .setName("coinflip") + .setDescription("A 50/50 chance to double your money!") + .addIntegerOption((opt) => opt.setName("amount").setDescription("How much to bet").setRequired(true).setMinValue(1)) + ) + .addSubcommand((sub) => + sub + .setName("dice") + .setDescription("Guess a 6-sided die roll. Win 5x your bet!") + .addIntegerOption((opt) => opt.setName("amount").setDescription("How much to bet").setRequired(true).setMinValue(1)) + .addIntegerOption((opt) => opt.setName("guess").setDescription("Your guess (1-6)").setRequired(true).setMinValue(1).setMaxValue(6)) + ) + .addSubcommand((sub) => + sub + .setName("roulette") + .setDescription("Open a roulette table and place multiple bets! (1-24, Red/Black, Even/Odd)") + ) ); }, @@ -130,13 +207,23 @@ export default { onInteraction: async (ctx, interaction) => { if (!interaction.isChatInputCommand()) return; + const group = interaction.options.getSubcommandGroup(false); const sub = interaction.options.getSubcommand(); - if (sub === "balance") await handleBalance(interaction); + if (group === "gamble") { + // Route gambling games + if (sub === "coinflip") await handleGambleCoinflip(interaction); + else if (sub === "dice") await handleGambleDice(interaction); + else if (sub === "roulette") await handleGambleRoulette(interaction); + } + else if (sub === "balance") await handleBalance(interaction); else if (sub === "shop") await handleShop(interaction); else if (sub === "buy") await handleBuy(interaction); else if (sub === "inventory") await handleInventory(interaction); else if (sub === "add-money") await handleAddMoney(interaction); + else if (sub === "set-balance") await handleSetBalance(interaction); + else if (sub === "add-item") await handleAddShopItem(interaction); + else if (sub === "remove-item") await handleRemoveShopItem(interaction); }, } as Cmd; @@ -161,38 +248,52 @@ async function handleBalance(interaction: ChatInputCommandInteraction) { async function handleShop(interaction: ChatInputCommandInteraction) { + const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); + const embed = new EmbedBuilder() .setTitle("šŸ›’ The Server Marketplace") - .setDescription("Use `/economy buy ` to purchase something!") + .setDescription("Use `/economy buy ` to purchase something!") .setColor(0x00ae86); - for (const item of SHOP_ITEMS) { - embed.addFields({ - name: `${item.name} — \`$${item.price}\``, - value: item.description, - inline: false, - }); + if (items.length === 0) { + embed.setDescription("The shop is currently empty. Admins need to add items!"); + } else { + for (const item of items) { + // šŸ‘‡ Determine if it says "āˆž" or a specific number, or "OUT OF STOCK" + let stockDisplay = item.stock === -1 ? "āˆž" : item.stock.toString(); + if (item.stock === 0) stockDisplay = "āŒ OUT OF STOCK"; + + embed.addFields({ + name: `${item.name} (\`${item.itemId}\`) — $${item.price}`, + value: `${item.description}\nšŸ“¦ **Stock:** ${stockDisplay}`, + inline: false, + }); + } } await interaction.reply({ embeds: [embed] }); } async function handleBuy(interaction: ChatInputCommandInteraction) { - const itemKey = interaction.options.getString("item", true); - const item = SHOP_ITEMS.find((i) => i.id === itemKey); + const itemKey = interaction.options.getString("item_id", true).toLowerCase(); + + // šŸ‘‡ Fetch the specific item from the database + const item = await ShopItem.findOne({ + where: { guildId: interaction.guildId!, itemId: itemKey } + }); if (!item) { - await interaction.reply({ content: "That item doesn't exist in our manifests.", ephemeral: true }); - return; + return interaction.reply({ content: "That item doesn't exist in our shop.", ephemeral: true }); } - // Fetch or create user profile + if (item.stock === 0) { + return interaction.reply({ content: `āŒ Sorry, **${item.name}** is completely sold out!`, ephemeral: true }); + } const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } }); - // Check if they have enough capital if (profile.balance < item.price) { await interaction.reply({ content: `āŒ You can't afford that! **${item.name}** costs \`$${item.price}\`, but you only have \`$${profile.balance}\`.`, @@ -201,11 +302,47 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { return; } + // ─── ADD THE ROLE LOGIC HERE ──────────────────────────────────────── + + let roleGrantedMessage = ""; + + // Check if this item is configured to give a role + if (item.roleId) { + if (interaction.member instanceof GuildMember) { + try { + // Check if they already have the role so they don't waste money + if (interaction.member.roles.cache.has(item.roleId)) { + return interaction.reply({ + content: `āŒ You already have the role granted by this item!`, + ephemeral: true + }); + } + + // Give them the role + await interaction.member.roles.add(item.roleId, `Purchased ${item.name} from the shop.`); + roleGrantedMessage = ` and granted you the <@&${item.roleId}> role`; + } catch (error) { + // If the bot's role is lower than the target role, this will fail + console.error("Failed to assign shop role:", error); + return interaction.reply({ + content: `āŒ Internal Error: I couldn't assign the role. Please make sure my bot role is positioned ABOVE the shop role in Server Settings.`, + ephemeral: true + }); + } + } + } + + // ─── RESUME NORMAL INVENTORY & BALANCE SAVING ─────────────────────── + + if (item.stock > 0) { + item.stock -= 1; + await item.save(); + } // Deduct money from account profile.balance -= item.price; await profile.save(); - // Add item to inventory (or increase quantity if they already own one) + // Add item to inventory database const [invItem, created] = await Inventory.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: 1 } @@ -217,7 +354,7 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } await interaction.reply({ - content: `šŸŽ‰ Success! You bought **${item.name}** for \`$${item.price}\`. Your remaining balance is \`$${profile.balance}\`.`, + content: `šŸŽ‰ Success! You bought **${item.name}** for \`$${item.price}\`${roleGrantedMessage}. Your remaining balance is \`$${profile.balance}\`.`, }); } @@ -282,4 +419,274 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { await interaction.reply({ content: `šŸŖ™ **Transaction Complete:** Successfully added \`$${amount}\` to ${targetUser.username}'s profile. Their new balance is \`$${profile.balance}\`.`, }); +} +async function handleSetBalance(interaction: ChatInputCommandInteraction) { + // Your exact staff role protection check + if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has("1262624821582364703")) { + return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); + } + + const targetUser = interaction.options.getUser("user", true); + const amount = interaction.options.getInteger("amount", true); + + if (amount < 0 || amount > 2_000_000_000) { + return interaction.reply({ content: "āŒ Invalid amount range (0 to 2B).", ephemeral: true }); + } + + // Update or insert into the database + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: targetUser.id }, + defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: amount } + }); + + profile.balance = amount; + await profile.save(); + + await interaction.reply({ + content: `āš™ļø **Database Updated:** ${targetUser.username}'s balance has been explicitly set to \`$${amount}\`.`, + }); +} +async function handleAddShopItem(interaction: ChatInputCommandInteraction) { + if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has("1234")) { + return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); + } + + const itemId = interaction.options.getString("id", true).toLowerCase(); + const name = interaction.options.getString("name", true); + const price = interaction.options.getInteger("price", true); + const description = interaction.options.getString("description", true); + const role = interaction.options.getRole("role", false); + const stock = interaction.options.getInteger("stock") ?? -1; // šŸ‘‡ Grab the stock, default to -1 + + if (price < 0) return interaction.reply({ content: "āŒ Price cannot be negative.", ephemeral: true }); + + const [item, created] = await ShopItem.findOrCreate({ + where: { guildId: interaction.guildId!, itemId: itemId }, + defaults: { + guildId: interaction.guildId!, + itemId: itemId, + name: name, + price: price, + description: description, + roleId: role?.id || null, + stock: stock // šŸ‘‡ Save the stock to the DB + } + }); + + if (!created) { + return interaction.reply({ content: `āŒ An item with the ID \`${itemId}\` already exists!`, ephemeral: true }); + } + + const stockMsg = stock === -1 ? "Infinite" : stock.toString(); + await interaction.reply({ content: `āœ… Created new shop item: **${name}** for \`$${price}\` (Stock: ${stockMsg}).` }); +} + +async function handleRemoveShopItem(interaction: ChatInputCommandInteraction) { + // Staff Check + if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has("1262624821582364703")) { + return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); + } + + const itemId = interaction.options.getString("id", true).toLowerCase(); + + const deleted = await ShopItem.destroy({ + where: { guildId: interaction.guildId!, itemId: itemId } + }); + + if (deleted === 0) { + return interaction.reply({ content: `āŒ Could not find an item with the ID \`${itemId}\`.`, ephemeral: true }); + } + + await interaction.reply({ content: `šŸ—‘ļø Successfully removed \`${itemId}\` from the shop.` }); +} + +async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { + const betAmount = interaction.options.getInteger("amount", true); + + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: interaction.user.id }, + defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } + }); + + if (profile.balance < betAmount) { + return interaction.reply({ + content: `āŒ You can't afford that! You only have \`$${profile.balance}\` to your name.`, + ephemeral: true, + }); + } + + // šŸ‘‡ Use your pickRandom utility to pull a random boolean from an array + const isWinner = randomUtils.pickRandom([true, false]); + + if (isWinner) { + profile.balance += betAmount; + await profile.save(); + await interaction.reply({ + content: `šŸŽ° **JACKPOT!** The coin landed in your favor. You won \`$${betAmount}\`!\nšŸ’° Your new balance is \`$${profile.balance}\`.` + }); + } else { + profile.balance -= betAmount; + await profile.save(); + await interaction.reply({ + content: `šŸ“‰ **Bust!** Lady Luck was not on your side today. You lost \`$${betAmount}\`.\nšŸ’ø Your remaining balance is \`$${profile.balance}\`.` + }); + } +} + +async function handleGambleDice(interaction: ChatInputCommandInteraction) { + const betAmount = interaction.options.getInteger("amount", true); + const guess = interaction.options.getInteger("guess", true); + + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: interaction.user.id }, + defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } + }); + + if (profile.balance < betAmount) { + return interaction.reply({ + content: `āŒ You only have \`$${profile.balance}\`. You can't bet what you don't own!`, + ephemeral: true, + }); + } + + // šŸ‘‡ Use your getRandomIntInclusive utility for a perfect 1-6 roll + const diceRoll = randomUtils.getRandomIntInclusive(1, 6); + + if (guess === diceRoll) { + const winnings = betAmount * 5; + profile.balance += winnings; + await profile.save(); + await interaction.reply({ + content: `šŸŽ² The die rolled a **${diceRoll}**!\nšŸŽ‰ **INCREDIBLE!** You guessed correctly and won \`$${winnings}\`!\nšŸ’° Your new balance is \`$${profile.balance}\`.` + }); + } else { + profile.balance -= betAmount; + await profile.save(); + await interaction.reply({ + content: `šŸŽ² The die rolled a **${diceRoll}**...\nšŸ“‰ You guessed ${guess}. You lost your bet of \`$${betAmount}\`.\nšŸ’ø Your remaining balance is \`$${profile.balance}\`.` + }); + } +} + +async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { + await interaction.reply({ + content: "šŸŽ” **MULTIPLAYER ROULETTE IS OPEN!**\n\n" + + "**Anyone** can jump in! Valid bets: `red`, `black`, `even`, `odd`, or a number `1` through `24`.\n" + + "**How to bet:** Type your bet and amount (e.g., `red 50`, `14 100`).\n" + + "**When ready:** Anyone can type `spin` to roll the wheel! (Auto-spins in 60s)." + }); + + // šŸ‘‡ 1. Update the state to track WHO made the bet + const bets: { userId: string; username: string; type: string; amount: number }[] = []; + + // šŸ‘‡ 2. Change the filter to allow ANY human (ignore bots) + const filter = (m: Message) => !m.author.bot; + + const channel = interaction.channel as TextChannel; + const collector = channel.createMessageCollector({ filter, time: 60000 }); + + collector.on("collect", async (m) => { + const input = m.content.toLowerCase().trim(); + + if (input === "spin") { + collector.stop("user_spun"); + return; + } + + const args = input.split(" "); + if (args.length !== 2) return; + + const betType = args[0]; + const amount = parseInt(args[1]); + + if (isNaN(amount) || amount <= 0) return; + + const validTextBets = ["red", "black", "even", "odd"]; + const betNumber = parseInt(betType); + const isValidNumber = !isNaN(betNumber) && betNumber >= 1 && betNumber <= 24; + + if (!validTextBets.includes(betType) && !isValidNumber) return; + + // šŸ‘‡ 3. Fetch the profile of the person who TYPED the message (not just the host) + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: m.author.id }, + defaults: { guildId: interaction.guildId!, userId: m.author.id, balance: 100 } + }); + + if (profile.balance < amount) { + const errorMsg = await m.reply(`āŒ You only have \`$${profile.balance}\`.`); + setTimeout(() => errorMsg.delete().catch(() => null), 3000); + return; + } + + // Deduct money instantly + profile.balance -= amount; + await profile.save(); + + // Save the bet with their user ID and username + bets.push({ userId: m.author.id, username: m.author.username, type: betType, amount }); + m.react("āœ…").catch(() => null); + }); + + collector.on("end", async () => { + if (bets.length === 0) { + return interaction.followUp("ā³ The table closed because no bets were placed."); + } + + await interaction.followUp("šŸŽ” **NO MORE BETS!** Spinning the wheel..."); + + const roll = randomUtils.getRandomIntInclusive(1, 24); + const redNumbers = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23]; + const rollColor = redNumbers.includes(roll) ? "red" : "black"; + const rollParity = roll % 2 === 0 ? "even" : "odd"; + const colorEmoji = rollColor === "red" ? "šŸ”“" : "⚫"; + + // Track total winnings per user for a clean summary + const playerResults: Record = {}; + + for (const bet of bets) { + if (!playerResults[bet.userId]) { + playerResults[bet.userId] = { username: bet.username, totalWon: 0, summary: "" }; + } + + let won = false; + let multiplier = 0; + + if (bet.type === rollColor) { won = true; multiplier = 2; } + else if (bet.type === rollParity) { won = true; multiplier = 2; } + else if (!isNaN(parseInt(bet.type)) && parseInt(bet.type) === roll) { won = true; multiplier = 24; } + + if (won) { + const winAmount = bet.amount * multiplier; + playerResults[bet.userId].totalWon += winAmount; + playerResults[bet.userId].summary += `āœ… \`${bet.type}\`: Won **$${winAmount}**\n`; + } else { + playerResults[bet.userId].summary += `āŒ \`${bet.type}\`: Lost\n`; + } + } + + // Process payouts and build the final message + let finalMessage = `### The wheel landed on **${roll} ${rollColor.toUpperCase()}** ${colorEmoji}!\n\n`; + + for (const [userId, result] of Object.entries(playerResults)) { + if (result.totalWon > 0) { + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: userId } + }); + + profile.balance += result.totalWon; + await profile.save(); + } + + finalMessage += `**${result.username}**:\n${result.summary}`; + if (result.totalWon > 0) { + finalMessage += `šŸ’° *Total Payout: $${result.totalWon}*\n`; + } else { + finalMessage += `šŸ’ø *Bust!*\n`; + } + finalMessage += `\n`; + } + + await interaction.followUp({ content: finalMessage }); + }); } \ No newline at end of file From 8b60dad6e1bad8f9b2a87ee209bdb550e804cc81 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 14:05:56 +0800 Subject: [PATCH 03/34] theo/monke nitpicks --- config.json.js | 12 +++ src/commands/fun/economy.ts | 164 +++++++++++++++++++++++------------- 2 files changed, 116 insertions(+), 60 deletions(-) diff --git a/config.json.js b/config.json.js index 01a3de5..47f1aff 100644 --- a/config.json.js +++ b/config.json.js @@ -161,6 +161,18 @@ Consider donating to one of the following people: banTag: "1406738115468722257", bypassId: "1257750834150637599" }, + economy:{ + shopItems:[ + { itemId: "super_role", + name: "Beta Access", + price: 2500, + description: "Purchase for access to beta builds!", + roleId: "1510652320432521327", + stock: -1 + } + ], + teamRole: "1262624821582364703" + }, wikisearch: { baseUrl: "https://amblelabs.dev/wiki", index: "/api/search", diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 1345479..c5f638c 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -8,13 +8,7 @@ import { } from "sequelize"; import type { Cmd } from "~/util/base"; import randomUtils from "~/util/rnd"; -// 1. Define the Shop Items -const SHOP_ITEMS = [ - { id: "cookie", name: "šŸŖ Cookie", price: 10, description: "A delicious chocolate chip cookie." }, - { id: "bronze_medal", name: "šŸ„‰ Bronze Medal", price: 150, description: "A basic medal to show off your presence." }, - { id: "gold_shield", name: "šŸ›”ļø Gold Shield", price: 500, description: "The ultimate flex of wealth and protection." }, - { id: "super_role", name: "šŸ‘‘ VIP Custom Role", price: 2500, description: "Redeemable for a unique colored role!", roleId: "1510652320432521327" } -]; +import config from "config.json"; // 2. Define the Database Models export class EconomyProfile extends Model< @@ -55,6 +49,7 @@ export class ShopItem extends Model< declare createdAt: CreationOptional; declare updatedAt: CreationOptional; } +const STARTING_BALANCE = 10 export default { data: { name: "economy" }, @@ -64,7 +59,7 @@ export default { { guildId: { type: DataTypes.STRING, primaryKey: true }, userId: { type: DataTypes.STRING, primaryKey: true }, - balance: { type: DataTypes.INTEGER, defaultValue: 10 }, + balance: { type: DataTypes.INTEGER, defaultValue: STARTING_BALANCE }, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE, }, @@ -105,6 +100,13 @@ export default { Inventory.belongsTo(EconomyProfile, { foreignKey: "userId", targetKey: "userId" }); await ctx.sql.sync(); + + const guilds = ctx.client.guilds.cache; + + for (const [guildId, guild] of guilds) { + // 3. Seed the default items for each server! + await seedDefaultShopItems(guildId); + } }, slash: (builder) => { @@ -116,7 +118,7 @@ export default { .setName("balance") .setDescription("Check your current balance or another user's balance") .addUserOption((opt) => - opt.setName("user").setDescription("The user to check").setRequired(true), + opt.setName("user").setDescription("The user to check").setRequired(false), ), ) .addSubcommand((sub) => @@ -129,15 +131,9 @@ export default { .addStringOption((opt) => opt .setName("item") - .setDescription("The item you want to buy") + .setDescription("The ID of the item you want to buy (e.g. 'vip_role')") .setRequired(true) - .addChoices( - ...SHOP_ITEMS.map((item) => ({ - name: `${item.name} ($${item.price})`, - value: item.id, - })), - ), - ), + ) ) .addSubcommand((sub) => sub.setName("inventory").setDescription("View items you currently own"), @@ -234,7 +230,7 @@ async function handleBalance(interaction: ChatInputCommandInteraction) { const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: targetUser.id }, - defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: 100 } + defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE } }); const embed = new EmbedBuilder() @@ -248,7 +244,9 @@ async function handleBalance(interaction: ChatInputCommandInteraction) { async function handleShop(interaction: ChatInputCommandInteraction) { - const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); + const items = await ShopItem.findAll({ + where: { guildId: interaction.guildId! } + }); const embed = new EmbedBuilder() .setTitle("šŸ›’ The Server Marketplace") @@ -275,9 +273,8 @@ async function handleShop(interaction: ChatInputCommandInteraction) { } async function handleBuy(interaction: ChatInputCommandInteraction) { - const itemKey = interaction.options.getString("item_id", true).toLowerCase(); + const itemKey = interaction.options.getString("item", true).toLowerCase(); - // šŸ‘‡ Fetch the specific item from the database const item = await ShopItem.findOne({ where: { guildId: interaction.guildId!, itemId: itemKey } }); @@ -302,11 +299,15 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { return; } - // ─── ADD THE ROLE LOGIC HERE ──────────────────────────────────────── - let roleGrantedMessage = ""; - // Check if this item is configured to give a role + // ─── RESUME NORMAL INVENTORY & BALANCE SAVING ─────────────────────── + + if (item.stock > 0) { + item.stock -= 1; + await item.save(); + } + if (item.roleId) { if (interaction.member instanceof GuildMember) { try { @@ -317,7 +318,7 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { ephemeral: true }); } - + profile.balance -= item.price; // Give them the role await interaction.member.roles.add(item.roleId, `Purchased ${item.name} from the shop.`); roleGrantedMessage = ` and granted you the <@&${item.roleId}> role`; @@ -331,15 +332,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } } } - - // ─── RESUME NORMAL INVENTORY & BALANCE SAVING ─────────────────────── - - if (item.stock > 0) { - item.stock -= 1; - await item.save(); - } - // Deduct money from account - profile.balance -= item.price; await profile.save(); // Add item to inventory database @@ -368,8 +360,13 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { return; } - // Map database entries to their descriptive shop names - const itemManifest = Object.fromEntries(SHOP_ITEMS.map((i) => [i.id, i.name])); + // šŸ‘‡ Fetch all shop items from the DB to figure out their display names + const allShopItems = await ShopItem.findAll({ + where: { guildId: interaction.guildId! } + }); + + // šŸ‘‡ Map database entries to their descriptive shop names dynamically + const itemManifest = Object.fromEntries(allShopItems.map((i) => [i.itemId, i.name])); const inventoryList = items .map((item) => { @@ -388,7 +385,7 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { async function handleAddMoney(interaction: ChatInputCommandInteraction) { // šŸ‘‡ Your exact role-check logic (Replace "1234" with your real Staff role ID) - if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has("1262624821582364703")) { + if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ You do not have the required staff role to grant currency.", ephemeral: true @@ -398,10 +395,10 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); - // Prevent staff from entering negative numbers to steal money - if (amount <= 0) { + // Prevent staff from entering negative and/or too big numbers to steal money + if (amount <= 0 || amount > 1000000) { return interaction.reply({ - content: "āŒ Please specify an amount greater than 0.", + content: "āŒ Please use an integer smaller than 1,000,000 and bigger than 0", ephemeral: true }); } @@ -422,7 +419,7 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { } async function handleSetBalance(interaction: ChatInputCommandInteraction) { // Your exact staff role protection check - if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has("1262624821582364703")) { + if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); } @@ -447,7 +444,7 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { }); } async function handleAddShopItem(interaction: ChatInputCommandInteraction) { - if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has("1234")) { + if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); } @@ -483,7 +480,7 @@ async function handleAddShopItem(interaction: ChatInputCommandInteraction) { async function handleRemoveShopItem(interaction: ChatInputCommandInteraction) { // Staff Check - if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has("1262624821582364703")) { + if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); } @@ -568,21 +565,37 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { } } +// šŸ‘‡ Track which channels currently have an active game running +const activeRouletteChannels = new Set(); + async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { + // šŸ‘‡ Guard: Prevent multiple tables in the same channel + if (activeRouletteChannels.has(interaction.channelId)) { + return interaction.reply({ + content: "āŒ There is already an active roulette table in this channel! Please wait for the current spin to finish.", + ephemeral: true + }); + } + + // Lock the channel + activeRouletteChannels.add(interaction.channelId); + await interaction.reply({ content: "šŸŽ” **MULTIPLAYER ROULETTE IS OPEN!**\n\n" + "**Anyone** can jump in! Valid bets: `red`, `black`, `even`, `odd`, or a number `1` through `24`.\n" + - "**How to bet:** Type your bet and amount (e.g., `red 50`, `14 100`).\n" + + "**How to bet:** Type `bet ` (e.g., `bet red 50`, `bet 14 100`).\n" + "**When ready:** Anyone can type `spin` to roll the wheel! (Auto-spins in 60s)." }); - // šŸ‘‡ 1. Update the state to track WHO made the bet const bets: { userId: string; username: string; type: string; amount: number }[] = []; - - // šŸ‘‡ 2. Change the filter to allow ANY human (ignore bots) const filter = (m: Message) => !m.author.bot; - const channel = interaction.channel as TextChannel; + if (!interaction.channel || !interaction.channel.isTextBased()) { + return interaction.reply({ + content: "āŒ This command can only be played in standard text channels!", + ephemeral: true + }); + } const collector = channel.createMessageCollector({ filter, time: 60000 }); collector.on("collect", async (m) => { @@ -593,11 +606,12 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { return; } + // šŸ‘‡ Guard: Force users to start their message with "bet" so innocent messages are ignored const args = input.split(" "); - if (args.length !== 2) return; + if (args.length !== 3 || args[0] !== "bet") return; - const betType = args[0]; - const amount = parseInt(args[1]); + const betType = args[1]; + const amount = parseInt(args[2]); if (isNaN(amount) || amount <= 0) return; @@ -607,7 +621,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { if (!validTextBets.includes(betType) && !isValidNumber) return; - // šŸ‘‡ 3. Fetch the profile of the person who TYPED the message (not just the host) const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: m.author.id }, defaults: { guildId: interaction.guildId!, userId: m.author.id, balance: 100 } @@ -623,12 +636,15 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { profile.balance -= amount; await profile.save(); - // Save the bet with their user ID and username + // Save the bet bets.push({ userId: m.author.id, username: m.author.username, type: betType, amount }); m.react("āœ…").catch(() => null); }); collector.on("end", async () => { + // šŸ‘‡ Unlock the channel so a new game can be started + activeRouletteChannels.delete(interaction.channelId); + if (bets.length === 0) { return interaction.followUp("ā³ The table closed because no bets were placed."); } @@ -641,14 +657,17 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const rollParity = roll % 2 === 0 ? "even" : "odd"; const colorEmoji = rollColor === "red" ? "šŸ”“" : "⚫"; - // Track total winnings per user for a clean summary - const playerResults: Record = {}; + // šŸ‘‡ Track total winnings AND total bets for the net profit math + const playerResults: Record = {}; for (const bet of bets) { if (!playerResults[bet.userId]) { - playerResults[bet.userId] = { username: bet.username, totalWon: 0, summary: "" }; + playerResults[bet.userId] = { username: bet.username, totalWon: 0, totalBet: 0, summary: "" }; } + // Accumulate everything they spent + playerResults[bet.userId].totalBet += bet.amount; + let won = false; let multiplier = 0; @@ -665,7 +684,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { } } - // Process payouts and build the final message let finalMessage = `### The wheel landed on **${roll} ${rollColor.toUpperCase()}** ${colorEmoji}!\n\n`; for (const [userId, result] of Object.entries(playerResults)) { @@ -678,15 +696,41 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { await profile.save(); } + // šŸ‘‡ Calculate actual Net Profit + const netProfit = result.totalWon - result.totalBet; + finalMessage += `**${result.username}**:\n${result.summary}`; - if (result.totalWon > 0) { - finalMessage += `šŸ’° *Total Payout: $${result.totalWon}*\n`; + + if (netProfit > 0) { + finalMessage += `šŸ“ˆ *Net Profit: +$${netProfit}*\n`; + } else if (netProfit < 0) { + finalMessage += `šŸ“‰ *Net Loss: -$${Math.abs(netProfit)}*\n`; } else { - finalMessage += `šŸ’ø *Bust!*\n`; + finalMessage += `āš–ļø *Broke Even!*\n`; } finalMessage += `\n`; } await interaction.followUp({ content: finalMessage }); }); +} + +async function seedDefaultShopItems(guildId: string) { + for (const item of config.economy.shopItems) { + await ShopItem.findOrCreate({ + // It searches the DB to see if this specific guild already has an item with this name + where: { guildId: guildId, name: item.name }, + + // If it doesn't exist, it creates it using the data from config.json + defaults: { + guildId: guildId, + itemId: item.itemId, + name: item.name, + price: item.price, + description: item.description, + roleId: item.roleId || null, + stock: item.stock + } + }); + } } \ No newline at end of file From 3adbeefa66c1db9336cac6888fdb65c271880954 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 14:13:12 +0800 Subject: [PATCH 04/34] added gambling restriction so its only allowed in one channel --- config.json.js | 3 ++- src/commands/fun/economy.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/config.json.js b/config.json.js index 47f1aff..92a4bee 100644 --- a/config.json.js +++ b/config.json.js @@ -171,7 +171,8 @@ Consider donating to one of the following people: stock: -1 } ], - teamRole: "1262624821582364703" + teamRole: "1262624821582364703", + gambleChannel: ["1522846518829125642"] }, wikisearch: { baseUrl: "https://amblelabs.dev/wiki", diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index c5f638c..dcd0e4f 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -207,7 +207,16 @@ export default { const sub = interaction.options.getSubcommand(); if (group === "gamble") { - // Route gambling games + if (!config.economy.gambleChannel.includes(interaction.channelId)) { + // Map the IDs into clickable channel links (e.g., #casino) + const allowedList = config.economy.gambleChannel.map(id => `<#${id}>`).join(", "); + + return interaction.reply({ + content: `āŒ The casino is closed here! Gambling commands can only be used in: ${allowedList}`, + ephemeral: true + }); + } + if (sub === "coinflip") await handleGambleCoinflip(interaction); else if (sub === "dice") await handleGambleDice(interaction); else if (sub === "roulette") await handleGambleRoulette(interaction); From 7281631dd821e1f2b6ed25e766e87e62a9002841 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 16:23:59 +0800 Subject: [PATCH 05/34] theo nitpicks --- config.json.js | 12 +- src/commands/fun/economy.ts | 276 +++++++++++++++++++++++++++++++----- 2 files changed, 248 insertions(+), 40 deletions(-) diff --git a/config.json.js b/config.json.js index 92a4bee..58558a6 100644 --- a/config.json.js +++ b/config.json.js @@ -161,15 +161,17 @@ Consider donating to one of the following people: banTag: "1406738115468722257", bypassId: "1257750834150637599" }, - economy:{ - shopItems:[ - { itemId: "super_role", - name: "Beta Access", + economy: { + shopItems: [ + { + itemId: "beta_role", + name: "Beta Access for 7 days", price: 2500, description: "Purchase for access to beta builds!", roleId: "1510652320432521327", + durationDays: 7, stock: -1 - } + }, ], teamRole: "1262624821582364703", gambleChannel: ["1522846518829125642"] diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index dcd0e4f..7ea014c 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -1,10 +1,19 @@ -import {EmbedBuilder, ChatInputCommandInteraction, GuildMember, Message, type TextChannel} from "discord.js"; +import { + EmbedBuilder, + ChatInputCommandInteraction, + GuildMember, + Message, + type TextChannel, + ComponentType, + ButtonStyle, ButtonBuilder, ActionRowBuilder +} from "discord.js"; import { DataTypes, Model, type CreationOptional, type InferAttributes, type InferCreationAttributes, + Op } from "sequelize"; import type { Cmd } from "~/util/base"; import randomUtils from "~/util/rnd"; @@ -46,9 +55,18 @@ export class ShopItem extends Model< declare price: number; declare roleId: CreationOptional; declare stock: CreationOptional; + declare durationDays: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; } + +export class TempRole extends Model, InferCreationAttributes> { + declare id: CreationOptional; + declare guildId: string; + declare userId: string; + declare roleId: string; + declare expiresAt: Date; +} const STARTING_BALANCE = 10 export default { data: { name: "economy" }, @@ -89,12 +107,23 @@ export default { price: { type: DataTypes.INTEGER, allowNull: false }, roleId: { type: DataTypes.STRING, allowNull: true }, stock: { type: DataTypes.INTEGER, defaultValue: -1 }, + durationDays: { type: DataTypes.INTEGER, allowNull: true }, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE, }, { sequelize: ctx.sql } ); + TempRole.init( + { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + guildId: { type: DataTypes.STRING, allowNull: false }, + userId: { type: DataTypes.STRING, allowNull: false }, + roleId: { type: DataTypes.STRING, allowNull: false }, + expiresAt: { type: DataTypes.DATE, allowNull: false }, + }, + { sequelize: ctx.sql } + ); // Establish relationships EconomyProfile.hasMany(Inventory, { foreignKey: "userId", sourceKey: "userId", onDelete: "CASCADE" }); Inventory.belongsTo(EconomyProfile, { foreignKey: "userId", targetKey: "userId" }); @@ -121,6 +150,10 @@ export default { opt.setName("user").setDescription("The user to check").setRequired(false), ), ) + .addSubcommand(sub => sub + .setName("leaderboard") + .setDescription("View the leaderboard") + ) .addSubcommand((sub) => sub.setName("shop").setDescription("View available items for purchase"), ) @@ -206,9 +239,10 @@ export default { const group = interaction.options.getSubcommandGroup(false); const sub = interaction.options.getSubcommand(); + // Handle the entire Gamble Group if (group === "gamble") { + // Guard: Check if the current channel is in our allowed list if (!config.economy.gambleChannel.includes(interaction.channelId)) { - // Map the IDs into clickable channel links (e.g., #casino) const allowedList = config.economy.gambleChannel.map(id => `<#${id}>`).join(", "); return interaction.reply({ @@ -217,24 +251,46 @@ export default { }); } - if (sub === "coinflip") await handleGambleCoinflip(interaction); - else if (sub === "dice") await handleGambleDice(interaction); - else if (sub === "roulette") await handleGambleRoulette(interaction); + // šŸ‘‡ Switch statement for the casino games + switch (sub) { + case "coinflip": + return await handleGambleCoinflip(interaction); + case "dice": + return await handleGambleDice(interaction); + case "roulette": + return await handleGambleRoulette(interaction); + } + return; // Exit here so it doesn't try to run the main commands switch below + } + + // šŸ‘‡ Switch statement for all other base economy commands + switch (sub) { + case "leaderboard": // šŸ‘‡ Add this line + return await handleLeaderboard(interaction); + case "balance": + return await handleBalance(interaction); + case "shop": + return await handleShop(interaction); + case "buy": + return await handleBuy(interaction); + case "inventory": + return await handleInventory(interaction); + case "add-money": + return await handleAddMoney(interaction); + case "set-balance": + return await handleSetBalance(interaction); + case "add-item": + return await handleAddShopItem(interaction); + case "remove-item": + return await handleRemoveShopItem(interaction); } - else if (sub === "balance") await handleBalance(interaction); - else if (sub === "shop") await handleShop(interaction); - else if (sub === "buy") await handleBuy(interaction); - else if (sub === "inventory") await handleInventory(interaction); - else if (sub === "add-money") await handleAddMoney(interaction); - else if (sub === "set-balance") await handleSetBalance(interaction); - else if (sub === "add-item") await handleAddShopItem(interaction); - else if (sub === "remove-item") await handleRemoveShopItem(interaction); }, } as Cmd; // ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── async function handleBalance(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ ephemeral: true }); const targetUser = interaction.options.getUser("user") || interaction.user; const [profile] = await EconomyProfile.findOrCreate({ @@ -248,7 +304,10 @@ async function handleBalance(interaction: ChatInputCommandInteraction) { .setColor(0x00ae86) .setThumbnail(targetUser.displayAvatarURL()); - await interaction.reply({ embeds: [embed] }); + + await interaction.editReply({ + embeds: [embed] + }); } @@ -316,28 +375,47 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { item.stock -= 1; await item.save(); } - + profile.balance -= item.price; if (item.roleId) { if (interaction.member instanceof GuildMember) { try { - // Check if they already have the role so they don't waste money - if (interaction.member.roles.cache.has(item.roleId)) { - return interaction.reply({ - content: `āŒ You already have the role granted by this item!`, - ephemeral: true + // If the item has a duration, track it! + if (item.durationDays) { + const timeToAdd = item.durationDays * 24 * 60 * 60 * 1000; // Convert days to milliseconds + + // Check if they already have an active subscription for this role + let tempRole = await TempRole.findOne({ + where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId } }); + + if (tempRole) { + // If they already have it, ADD the new days to their current expiration date (Stacking!) + tempRole.expiresAt = new Date(tempRole.expiresAt.getTime() + timeToAdd); + await tempRole.save(); + } else { + // Start a brand new subscription + await TempRole.create({ + guildId: interaction.guildId!, + userId: interaction.user.id, + roleId: item.roleId, + expiresAt: new Date(Date.now() + timeToAdd) + }); + } + + await interaction.member.roles.add(item.roleId, `Purchased ${item.durationDays} day pass.`); + roleGrantedMessage = ` and granted you the <@&${item.roleId}> role for **${item.durationDays} days**!`; + + } else { + // Permanent role logic + if (interaction.member.roles.cache.has(item.roleId)) { + return interaction.reply({ content: `āŒ You already have this permanent role!`, ephemeral: true }); + } + await interaction.member.roles.add(item.roleId, `Purchased permanent role.`); + roleGrantedMessage = ` and granted you the <@&${item.roleId}> role permanently!`; } - profile.balance -= item.price; - // Give them the role - await interaction.member.roles.add(item.roleId, `Purchased ${item.name} from the shop.`); - roleGrantedMessage = ` and granted you the <@&${item.roleId}> role`; } catch (error) { - // If the bot's role is lower than the target role, this will fail console.error("Failed to assign shop role:", error); - return interaction.reply({ - content: `āŒ Internal Error: I couldn't assign the role. Please make sure my bot role is positioned ABOVE the shop role in Server Settings.`, - ephemeral: true - }); + return interaction.reply({ content: `āŒ Internal Error: Please make sure my bot role is ABOVE the shop role in Server Settings.`, ephemeral: true }); } } } @@ -394,7 +472,7 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { async function handleAddMoney(interaction: ChatInputCommandInteraction) { // šŸ‘‡ Your exact role-check logic (Replace "1234" with your real Staff role ID) - if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has(config.economy.teamRole)) { + if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ You do not have the required staff role to grant currency.", ephemeral: true @@ -428,7 +506,7 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { } async function handleSetBalance(interaction: ChatInputCommandInteraction) { // Your exact staff role protection check - if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has(config.economy.teamRole)) { + if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); } @@ -453,7 +531,7 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { }); } async function handleAddShopItem(interaction: ChatInputCommandInteraction) { - if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has(config.economy.teamRole)) { + if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); } @@ -489,7 +567,7 @@ async function handleAddShopItem(interaction: ChatInputCommandInteraction) { async function handleRemoveShopItem(interaction: ChatInputCommandInteraction) { // Staff Check - if (interaction.member instanceof GuildMember && !interaction.member.roles.cache.has(config.economy.teamRole)) { + if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); } @@ -728,7 +806,7 @@ async function seedDefaultShopItems(guildId: string) { for (const item of config.economy.shopItems) { await ShopItem.findOrCreate({ // It searches the DB to see if this specific guild already has an item with this name - where: { guildId: guildId, name: item.name }, + where: {guildId: guildId, name: item.name}, // If it doesn't exist, it creates it using the data from config.json defaults: { @@ -738,8 +816,136 @@ async function seedDefaultShopItems(guildId: string) { price: item.price, description: item.description, roleId: item.roleId || null, + durationDays: item.durationDays || null, // šŸ‘‡ Add this stock: item.stock - } + } }); } +} + +async function handleLeaderboard(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + + const PAGE_SIZE = 20; + let currentPage = 1; + + // šŸ‘‡ 1. Get the total number of players to calculate max pages + const totalProfiles = await EconomyProfile.count({ where: { guildId: interaction.guildId! } }); + + if (totalProfiles === 0) { + return interaction.editReply("šŸ“‰ The economy is completely empty. Nobody has any money yet!"); + } + + const maxPage = Math.ceil(totalProfiles / PAGE_SIZE); + + // šŸ‘‡ 2. Helper function to fetch and format a specific page + const generatePage = async (page: number) => { + const offset = (page - 1) * PAGE_SIZE; + + const topProfiles = await EconomyProfile.findAll({ + where: { guildId: interaction.guildId! }, + order: [['balance', 'DESC']], + limit: PAGE_SIZE, + offset: offset + }); + + let description = ""; + + for (let i = 0; i < topProfiles.length; i++) { + const profile = topProfiles[i]; + + // šŸ‘‡ Calculate true rank by counting how many people have MORE money + const higherBalances = await EconomyProfile.count({ + where: { + guildId: interaction.guildId!, + balance: { [Op.gt]: profile.balance } // šŸ‘ˆ Changed to Op.gt + } + }); + const rank = higherBalances + 1; + + let username = "Unknown User"; + + try { + const user = await interaction.client.users.fetch(profile.userId); + username = user.username; + } catch { + username = "*Departed User*"; + } + + let rankEmoji = "šŸ”¹"; + if (rank === 1) rankEmoji = "šŸ„‡"; + else if (rank === 2) rankEmoji = "🄈"; + else if (rank === 3) rankEmoji = "šŸ„‰"; + else rankEmoji = `**#${rank}**`; + + description += `${rankEmoji} ${username} — **$${profile.balance}**\n`; + } + + return new EmbedBuilder() + .setTitle("šŸ† Economy Leaderboard") + .setDescription(description) + .setColor(0xFFD700) + .setFooter({ text: `Page ${page} of ${maxPage} | Total Players: ${totalProfiles}` }); + }; + + // šŸ‘‡ 3. Helper function to generate the Prev/Next buttons + const generateButtons = (page: number) => { + const row = new ActionRowBuilder(); + row.addComponents( + new ButtonBuilder() + .setCustomId('prev_page') + .setLabel('ā—€ Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(page === 1), // Disabled on page 1 + new ButtonBuilder() + .setCustomId('next_page') + .setLabel('Next ā–¶') + .setStyle(ButtonStyle.Primary) + .setDisabled(page === maxPage) // Disabled on the last page + ); + return row; + }; + + // šŸ‘‡ 4. Send the first page + const initialEmbed = await generatePage(currentPage); + + // Only show buttons if there is more than 1 page + const components = maxPage > 1 ? [generateButtons(currentPage)] : []; + + const message = await interaction.editReply({ + embeds: [initialEmbed], + components: components + }); + + if (maxPage <= 1) return; // Exit early if no pagination is needed + + // šŸ‘‡ 5. Create the Button Collector + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 60000 // Buttons stay active for 60 seconds + }); + + collector.on("collect", async (i) => { + // Security check: Only the person who ran the command can click the buttons + await i.deferUpdate(); + if (i.customId === 'prev_page') currentPage--; + if (i.customId === 'next_page') currentPage++; + + const newEmbed = await generatePage(currentPage); + const newButtons = generateButtons(currentPage); + + // Instantly update the message with the new page + await i.editReply({ + embeds: [newEmbed], + components: [newButtons] + }); + }); + + collector.on("end", async () => { + // When the 60 seconds are up, disable the buttons so they don't sit there active forever + const disabledRow = generateButtons(currentPage); + disabledRow.components.forEach(c => c.setDisabled(true)); + + await interaction.editReply({ components: [disabledRow] }).catch(() => null); + }); } \ No newline at end of file From 18eb115dd251c20c2226ec871e6e8d1cac29a035 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 16:28:13 +0800 Subject: [PATCH 06/34] theo nitpicks 2.0 --- src/commands/fun/economy.ts | 87 ++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 7ea014c..d1f7f87 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -240,49 +240,54 @@ export default { const sub = interaction.options.getSubcommand(); // Handle the entire Gamble Group - if (group === "gamble") { - // Guard: Check if the current channel is in our allowed list - if (!config.economy.gambleChannel.includes(interaction.channelId)) { - const allowedList = config.economy.gambleChannel.map(id => `<#${id}>`).join(", "); - - return interaction.reply({ - content: `āŒ The casino is closed here! Gambling commands can only be used in: ${allowedList}`, - ephemeral: true - }); - } + switch (group) { - // šŸ‘‡ Switch statement for the casino games - switch (sub) { - case "coinflip": - return await handleGambleCoinflip(interaction); - case "dice": - return await handleGambleDice(interaction); - case "roulette": - return await handleGambleRoulette(interaction); - } - return; // Exit here so it doesn't try to run the main commands switch below - } + case "gamble": + // Guard: Check if the current channel is in our allowed list + if (!config.economy.gambleChannel.includes(interaction.channelId)) { + const allowedList = config.economy.gambleChannel.map((id: string) => `<#${id}>`).join(", "); - // šŸ‘‡ Switch statement for all other base economy commands - switch (sub) { - case "leaderboard": // šŸ‘‡ Add this line - return await handleLeaderboard(interaction); - case "balance": - return await handleBalance(interaction); - case "shop": - return await handleShop(interaction); - case "buy": - return await handleBuy(interaction); - case "inventory": - return await handleInventory(interaction); - case "add-money": - return await handleAddMoney(interaction); - case "set-balance": - return await handleSetBalance(interaction); - case "add-item": - return await handleAddShopItem(interaction); - case "remove-item": - return await handleRemoveShopItem(interaction); + return interaction.reply({ + content: `āŒ Gambling commands can only be used in ${allowedList}`, + ephemeral: true + }); + } + + // Inner switch for the casino games + switch (sub) { + case "coinflip": + return await handleGambleCoinflip(interaction); + case "dice": + return await handleGambleDice(interaction); + case "roulette": + return await handleGambleRoulette(interaction); + } + return; // Exits the gamble case + + case null: + default: + // šŸ‘‡ Inner switch for all base economy commands (where group is null) + switch (sub) { + case "leaderboard": + return await handleLeaderboard(interaction); + case "balance": + return await handleBalance(interaction); + case "shop": + return await handleShop(interaction); + case "buy": + return await handleBuy(interaction); + case "inventory": + return await handleInventory(interaction); + case "add-money": + return await handleAddMoney(interaction); + case "set-balance": + return await handleSetBalance(interaction); + case "add-item": + return await handleAddShopItem(interaction); + case "remove-item": + return await handleRemoveShopItem(interaction); + } + return; } }, } as Cmd; From 6c5a8c268f38f22ebe282a50e64a91eda074229e Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 18:41:43 +0800 Subject: [PATCH 07/34] theo nitpicks? --- src/commands/fun/economy.ts | 41 +++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index d1f7f87..2db46ec 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -13,7 +13,7 @@ import { type CreationOptional, type InferAttributes, type InferCreationAttributes, - Op + Op, Sequelize } from "sequelize"; import type { Cmd } from "~/util/base"; import randomUtils from "~/util/rnd"; @@ -831,16 +831,15 @@ async function seedDefaultShopItems(guildId: string) { async function handleLeaderboard(interaction: ChatInputCommandInteraction) { await interaction.deferReply(); - const PAGE_SIZE = 20; + const PAGE_SIZE = 10; let currentPage = 1; // šŸ‘‡ 1. Get the total number of players to calculate max pages - const totalProfiles = await EconomyProfile.count({ where: { guildId: interaction.guildId! } }); - + const actualCount = await EconomyProfile.count({ where: { guildId: interaction.guildId! } }); + const totalProfiles = Math.min(actualCount, 100); if (totalProfiles === 0) { return interaction.editReply("šŸ“‰ The economy is completely empty. Nobody has any money yet!"); } - const maxPage = Math.ceil(totalProfiles / PAGE_SIZE); // šŸ‘‡ 2. Helper function to fetch and format a specific page @@ -849,27 +848,21 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { const topProfiles = await EconomyProfile.findAll({ where: { guildId: interaction.guildId! }, + attributes: { + include: [ + [Sequelize.literal('(RANK() OVER (ORDER BY balance DESC))'), 'rank'] + ] + }, order: [['balance', 'DESC']], limit: PAGE_SIZE, offset: offset }); - - let description = ""; - - for (let i = 0; i < topProfiles.length; i++) { - const profile = topProfiles[i]; - - // šŸ‘‡ Calculate true rank by counting how many people have MORE money - const higherBalances = await EconomyProfile.count({ - where: { - guildId: interaction.guildId!, - balance: { [Op.gt]: profile.balance } // šŸ‘ˆ Changed to Op.gt - } - }); - const rank = higherBalances + 1; + // Instead of waiting for User 1, then User 2, we use Promise.all to fetch all 20 concurrently. + const formatPromises = topProfiles.map(async (profile) => { + // Extract the rank that the database calculated for us + const rank = profile.get('rank') as number; let username = "Unknown User"; - try { const user = await interaction.client.users.fetch(profile.userId); username = user.username; @@ -883,8 +876,12 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { else if (rank === 3) rankEmoji = "šŸ„‰"; else rankEmoji = `**#${rank}**`; - description += `${rankEmoji} ${username} — **$${profile.balance}**\n`; - } + return `${rankEmoji} ${username} — **$${profile.balance}**`; + }); + + // Wait for all 20 formatting promises to finish, then join them with newlines + const descriptionLines = await Promise.all(formatPromises); + const description = descriptionLines.join("\n") || "No players found."; return new EmbedBuilder() .setTitle("šŸ† Economy Leaderboard") From 2aa94526c3718e67b950e6c31901dbcc0b4943c9 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 19:13:38 +0800 Subject: [PATCH 08/34] theo nitpicks? --- src/commands/fun/economy.ts | 45 ++++++++++++++----------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 2db46ec..d8a5584 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -295,23 +295,22 @@ export default { // ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── async function handleBalance(interaction: ChatInputCommandInteraction) { - await interaction.deferReply({ ephemeral: true }); + // 1. Check if they pinged a specific user to look at, otherwise default to themselves const targetUser = interaction.options.getUser("user") || interaction.user; - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: targetUser.id }, - defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE } - }); - - const embed = new EmbedBuilder() - .setTitle(`${targetUser.username}'s Vault`) - .setDescription(`šŸ’µ **Balance:** \`$${profile.balance}\``) - .setColor(0x00ae86) - .setThumbnail(targetUser.displayAvatarURL()); - + // šŸ‘‡ 2. Put the line right here! + // We look up the targetUser's ID in this specific server. + const balance = (await EconomyProfile.findOne({ + where: { + guildId: interaction.guildId!, + userId: targetUser.id + } + }))?.balance ?? STARTING_BALANCE; - await interaction.editReply({ - embeds: [embed] + // 3. Send the message back to the chat + await interaction.reply({ + content: `šŸ’° <@${targetUser.id}> currently has **$${balance}**.`, + ephemeral: true }); } @@ -829,7 +828,7 @@ async function seedDefaultShopItems(guildId: string) { } async function handleLeaderboard(interaction: ChatInputCommandInteraction) { - await interaction.deferReply(); + await interaction.deferReply({ ephemeral: true }); const PAGE_SIZE = 10; let currentPage = 1; @@ -858,17 +857,10 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { offset: offset }); // Instead of waiting for User 1, then User 2, we use Promise.all to fetch all 20 concurrently. - const formatPromises = topProfiles.map(async (profile) => { + const descriptionLines = topProfiles.map((profile) => { // Extract the rank that the database calculated for us const rank = profile.get('rank') as number; - - let username = "Unknown User"; - try { - const user = await interaction.client.users.fetch(profile.userId); - username = user.username; - } catch { - username = "*Departed User*"; - } + const userMention = `<@${profile.userId}>`; let rankEmoji = "šŸ”¹"; if (rank === 1) rankEmoji = "šŸ„‡"; @@ -876,11 +868,8 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { else if (rank === 3) rankEmoji = "šŸ„‰"; else rankEmoji = `**#${rank}**`; - return `${rankEmoji} ${username} — **$${profile.balance}**`; + return `${rankEmoji} ${userMention} — **$${profile.balance}**`; }); - - // Wait for all 20 formatting promises to finish, then join them with newlines - const descriptionLines = await Promise.all(formatPromises); const description = descriptionLines.join("\n") || "No players found."; return new EmbedBuilder() From c298749b0a957b7a3d49660f46a4e36b3306d9ce Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 20:33:05 +0800 Subject: [PATCH 09/34] theo nitpicks? --- config.json.js | 5 ++++- src/commands/fun/economy.ts | 22 +++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/config.json.js b/config.json.js index 0eba34e..133cf09 100644 --- a/config.json.js +++ b/config.json.js @@ -162,6 +162,7 @@ Consider donating to one of the following people: bypassId: "1257750834150637599" }, economy: { + currencyFormat: "{0}$", shopItems: [ { itemId: "beta_role", @@ -174,7 +175,9 @@ Consider donating to one of the following people: }, ], teamRole: "1262624821582364703", - gambleChannel: ["1522846518829125642"] + gambleChannel: ["1522846518829125642"], + addMoney: "{3} **Transaction Complete:** Successfully added `{0}` to {1}'s profile. Their new balance is `{2}`.", + coinEmoji:"<:al_logo:1492686347666980944>" }, swear: { period: 60 * 1000, diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index d8a5584..adf6594 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -16,6 +16,7 @@ import { Op, Sequelize } from "sequelize"; import type { Cmd } from "~/util/base"; +import { format } from "~/util/base"; import randomUtils from "~/util/rnd"; import config from "config.json"; @@ -475,7 +476,7 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { } async function handleAddMoney(interaction: ChatInputCommandInteraction) { - // šŸ‘‡ Your exact role-check logic (Replace "1234" with your real Staff role ID) + // Check for the staff role if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { return interaction.reply({ content: "āŒ You do not have the required staff role to grant currency.", @@ -486,7 +487,7 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); - // Prevent staff from entering negative and/or too big numbers to steal money + // Prevent staff from entering negative and/or too big numbers if (amount <= 0 || amount > 1000000) { return interaction.reply({ content: "āŒ Please use an integer smaller than 1,000,000 and bigger than 0", @@ -504,9 +505,20 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { profile.balance += amount; await profile.save(); - await interaction.reply({ - content: `šŸŖ™ **Transaction Complete:** Successfully added \`$${amount}\` to ${targetUser.username}'s profile. Their new balance is \`$${profile.balance}\`.`, - }); + // 1. Format the money amounts (Outputs: "100$") + const formattedAmount = format(config.economy.currencyFormat, amount); + const formattedBalance = format(config.economy.currencyFormat, profile.balance); + + // 2. Inject those strings and the username into your config message + const replyMessage = format( + config.economy.addMoney, // šŸ‘ˆ Fixed this path to match your config structure! + formattedAmount, // Becomes {0} + targetUser.username, // Becomes {1} + formattedBalance, + config.economy.coinEmoji + ); + + await interaction.reply(replyMessage); } async function handleSetBalance(interaction: ChatInputCommandInteraction) { // Your exact staff role protection check From 242ce2d53865c0aec09d53b3a4cab5cb0022c4e0 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 21:32:21 +0800 Subject: [PATCH 10/34] just in case i do a monke and deleted lal of it --- config.json.js | 2 +- src/commands/fun/economy.ts | 302 ++++++++++++++++++++++-------------- 2 files changed, 185 insertions(+), 119 deletions(-) diff --git a/config.json.js b/config.json.js index 133cf09..d2078c2 100644 --- a/config.json.js +++ b/config.json.js @@ -174,7 +174,7 @@ Consider donating to one of the following people: stock: -1 }, ], - teamRole: "1262624821582364703", + teamRole: ["1262624821582364703"], gambleChannel: ["1522846518829125642"], addMoney: "{3} **Transaction Complete:** Successfully added `{0}` to {1}'s profile. Their new balance is `{2}`.", coinEmoji:"<:al_logo:1492686347666980944>" diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index adf6594..f85ac1b 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -229,6 +229,13 @@ export default { sub .setName("roulette") .setDescription("Open a roulette table and place multiple bets! (1-24, Red/Black, Even/Odd)") + .addIntegerOption(option => + option.setName("seconds") + .setDescription("How many seconds should the table stay open? (Default: 60)") + .setRequired(false) + .setMinValue(15) // Give people at least 15 seconds to bet! + .setMaxValue(1800) // Max 30 minutes (1800 seconds) + ) ) ); @@ -236,7 +243,7 @@ export default { onInteraction: async (ctx, interaction) => { if (!interaction.isChatInputCommand()) return; - + await interaction.deferReply(); const group = interaction.options.getSubcommandGroup(false); const sub = interaction.options.getSubcommand(); @@ -244,8 +251,13 @@ export default { switch (group) { case "gamble": - // Guard: Check if the current channel is in our allowed list - if (!config.economy.gambleChannel.includes(interaction.channelId)) { + const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => + interaction.member.roles.cache.has(roleId) + ); + const isExplicitAdmin = interaction.inCachedGuild() && interaction.member.permissions.has("Administrator"); + + // ā›” Guard: Block them only if they lack a bypass role AND aren't an Admin AND are in the wrong channel + if (!hasBypassRole && !isExplicitAdmin && !config.economy.gambleChannel.includes(interaction.channelId)) { const allowedList = config.economy.gambleChannel.map((id: string) => `<#${id}>`).join(", "); return interaction.reply({ @@ -477,9 +489,13 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { async function handleAddMoney(interaction: ChatInputCommandInteraction) { // Check for the staff role - if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => + interaction.member.roles.cache.has(roleId) + ); + + if (!isStaff) { return interaction.reply({ - content: "āŒ You do not have the required staff role to grant currency.", + content: "āŒ You do not have a required staff role to use this command.", ephemeral: true }); } @@ -490,7 +506,7 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { // Prevent staff from entering negative and/or too big numbers if (amount <= 0 || amount > 1000000) { return interaction.reply({ - content: "āŒ Please use an integer smaller than 1,000,000 and bigger than 0", + content: "āŒ Please use an integer smaller than or equal to 1,000,000 and bigger than 0", ephemeral: true }); } @@ -522,8 +538,15 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { } async function handleSetBalance(interaction: ChatInputCommandInteraction) { // Your exact staff role protection check - if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { - return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => + interaction.member.roles.cache.has(roleId) + ); + + if (!isStaff) { + return interaction.reply({ + content: "āŒ You do not have a required staff role to use this command.", + ephemeral: true + }); } const targetUser = interaction.options.getUser("user", true); @@ -547,8 +570,15 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { }); } async function handleAddShopItem(interaction: ChatInputCommandInteraction) { - if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { - return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => + interaction.member.roles.cache.has(roleId) + ); + + if (!isStaff) { + return interaction.reply({ + content: "āŒ You do not have a required staff role to use this command.", + ephemeral: true + }); } const itemId = interaction.options.getString("id", true).toLowerCase(); @@ -583,8 +613,15 @@ async function handleAddShopItem(interaction: ChatInputCommandInteraction) { async function handleRemoveShopItem(interaction: ChatInputCommandInteraction) { // Staff Check - if (!interaction.inCachedGuild() || !interaction.member.roles.cache.has(config.economy.teamRole)) { - return interaction.reply({ content: "āŒ Staff only.", ephemeral: true }); + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => + interaction.member.roles.cache.has(roleId) + ); + + if (!isStaff) { + return interaction.reply({ + content: "āŒ You do not have a required staff role to use this command.", + ephemeral: true + }); } const itemId = interaction.options.getString("id", true).toLowerCase(); @@ -668,153 +705,182 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { } } -// šŸ‘‡ Track which channels currently have an active game running -const activeRouletteChannels = new Set(); +interface RouletteBet { + userId: string; + username: string; + amount: number; + betType: "red" | "black" | "even" | "odd"; +} async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { - // šŸ‘‡ Guard: Prevent multiple tables in the same channel - if (activeRouletteChannels.has(interaction.channelId)) { - return interaction.reply({ - content: "āŒ There is already an active roulette table in this channel! Please wait for the current spin to finish.", - ephemeral: true - }); - } + // āŒ REMOVED interaction.deferReply() from here since it's now handled at the entry point - // Lock the channel - activeRouletteChannels.add(interaction.channelId); + // ā±ļø Get the custom time in seconds, or default to 60 seconds + const customSeconds = interaction.options.getInteger("seconds") || 60; + const timeMs = customSeconds * 1000; - await interaction.reply({ - content: "šŸŽ” **MULTIPLAYER ROULETTE IS OPEN!**\n\n" + - "**Anyone** can jump in! Valid bets: `red`, `black`, `even`, `odd`, or a number `1` through `24`.\n" + - "**How to bet:** Type `bet ` (e.g., `bet red 50`, `bet 14 100`).\n" + - "**When ready:** Anyone can type `spin` to roll the wheel! (Auto-spins in 60s)." + // šŸŽ° Edit the existing deferred reply safely + const initialReply = await interaction.editReply({ + content: `šŸŽ° **${interaction.user.username}** opened a Roulette Table for **${customSeconds} seconds**! Join the thread below to place your bets.` }); - const bets: { userId: string; username: string; type: string; amount: number }[] = []; - const filter = (m: Message) => !m.author.bot; - const channel = interaction.channel as TextChannel; - if (!interaction.channel || !interaction.channel.isTextBased()) { - return interaction.reply({ - content: "āŒ This command can only be played in standard text channels!", - ephemeral: true - }); - } - const collector = channel.createMessageCollector({ filter, time: 60000 }); + const thread = await initialReply.startThread({ + name: `šŸŽ° Roulette Table - ${interaction.user.username}`, + autoArchiveDuration: 60, + reason: "Roulette Game Room" + }); + + const bets: RouletteBet[] = []; + + // Mention the time limit in seconds + await thread.send( + `šŸŽ” **Roulette Table Opened!** (Closes in ${customSeconds} seconds)\n\n` + + `To enter, type your bet choice followed by your amount. **Example: \`red 250\`**\n` + + `• \`red \` (2x payout)\n` + + `• \`black \` (2x payout)\n` + + `• \`even \` (2x payout)\n` + + `• \`odd \` (2x payout)\n\n` + + `šŸ‘ _The bot will react with āœ… if your bet is accepted, or āŒ if something is wrong._\n` + + `šŸ‘‘ **<@${interaction.user.id}>**, type \`spin\` when everyone is ready!` + ); - collector.on("collect", async (m) => { - const input = m.content.toLowerCase().trim(); + // ā±ļø Plug the dynamic millisecond timer into the collector + const collector = thread.createMessageCollector({ + filter: (m) => !m.author.bot, + time: timeMs + }); + + collector.on("collect", async (message) => { + const args = message.content.trim().toLowerCase().split(/\s+/); + const commandOrType = args[0]; + + if (commandOrType === "spin") { + if (message.author.id !== interaction.user.id) { + return void await message.react("āŒ"); + } + if (bets.length === 0) { + return void await message.react("āŒ"); + } - if (input === "spin") { - collector.stop("user_spun"); + collector.stop("spun"); return; } - // šŸ‘‡ Guard: Force users to start their message with "bet" so innocent messages are ignored - const args = input.split(" "); - if (args.length !== 3 || args[0] !== "bet") return; - - const betType = args[1]; - const amount = parseInt(args[2]); + const validBetTypes = ["red", "black", "even", "odd"]; + if (validBetTypes.includes(commandOrType)) { + const amountStr = args[1]; - if (isNaN(amount) || amount <= 0) return; + if (!amountStr) return void await message.react("āŒ"); - const validTextBets = ["red", "black", "even", "odd"]; - const betNumber = parseInt(betType); - const isValidNumber = !isNaN(betNumber) && betNumber >= 1 && betNumber <= 24; + const amount = parseInt(amountStr, 10); + if (isNaN(amount) || amount <= 0) return void await message.react("āŒ"); - if (!validTextBets.includes(betType) && !isValidNumber) return; + const [profile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: message.author.id }, + defaults: { guildId: interaction.guildId!, userId: message.author.id, balance: STARTING_BALANCE } + }); - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: m.author.id }, - defaults: { guildId: interaction.guildId!, userId: m.author.id, balance: 100 } - }); + if (profile.balance < amount) return void await message.react("āŒ"); - if (profile.balance < amount) { - const errorMsg = await m.reply(`āŒ You only have \`$${profile.balance}\`.`); - setTimeout(() => errorMsg.delete().catch(() => null), 3000); - return; - } + profile.balance -= amount; + await profile.save(); - // Deduct money instantly - profile.balance -= amount; - await profile.save(); + bets.push({ + userId: message.author.id, + username: message.author.username, + amount: amount, + betType: commandOrType as any + }); - // Save the bet - bets.push({ userId: m.author.id, username: m.author.username, type: betType, amount }); - m.react("āœ…").catch(() => null); + await message.react("āœ…"); + } }); - collector.on("end", async () => { - // šŸ‘‡ Unlock the channel so a new game can be started - activeRouletteChannels.delete(interaction.channelId); - - if (bets.length === 0) { - return interaction.followUp("ā³ The table closed because no bets were placed."); + collector.on("end", async (_, reason) => { + if (reason !== "spun") { + await thread.send("ā° Table closed automatically due to inactivity."); + for (const bet of bets) { + const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); + if (profile) { + profile.balance += bet.amount; + await profile.save(); + } + } + await thread.setLocked(true); + await thread.setArchived(true); + return; } - await interaction.followUp("šŸŽ” **NO MORE BETS!** Spinning the wheel..."); + const winningNumber = Math.floor(Math.random() * 37); + const redNumbers = [1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]; - const roll = randomUtils.getRandomIntInclusive(1, 24); - const redNumbers = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23]; - const rollColor = redNumbers.includes(roll) ? "red" : "black"; - const rollParity = roll % 2 === 0 ? "even" : "odd"; - const colorEmoji = rollColor === "red" ? "šŸ”“" : "⚫"; + let color: "green" | "red" | "black" = "green"; + if (winningNumber > 0) { + color = redNumbers.includes(winningNumber) ? "red" : "black"; + } - // šŸ‘‡ Track total winnings AND total bets for the net profit math - const playerResults: Record = {}; + const isEven = winningNumber > 0 && winningNumber % 2 === 0; + const isOdd = winningNumber > 0 && winningNumber % 2 !== 0; - for (const bet of bets) { - if (!playerResults[bet.userId]) { - playerResults[bet.userId] = { username: bet.username, totalWon: 0, totalBet: 0, summary: "" }; - } + await thread.send("✨ *The wheel is spinning...* ✨"); - // Accumulate everything they spent - playerResults[bet.userId].totalBet += bet.amount; + const userBreakdowns = new Map(); + const userNetTotals = new Map(); + for (const bet of bets) { let won = false; - let multiplier = 0; - if (bet.type === rollColor) { won = true; multiplier = 2; } - else if (bet.type === rollParity) { won = true; multiplier = 2; } - else if (!isNaN(parseInt(bet.type)) && parseInt(bet.type) === roll) { won = true; multiplier = 24; } + if (bet.betType === "red" && color === "red") won = true; + if (bet.betType === "black" && color === "black") won = true; + if (bet.betType === "even" && isEven) won = true; + if (bet.betType === "odd" && isOdd) won = true; - if (won) { - const winAmount = bet.amount * multiplier; - playerResults[bet.userId].totalWon += winAmount; - playerResults[bet.userId].summary += `āœ… \`${bet.type}\`: Won **$${winAmount}**\n`; - } else { - playerResults[bet.userId].summary += `āŒ \`${bet.type}\`: Lost\n`; - } - } + const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); - let finalMessage = `### The wheel landed on **${roll} ${rollColor.toUpperCase()}** ${colorEmoji}!\n\n`; + const currentNet = userNetTotals.get(bet.userId) ?? 0; + if (!userBreakdowns.has(bet.userId)) { + userBreakdowns.set(bet.userId, []); + } - for (const [userId, result] of Object.entries(playerResults)) { - if (result.totalWon > 0) { - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: userId } - }); + const formattedBetAmount = format(config.economy.currencyFormat, bet.amount); - profile.balance += result.totalWon; + if (won && profile) { + const winnings = bet.amount * 2; + profile.balance += winnings; await profile.save(); + + const formattedWinnings = format(config.economy.currencyFormat, winnings); + userBreakdowns.get(bet.userId)!.push(`${bet.betType}: Won ${formattedWinnings}`); + userNetTotals.set(bet.userId, currentNet + bet.amount); + } else { + userBreakdowns.get(bet.userId)!.push(`${bet.betType}: Lost ${formattedBetAmount}`); + userNetTotals.set(bet.userId, currentNet - bet.amount); } + } - // šŸ‘‡ Calculate actual Net Profit - const netProfit = result.totalWon - result.totalBet; + const emoji = color === "red" ? "šŸ”“" : color === "black" ? "⚫" : "🟢"; + let outputMessage = `šŸ **The wheel landed on ${winningNumber} ${color.toUpperCase()} ${emoji} !**\n\n`; - finalMessage += `**${result.username}**:\n${result.summary}`; + for (const [userId, breakdownArray] of userBreakdowns.entries()) { + const member = await thread.guild.members.fetch(userId).catch(() => null); + const displayName = member ? member.displayName : `User(${userId})`; - if (netProfit > 0) { - finalMessage += `šŸ“ˆ *Net Profit: +$${netProfit}*\n`; - } else if (netProfit < 0) { - finalMessage += `šŸ“‰ *Net Loss: -$${Math.abs(netProfit)}*\n`; - } else { - finalMessage += `āš–ļø *Broke Even!*\n`; + const netValue = userNetTotals.get(userId) ?? 0; + let netStatus = "Broke Even!"; + + if (netValue > 0) { + netStatus = `Won Net ${format(config.economy.currencyFormat, netValue)}!`; + } else if (netValue < 0) { + netStatus = `Lost Net ${format(config.economy.currencyFormat, Math.abs(netValue))}!`; } - finalMessage += `\n`; + + const betHistoryStr = breakdownArray.join(" "); + outputMessage += `**${displayName}**: ${betHistoryStr} | **${netStatus}**\n`; } - await interaction.followUp({ content: finalMessage }); + await thread.send(outputMessage); + await thread.setLocked(true); + await thread.setArchived(true); }); } From b8d13d6999f8c2dc42d2468369253450e3dbccb2 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 4 Jul 2026 22:05:21 +0800 Subject: [PATCH 11/34] removed comments --- src/commands/fun/economy.ts | 93 +++++++++---------------------------- 1 file changed, 22 insertions(+), 71 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index f85ac1b..63d97bc 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -2,8 +2,6 @@ import { EmbedBuilder, ChatInputCommandInteraction, GuildMember, - Message, - type TextChannel, ComponentType, ButtonStyle, ButtonBuilder, ActionRowBuilder } from "discord.js"; @@ -68,7 +66,8 @@ export class TempRole extends Model, InferCreationAttr declare roleId: string; declare expiresAt: Date; } -const STARTING_BALANCE = 10 +const STARTING_BALANCE = 10; + export default { data: { name: "economy" }, @@ -201,7 +200,6 @@ export default { .addRoleOption((opt) => opt.setName("role").setDescription("Optional: A role to give upon purchase").setRequired(false)) .addIntegerOption((opt) => opt.setName("stock").setDescription("Amount available (leave blank for infinite stock)").setRequired(false)) ) - // šŸ‘‡ Admin Command: Remove Shop Item .addSubcommand((sub) => sub .setName("remove-item") @@ -233,30 +231,26 @@ export default { option.setName("seconds") .setDescription("How many seconds should the table stay open? (Default: 60)") .setRequired(false) - .setMinValue(15) // Give people at least 15 seconds to bet! - .setMaxValue(1800) // Max 30 minutes (1800 seconds) + .setMinValue(15) + .setMaxValue(1800) ) ) ); - }, onInteraction: async (ctx, interaction) => { if (!interaction.isChatInputCommand()) return; - await interaction.deferReply(); const group = interaction.options.getSubcommandGroup(false); const sub = interaction.options.getSubcommand(); // Handle the entire Gamble Group switch (group) { - case "gamble": const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) ); const isExplicitAdmin = interaction.inCachedGuild() && interaction.member.permissions.has("Administrator"); - // ā›” Guard: Block them only if they lack a bypass role AND aren't an Admin AND are in the wrong channel if (!hasBypassRole && !isExplicitAdmin && !config.economy.gambleChannel.includes(interaction.channelId)) { const allowedList = config.economy.gambleChannel.map((id: string) => `<#${id}>`).join(", "); @@ -266,7 +260,6 @@ export default { }); } - // Inner switch for the casino games switch (sub) { case "coinflip": return await handleGambleCoinflip(interaction); @@ -275,11 +268,10 @@ export default { case "roulette": return await handleGambleRoulette(interaction); } - return; // Exits the gamble case + return; case null: default: - // šŸ‘‡ Inner switch for all base economy commands (where group is null) switch (sub) { case "leaderboard": return await handleLeaderboard(interaction); @@ -308,11 +300,8 @@ export default { // ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── async function handleBalance(interaction: ChatInputCommandInteraction) { - // 1. Check if they pinged a specific user to look at, otherwise default to themselves const targetUser = interaction.options.getUser("user") || interaction.user; - // šŸ‘‡ 2. Put the line right here! - // We look up the targetUser's ID in this specific server. const balance = (await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, @@ -320,14 +309,12 @@ async function handleBalance(interaction: ChatInputCommandInteraction) { } }))?.balance ?? STARTING_BALANCE; - // 3. Send the message back to the chat await interaction.reply({ content: `šŸ’° <@${targetUser.id}> currently has **$${balance}**.`, ephemeral: true }); } - async function handleShop(interaction: ChatInputCommandInteraction) { const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } @@ -342,7 +329,6 @@ async function handleShop(interaction: ChatInputCommandInteraction) { embed.setDescription("The shop is currently empty. Admins need to add items!"); } else { for (const item of items) { - // šŸ‘‡ Determine if it says "āˆž" or a specific number, or "OUT OF STOCK" let stockDisplay = item.stock === -1 ? "āˆž" : item.stock.toString(); if (item.stock === 0) stockDisplay = "āŒ OUT OF STOCK"; @@ -386,8 +372,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { let roleGrantedMessage = ""; - // ─── RESUME NORMAL INVENTORY & BALANCE SAVING ─────────────────────── - if (item.stock > 0) { item.stock -= 1; await item.save(); @@ -396,21 +380,17 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { if (item.roleId) { if (interaction.member instanceof GuildMember) { try { - // If the item has a duration, track it! if (item.durationDays) { - const timeToAdd = item.durationDays * 24 * 60 * 60 * 1000; // Convert days to milliseconds + const timeToAdd = item.durationDays * 24 * 60 * 60 * 1000; - // Check if they already have an active subscription for this role let tempRole = await TempRole.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId } }); if (tempRole) { - // If they already have it, ADD the new days to their current expiration date (Stacking!) tempRole.expiresAt = new Date(tempRole.expiresAt.getTime() + timeToAdd); await tempRole.save(); } else { - // Start a brand new subscription await TempRole.create({ guildId: interaction.guildId!, userId: interaction.user.id, @@ -423,7 +403,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { roleGrantedMessage = ` and granted you the <@&${item.roleId}> role for **${item.durationDays} days**!`; } else { - // Permanent role logic if (interaction.member.roles.cache.has(item.roleId)) { return interaction.reply({ content: `āŒ You already have this permanent role!`, ephemeral: true }); } @@ -438,7 +417,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } await profile.save(); - // Add item to inventory database const [invItem, created] = await Inventory.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: 1 } @@ -464,12 +442,10 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { return; } - // šŸ‘‡ Fetch all shop items from the DB to figure out their display names const allShopItems = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); - // šŸ‘‡ Map database entries to their descriptive shop names dynamically const itemManifest = Object.fromEntries(allShopItems.map((i) => [i.itemId, i.name])); const inventoryList = items @@ -488,7 +464,6 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { } async function handleAddMoney(interaction: ChatInputCommandInteraction) { - // Check for the staff role const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) ); @@ -503,7 +478,6 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); - // Prevent staff from entering negative and/or too big numbers if (amount <= 0 || amount > 1000000) { return interaction.reply({ content: "āŒ Please use an integer smaller than or equal to 1,000,000 and bigger than 0", @@ -511,33 +485,29 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { }); } - // Fetch their profile, or create it if they've never interacted with the economy system const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: targetUser.id }, defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: 100 } }); - // Credit the money and save back to the database profile.balance += amount; await profile.save(); - // 1. Format the money amounts (Outputs: "100$") const formattedAmount = format(config.economy.currencyFormat, amount); const formattedBalance = format(config.economy.currencyFormat, profile.balance); - // 2. Inject those strings and the username into your config message const replyMessage = format( - config.economy.addMoney, // šŸ‘ˆ Fixed this path to match your config structure! - formattedAmount, // Becomes {0} - targetUser.username, // Becomes {1} + config.economy.addMoney, + formattedAmount, + targetUser.username, formattedBalance, config.economy.coinEmoji ); await interaction.reply(replyMessage); } + async function handleSetBalance(interaction: ChatInputCommandInteraction) { - // Your exact staff role protection check const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) ); @@ -556,7 +526,6 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { return interaction.reply({ content: "āŒ Invalid amount range (0 to 2B).", ephemeral: true }); } - // Update or insert into the database const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: targetUser.id }, defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: amount } @@ -569,6 +538,7 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { content: `āš™ļø **Database Updated:** ${targetUser.username}'s balance has been explicitly set to \`$${amount}\`.`, }); } + async function handleAddShopItem(interaction: ChatInputCommandInteraction) { const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) @@ -586,7 +556,7 @@ async function handleAddShopItem(interaction: ChatInputCommandInteraction) { const price = interaction.options.getInteger("price", true); const description = interaction.options.getString("description", true); const role = interaction.options.getRole("role", false); - const stock = interaction.options.getInteger("stock") ?? -1; // šŸ‘‡ Grab the stock, default to -1 + const stock = interaction.options.getInteger("stock") ?? -1; if (price < 0) return interaction.reply({ content: "āŒ Price cannot be negative.", ephemeral: true }); @@ -599,7 +569,7 @@ async function handleAddShopItem(interaction: ChatInputCommandInteraction) { price: price, description: description, roleId: role?.id || null, - stock: stock // šŸ‘‡ Save the stock to the DB + stock: stock } }); @@ -612,7 +582,6 @@ async function handleAddShopItem(interaction: ChatInputCommandInteraction) { } async function handleRemoveShopItem(interaction: ChatInputCommandInteraction) { - // Staff Check const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) ); @@ -652,7 +621,6 @@ async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { }); } - // šŸ‘‡ Use your pickRandom utility to pull a random boolean from an array const isWinner = randomUtils.pickRandom([true, false]); if (isWinner) { @@ -686,7 +654,6 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { }); } - // šŸ‘‡ Use your getRandomIntInclusive utility for a perfect 1-6 roll const diceRoll = randomUtils.getRandomIntInclusive(1, 6); if (guess === diceRoll) { @@ -713,13 +680,12 @@ interface RouletteBet { } async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { - // āŒ REMOVED interaction.deferReply() from here since it's now handled at the entry point + // ā±ļø Defer here directly since roulette takes time to process threads and is a public game + await interaction.deferReply(); - // ā±ļø Get the custom time in seconds, or default to 60 seconds const customSeconds = interaction.options.getInteger("seconds") || 60; const timeMs = customSeconds * 1000; - // šŸŽ° Edit the existing deferred reply safely const initialReply = await interaction.editReply({ content: `šŸŽ° **${interaction.user.username}** opened a Roulette Table for **${customSeconds} seconds**! Join the thread below to place your bets.` }); @@ -732,7 +698,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const bets: RouletteBet[] = []; - // Mention the time limit in seconds await thread.send( `šŸŽ” **Roulette Table Opened!** (Closes in ${customSeconds} seconds)\n\n` + `To enter, type your bet choice followed by your amount. **Example: \`red 250\`**\n` + @@ -744,7 +709,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { `šŸ‘‘ **<@${interaction.user.id}>**, type \`spin\` when everyone is ready!` ); - // ā±ļø Plug the dynamic millisecond timer into the collector const collector = thread.createMessageCollector({ filter: (m) => !m.author.bot, time: timeMs @@ -887,10 +851,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { async function seedDefaultShopItems(guildId: string) { for (const item of config.economy.shopItems) { await ShopItem.findOrCreate({ - // It searches the DB to see if this specific guild already has an item with this name where: {guildId: guildId, name: item.name}, - - // If it doesn't exist, it creates it using the data from config.json defaults: { guildId: guildId, itemId: item.itemId, @@ -898,7 +859,7 @@ async function seedDefaultShopItems(guildId: string) { price: item.price, description: item.description, roleId: item.roleId || null, - durationDays: item.durationDays || null, // šŸ‘‡ Add this + durationDays: item.durationDays || null, stock: item.stock } }); @@ -906,12 +867,12 @@ async function seedDefaultShopItems(guildId: string) { } async function handleLeaderboard(interaction: ChatInputCommandInteraction) { + // ā±ļø Defer explicitly here as ephemeral since leaderboard is highly customized await interaction.deferReply({ ephemeral: true }); const PAGE_SIZE = 10; let currentPage = 1; - // šŸ‘‡ 1. Get the total number of players to calculate max pages const actualCount = await EconomyProfile.count({ where: { guildId: interaction.guildId! } }); const totalProfiles = Math.min(actualCount, 100); if (totalProfiles === 0) { @@ -919,7 +880,6 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { } const maxPage = Math.ceil(totalProfiles / PAGE_SIZE); - // šŸ‘‡ 2. Helper function to fetch and format a specific page const generatePage = async (page: number) => { const offset = (page - 1) * PAGE_SIZE; @@ -934,9 +894,8 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { limit: PAGE_SIZE, offset: offset }); - // Instead of waiting for User 1, then User 2, we use Promise.all to fetch all 20 concurrently. + const descriptionLines = topProfiles.map((profile) => { - // Extract the rank that the database calculated for us const rank = profile.get('rank') as number; const userMention = `<@${profile.userId}>`; @@ -957,7 +916,6 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { .setFooter({ text: `Page ${page} of ${maxPage} | Total Players: ${totalProfiles}` }); }; - // šŸ‘‡ 3. Helper function to generate the Prev/Next buttons const generateButtons = (page: number) => { const row = new ActionRowBuilder(); row.addComponents( @@ -965,20 +923,17 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { .setCustomId('prev_page') .setLabel('ā—€ Previous') .setStyle(ButtonStyle.Primary) - .setDisabled(page === 1), // Disabled on page 1 + .setDisabled(page === 1), new ButtonBuilder() .setCustomId('next_page') .setLabel('Next ā–¶') .setStyle(ButtonStyle.Primary) - .setDisabled(page === maxPage) // Disabled on the last page + .setDisabled(page === maxPage) ); return row; }; - // šŸ‘‡ 4. Send the first page const initialEmbed = await generatePage(currentPage); - - // Only show buttons if there is more than 1 page const components = maxPage > 1 ? [generateButtons(currentPage)] : []; const message = await interaction.editReply({ @@ -986,16 +941,14 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { components: components }); - if (maxPage <= 1) return; // Exit early if no pagination is needed + if (maxPage <= 1) return; - // šŸ‘‡ 5. Create the Button Collector const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, - time: 60000 // Buttons stay active for 60 seconds + time: 60000 }); collector.on("collect", async (i) => { - // Security check: Only the person who ran the command can click the buttons await i.deferUpdate(); if (i.customId === 'prev_page') currentPage--; if (i.customId === 'next_page') currentPage++; @@ -1003,7 +956,6 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { const newEmbed = await generatePage(currentPage); const newButtons = generateButtons(currentPage); - // Instantly update the message with the new page await i.editReply({ embeds: [newEmbed], components: [newButtons] @@ -1011,7 +963,6 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { }); collector.on("end", async () => { - // When the 60 seconds are up, disable the buttons so they don't sit there active forever const disabledRow = generateButtons(currentPage); disabledRow.components.forEach(c => c.setDisabled(true)); From e3e528aac3d5600cffb2919845fa5f0332aa6a2e Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sun, 5 Jul 2026 00:21:34 +0800 Subject: [PATCH 12/34] fixed constant crashing --- src/commands/fun/economy.ts | 504 +++++++++++------------------------- 1 file changed, 148 insertions(+), 356 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 63d97bc..1af64a7 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -2,8 +2,13 @@ import { EmbedBuilder, ChatInputCommandInteraction, GuildMember, + Message, + type TextChannel, ComponentType, - ButtonStyle, ButtonBuilder, ActionRowBuilder + ButtonStyle, + ButtonBuilder, + ActionRowBuilder, + MessageFlags // Required for modern ephemeral responses } from "discord.js"; import { DataTypes, @@ -72,7 +77,6 @@ export default { data: { name: "economy" }, setup: async (ctx) => { - // Initialize Economy Profiles (Composite Primary Key of Guild + User) EconomyProfile.init( { guildId: { type: DataTypes.STRING, primaryKey: true }, @@ -84,7 +88,6 @@ export default { { sequelize: ctx.sql }, ); - // Initialize Inventory Tracking Inventory.init( { id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, @@ -101,7 +104,7 @@ export default { ShopItem.init( { guildId: { type: DataTypes.STRING, primaryKey: true }, - itemId: { type: DataTypes.STRING, primaryKey: true }, // The ID users type to buy + itemId: { type: DataTypes.STRING, primaryKey: true }, name: { type: DataTypes.STRING, allowNull: false }, description: { type: DataTypes.STRING, allowNull: false }, price: { type: DataTypes.INTEGER, allowNull: false }, @@ -124,16 +127,14 @@ export default { }, { sequelize: ctx.sql } ); - // Establish relationships + EconomyProfile.hasMany(Inventory, { foreignKey: "userId", sourceKey: "userId", onDelete: "CASCADE" }); Inventory.belongsTo(EconomyProfile, { foreignKey: "userId", targetKey: "userId" }); await ctx.sql.sync(); const guilds = ctx.client.guilds.cache; - for (const [guildId, guild] of guilds) { - // 3. Seed the default items for each server! await seedDefaultShopItems(guildId); } }, @@ -146,41 +147,23 @@ export default { sub .setName("balance") .setDescription("Check your current balance or another user's balance") - .addUserOption((opt) => - opt.setName("user").setDescription("The user to check").setRequired(false), - ), - ) - .addSubcommand(sub => sub - .setName("leaderboard") - .setDescription("View the leaderboard") - ) - .addSubcommand((sub) => - sub.setName("shop").setDescription("View available items for purchase"), + .addUserOption((opt) => opt.setName("user").setDescription("The user to check").setRequired(false)), ) + .addSubcommand(sub => sub.setName("leaderboard").setDescription("View the leaderboard")) + .addSubcommand((sub) => sub.setName("shop").setDescription("View available items for purchase")) .addSubcommand((sub) => sub .setName("buy") .setDescription("Purchase an item from the shop") - .addStringOption((opt) => - opt - .setName("item") - .setDescription("The ID of the item you want to buy (e.g. 'vip_role')") - .setRequired(true) - ) - ) - .addSubcommand((sub) => - sub.setName("inventory").setDescription("View items you currently own"), + .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to buy (e.g. 'vip_role')").setRequired(true)) ) + .addSubcommand((sub) => sub.setName("inventory").setDescription("View items you currently own")) .addSubcommand((sub) => sub .setName("add-money") .setDescription("Add money to a user's balance (Admin/Staff Only)") - .addUserOption((opt) => - opt.setName("user").setDescription("The user receiving the money").setRequired(true), - ) - .addIntegerOption((opt) => - opt.setName("amount").setDescription("The amount of money to add").setRequired(true), - ), + .addUserOption((opt) => opt.setName("user").setDescription("The user receiving the money").setRequired(true)) + .addIntegerOption((opt) => opt.setName("amount").setDescription("The amount of money to add").setRequired(true)), ) .addSubcommand((sub) => sub @@ -240,12 +223,31 @@ export default { onInteraction: async (ctx, interaction) => { if (!interaction.isChatInputCommand()) return; + + const sub = interaction.options.getSubcommand(false); const group = interaction.options.getSubcommandGroup(false); - const sub = interaction.options.getSubcommand(); - // Handle the entire Gamble Group + // 1. Identify which commands should be public in the chat + const publicCommands = ["coinflip", "dice", "roulette", "shop"]; + const isPublic = sub && publicCommands.includes(sub); + + // 2. Instantly defer at the highest level! (Using strict MessageFlags to fix the deprecation warning) + try { + if (!isPublic) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + } else { + await interaction.deferReply(); + } + } catch (error) { + // CRITICAL FIX: If defer fails (due to 3s timeout), STOP RUNNING! + // This guarantees the bot will not crash on an editReply later. + console.error("[Economy] Interaction token expired upstream. Safely aborting command."); + return; + } + + // Proceed to the handlers only if deferral was completely successful switch (group) { - case "gamble": + case "gamble": { const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) ); @@ -253,73 +255,51 @@ export default { if (!hasBypassRole && !isExplicitAdmin && !config.economy.gambleChannel.includes(interaction.channelId)) { const allowedList = config.economy.gambleChannel.map((id: string) => `<#${id}>`).join(", "); - - return interaction.reply({ - content: `āŒ Gambling commands can only be used in ${allowedList}`, - ephemeral: true - }); + return void await interaction.editReply({ content: `āŒ Gambling commands can only be used in ${allowedList}` }); } switch (sub) { - case "coinflip": - return await handleGambleCoinflip(interaction); - case "dice": - return await handleGambleDice(interaction); - case "roulette": - return await handleGambleRoulette(interaction); + case "coinflip": return await handleGambleCoinflip(interaction); + case "dice": return await handleGambleDice(interaction); + case "roulette": return await handleGambleRoulette(interaction); } return; + } case null: - default: + default: { switch (sub) { - case "leaderboard": - return await handleLeaderboard(interaction); - case "balance": - return await handleBalance(interaction); - case "shop": - return await handleShop(interaction); - case "buy": - return await handleBuy(interaction); - case "inventory": - return await handleInventory(interaction); - case "add-money": - return await handleAddMoney(interaction); - case "set-balance": - return await handleSetBalance(interaction); - case "add-item": - return await handleAddShopItem(interaction); - case "remove-item": - return await handleRemoveShopItem(interaction); + case "leaderboard": return await handleLeaderboard(interaction); + case "balance": return await handleBalance(interaction); + case "shop": return await handleShop(interaction); + case "buy": return await handleBuy(interaction); + case "inventory": return await handleInventory(interaction); + case "add-money": return await handleAddMoney(interaction); + case "set-balance": return await handleSetBalance(interaction); + case "add-item": return await handleAddShopItem(interaction); + case "remove-item": return await handleRemoveShopItem(interaction); } return; + } } }, } as Cmd; // ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── +// Note: NONE of these contain `deferReply` anymore. That is handled 100% globally now. async function handleBalance(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user") || interaction.user; const balance = (await EconomyProfile.findOne({ - where: { - guildId: interaction.guildId!, - userId: targetUser.id - } + where: { guildId: interaction.guildId!, userId: targetUser.id } }))?.balance ?? STARTING_BALANCE; - await interaction.reply({ - content: `šŸ’° <@${targetUser.id}> currently has **$${balance}**.`, - ephemeral: true - }); + await interaction.editReply({ content: `šŸ’° <@${targetUser.id}> currently has **$${balance}**.` }); } async function handleShop(interaction: ChatInputCommandInteraction) { - const items = await ShopItem.findAll({ - where: { guildId: interaction.guildId! } - }); - + const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); const embed = new EmbedBuilder() .setTitle("šŸ›’ The Server Marketplace") .setDescription("Use `/economy buy ` to purchase something!") @@ -340,34 +320,23 @@ async function handleShop(interaction: ChatInputCommandInteraction) { } } - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); } async function handleBuy(interaction: ChatInputCommandInteraction) { const itemKey = interaction.options.getString("item", true).toLowerCase(); + const item = await ShopItem.findOne({ where: { guildId: interaction.guildId!, itemId: itemKey } }); - const item = await ShopItem.findOne({ - where: { guildId: interaction.guildId!, itemId: itemKey } - }); + if (!item) return void await interaction.editReply({ content: "That item doesn't exist in our shop." }); + if (item.stock === 0) return void await interaction.editReply({ content: `āŒ Sorry, **${item.name}** is completely sold out!` }); - if (!item) { - return interaction.reply({ content: "That item doesn't exist in our shop.", ephemeral: true }); - } - - if (item.stock === 0) { - return interaction.reply({ content: `āŒ Sorry, **${item.name}** is completely sold out!`, ephemeral: true }); - } const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } }); if (profile.balance < item.price) { - await interaction.reply({ - content: `āŒ You can't afford that! **${item.name}** costs \`$${item.price}\`, but you only have \`$${profile.balance}\`.`, - ephemeral: true, - }); - return; + return void await interaction.editReply({ content: `āŒ You can't afford that! **${item.name}** costs \`$${item.price}\`, but you only have \`$${profile.balance}\`.` }); } let roleGrantedMessage = ""; @@ -377,44 +346,38 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { await item.save(); } profile.balance -= item.price; - if (item.roleId) { - if (interaction.member instanceof GuildMember) { - try { - if (item.durationDays) { - const timeToAdd = item.durationDays * 24 * 60 * 60 * 1000; - - let tempRole = await TempRole.findOne({ - where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId } - }); - if (tempRole) { - tempRole.expiresAt = new Date(tempRole.expiresAt.getTime() + timeToAdd); - await tempRole.save(); - } else { - await TempRole.create({ - guildId: interaction.guildId!, - userId: interaction.user.id, - roleId: item.roleId, - expiresAt: new Date(Date.now() + timeToAdd) - }); - } - - await interaction.member.roles.add(item.roleId, `Purchased ${item.durationDays} day pass.`); - roleGrantedMessage = ` and granted you the <@&${item.roleId}> role for **${item.durationDays} days**!`; + if (item.roleId && interaction.member instanceof GuildMember) { + try { + if (item.durationDays) { + const timeToAdd = item.durationDays * 24 * 60 * 60 * 1000; + let tempRole = await TempRole.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId } }); + if (tempRole) { + tempRole.expiresAt = new Date(tempRole.expiresAt.getTime() + timeToAdd); + await tempRole.save(); } else { - if (interaction.member.roles.cache.has(item.roleId)) { - return interaction.reply({ content: `āŒ You already have this permanent role!`, ephemeral: true }); - } - await interaction.member.roles.add(item.roleId, `Purchased permanent role.`); - roleGrantedMessage = ` and granted you the <@&${item.roleId}> role permanently!`; + await TempRole.create({ + guildId: interaction.guildId!, userId: interaction.user.id, + roleId: item.roleId, expiresAt: new Date(Date.now() + timeToAdd) + }); } - } catch (error) { - console.error("Failed to assign shop role:", error); - return interaction.reply({ content: `āŒ Internal Error: Please make sure my bot role is ABOVE the shop role in Server Settings.`, ephemeral: true }); + + await interaction.member.roles.add(item.roleId, `Purchased ${item.durationDays} day pass.`); + roleGrantedMessage = ` and granted you the <@&${item.roleId}> role for **${item.durationDays} days**!`; + } else { + if (interaction.member.roles.cache.has(item.roleId)) { + return void await interaction.editReply({ content: `āŒ You already have this permanent role!` }); + } + await interaction.member.roles.add(item.roleId, `Purchased permanent role.`); + roleGrantedMessage = ` and granted you the <@&${item.roleId}> role permanently!`; } + } catch (error) { + console.error("Failed to assign shop role:", error); + return void await interaction.editReply({ content: `āŒ Internal Error: Please make sure my bot role is ABOVE the shop role in Server Settings.` }); } } + await profile.save(); const [invItem, created] = await Inventory.findOrCreate({ @@ -427,67 +390,37 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { await invItem.save(); } - await interaction.reply({ - content: `šŸŽ‰ Success! You bought **${item.name}** for \`$${item.price}\`${roleGrantedMessage}. Your remaining balance is \`$${profile.balance}\`.`, - }); + await interaction.editReply({ content: `šŸŽ‰ Success! You bought **${item.name}** for \`$${item.price}\`${roleGrantedMessage}. Your remaining balance is \`$${profile.balance}\`.` }); } async function handleInventory(interaction: ChatInputCommandInteraction) { - const items = await Inventory.findAll({ - where: { guildId: interaction.guildId!, userId: interaction.user.id } - }); - - if (items.length === 0) { - await interaction.reply({ content: "šŸŽ’ Your inventory is completely empty. Go buy something!", ephemeral: true }); - return; - } - - const allShopItems = await ShopItem.findAll({ - where: { guildId: interaction.guildId! } - }); + const items = await Inventory.findAll({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); + if (items.length === 0) return void await interaction.editReply({ content: "šŸŽ’ Your inventory is completely empty. Go buy something!" }); + const allShopItems = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); const itemManifest = Object.fromEntries(allShopItems.map((i) => [i.itemId, i.name])); - const inventoryList = items - .map((item) => { - const visualName = itemManifest[item.itemKey] || `āš™ļø Unknown Item (${item.itemKey})`; - return `${visualName} x\`${item.quantity}\``; - }) - .join("\n"); + const inventoryList = items.map((item) => { + const visualName = itemManifest[item.itemKey] || `āš™ļø Unknown Item (${item.itemKey})`; + return `${visualName} x\`${item.quantity}\``; + }).join("\n"); - const embed = new EmbedBuilder() - .setTitle(`šŸŽ’ ${interaction.user.username}'s Inventory`) - .setDescription(inventoryList) - .setColor(0x00ae86); - - await interaction.reply({ embeds: [embed] }); + const embed = new EmbedBuilder().setTitle(`šŸŽ’ ${interaction.user.username}'s Inventory`).setDescription(inventoryList).setColor(0x00ae86); + await interaction.editReply({ embeds: [embed] }); } async function handleAddMoney(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => - interaction.member.roles.cache.has(roleId) - ); - - if (!isStaff) { - return interaction.reply({ - content: "āŒ You do not have a required staff role to use this command.", - ephemeral: true - }); - } + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); + if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); - if (amount <= 0 || amount > 1000000) { - return interaction.reply({ - content: "āŒ Please use an integer smaller than or equal to 1,000,000 and bigger than 0", - ephemeral: true - }); - } + if (amount <= 0 || amount > 1000000) return void await interaction.editReply({ content: "āŒ Please use an integer smaller than or equal to 1,000,000 and bigger than 0" }); const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: targetUser.id }, - defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: 100 } + defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE } }); profile.balance += amount; @@ -495,36 +428,19 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { const formattedAmount = format(config.economy.currencyFormat, amount); const formattedBalance = format(config.economy.currencyFormat, profile.balance); + const replyMessage = format(config.economy.addMoney, formattedAmount, targetUser.username, formattedBalance, config.economy.coinEmoji); - const replyMessage = format( - config.economy.addMoney, - formattedAmount, - targetUser.username, - formattedBalance, - config.economy.coinEmoji - ); - - await interaction.reply(replyMessage); + await interaction.editReply({ content: replyMessage }); } async function handleSetBalance(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => - interaction.member.roles.cache.has(roleId) - ); - - if (!isStaff) { - return interaction.reply({ - content: "āŒ You do not have a required staff role to use this command.", - ephemeral: true - }); - } + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); + if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); - if (amount < 0 || amount > 2_000_000_000) { - return interaction.reply({ content: "āŒ Invalid amount range (0 to 2B).", ephemeral: true }); - } + if (amount < 0 || amount > 2_000_000_000) return void await interaction.editReply({ content: "āŒ Invalid amount range (0 to 2B)." }); const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: targetUser.id }, @@ -534,22 +450,12 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { profile.balance = amount; await profile.save(); - await interaction.reply({ - content: `āš™ļø **Database Updated:** ${targetUser.username}'s balance has been explicitly set to \`$${amount}\`.`, - }); + await interaction.editReply({ content: `āš™ļø **Database Updated:** ${targetUser.username}'s balance has been explicitly set to \`$${amount}\`.` }); } async function handleAddShopItem(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => - interaction.member.roles.cache.has(roleId) - ); - - if (!isStaff) { - return interaction.reply({ - content: "āŒ You do not have a required staff role to use this command.", - ephemeral: true - }); - } + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); + if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); const itemId = interaction.options.getString("id", true).toLowerCase(); const name = interaction.options.getString("name", true); @@ -558,67 +464,40 @@ async function handleAddShopItem(interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role", false); const stock = interaction.options.getInteger("stock") ?? -1; - if (price < 0) return interaction.reply({ content: "āŒ Price cannot be negative.", ephemeral: true }); + if (price < 0) return void await interaction.editReply({ content: "āŒ Price cannot be negative." }); const [item, created] = await ShopItem.findOrCreate({ where: { guildId: interaction.guildId!, itemId: itemId }, - defaults: { - guildId: interaction.guildId!, - itemId: itemId, - name: name, - price: price, - description: description, - roleId: role?.id || null, - stock: stock - } + defaults: { guildId: interaction.guildId!, itemId: itemId, name: name, price: price, description: description, roleId: role?.id || null, stock: stock } }); - if (!created) { - return interaction.reply({ content: `āŒ An item with the ID \`${itemId}\` already exists!`, ephemeral: true }); - } + if (!created) return void await interaction.editReply({ content: `āŒ An item with the ID \`${itemId}\` already exists!` }); const stockMsg = stock === -1 ? "Infinite" : stock.toString(); - await interaction.reply({ content: `āœ… Created new shop item: **${name}** for \`$${price}\` (Stock: ${stockMsg}).` }); + await interaction.editReply({ content: `āœ… Created new shop item: **${name}** for \`$${price}\` (Stock: ${stockMsg}).` }); } async function handleRemoveShopItem(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => - interaction.member.roles.cache.has(roleId) - ); - - if (!isStaff) { - return interaction.reply({ - content: "āŒ You do not have a required staff role to use this command.", - ephemeral: true - }); - } + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); + if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); const itemId = interaction.options.getString("id", true).toLowerCase(); - const deleted = await ShopItem.destroy({ - where: { guildId: interaction.guildId!, itemId: itemId } - }); - - if (deleted === 0) { - return interaction.reply({ content: `āŒ Could not find an item with the ID \`${itemId}\`.`, ephemeral: true }); - } + const deleted = await ShopItem.destroy({ where: { guildId: interaction.guildId!, itemId: itemId } }); + if (deleted === 0) return void await interaction.editReply({ content: `āŒ Could not find an item with the ID \`${itemId}\`.` }); - await interaction.reply({ content: `šŸ—‘ļø Successfully removed \`${itemId}\` from the shop.` }); + await interaction.editReply({ content: `šŸ—‘ļø Successfully removed \`${itemId}\` from the shop.` }); } async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { const betAmount = interaction.options.getInteger("amount", true); - const [profile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } }); if (profile.balance < betAmount) { - return interaction.reply({ - content: `āŒ You can't afford that! You only have \`$${profile.balance}\` to your name.`, - ephemeral: true, - }); + return void await interaction.editReply({ content: `āŒ You can't afford that! You only have \`$${profile.balance}\` to your name.` }); } const isWinner = randomUtils.pickRandom([true, false]); @@ -626,15 +505,11 @@ async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { if (isWinner) { profile.balance += betAmount; await profile.save(); - await interaction.reply({ - content: `šŸŽ° **JACKPOT!** The coin landed in your favor. You won \`$${betAmount}\`!\nšŸ’° Your new balance is \`$${profile.balance}\`.` - }); + await interaction.editReply({ content: `šŸŽ° **JACKPOT!** The coin landed in your favor. You won \`$${betAmount}\`!\nšŸ’° Your new balance is \`$${profile.balance}\`.` }); } else { profile.balance -= betAmount; await profile.save(); - await interaction.reply({ - content: `šŸ“‰ **Bust!** Lady Luck was not on your side today. You lost \`$${betAmount}\`.\nšŸ’ø Your remaining balance is \`$${profile.balance}\`.` - }); + await interaction.editReply({ content: `šŸ“‰ **Bust!** Lady Luck was not on your side today. You lost \`$${betAmount}\`.\nšŸ’ø Your remaining balance is \`$${profile.balance}\`.` }); } } @@ -648,10 +523,7 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { }); if (profile.balance < betAmount) { - return interaction.reply({ - content: `āŒ You only have \`$${profile.balance}\`. You can't bet what you don't own!`, - ephemeral: true, - }); + return void await interaction.editReply({ content: `āŒ You only have \`$${profile.balance}\`. You can't bet what you don't own!` }); } const diceRoll = randomUtils.getRandomIntInclusive(1, 6); @@ -660,15 +532,11 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { const winnings = betAmount * 5; profile.balance += winnings; await profile.save(); - await interaction.reply({ - content: `šŸŽ² The die rolled a **${diceRoll}**!\nšŸŽ‰ **INCREDIBLE!** You guessed correctly and won \`$${winnings}\`!\nšŸ’° Your new balance is \`$${profile.balance}\`.` - }); + await interaction.editReply({ content: `šŸŽ² The die rolled a **${diceRoll}**!\nšŸŽ‰ **INCREDIBLE!** You guessed correctly and won \`$${winnings}\`!\nšŸ’° Your new balance is \`$${profile.balance}\`.` }); } else { profile.balance -= betAmount; await profile.save(); - await interaction.reply({ - content: `šŸŽ² The die rolled a **${diceRoll}**...\nšŸ“‰ You guessed ${guess}. You lost your bet of \`$${betAmount}\`.\nšŸ’ø Your remaining balance is \`$${profile.balance}\`.` - }); + await interaction.editReply({ content: `šŸŽ² The die rolled a **${diceRoll}**...\nšŸ“‰ You guessed ${guess}. You lost your bet of \`$${betAmount}\`.\nšŸ’ø Your remaining balance is \`$${profile.balance}\`.` }); } } @@ -680,9 +548,6 @@ interface RouletteBet { } async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { - // ā±ļø Defer here directly since roulette takes time to process threads and is a public game - await interaction.deferReply(); - const customSeconds = interaction.options.getInteger("seconds") || 60; const timeMs = customSeconds * 1000; @@ -709,23 +574,15 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { `šŸ‘‘ **<@${interaction.user.id}>**, type \`spin\` when everyone is ready!` ); - const collector = thread.createMessageCollector({ - filter: (m) => !m.author.bot, - time: timeMs - }); + const collector = thread.createMessageCollector({ filter: (m) => !m.author.bot, time: timeMs }); collector.on("collect", async (message) => { const args = message.content.trim().toLowerCase().split(/\s+/); const commandOrType = args[0]; if (commandOrType === "spin") { - if (message.author.id !== interaction.user.id) { - return void await message.react("āŒ"); - } - if (bets.length === 0) { - return void await message.react("āŒ"); - } - + if (message.author.id !== interaction.user.id) return void await message.react("āŒ"); + if (bets.length === 0) return void await message.react("āŒ"); collector.stop("spun"); return; } @@ -733,7 +590,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const validBetTypes = ["red", "black", "even", "odd"]; if (validBetTypes.includes(commandOrType)) { const amountStr = args[1]; - if (!amountStr) return void await message.react("āŒ"); const amount = parseInt(amountStr, 10); @@ -749,13 +605,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { profile.balance -= amount; await profile.save(); - bets.push({ - userId: message.author.id, - username: message.author.username, - amount: amount, - betType: commandOrType as any - }); - + bets.push({ userId: message.author.id, username: message.author.username, amount: amount, betType: commandOrType as any }); await message.react("āœ…"); } }); @@ -765,10 +615,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { await thread.send("ā° Table closed automatically due to inactivity."); for (const bet of bets) { const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); - if (profile) { - profile.balance += bet.amount; - await profile.save(); - } + if (profile) { profile.balance += bet.amount; await profile.save(); } } await thread.setLocked(true); await thread.setArchived(true); @@ -777,11 +624,8 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const winningNumber = Math.floor(Math.random() * 37); const redNumbers = [1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]; - let color: "green" | "red" | "black" = "green"; - if (winningNumber > 0) { - color = redNumbers.includes(winningNumber) ? "red" : "black"; - } + if (winningNumber > 0) color = redNumbers.includes(winningNumber) ? "red" : "black"; const isEven = winningNumber > 0 && winningNumber % 2 === 0; const isOdd = winningNumber > 0 && winningNumber % 2 !== 0; @@ -793,18 +637,14 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { for (const bet of bets) { let won = false; - if (bet.betType === "red" && color === "red") won = true; if (bet.betType === "black" && color === "black") won = true; if (bet.betType === "even" && isEven) won = true; if (bet.betType === "odd" && isOdd) won = true; const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); - const currentNet = userNetTotals.get(bet.userId) ?? 0; - if (!userBreakdowns.has(bet.userId)) { - userBreakdowns.set(bet.userId, []); - } + if (!userBreakdowns.has(bet.userId)) userBreakdowns.set(bet.userId, []); const formattedBetAmount = format(config.economy.currencyFormat, bet.amount); @@ -812,7 +652,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const winnings = bet.amount * 2; profile.balance += winnings; await profile.save(); - const formattedWinnings = format(config.economy.currencyFormat, winnings); userBreakdowns.get(bet.userId)!.push(`${bet.betType}: Won ${formattedWinnings}`); userNetTotals.set(bet.userId, currentNet + bet.amount); @@ -828,18 +667,13 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { for (const [userId, breakdownArray] of userBreakdowns.entries()) { const member = await thread.guild.members.fetch(userId).catch(() => null); const displayName = member ? member.displayName : `User(${userId})`; - const netValue = userNetTotals.get(userId) ?? 0; let netStatus = "Broke Even!"; - if (netValue > 0) { - netStatus = `Won Net ${format(config.economy.currencyFormat, netValue)}!`; - } else if (netValue < 0) { - netStatus = `Lost Net ${format(config.economy.currencyFormat, Math.abs(netValue))}!`; - } + if (netValue > 0) netStatus = `Won Net ${format(config.economy.currencyFormat, netValue)}!`; + else if (netValue < 0) netStatus = `Lost Net ${format(config.economy.currencyFormat, Math.abs(netValue))}!`; - const betHistoryStr = breakdownArray.join(" "); - outputMessage += `**${displayName}**: ${betHistoryStr} | **${netStatus}**\n`; + outputMessage += `**${displayName}**: ${breakdownArray.join(" ")} | **${netStatus}**\n`; } await thread.send(outputMessage); @@ -853,65 +687,45 @@ async function seedDefaultShopItems(guildId: string) { await ShopItem.findOrCreate({ where: {guildId: guildId, name: item.name}, defaults: { - guildId: guildId, - itemId: item.itemId, - name: item.name, - price: item.price, - description: item.description, - roleId: item.roleId || null, - durationDays: item.durationDays || null, - stock: item.stock + guildId: guildId, itemId: item.itemId, name: item.name, price: item.price, + description: item.description, roleId: item.roleId || null, + durationDays: item.durationDays || null, stock: item.stock } }); } } async function handleLeaderboard(interaction: ChatInputCommandInteraction) { - // ā±ļø Defer explicitly here as ephemeral since leaderboard is highly customized - await interaction.deferReply({ ephemeral: true }); - const PAGE_SIZE = 10; let currentPage = 1; const actualCount = await EconomyProfile.count({ where: { guildId: interaction.guildId! } }); const totalProfiles = Math.min(actualCount, 100); - if (totalProfiles === 0) { - return interaction.editReply("šŸ“‰ The economy is completely empty. Nobody has any money yet!"); - } + if (totalProfiles === 0) return void await interaction.editReply("šŸ“‰ The economy is completely empty. Nobody has any money yet!"); const maxPage = Math.ceil(totalProfiles / PAGE_SIZE); const generatePage = async (page: number) => { const offset = (page - 1) * PAGE_SIZE; - const topProfiles = await EconomyProfile.findAll({ where: { guildId: interaction.guildId! }, - attributes: { - include: [ - [Sequelize.literal('(RANK() OVER (ORDER BY balance DESC))'), 'rank'] - ] - }, - order: [['balance', 'DESC']], - limit: PAGE_SIZE, - offset: offset + attributes: { include: [[Sequelize.literal('(RANK() OVER (ORDER BY balance DESC))'), 'rank']] }, + order: [['balance', 'DESC']], limit: PAGE_SIZE, offset: offset }); const descriptionLines = topProfiles.map((profile) => { const rank = profile.get('rank') as number; const userMention = `<@${profile.userId}>`; - let rankEmoji = "šŸ”¹"; if (rank === 1) rankEmoji = "šŸ„‡"; else if (rank === 2) rankEmoji = "🄈"; else if (rank === 3) rankEmoji = "šŸ„‰"; else rankEmoji = `**#${rank}**`; - return `${rankEmoji} ${userMention} — **$${profile.balance}**`; }); - const description = descriptionLines.join("\n") || "No players found."; return new EmbedBuilder() .setTitle("šŸ† Economy Leaderboard") - .setDescription(description) + .setDescription(descriptionLines.join("\n") || "No players found.") .setColor(0xFFD700) .setFooter({ text: `Page ${page} of ${maxPage} | Total Players: ${totalProfiles}` }); }; @@ -919,16 +733,8 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { const generateButtons = (page: number) => { const row = new ActionRowBuilder(); row.addComponents( - new ButtonBuilder() - .setCustomId('prev_page') - .setLabel('ā—€ Previous') - .setStyle(ButtonStyle.Primary) - .setDisabled(page === 1), - new ButtonBuilder() - .setCustomId('next_page') - .setLabel('Next ā–¶') - .setStyle(ButtonStyle.Primary) - .setDisabled(page === maxPage) + new ButtonBuilder().setCustomId('economy:prev_page').setLabel('ā—€ Previous').setStyle(ButtonStyle.Primary).setDisabled(page === 1), + new ButtonBuilder().setCustomId('economy:next_page').setLabel('Next ā–¶').setStyle(ButtonStyle.Primary).setDisabled(page === maxPage) ); return row; }; @@ -936,36 +742,22 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { const initialEmbed = await generatePage(currentPage); const components = maxPage > 1 ? [generateButtons(currentPage)] : []; - const message = await interaction.editReply({ - embeds: [initialEmbed], - components: components - }); - + const message = await interaction.editReply({ embeds: [initialEmbed], components: components }); if (maxPage <= 1) return; - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 60000 - }); + const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, time: 60000 }); collector.on("collect", async (i) => { await i.deferUpdate(); - if (i.customId === 'prev_page') currentPage--; - if (i.customId === 'next_page') currentPage++; + if (i.customId === 'economy:prev_page') currentPage--; + if (i.customId === 'economy:next_page') currentPage++; - const newEmbed = await generatePage(currentPage); - const newButtons = generateButtons(currentPage); - - await i.editReply({ - embeds: [newEmbed], - components: [newButtons] - }); + await i.editReply({ embeds: [await generatePage(currentPage)], components: [generateButtons(currentPage)] }); }); collector.on("end", async () => { const disabledRow = generateButtons(currentPage); disabledRow.components.forEach(c => c.setDisabled(true)); - await interaction.editReply({ components: [disabledRow] }).catch(() => null); }); } \ No newline at end of file From b3a2d31102141c8c2cba875a1ff17609e208700b Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sun, 5 Jul 2026 00:37:32 +0800 Subject: [PATCH 13/34] theos old new nitpicks --- src/commands/fun/economy.ts | 140 ++++++++++++++++++++++++++---------- 1 file changed, 102 insertions(+), 38 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 1af64a7..ba31c60 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -8,7 +8,7 @@ import { ButtonStyle, ButtonBuilder, ActionRowBuilder, - MessageFlags // Required for modern ephemeral responses + MessageFlags } from "discord.js"; import { DataTypes, @@ -31,6 +31,7 @@ export class EconomyProfile extends Model< declare guildId: string; declare userId: string; declare balance: CreationOptional; + declare lastWageClaim: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; } @@ -72,6 +73,8 @@ export class TempRole extends Model, InferCreationAttr declare expiresAt: Date; } const STARTING_BALANCE = 10; +const WAGE_AMOUNT = 50; +const WAGE_COOLDOWN_HOURS = 24; export default { data: { name: "economy" }, @@ -82,6 +85,7 @@ export default { guildId: { type: DataTypes.STRING, primaryKey: true }, userId: { type: DataTypes.STRING, primaryKey: true }, balance: { type: DataTypes.INTEGER, defaultValue: STARTING_BALANCE }, + lastWageClaim: { type: DataTypes.DATE, allowNull: true }, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE, }, @@ -149,6 +153,7 @@ export default { .setDescription("Check your current balance or another user's balance") .addUserOption((opt) => opt.setName("user").setDescription("The user to check").setRequired(false)), ) + .addSubcommand(sub => sub.setName("wage").setDescription("Collect your regular salary!")) .addSubcommand(sub => sub.setName("leaderboard").setDescription("View the leaderboard")) .addSubcommand((sub) => sub.setName("shop").setDescription("View available items for purchase")) .addSubcommand((sub) => @@ -189,6 +194,12 @@ export default { .setDescription("Remove an item from the server shop (Staff Only)") .addStringOption((opt) => opt.setName("id").setDescription("The ID of the item to delete").setRequired(true)) ) + .addSubcommand((sub) => + sub + .setName("inflation") + .setDescription("Increase all shop prices by a percentage to combat wealth (Staff Only)") + .addNumberOption((opt) => opt.setName("percentage").setDescription("Percentage to increase (e.g. 10 for 10%)").setRequired(true)) + ) .addSubcommandGroup((group) => group .setName("gamble") @@ -227,11 +238,9 @@ export default { const sub = interaction.options.getSubcommand(false); const group = interaction.options.getSubcommandGroup(false); - // 1. Identify which commands should be public in the chat const publicCommands = ["coinflip", "dice", "roulette", "shop"]; const isPublic = sub && publicCommands.includes(sub); - // 2. Instantly defer at the highest level! (Using strict MessageFlags to fix the deprecation warning) try { if (!isPublic) { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); @@ -239,13 +248,10 @@ export default { await interaction.deferReply(); } } catch (error) { - // CRITICAL FIX: If defer fails (due to 3s timeout), STOP RUNNING! - // This guarantees the bot will not crash on an editReply later. console.error("[Economy] Interaction token expired upstream. Safely aborting command."); return; } - // Proceed to the handlers only if deferral was completely successful switch (group) { case "gamble": { const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => @@ -269,6 +275,8 @@ export default { case null: default: { switch (sub) { + case "wage": return await handleWage(interaction); + case "inflation": return await handleInflation(interaction); case "leaderboard": return await handleLeaderboard(interaction); case "balance": return await handleBalance(interaction); case "shop": return await handleShop(interaction); @@ -286,11 +294,68 @@ export default { } as Cmd; // ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── -// Note: NONE of these contain `deferReply` anymore. That is handled 100% globally now. + +async function handleWage(interaction: ChatInputCommandInteraction) { + let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); + const now = new Date(); + + if (profile && profile.lastWageClaim) { + const diffMs = now.getTime() - profile.lastWageClaim.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + + if (diffHours < WAGE_COOLDOWN_HOURS) { + const remainingHours = Math.ceil(WAGE_COOLDOWN_HOURS - diffHours); + return void await interaction.editReply({ + content: `ā³ You have already collected your wage recently! Come back in **${remainingHours} hours**.` + }); + } + } + + if (!profile) { + profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); + } + + profile.balance += WAGE_AMOUNT; + profile.lastWageClaim = now; + await profile.save(); + + await interaction.editReply({ + content: `šŸ’µ You clocked in and collected your wage of **$${WAGE_AMOUNT}**! Your new balance is **$${profile.balance}**.` + }); +} + +async function handleInflation(interaction: ChatInputCommandInteraction) { + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); + if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); + + const percentage = interaction.options.getNumber("percentage", true); + + if (percentage <= 0) { + return void await interaction.editReply({ content: "āŒ Please provide a percentage greater than 0." }); + } + + const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); + + if (items.length === 0) { + return void await interaction.editReply({ content: "āŒ There are no items in the shop to inflate." }); + } + + const multiplier = 1 + (percentage / 100); + + for (const item of items) { + item.price = Math.round(item.price * multiplier); + await item.save(); + } + + await interaction.editReply({ + content: `šŸ“ˆ **Inflation Applied!** All shop items have been increased in price by **${percentage}%**.` + }); +} async function handleBalance(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user") || interaction.user; + // Fast, lightweight query. No rows created! const balance = (await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: targetUser.id } }))?.balance ?? STARTING_BALANCE; @@ -330,13 +395,16 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { if (!item) return void await interaction.editReply({ content: "That item doesn't exist in our shop." }); if (item.stock === 0) return void await interaction.editReply({ content: `āŒ Sorry, **${item.name}** is completely sold out!` }); - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: interaction.user.id }, - defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } - }); + let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); + const currentBalance = profile?.balance ?? STARTING_BALANCE; + + if (currentBalance < item.price) { + return void await interaction.editReply({ content: `āŒ You can't afford that! **${item.name}** costs \`$${item.price}\`, but you only have \`$${currentBalance}\`.` }); + } - if (profile.balance < item.price) { - return void await interaction.editReply({ content: `āŒ You can't afford that! **${item.name}** costs \`$${item.price}\`, but you only have \`$${profile.balance}\`.` }); + // Now that they passed the check, securely create the profile if it doesn't exist + if (!profile) { + profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); } let roleGrantedMessage = ""; @@ -418,10 +486,8 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { if (amount <= 0 || amount > 1000000) return void await interaction.editReply({ content: "āŒ Please use an integer smaller than or equal to 1,000,000 and bigger than 0" }); - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: targetUser.id }, - defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE } - }); + let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: targetUser.id } }); + if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE }); profile.balance += amount; await profile.save(); @@ -442,10 +508,8 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { if (amount < 0 || amount > 2_000_000_000) return void await interaction.editReply({ content: "āŒ Invalid amount range (0 to 2B)." }); - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: targetUser.id }, - defaults: { guildId: interaction.guildId!, userId: targetUser.id, balance: amount } - }); + let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: targetUser.id } }); + if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE }); profile.balance = amount; await profile.save(); @@ -491,15 +555,15 @@ async function handleRemoveShopItem(interaction: ChatInputCommandInteraction) { async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { const betAmount = interaction.options.getInteger("amount", true); - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: interaction.user.id }, - defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } - }); + let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); + const currentBalance = profile?.balance ?? STARTING_BALANCE; - if (profile.balance < betAmount) { - return void await interaction.editReply({ content: `āŒ You can't afford that! You only have \`$${profile.balance}\` to your name.` }); + if (currentBalance < betAmount) { + return void await interaction.editReply({ content: `āŒ You can't afford that! You only have \`$${currentBalance}\` to your name.` }); } + if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); + const isWinner = randomUtils.pickRandom([true, false]); if (isWinner) { @@ -517,15 +581,15 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { const betAmount = interaction.options.getInteger("amount", true); const guess = interaction.options.getInteger("guess", true); - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: interaction.user.id }, - defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: 100 } - }); + let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); + const currentBalance = profile?.balance ?? STARTING_BALANCE; - if (profile.balance < betAmount) { - return void await interaction.editReply({ content: `āŒ You only have \`$${profile.balance}\`. You can't bet what you don't own!` }); + if (currentBalance < betAmount) { + return void await interaction.editReply({ content: `āŒ You only have \`$${currentBalance}\`. You can't bet what you don't own!` }); } + if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); + const diceRoll = randomUtils.getRandomIntInclusive(1, 6); if (guess === diceRoll) { @@ -595,12 +659,12 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const amount = parseInt(amountStr, 10); if (isNaN(amount) || amount <= 0) return void await message.react("āŒ"); - const [profile] = await EconomyProfile.findOrCreate({ - where: { guildId: interaction.guildId!, userId: message.author.id }, - defaults: { guildId: interaction.guildId!, userId: message.author.id, balance: STARTING_BALANCE } - }); + let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: message.author.id } }); + const currentBalance = profile?.balance ?? STARTING_BALANCE; + + if (currentBalance < amount) return void await message.react("āŒ"); - if (profile.balance < amount) return void await message.react("āŒ"); + if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: message.author.id, balance: STARTING_BALANCE }); profile.balance -= amount; await profile.save(); From 5afca854c869fdbb6a01bd34e0dab741298b572e Mon Sep 17 00:00:00 2001 From: vmbbi Date: Mon, 6 Jul 2026 00:53:34 +0800 Subject: [PATCH 14/34] theos nitpicks but slightly not complete cause im lazy --- config.json.js | 37 +++- src/commands/fun/economy.ts | 377 ++++++++++++++++++++++++------------ 2 files changed, 280 insertions(+), 134 deletions(-) diff --git a/config.json.js b/config.json.js index d2078c2..1df9aa4 100644 --- a/config.json.js +++ b/config.json.js @@ -161,11 +161,20 @@ Consider donating to one of the following people: banTag: "1406738115468722257", bypassId: "1257750834150637599" }, - economy: { - currencyFormat: "{0}$", + "economy": { + currencyFormat: "{amount}$", shopItems: [ { - itemId: "beta_role", + itemId: "beta_role_3", + name: "Beta Access for 3 days", + price: 2500, + description: "Purchase for access to beta builds!", + roleId: "1510652320432521327", + durationDays: 3, + stock: -1 + }, + { + itemId: "beta_role_7", name: "Beta Access for 7 days", price: 2500, description: "Purchase for access to beta builds!", @@ -173,11 +182,29 @@ Consider donating to one of the following people: durationDays: 7, stock: -1 }, + { + itemId: "beta_role_30", + name: "Beta Access for 30 days", + price: 2500, + description: "Purchase for access to beta builds!", + roleId: "1510652320432521327", + durationDays: 30, + stock: -1 + }, ], teamRole: ["1262624821582364703"], gambleChannel: ["1522846518829125642"], - addMoney: "{3} **Transaction Complete:** Successfully added `{0}` to {1}'s profile. Their new balance is `{2}`.", - coinEmoji:"<:al_logo:1492686347666980944>" + addMoney: "{emoji} **Transaction Complete:** Successfully added `{added}` to {user}'s profile. Their new balance is `{newBalance}`.", + coinEmoji: "<:al_logo:1492686347666980944>", + cantAfford: "āŒ You only have \\`${userBalance}\\`. You don't have enough money to bet!", + isntStaff: "āŒ You do not have a required staff role to use this command.", + balanceMessage: "{emoji} <@{targetUser}> currently has **${balance}**.", + notItem: "That item doesn't exist in our shop.", + soldOut: "āŒ Sorry, **${name}** is completely sold out!", + shopCantAfford: "`āŒ You can't afford that! **{name}** costs \`${price}\`, but you only have \`${balance}\`.", + successBuy: "šŸŽ‰ Successfully bought **{name}** for \`${price}\`{message}. Your remaining balance is \`$${balance}\`.", + permaRole: " and granted you the <@&{roleId}> role permanently!", + tempRole: ` and granted you the <@&{roleId}> role for **{durationDays} days**!` }, swear: { period: 60 * 1000, diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index ba31c60..8b6b3ac 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -61,6 +61,7 @@ export class ShopItem extends Model< declare roleId: CreationOptional; declare stock: CreationOptional; declare durationDays: CreationOptional; + declare useMessage: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; } @@ -115,6 +116,7 @@ export default { roleId: { type: DataTypes.STRING, allowNull: true }, stock: { type: DataTypes.INTEGER, defaultValue: -1 }, durationDays: { type: DataTypes.INTEGER, allowNull: true }, + useMessage: { type: DataTypes.STRING, allowNull: true }, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE, }, @@ -141,6 +143,27 @@ export default { for (const [guildId, guild] of guilds) { await seedDefaultShopItems(guildId); } + setInterval(async () => { + try { + const now = new Date(); + const expiredPasses = await TempRole.findAll({ + where: { expiresAt: { [Op.lte]: now } } + }); + + for (const record of expiredPasses) { + const guild = ctx.client.guilds.cache.get(record.guildId); + if (guild) { + const member = await guild.members.fetch(record.userId).catch(() => null); + if (member && member.roles.cache.has(record.roleId)) { + await member.roles.remove(record.roleId, "šŸ•’ Temporary shop item duration expired."); + } + } + await record.destroy(); // Purge record from DB + } + } catch (err) { + console.error("[Sweeper Worker Error]:", err); + } + }, 3600000); }, slash: (builder) => { @@ -162,6 +185,12 @@ export default { .setDescription("Purchase an item from the shop") .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to buy (e.g. 'vip_role')").setRequired(true)) ) + .addSubcommand((sub) => + sub + .setName("use") + .setDescription("Use a consumable item from your inventory") + .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to use").setRequired(true)) + ) .addSubcommand((sub) => sub.setName("inventory").setDescription("View items you currently own")) .addSubcommand((sub) => sub @@ -177,23 +206,6 @@ export default { .addUserOption((opt) => opt.setName("user").setDescription("The target user").setRequired(true)) .addIntegerOption((opt) => opt.setName("amount").setDescription("The exact balance to set").setRequired(true)), ) - .addSubcommand((sub) => - sub - .setName("add-item") - .setDescription("Create a new item in the server shop (Staff Only)") - .addStringOption((opt) => opt.setName("id").setDescription("A short ID for buying (e.g. 'cookie')").setRequired(true)) - .addStringOption((opt) => opt.setName("name").setDescription("The display name (e.g. 'šŸŖ Cookie')").setRequired(true)) - .addIntegerOption((opt) => opt.setName("price").setDescription("Cost of the item").setRequired(true)) - .addStringOption((opt) => opt.setName("description").setDescription("What the item does").setRequired(true)) - .addRoleOption((opt) => opt.setName("role").setDescription("Optional: A role to give upon purchase").setRequired(false)) - .addIntegerOption((opt) => opt.setName("stock").setDescription("Amount available (leave blank for infinite stock)").setRequired(false)) - ) - .addSubcommand((sub) => - sub - .setName("remove-item") - .setDescription("Remove an item from the server shop (Staff Only)") - .addStringOption((opt) => opt.setName("id").setDescription("The ID of the item to delete").setRequired(true)) - ) .addSubcommand((sub) => sub .setName("inflation") @@ -235,35 +247,54 @@ export default { onInteraction: async (ctx, interaction) => { if (!interaction.isChatInputCommand()) return; - const sub = interaction.options.getSubcommand(false); + const EPHEMERAL_MAPPING: Record = { + "balance": true, + "wage": true, + "leaderboard": true, + "inventory": true, + "buy": true, + "add-money": true, + "set-balance": true, + "inflation": true, + + // Set these to false so they are wide open to the public channel + "shop": false, + "coinflip": false, + "dice": false, + "roulette": false, + "use": false, + }; + + const sub = interaction.options.getSubcommand(true); // Changed to true because a subcommand is guaranteed const group = interaction.options.getSubcommandGroup(false); - const publicCommands = ["coinflip", "dice", "roulette", "shop"]; - const isPublic = sub && publicCommands.includes(sub); - - try { - if (!isPublic) { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - } else { - await interaction.deferReply(); + // 1. FAST SYNC CHECK: Check gambling permissions BEFORE deferring anything + if (group === "gamble") { + const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => + interaction.member.roles.cache.has(roleId) + ); + const isExplicitAdmin = interaction.inCachedGuild() && interaction.member.permissions.has("Administrator"); + + if (!hasBypassRole && !isExplicitAdmin && !config.economy.gambleChannel.includes(interaction.channelId)) { + const allowedList = config.economy.gambleChannel.map((id: string) => `<#${id}>`).join(", "); + return void await interaction.reply({ + content: `āŒ Gambling commands can only be used in ${allowedList}`, + flags: MessageFlags.Ephemeral + }); } - } catch (error) { - console.error("[Economy] Interaction token expired upstream. Safely aborting command."); - return; } + // 2. INDIVIDUAL VISIBILITY LOOKUP: Safely uses the guaranteed sub string + const isEphemeral = EPHEMERAL_MAPPING[sub] ?? false; + + // 3. SAFE DEFER: Instantly secure the connection before any heavy logic + await interaction.deferReply({ + flags: isEphemeral ? MessageFlags.Ephemeral : undefined + }); + + // 4. ROUTE SAFELY TO HANDLERS switch (group) { case "gamble": { - const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => - interaction.member.roles.cache.has(roleId) - ); - const isExplicitAdmin = interaction.inCachedGuild() && interaction.member.permissions.has("Administrator"); - - if (!hasBypassRole && !isExplicitAdmin && !config.economy.gambleChannel.includes(interaction.channelId)) { - const allowedList = config.economy.gambleChannel.map((id: string) => `<#${id}>`).join(", "); - return void await interaction.editReply({ content: `āŒ Gambling commands can only be used in ${allowedList}` }); - } - switch (sub) { case "coinflip": return await handleGambleCoinflip(interaction); case "dice": return await handleGambleDice(interaction); @@ -281,11 +312,10 @@ export default { case "balance": return await handleBalance(interaction); case "shop": return await handleShop(interaction); case "buy": return await handleBuy(interaction); + case "use": return await handleUse(interaction); case "inventory": return await handleInventory(interaction); case "add-money": return await handleAddMoney(interaction); case "set-balance": return await handleSetBalance(interaction); - case "add-item": return await handleAddShopItem(interaction); - case "remove-item": return await handleRemoveShopItem(interaction); } return; } @@ -293,6 +323,37 @@ export default { }, } as Cmd; +// ── UTILITY ────────────────────────────────────────────────────────────── + +async function hasSufficientFunds( + interaction: ChatInputCommandInteraction, + currentBalance: number, + amountNeeded: number, + customErrorMessage?: string +): Promise { + if (currentBalance < amountNeeded) { + const msg = customErrorMessage || format(config.economy.cantAfford, {userBalance: currentBalance}); + await interaction.editReply({ content: msg }); + return false; + } + return true; +} + +/** + * Checks if the user has a required staff role. If not, it replies with an error and returns false. + */ +async function hasStaffPermission(interaction: ChatInputCommandInteraction): Promise { + const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => + interaction.member.roles.cache.has(roleId) + ); + + if (!isStaff) { + await interaction.editReply({ content: config.economy.isntStaff }); + return false; + } + + return true; +} // ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── async function handleWage(interaction: ChatInputCommandInteraction) { @@ -325,8 +386,7 @@ async function handleWage(interaction: ChatInputCommandInteraction) { } async function handleInflation(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); - if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); + if (!(await hasStaffPermission(interaction))) return; const percentage = interaction.options.getNumber("percentage", true); @@ -355,18 +415,22 @@ async function handleInflation(interaction: ChatInputCommandInteraction) { async function handleBalance(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user") || interaction.user; - // Fast, lightweight query. No rows created! const balance = (await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: targetUser.id } }))?.balance ?? STARTING_BALANCE; - await interaction.editReply({ content: `šŸ’° <@${targetUser.id}> currently has **$${balance}**.` }); + await interaction.editReply({ content: format(config.economy.balanceMessage,{ + emoji: config.economy.coinEmoji, + targetUser:targetUser.id, + balance:balance + }) + }); } async function handleShop(interaction: ChatInputCommandInteraction) { const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); const embed = new EmbedBuilder() - .setTitle("šŸ›’ The Server Marketplace") + .setTitle("šŸ›’ The Server Shop") .setDescription("Use `/economy buy ` to purchase something!") .setColor(0x00ae86); @@ -392,17 +456,19 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { const itemKey = interaction.options.getString("item", true).toLowerCase(); const item = await ShopItem.findOne({ where: { guildId: interaction.guildId!, itemId: itemKey } }); - if (!item) return void await interaction.editReply({ content: "That item doesn't exist in our shop." }); - if (item.stock === 0) return void await interaction.editReply({ content: `āŒ Sorry, **${item.name}** is completely sold out!` }); + if (!item) return void await interaction.editReply({ content: config.economy.notItem }); + if (item.stock === 0) return void await interaction.editReply({ content: format(config.economy.soldOut, {name: item.name}) }); let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); const currentBalance = profile?.balance ?? STARTING_BALANCE; - if (currentBalance < item.price) { - return void await interaction.editReply({ content: `āŒ You can't afford that! **${item.name}** costs \`$${item.price}\`, but you only have \`$${currentBalance}\`.` }); - } + const shopErrorMessage = format(config.economy.shopCantAfford,{ + name: item.name, + price: item.price, + balance: currentBalance, + }); + if (!(await hasSufficientFunds(interaction, currentBalance, item.price, shopErrorMessage))) return; - // Now that they passed the check, securely create the profile if it doesn't exist if (!profile) { profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); } @@ -418,6 +484,8 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { if (item.roleId && interaction.member instanceof GuildMember) { try { if (item.durationDays) { + // For testing, if your item config gives a small number (like seconds or milliseconds), + // make sure timeToAdd matches it. Assuming durationDays is days: const timeToAdd = item.durationDays * 24 * 60 * 60 * 1000; let tempRole = await TempRole.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId } }); @@ -432,17 +500,40 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } await interaction.member.roles.add(item.roleId, `Purchased ${item.durationDays} day pass.`); - roleGrantedMessage = ` and granted you the <@&${item.roleId}> role for **${item.durationDays} days**!`; + roleGrantedMessage = format(config.economy.tempRole, {roleId: item.roleId, durationDays: item.durationDays}); + + // šŸ”„ INSTANT REMOVAL TIMER + // Calculates the remaining time dynamically and fires exactly when it hits 0 + const msRemaining = item.durationDays * 24 * 60 * 60 * 1000; // Match your duration unit here + const memberRef = interaction.member; + const targetRoleId = item.roleId; + const targetGuildId = interaction.guildId!; + const targetUserId = interaction.user.id; + + setTimeout(async () => { + try { + // Double check the DB to ensure they didn't buy an extension in the meantime + const currentRecord = await TempRole.findOne({ where: { guildId: targetGuildId, userId: targetUserId, roleId: targetRoleId } }); + if (currentRecord && currentRecord.expiresAt <= new Date()) { + if (memberRef.roles.cache.has(targetRoleId)) { + await memberRef.roles.remove(targetRoleId, "šŸ•’ Temporary shop item duration expired."); + } + await currentRecord.destroy(); + } + } catch (err) { + console.error("[Instant Timer Error] Failed to remove role:", err); + } + }, msRemaining); + } else { if (interaction.member.roles.cache.has(item.roleId)) { return void await interaction.editReply({ content: `āŒ You already have this permanent role!` }); } await interaction.member.roles.add(item.roleId, `Purchased permanent role.`); - roleGrantedMessage = ` and granted you the <@&${item.roleId}> role permanently!`; + roleGrantedMessage = format(config.economy.permaRole, {roleId: item.roleId}); } } catch (error) { console.error("Failed to assign shop role:", error); - return void await interaction.editReply({ content: `āŒ Internal Error: Please make sure my bot role is ABOVE the shop role in Server Settings.` }); } } @@ -458,7 +549,10 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { await invItem.save(); } - await interaction.editReply({ content: `šŸŽ‰ Success! You bought **${item.name}** for \`$${item.price}\`${roleGrantedMessage}. Your remaining balance is \`$${profile.balance}\`.` }); + await interaction.editReply({ content: format(config.economy.successBuy, { + name: item.name, price: item.price, message: roleGrantedMessage, balance: profile.balance + }) + }); } async function handleInventory(interaction: ChatInputCommandInteraction) { @@ -477,10 +571,55 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { await interaction.editReply({ embeds: [embed] }); } -async function handleAddMoney(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); - if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); +async function handleUse(interaction: ChatInputCommandInteraction) { + const itemKey = interaction.options.getString("item", true).toLowerCase(); + + // 1. Check if the user actually owns the item + const invItem = await Inventory.findOne({ + where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey } + }); + if (!invItem || invItem.quantity <= 0) { + return void await interaction.editReply({ + content: `āŒ You don't have any \`${itemKey}\` in your inventory! Buy one from the shop first.` + }); + } + + // 2. Fetch the item's data to get the custom useMessage + const shopItem = await ShopItem.findOne({ + where: { guildId: interaction.guildId!, itemId: itemKey } + }); + + if (!shopItem) { + return void await interaction.editReply({ content: `āŒ This item no longer exists in the server shop database.` }); + } + + // 3. Check if it's actually a usable item + if (!shopItem.useMessage) { + return void await interaction.editReply({ + content: `āŒ The **${shopItem.name}** is not a consumable item. (If it's a role item, it was used automatically when you bought it!)` + }); + } + + // 4. Consume the item from their inventory + invItem.quantity -= 1; + if (invItem.quantity <= 0) { + await invItem.destroy(); // Remove the row completely if they are out + } else { + await invItem.save(); // Otherwise just save the lowered quantity + } + + // 5. Send the custom message! + // (Bonus: We replace "{user}" so you can dynamically ping the user in the custom message!) + const customReply = shopItem.useMessage.replace(/{user}/g, `<@${interaction.user.id}>`); + + await interaction.editReply({ + content: `šŸ“¦ **${interaction.user.username}** used a **${shopItem.name}**!\n\n${customReply}` + }); +} + +async function handleAddMoney(interaction: ChatInputCommandInteraction) { + if (!(await hasStaffPermission(interaction))) return; const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); @@ -492,17 +631,21 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { profile.balance += amount; await profile.save(); - const formattedAmount = format(config.economy.currencyFormat, amount); - const formattedBalance = format(config.economy.currencyFormat, profile.balance); - const replyMessage = format(config.economy.addMoney, formattedAmount, targetUser.username, formattedBalance, config.economy.coinEmoji); + // šŸ‘‡ Changed to pass the named object to format() + const formattedAmount = format(config.economy.currencyFormat, { amount: amount }); + const formattedBalance = format(config.economy.currencyFormat, { amount: profile.balance }); + const replyMessage = format(config.economy.addMoney, { + emoji: config.economy.coinEmoji, + added: formattedAmount, + user: targetUser.username, + newBalance: formattedBalance + }); await interaction.editReply({ content: replyMessage }); } async function handleSetBalance(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); - if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); - + if (!(await hasStaffPermission(interaction))) return; const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); @@ -517,50 +660,12 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { await interaction.editReply({ content: `āš™ļø **Database Updated:** ${targetUser.username}'s balance has been explicitly set to \`$${amount}\`.` }); } -async function handleAddShopItem(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); - if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); - - const itemId = interaction.options.getString("id", true).toLowerCase(); - const name = interaction.options.getString("name", true); - const price = interaction.options.getInteger("price", true); - const description = interaction.options.getString("description", true); - const role = interaction.options.getRole("role", false); - const stock = interaction.options.getInteger("stock") ?? -1; - - if (price < 0) return void await interaction.editReply({ content: "āŒ Price cannot be negative." }); - - const [item, created] = await ShopItem.findOrCreate({ - where: { guildId: interaction.guildId!, itemId: itemId }, - defaults: { guildId: interaction.guildId!, itemId: itemId, name: name, price: price, description: description, roleId: role?.id || null, stock: stock } - }); - - if (!created) return void await interaction.editReply({ content: `āŒ An item with the ID \`${itemId}\` already exists!` }); - - const stockMsg = stock === -1 ? "Infinite" : stock.toString(); - await interaction.editReply({ content: `āœ… Created new shop item: **${name}** for \`$${price}\` (Stock: ${stockMsg}).` }); -} - -async function handleRemoveShopItem(interaction: ChatInputCommandInteraction) { - const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId)); - if (!isStaff) return void await interaction.editReply({ content: "āŒ You do not have a required staff role to use this command." }); - - const itemId = interaction.options.getString("id", true).toLowerCase(); - - const deleted = await ShopItem.destroy({ where: { guildId: interaction.guildId!, itemId: itemId } }); - if (deleted === 0) return void await interaction.editReply({ content: `āŒ Could not find an item with the ID \`${itemId}\`.` }); - - await interaction.editReply({ content: `šŸ—‘ļø Successfully removed \`${itemId}\` from the shop.` }); -} - async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { const betAmount = interaction.options.getInteger("amount", true); let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); const currentBalance = profile?.balance ?? STARTING_BALANCE; - if (currentBalance < betAmount) { - return void await interaction.editReply({ content: `āŒ You can't afford that! You only have \`$${currentBalance}\` to your name.` }); - } + if (!(await hasSufficientFunds(interaction, currentBalance, betAmount))) return; if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); @@ -584,9 +689,7 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); const currentBalance = profile?.balance ?? STARTING_BALANCE; - if (currentBalance < betAmount) { - return void await interaction.editReply({ content: `āŒ You only have \`$${currentBalance}\`. You can't bet what you don't own!` }); - } + if (!(await hasSufficientFunds(interaction, currentBalance, betAmount))) return; if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); @@ -608,7 +711,8 @@ interface RouletteBet { userId: string; username: string; amount: number; - betType: "red" | "black" | "even" | "odd"; + betType: "red" | "black" | "even" | "odd" | "number"; + betNumber?: number; } async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { @@ -630,6 +734,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { await thread.send( `šŸŽ” **Roulette Table Opened!** (Closes in ${customSeconds} seconds)\n\n` + `To enter, type your bet choice followed by your amount. **Example: \`red 250\`**\n` + + `• \`0-36 \` (36x payout)\n` + `• \`red \` (2x payout)\n` + `• \`black \` (2x payout)\n` + `• \`even \` (2x payout)\n` + @@ -652,7 +757,10 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { } const validBetTypes = ["red", "black", "even", "odd"]; - if (validBetTypes.includes(commandOrType)) { + const parsedNumber = parseInt(commandOrType, 10); + const isNumberBet = !isNaN(parsedNumber) && parsedNumber >= 0 && parsedNumber <= 36; + + if (validBetTypes.includes(commandOrType) || isNumberBet) { const amountStr = args[1]; if (!amountStr) return void await message.react("āŒ"); @@ -669,7 +777,14 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { profile.balance -= amount; await profile.save(); - bets.push({ userId: message.author.id, username: message.author.username, amount: amount, betType: commandOrType as any }); + // Store the bet, assigning the betNumber if it's a number bet + bets.push({ + userId: message.author.id, + username: message.author.username, + amount: amount, + betType: isNumberBet ? "number" : (commandOrType as any), + betNumber: isNumberBet ? parsedNumber : undefined + }); await message.react("āœ…"); } }); @@ -700,27 +815,26 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const userNetTotals = new Map(); for (const bet of bets) { - let won = false; - if (bet.betType === "red" && color === "red") won = true; - if (bet.betType === "black" && color === "black") won = true; - if (bet.betType === "even" && isEven) won = true; - if (bet.betType === "odd" && isOdd) won = true; + let won = bet.betType === color || (bet.betType === "even" && isEven) || (bet.betType === "odd" && isOdd) || bet.betNumber === winningNumber; + // 2. Quick inline variables for payout and display + let payoutMultiplier = bet.betType === "number" ? 36 : 2; + let betDisplay = bet.betType === "number" ? `Number ${bet.betNumber}` : bet.betType; const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); const currentNet = userNetTotals.get(bet.userId) ?? 0; if (!userBreakdowns.has(bet.userId)) userBreakdowns.set(bet.userId, []); - const formattedBetAmount = format(config.economy.currencyFormat, bet.amount); + const formattedBetAmount = format(config.economy.currencyFormat, { amount: bet.amount }); if (won && profile) { - const winnings = bet.amount * 2; + const winnings = bet.amount * payoutMultiplier; profile.balance += winnings; await profile.save(); - const formattedWinnings = format(config.economy.currencyFormat, winnings); - userBreakdowns.get(bet.userId)!.push(`${bet.betType}: Won ${formattedWinnings}`); - userNetTotals.set(bet.userId, currentNet + bet.amount); + const formattedWinnings = format(config.economy.currencyFormat, { amount: winnings }); + userBreakdowns.get(bet.userId)!.push(`${betDisplay}: Won ${formattedWinnings}`); + userNetTotals.set(bet.userId, currentNet + (winnings - bet.amount)); } else { - userBreakdowns.get(bet.userId)!.push(`${bet.betType}: Lost ${formattedBetAmount}`); + userBreakdowns.get(bet.userId)!.push(`${betDisplay}: Lost ${formattedBetAmount}`); userNetTotals.set(bet.userId, currentNet - bet.amount); } } @@ -729,15 +843,15 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { let outputMessage = `šŸ **The wheel landed on ${winningNumber} ${color.toUpperCase()} ${emoji} !**\n\n`; for (const [userId, breakdownArray] of userBreakdowns.entries()) { - const member = await thread.guild.members.fetch(userId).catch(() => null); - const displayName = member ? member.displayName : `User(${userId})`; + const userMention = `<@${userId}>`; const netValue = userNetTotals.get(userId) ?? 0; let netStatus = "Broke Even!"; - if (netValue > 0) netStatus = `Won Net ${format(config.economy.currencyFormat, netValue)}!`; - else if (netValue < 0) netStatus = `Lost Net ${format(config.economy.currencyFormat, Math.abs(netValue))}!`; + // šŸ‘‡ Update the format() call to pass objects + if (netValue > 0) netStatus = `Won Net ${format(config.economy.currencyFormat, { amount: netValue })}!`; + else if (netValue < 0) netStatus = `Lost Net ${format(config.economy.currencyFormat, { amount: Math.abs(netValue) })}!`; - outputMessage += `**${displayName}**: ${breakdownArray.join(" ")} | **${netStatus}**\n`; + outputMessage += `**${userMention}**:\n${breakdownArray.join("\n")} | **${netStatus}**\n`; } await thread.send(outputMessage); @@ -751,9 +865,14 @@ async function seedDefaultShopItems(guildId: string) { await ShopItem.findOrCreate({ where: {guildId: guildId, name: item.name}, defaults: { - guildId: guildId, itemId: item.itemId, name: item.name, price: item.price, - description: item.description, roleId: item.roleId || null, - durationDays: item.durationDays || null, stock: item.stock + guildId: guildId, + itemId: item.itemId, + name: item.name, + price: item.price, + description: item.description, + roleId: item.roleId || null, + durationDays: item.durationDays || null, + stock: item.stock } }); } From c3369e2a43c6bdbf8b45fa4666cd006679dbe94e Mon Sep 17 00:00:00 2001 From: vmbbi Date: Mon, 6 Jul 2026 01:56:32 +0800 Subject: [PATCH 15/34] added weed and use and cigarettes --- config.json.js | 41 +++++++++++++++---- src/commands/fun/economy.ts | 78 ++++++++++++++++++++++++------------- 2 files changed, 84 insertions(+), 35 deletions(-) diff --git a/config.json.js b/config.json.js index 1df9aa4..b05fa78 100644 --- a/config.json.js +++ b/config.json.js @@ -161,7 +161,7 @@ Consider donating to one of the following people: banTag: "1406738115468722257", bypassId: "1257750834150637599" }, - "economy": { + economy: { currencyFormat: "{amount}$", shopItems: [ { @@ -191,6 +191,22 @@ Consider donating to one of the following people: durationDays: 30, stock: -1 }, + { + itemId: "weed", + name: "WEED", + price: 25, + description: "Purchase to smoke!", + useMessage: "You smoked the weed, feeling peace within", + stock: -1 + }, + { + itemId: "cigarette", + name: "cigarette", + price: 20, + description: "Purchase 500!", + useMessage: "The aroma is most pleasing", + stock: -1 + }, ], teamRole: ["1262624821582364703"], gambleChannel: ["1522846518829125642"], @@ -199,12 +215,23 @@ Consider donating to one of the following people: cantAfford: "āŒ You only have \\`${userBalance}\\`. You don't have enough money to bet!", isntStaff: "āŒ You do not have a required staff role to use this command.", balanceMessage: "{emoji} <@{targetUser}> currently has **${balance}**.", - notItem: "That item doesn't exist in our shop.", - soldOut: "āŒ Sorry, **${name}** is completely sold out!", - shopCantAfford: "`āŒ You can't afford that! **{name}** costs \`${price}\`, but you only have \`${balance}\`.", - successBuy: "šŸŽ‰ Successfully bought **{name}** for \`${price}\`{message}. Your remaining balance is \`$${balance}\`.", - permaRole: " and granted you the <@&{roleId}> role permanently!", - tempRole: ` and granted you the <@&{roleId}> role for **{durationDays} days**!` + shop:{ + notItem: "That item doesn't exist in our shop.", + soldOut: "āŒ Sorry, **${name}** is completely sold out!", + cantAfford: "`āŒ You can't afford that! **{name}** costs \`${price}\`, but you only have \`${balance}\`.", + successBuy: "šŸŽ‰ Successfully bought **{name}** for \`${price}\`{message}. Your remaining balance is \`$${balance}\`.", + permaRole: " and granted you the <@&{roleId}> role permanently!", + tempRole: ` and granted you the <@&{roleId}> role for **{durationDays} days**!`, + permRoleOwned: "āŒ You already have this permanent role!", + notEnough: "āŒ There are only **{stock}** of this item left in stock!", + notMultiple: "āŒ You can only purchase one role-based pass at a time!" + }, + inv:{ + empty: "šŸŽ’ Your inventory is completely empty. Go buy something!", + lack: "āŒ You don't have any \`{item}\` in your inventory! Buy one from the shop first.", + nonexistent: "āŒ This item no longer exists in the server shop database.", + nonconsumable: "āŒ The **{name}** is not a consumable item. (If it's a role item, it was used automatically when you bought it!)" + } }, swear: { period: 60 * 1000, diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 8b6b3ac..954ec22 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -184,13 +184,14 @@ export default { .setName("buy") .setDescription("Purchase an item from the shop") .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to buy (e.g. 'vip_role')").setRequired(true)) + .addIntegerOption((opt) => opt.setName("quantity").setDescription("How many to buy?").setMinValue(1)) ) .addSubcommand((sub) => sub .setName("use") .setDescription("Use a consumable item from your inventory") .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to use").setRequired(true)) - ) + ) .addSubcommand((sub) => sub.setName("inventory").setDescription("View items you currently own")) .addSubcommand((sub) => sub @@ -252,7 +253,7 @@ export default { "wage": true, "leaderboard": true, "inventory": true, - "buy": true, + "buy": false, "add-money": true, "set-balance": true, "inflation": true, @@ -454,20 +455,37 @@ async function handleShop(interaction: ChatInputCommandInteraction) { async function handleBuy(interaction: ChatInputCommandInteraction) { const itemKey = interaction.options.getString("item", true).toLowerCase(); + // 1. Fetch the quantity option from the command (defaults to 1 if empty) + const quantity = interaction.options.getInteger("quantity") ?? 1; + const item = await ShopItem.findOne({ where: { guildId: interaction.guildId!, itemId: itemKey } }); + if (!item) return void await interaction.editReply({ content: config.economy.shop.notItem }); - if (!item) return void await interaction.editReply({ content: config.economy.notItem }); - if (item.stock === 0) return void await interaction.editReply({ content: format(config.economy.soldOut, {name: item.name}) }); + // 2. Check if the shop has enough stock for the requested quantity + if (item.stock !== -1 && item.stock < quantity) { + if (item.stock === 0) { + return void await interaction.editReply({ content: format(config.economy.shop.soldOut, {name: item.name}) }); + } + return void await interaction.editReply({ content: format(config.economy.shop.notEnough, {stock: item.stock} )}); + } + + // 3. Safeguard: Prevent ordering multiples of items that immediately grant roles + if (item.roleId && quantity > 1) { + return void await interaction.editReply({ content: config.economy.shop.notMultiple }); + } let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); const currentBalance = profile?.balance ?? STARTING_BALANCE; - const shopErrorMessage = format(config.economy.shopCantAfford,{ - name: item.name, - price: item.price, + // 4. Calculate total cost for the order + const totalCost = item.price * quantity; + + const shopErrorMessage = format(config.economy.shop.cantAfford,{ + name: quantity > 1 ? `${quantity}x ${item.name}` : item.name, + price: totalCost, balance: currentBalance, }); - if (!(await hasSufficientFunds(interaction, currentBalance, item.price, shopErrorMessage))) return; + if (!(await hasSufficientFunds(interaction, currentBalance, totalCost, shopErrorMessage))) return; if (!profile) { profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); @@ -475,17 +493,17 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { let roleGrantedMessage = ""; + // 5. Apply the correct stock deductions if (item.stock > 0) { - item.stock -= 1; + item.stock -= quantity; await item.save(); } - profile.balance -= item.price; + profile.balance -= totalCost; + // 6. Role Assignment Logic (Safe because quantity is guaranteed to be 1 here) if (item.roleId && interaction.member instanceof GuildMember) { try { if (item.durationDays) { - // For testing, if your item config gives a small number (like seconds or milliseconds), - // make sure timeToAdd matches it. Assuming durationDays is days: const timeToAdd = item.durationDays * 24 * 60 * 60 * 1000; let tempRole = await TempRole.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId } }); @@ -500,11 +518,10 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } await interaction.member.roles.add(item.roleId, `Purchased ${item.durationDays} day pass.`); - roleGrantedMessage = format(config.economy.tempRole, {roleId: item.roleId, durationDays: item.durationDays}); + roleGrantedMessage = format(config.economy.shop.tempRole, {roleId: item.roleId, durationDays: item.durationDays}); - // šŸ”„ INSTANT REMOVAL TIMER - // Calculates the remaining time dynamically and fires exactly when it hits 0 - const msRemaining = item.durationDays * 24 * 60 * 60 * 1000; // Match your duration unit here + // INSTANT REMOVAL TIMER + const msRemaining = item.durationDays * 24 * 60 * 60 * 1000; const memberRef = interaction.member; const targetRoleId = item.roleId; const targetGuildId = interaction.guildId!; @@ -512,7 +529,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { setTimeout(async () => { try { - // Double check the DB to ensure they didn't buy an extension in the meantime const currentRecord = await TempRole.findOne({ where: { guildId: targetGuildId, userId: targetUserId, roleId: targetRoleId } }); if (currentRecord && currentRecord.expiresAt <= new Date()) { if (memberRef.roles.cache.has(targetRoleId)) { @@ -527,10 +543,10 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } else { if (interaction.member.roles.cache.has(item.roleId)) { - return void await interaction.editReply({ content: `āŒ You already have this permanent role!` }); + return void await interaction.editReply({ content: config.economy.shop.permRoleOwned }); } await interaction.member.roles.add(item.roleId, `Purchased permanent role.`); - roleGrantedMessage = format(config.economy.permaRole, {roleId: item.roleId}); + roleGrantedMessage = format(config.economy.shop.permaRole, {roleId: item.roleId}); } } catch (error) { console.error("Failed to assign shop role:", error); @@ -539,25 +555,30 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { await profile.save(); + // 7. Save the bulk amount into the inventory cleanly const [invItem, created] = await Inventory.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, - defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: 1 } + defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: quantity } }); if (!created) { - invItem.quantity += 1; + invItem.quantity += quantity; await invItem.save(); } - await interaction.editReply({ content: format(config.economy.successBuy, { - name: item.name, price: item.price, message: roleGrantedMessage, balance: profile.balance + // 8. Inform the user with total price breakdown + await interaction.editReply({ content: format(config.economy.shop.successBuy, { + name: quantity > 1 ? `${quantity}x ${item.name}` : item.name, + price: totalCost, + message: roleGrantedMessage, + balance: profile.balance }) }); } async function handleInventory(interaction: ChatInputCommandInteraction) { const items = await Inventory.findAll({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); - if (items.length === 0) return void await interaction.editReply({ content: "šŸŽ’ Your inventory is completely empty. Go buy something!" }); + if (items.length === 0) return void await interaction.editReply({ content: config.economy.inv.empty }); const allShopItems = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); const itemManifest = Object.fromEntries(allShopItems.map((i) => [i.itemId, i.name])); @@ -581,7 +602,7 @@ async function handleUse(interaction: ChatInputCommandInteraction) { if (!invItem || invItem.quantity <= 0) { return void await interaction.editReply({ - content: `āŒ You don't have any \`${itemKey}\` in your inventory! Buy one from the shop first.` + content: format(config.economy.inv.lack, {item: itemKey}) }); } @@ -591,13 +612,13 @@ async function handleUse(interaction: ChatInputCommandInteraction) { }); if (!shopItem) { - return void await interaction.editReply({ content: `āŒ This item no longer exists in the server shop database.` }); + return void await interaction.editReply({ content: config.economy.inv.nonexistent }); } // 3. Check if it's actually a usable item if (!shopItem.useMessage) { return void await interaction.editReply({ - content: `āŒ The **${shopItem.name}** is not a consumable item. (If it's a role item, it was used automatically when you bought it!)` + content: format(config.economy.inv.nonconsumable, {name: shopItem.name}) }); } @@ -614,7 +635,7 @@ async function handleUse(interaction: ChatInputCommandInteraction) { const customReply = shopItem.useMessage.replace(/{user}/g, `<@${interaction.user.id}>`); await interaction.editReply({ - content: `šŸ“¦ **${interaction.user.username}** used a **${shopItem.name}**!\n\n${customReply}` + content: `šŸ“¦ **<&${interaction.user.id}>** used a **${shopItem.name}**!\n\n${customReply}` }); } @@ -872,6 +893,7 @@ async function seedDefaultShopItems(guildId: string) { description: item.description, roleId: item.roleId || null, durationDays: item.durationDays || null, + useMessage: item.useMessage || null, stock: item.stock } }); From 7f6980d414610dfe944ec48e2781473823218b66 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Mon, 6 Jul 2026 03:26:40 +0800 Subject: [PATCH 16/34] theo nitpicks --- config.json.js | 9 ++++++++- src/commands/fun/economy.ts | 33 +++++++++++++++++---------------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/config.json.js b/config.json.js index b05fa78..d0e9fab 100644 --- a/config.json.js +++ b/config.json.js @@ -231,7 +231,14 @@ Consider donating to one of the following people: lack: "āŒ You don't have any \`{item}\` in your inventory! Buy one from the shop first.", nonexistent: "āŒ This item no longer exists in the server shop database.", nonconsumable: "āŒ The **{name}** is not a consumable item. (If it's a role item, it was used automatically when you bought it!)" - } + }, + limit: "āŒ Please use an integer smaller than or equal to 1,000,000,000 and bigger than 0", + setBalance:{ + invalid: "āŒ Invalid amount range (0 to 2B).", + setTo: "āš™ļø **Database Updated:** {username}'s balance has been explicitly set to \`${amount}\`." + }, + betWin: "šŸŽ° **JACKPOT!** The {thing} landed in your favor.\n{dice}\nYou won \`${betAmount}\`!\n{emoji} Your new balance is \`${balance}\`.", + betLost: "šŸ“‰ **Bust!** Lady Luck was not on your side today.\n{dice}\nYou lost \`${betAmount}\`.\n{emoji} Your remaining balance is \`${balance}\`." }, swear: { period: 60 * 1000, diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 954ec22..e4f6038 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -644,7 +644,7 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); - if (amount <= 0 || amount > 1000000) return void await interaction.editReply({ content: "āŒ Please use an integer smaller than or equal to 1,000,000 and bigger than 0" }); + if (amount <= 0 || amount > 1000000000) return void await interaction.editReply({ content: config.economy.limit }); let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: targetUser.id } }); if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE }); @@ -670,7 +670,7 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user", true); const amount = interaction.options.getInteger("amount", true); - if (amount < 0 || amount > 2_000_000_000) return void await interaction.editReply({ content: "āŒ Invalid amount range (0 to 2B)." }); + if (amount < 0 || amount > 2_000_000_000) return void await interaction.editReply({ content: config.economy.setBalance.invalid }); let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: targetUser.id } }); if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE }); @@ -678,7 +678,7 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { profile.balance = amount; await profile.save(); - await interaction.editReply({ content: `āš™ļø **Database Updated:** ${targetUser.username}'s balance has been explicitly set to \`$${amount}\`.` }); + await interaction.editReply({ content: format(config.economy.setBalance.setTo, {username: targetUser.username, amount: amount}) }); } async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { @@ -695,11 +695,11 @@ async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { if (isWinner) { profile.balance += betAmount; await profile.save(); - await interaction.editReply({ content: `šŸŽ° **JACKPOT!** The coin landed in your favor. You won \`$${betAmount}\`!\nšŸ’° Your new balance is \`$${profile.balance}\`.` }); + await interaction.editReply({ content: format(config.economy.betWin, {thing: "coin", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance, dice: ""}) }); } else { profile.balance -= betAmount; await profile.save(); - await interaction.editReply({ content: `šŸ“‰ **Bust!** Lady Luck was not on your side today. You lost \`$${betAmount}\`.\nšŸ’ø Your remaining balance is \`$${profile.balance}\`.` }); + await interaction.editReply({ content: format(config.economy.betLost, {dice: "", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance}) }); } } @@ -720,11 +720,11 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { const winnings = betAmount * 5; profile.balance += winnings; await profile.save(); - await interaction.editReply({ content: `šŸŽ² The die rolled a **${diceRoll}**!\nšŸŽ‰ **INCREDIBLE!** You guessed correctly and won \`$${winnings}\`!\nšŸ’° Your new balance is \`$${profile.balance}\`.` }); + await interaction.editReply({ content: format(config.economy.betWin, {thing: "dice", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance, dice: `It rolled a ${diceRoll}`}) }); } else { profile.balance -= betAmount; await profile.save(); - await interaction.editReply({ content: `šŸŽ² The die rolled a **${diceRoll}**...\nšŸ“‰ You guessed ${guess}. You lost your bet of \`$${betAmount}\`.\nšŸ’ø Your remaining balance is \`$${profile.balance}\`.` }); + await interaction.editReply({ content: format(config.economy.betLost, {dice: `The dice rolled ${diceRoll} while you guessed ${guess}`, betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance}) }); } } @@ -732,7 +732,7 @@ interface RouletteBet { userId: string; username: string; amount: number; - betType: "red" | "black" | "even" | "odd" | "number"; + betType: "red" | "black" | "even" | "odd" | "number" | "green"; betNumber?: number; } @@ -755,9 +755,10 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { await thread.send( `šŸŽ” **Roulette Table Opened!** (Closes in ${customSeconds} seconds)\n\n` + `To enter, type your bet choice followed by your amount. **Example: \`red 250\`**\n` + - `• \`0-36 \` (36x payout)\n` + - `• \`red \` (2x payout)\n` + - `• \`black \` (2x payout)\n` + + `• \`0-36 \` (8x payout)\n` + + `• \`green \` (8x payout) 🟢\n` + // šŸ‘ˆ Added to instructions + `• \`red \` (2x payout) šŸ”“\n` + + `• \`black \` (2x payout) ⚫\n` + `• \`even \` (2x payout)\n` + `• \`odd \` (2x payout)\n\n` + `šŸ‘ _The bot will react with āœ… if your bet is accepted, or āŒ if something is wrong._\n` + @@ -777,7 +778,8 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { return; } - const validBetTypes = ["red", "black", "even", "odd"]; + // šŸ‘ˆ Added "green" to the allowed string types here + const validBetTypes = ["red", "black", "even", "odd", "green"]; const parsedNumber = parseInt(commandOrType, 10); const isNumberBet = !isNaN(parsedNumber) && parsedNumber >= 0 && parsedNumber <= 36; @@ -798,7 +800,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { profile.balance -= amount; await profile.save(); - // Store the bet, assigning the betNumber if it's a number bet bets.push({ userId: message.author.id, username: message.author.username, @@ -837,8 +838,9 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { for (const bet of bets) { let won = bet.betType === color || (bet.betType === "even" && isEven) || (bet.betType === "odd" && isOdd) || bet.betNumber === winningNumber; - // 2. Quick inline variables for payout and display - let payoutMultiplier = bet.betType === "number" ? 36 : 2; + + // šŸ‘ˆ Update payout check so BOTH number bets and explicit "green" bets reward 36x payout + let payoutMultiplier = (bet.betType === "number" || bet.betType === "green") ? 36 : 2; let betDisplay = bet.betType === "number" ? `Number ${bet.betNumber}` : bet.betType; const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); @@ -868,7 +870,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const netValue = userNetTotals.get(userId) ?? 0; let netStatus = "Broke Even!"; - // šŸ‘‡ Update the format() call to pass objects if (netValue > 0) netStatus = `Won Net ${format(config.economy.currencyFormat, { amount: netValue })}!`; else if (netValue < 0) netStatus = `Lost Net ${format(config.economy.currencyFormat, { amount: Math.abs(netValue) })}!`; From 33be7dcf88518f86bff2b785efd6c87fb4c24387 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Mon, 6 Jul 2026 03:35:00 +0800 Subject: [PATCH 17/34] theo nitpicks --- config.json.js | 4 ++-- src/commands/fun/economy.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config.json.js b/config.json.js index d0e9fab..5b4bda4 100644 --- a/config.json.js +++ b/config.json.js @@ -210,7 +210,7 @@ Consider donating to one of the following people: ], teamRole: ["1262624821582364703"], gambleChannel: ["1522846518829125642"], - addMoney: "{emoji} **Transaction Complete:** Successfully added `{added}` to {user}'s profile. Their new balance is `{newBalance}`.", + addMoney: "{emoji} **Transaction Complete:** Successfully added `{added}` to <@{user}>'s profile. Their new balance is `{newBalance}`.", coinEmoji: "<:al_logo:1492686347666980944>", cantAfford: "āŒ You only have \\`${userBalance}\\`. You don't have enough money to bet!", isntStaff: "āŒ You do not have a required staff role to use this command.", @@ -235,7 +235,7 @@ Consider donating to one of the following people: limit: "āŒ Please use an integer smaller than or equal to 1,000,000,000 and bigger than 0", setBalance:{ invalid: "āŒ Invalid amount range (0 to 2B).", - setTo: "āš™ļø **Database Updated:** {username}'s balance has been explicitly set to \`${amount}\`." + setTo: "āš™ļø **Database Updated:** <@{user}>'s balance has been explicitly set to \`${amount}\`." }, betWin: "šŸŽ° **JACKPOT!** The {thing} landed in your favor.\n{dice}\nYou won \`${betAmount}\`!\n{emoji} Your new balance is \`${balance}\`.", betLost: "šŸ“‰ **Bust!** Lady Luck was not on your side today.\n{dice}\nYou lost \`${betAmount}\`.\n{emoji} Your remaining balance is \`${balance}\`." diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index e4f6038..eaa9a76 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -588,7 +588,7 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { return `${visualName} x\`${item.quantity}\``; }).join("\n"); - const embed = new EmbedBuilder().setTitle(`šŸŽ’ ${interaction.user.username}'s Inventory`).setDescription(inventoryList).setColor(0x00ae86); + const embed = new EmbedBuilder().setTitle(`šŸŽ’ <@${interaction.user.id}>'s Inventory`).setDescription(inventoryList).setColor(0x00ae86); await interaction.editReply({ embeds: [embed] }); } @@ -658,7 +658,7 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { const replyMessage = format(config.economy.addMoney, { emoji: config.economy.coinEmoji, added: formattedAmount, - user: targetUser.username, + user: targetUser.id, newBalance: formattedBalance }); @@ -678,7 +678,7 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { profile.balance = amount; await profile.save(); - await interaction.editReply({ content: format(config.economy.setBalance.setTo, {username: targetUser.username, amount: amount}) }); + await interaction.editReply({ content: format(config.economy.setBalance.setTo, {user: targetUser.id, amount: amount}) }); } async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { @@ -741,11 +741,11 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const timeMs = customSeconds * 1000; const initialReply = await interaction.editReply({ - content: `šŸŽ° **${interaction.user.username}** opened a Roulette Table for **${customSeconds} seconds**! Join the thread below to place your bets.` + content: `šŸŽ° **<@${interaction.user.id}>** opened a Roulette Table for **${customSeconds} seconds**! Join the thread below to place your bets.` }); const thread = await initialReply.startThread({ - name: `šŸŽ° Roulette Table - ${interaction.user.username}`, + name: `šŸŽ° Roulette Table - <@${interaction.user.id}>`, autoArchiveDuration: 60, reason: "Roulette Game Room" }); From 809cbf282376d76b2e96d9d4a21a44908ff9a79a Mon Sep 17 00:00:00 2001 From: vmbbi Date: Mon, 6 Jul 2026 03:41:10 +0800 Subject: [PATCH 18/34] fix & not @ --- src/commands/fun/economy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index eaa9a76..4f3b9b6 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -635,7 +635,7 @@ async function handleUse(interaction: ChatInputCommandInteraction) { const customReply = shopItem.useMessage.replace(/{user}/g, `<@${interaction.user.id}>`); await interaction.editReply({ - content: `šŸ“¦ **<&${interaction.user.id}>** used a **${shopItem.name}**!\n\n${customReply}` + content: `šŸ“¦ **<@${interaction.user.id}>** used a **${shopItem.name}**!\n\n${customReply}` }); } From 6ac43038056525010743ba4efbb919500503eb8d Mon Sep 17 00:00:00 2001 From: vmbbi Date: Thu, 9 Jul 2026 00:15:38 +0800 Subject: [PATCH 19/34] theos final? nitpicks --- config.json.js | 16 ++-- src/commands/fun/economy.ts | 167 ++++++++++++++++++++---------------- src/util/paginator2.ts | 80 +++++++++++++++++ 3 files changed, 179 insertions(+), 84 deletions(-) create mode 100644 src/util/paginator2.ts diff --git a/config.json.js b/config.json.js index 5b4bda4..0a21911 100644 --- a/config.json.js +++ b/config.json.js @@ -191,14 +191,6 @@ Consider donating to one of the following people: durationDays: 30, stock: -1 }, - { - itemId: "weed", - name: "WEED", - price: 25, - description: "Purchase to smoke!", - useMessage: "You smoked the weed, feeling peace within", - stock: -1 - }, { itemId: "cigarette", name: "cigarette", @@ -238,7 +230,13 @@ Consider donating to one of the following people: setTo: "āš™ļø **Database Updated:** <@{user}>'s balance has been explicitly set to \`${amount}\`." }, betWin: "šŸŽ° **JACKPOT!** The {thing} landed in your favor.\n{dice}\nYou won \`${betAmount}\`!\n{emoji} Your new balance is \`${balance}\`.", - betLost: "šŸ“‰ **Bust!** Lady Luck was not on your side today.\n{dice}\nYou lost \`${betAmount}\`.\n{emoji} Your remaining balance is \`${balance}\`." + betLost: "šŸ“‰ **Bust!** Lady Luck was not on your side today.\n{dice}\nYou lost \`${betAmount}\`.\n{emoji} Your remaining balance is \`${balance}\`.", + wages: { + defaultAmount: 0, + roleSalaries: { + "1262624821582364703": 500, + } + } }, swear: { period: 60 * 1000, diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 4f3b9b6..68ec532 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -20,8 +20,10 @@ import { } from "sequelize"; import type { Cmd } from "~/util/base"; import { format } from "~/util/base"; -import randomUtils from "~/util/rnd"; +import rnd from "~/util/rnd"; import config from "config.json"; +import { randomInt } from "crypto"; +import { paginate } from "../../util/paginator2.ts"; // 2. Define the Database Models export class EconomyProfile extends Model< @@ -250,9 +252,9 @@ export default { const EPHEMERAL_MAPPING: Record = { "balance": true, - "wage": true, "leaderboard": true, "inventory": true, + "wage": true, "buy": false, "add-money": true, "set-balance": true, @@ -307,9 +309,9 @@ export default { case null: default: { switch (sub) { - case "wage": return await handleWage(interaction); case "inflation": return await handleInflation(interaction); case "leaderboard": return await handleLeaderboard(interaction); + case "wage": return await handleWage(interaction) case "balance": return await handleBalance(interaction); case "shop": return await handleShop(interaction); case "buy": return await handleBuy(interaction); @@ -340,6 +342,24 @@ async function hasSufficientFunds( return true; } +function calculateWage(member: GuildMember): number { + const wageConfig = config.economy.wages; + + // 1. Start with an array containing just the baseline default wage + const matchingSalaries: number[] = [wageConfig.defaultAmount]; + + // 2. Map through the config roles. If the member has the role, push its salary to the array + for (const [roleId, salary] of Object.entries(wageConfig.roleSalaries)) { + if (member.roles.cache.has(roleId)) { + matchingSalaries.push(salary as number); + } + } + + // 3. Return the absolute highest value found. + // If they have no special roles, Math.max(100) safely returns 100! + return Math.max(...matchingSalaries); +} + /** * Checks if the user has a required staff role. If not, it replies with an error and returns false. */ @@ -358,31 +378,41 @@ async function hasStaffPermission(interaction: ChatInputCommandInteraction): Pro // ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── async function handleWage(interaction: ChatInputCommandInteraction) { - let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); - const now = new Date(); + if (!interaction.inCachedGuild()) return; - if (profile && profile.lastWageClaim) { - const diffMs = now.getTime() - profile.lastWageClaim.getTime(); - const diffHours = diffMs / (1000 * 60 * 60); + let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId, userId: interaction.user.id } }); + if (!profile) { + profile = await EconomyProfile.create({ guildId: interaction.guildId, userId: interaction.user.id, balance: STARTING_BALANCE }); + } - if (diffHours < WAGE_COOLDOWN_HOURS) { - const remainingHours = Math.ceil(WAGE_COOLDOWN_HOURS - diffHours); + const now = new Date(); + const cooldownMs = WAGE_COOLDOWN_HOURS * 60 * 60 * 1000; + + // 1. Check Cooldown + if (profile.lastWageClaim) { + const timeSinceLastClaim = now.getTime() - profile.lastWageClaim.getTime(); + if (timeSinceLastClaim < cooldownMs) { + const remainingMs = cooldownMs - timeSinceLastClaim; + const remainingHours = (remainingMs / (1000 * 60 * 60)).toFixed(1); return void await interaction.editReply({ - content: `ā³ You have already collected your wage recently! Come back in **${remainingHours} hours**.` + content: `ā³ You are still on cooldown! Please wait **${remainingHours} hours** before claiming your next wage.` }); } } - if (!profile) { - profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); - } + // 2. THIS IS WHERE calculateWage IS USED! šŸš€ + const salaryAmount = calculateWage(interaction.member); - profile.balance += WAGE_AMOUNT; + // 3. Apply the money and reset the cooldown timer + profile.balance += salaryAmount; profile.lastWageClaim = now; await profile.save(); + const formattedSalary = format(config.economy.currencyFormat, { amount: salaryAmount }); + const formattedBalance = format(config.economy.currencyFormat, { amount: profile.balance }); + await interaction.editReply({ - content: `šŸ’µ You clocked in and collected your wage of **$${WAGE_AMOUNT}**! Your new balance is **$${profile.balance}**.` + content: `šŸ’µ You worked a hard shift and claimed your wage of **${formattedSalary}**!\nšŸ¦ **New Balance:** ${formattedBalance}` }); } @@ -690,9 +720,9 @@ async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); - const isWinner = randomUtils.pickRandom([true, false]); + const isWinner = randomInt(0,2); - if (isWinner) { + if (isWinner == 1) { profile.balance += betAmount; await profile.save(); await interaction.editReply({ content: format(config.economy.betWin, {thing: "coin", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance, dice: ""}) }); @@ -714,7 +744,7 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); - const diceRoll = randomUtils.getRandomIntInclusive(1, 6); + const diceRoll = randomInt(1, 7); if (guess === diceRoll) { const winnings = betAmount * 5; @@ -823,7 +853,8 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { return; } - const winningNumber = Math.floor(Math.random() * 37); + + const winningNumber = randomInt(0, 37); const redNumbers = [1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]; let color: "green" | "red" | "black" = "green"; if (winningNumber > 0) color = redNumbers.includes(winningNumber) ? "red" : "black"; @@ -902,68 +933,54 @@ async function seedDefaultShopItems(guildId: string) { } async function handleLeaderboard(interaction: ChatInputCommandInteraction) { - const PAGE_SIZE = 10; - let currentPage = 1; - - const actualCount = await EconomyProfile.count({ where: { guildId: interaction.guildId! } }); - const totalProfiles = Math.min(actualCount, 100); - if (totalProfiles === 0) return void await interaction.editReply("šŸ“‰ The economy is completely empty. Nobody has any money yet!"); - const maxPage = Math.ceil(totalProfiles / PAGE_SIZE); - - const generatePage = async (page: number) => { - const offset = (page - 1) * PAGE_SIZE; - const topProfiles = await EconomyProfile.findAll({ - where: { guildId: interaction.guildId! }, - attributes: { include: [[Sequelize.literal('(RANK() OVER (ORDER BY balance DESC))'), 'rank']] }, - order: [['balance', 'DESC']], limit: PAGE_SIZE, offset: offset - }); + // 1. Fetch all profiles in the current guild, sorted by balance descending + const profiles = await EconomyProfile.findAll({ + where: { guildId: interaction.guildId! }, + order: [["balance", "DESC"]] + }); - const descriptionLines = topProfiles.map((profile) => { - const rank = profile.get('rank') as number; - const userMention = `<@${profile.userId}>`; - let rankEmoji = "šŸ”¹"; - if (rank === 1) rankEmoji = "šŸ„‡"; - else if (rank === 2) rankEmoji = "🄈"; - else if (rank === 3) rankEmoji = "šŸ„‰"; - else rankEmoji = `**#${rank}**`; - return `${rankEmoji} ${userMention} — **$${profile.balance}**`; + if (profiles.length === 0) { + return void await interaction.editReply({ + content: "šŸ“‰ The leaderboard is currently empty! No one has a bank account yet." }); + } - return new EmbedBuilder() - .setTitle("šŸ† Economy Leaderboard") - .setDescription(descriptionLines.join("\n") || "No players found.") - .setColor(0xFFD700) - .setFooter({ text: `Page ${page} of ${maxPage} | Total Players: ${totalProfiles}` }); - }; - - const generateButtons = (page: number) => { - const row = new ActionRowBuilder(); - row.addComponents( - new ButtonBuilder().setCustomId('economy:prev_page').setLabel('ā—€ Previous').setStyle(ButtonStyle.Primary).setDisabled(page === 1), - new ButtonBuilder().setCustomId('economy:next_page').setLabel('Next ā–¶').setStyle(ButtonStyle.Primary).setDisabled(page === maxPage) - ); - return row; - }; + const USERS_PER_PAGE = 4; + const pages: EmbedBuilder[] = []; + const totalPages = Math.ceil(profiles.length / USERS_PER_PAGE); - const initialEmbed = await generatePage(currentPage); - const components = maxPage > 1 ? [generateButtons(currentPage)] : []; + // 2. Loop through profiles and slice them into chunks of 10 + for (let i = 0; i < profiles.length; i += USERS_PER_PAGE) { + const chunk = profiles.slice(i, i + USERS_PER_PAGE); + const currentPage = Math.floor(i / USERS_PER_PAGE) + 1; - const message = await interaction.editReply({ embeds: [initialEmbed], components: components }); - if (maxPage <= 1) return; + const embed = new EmbedBuilder() + .setTitle(`šŸ† ${interaction.guild?.name || "Server"} Wealth Leaderboard`) + .setColor("#F1C40F") // Clean Gold Color + .setTimestamp(); - const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, time: 60000 }); + let description = ""; - collector.on("collect", async (i) => { - await i.deferUpdate(); - if (i.customId === 'economy:prev_page') currentPage--; - if (i.customId === 'economy:next_page') currentPage++; + // 3. Build the text rows for the current page chunk + chunk.forEach((profile, index) => { + const globalRank = i + index + 1; + let rankDisplay = `**#${globalRank}**`; - await i.editReply({ embeds: [await generatePage(currentPage)], components: [generateButtons(currentPage)] }); - }); + // Style up the top 3 with shiny medals + if (globalRank === 1) rankDisplay = "šŸ„‡"; + else if (globalRank === 2) rankDisplay = "🄈"; + else if (globalRank === 3) rankDisplay = "šŸ„‰"; - collector.on("end", async () => { - const disabledRow = generateButtons(currentPage); - disabledRow.components.forEach(c => c.setDisabled(true)); - await interaction.editReply({ components: [disabledRow] }).catch(() => null); - }); + const formattedBalance = format(config.economy.currencyFormat, { amount: profile.balance }); + description += `${rankDisplay} <@${profile.userId}> — **${formattedBalance}**\n`; + }); + + embed.setDescription(description); + embed.setFooter({ text: `Page ${currentPage} of ${totalPages} • Total Players: ${profiles.length}` }); + + pages.push(embed); + } + + // 4. Pass the array of embeds into your pagination utility! + await paginate(interaction, pages); } \ No newline at end of file diff --git a/src/util/paginator2.ts b/src/util/paginator2.ts new file mode 100644 index 0000000..88d9915 --- /dev/null +++ b/src/util/paginator2.ts @@ -0,0 +1,80 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + ComponentType, + EmbedBuilder +} from "discord.js"; + +/** + * Paginates an array of EmbedBuilders with interactive Previous/Next buttons. + * + * @param interaction The initial command interaction + * @param pages An array of EmbedBuilders representing the pages + * @param timeout How long the buttons should remain active in milliseconds (default: 60s) + */ +export async function paginate( + interaction: ChatInputCommandInteraction, + pages: EmbedBuilder[], + timeout: number = 60000 +) { + if (!pages || pages.length === 0) throw new Error("Pages array cannot be empty."); + + // If there's only one page, just send it without buttons + if (pages.length === 1) { + return await interaction.editReply({ embeds: [pages[0]], components: [] }); + } + + let index = 0; + + const prevButton = new ButtonBuilder() + .setCustomId("prev") + .setLabel("ā—€ Previous") + .setStyle(ButtonStyle.Secondary) + .setDisabled(true); // Disabled on the first page + + const nextButton = new ButtonBuilder() + .setCustomId("next") + .setLabel("Next ā–¶") + .setStyle(ButtonStyle.Primary); + + const getRow = () => new ActionRowBuilder().addComponents(prevButton, nextButton); + + const message = await interaction.editReply({ + embeds: [pages[index]], + components: [getRow()] + }); + + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: timeout, + // Ensure only the person who ran the command can click the buttons + filter: (i) => i.user.id === interaction.user.id + }); + + collector.on("collect", async (i) => { + if (i.customId === "prev") index--; + else if (i.customId === "next") index++; + + // Dynamically disable buttons based on the new index + prevButton.setDisabled(index === 0); + nextButton.setDisabled(index === pages.length - 1); + + await i.update({ + embeds: [pages[index]], + components: [getRow()] + }); + }); + + collector.on("end", async () => { + // When time expires, disable all buttons and edit the message + prevButton.setDisabled(true); + nextButton.setDisabled(true); + + await message.edit({ components: [getRow()] }).catch(() => { + // Catch error in case the message was deleted before the timer ended + console.warn("Could not disable pagination buttons (message deleted)."); + }); + }); +} \ No newline at end of file From c12f9000a67ddcf41d0f95b2f1523fd29bb1c875 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Thu, 9 Jul 2026 00:32:58 +0800 Subject: [PATCH 20/34] added auto complete and changed prices --- config.json.js | 4 ++-- src/commands/fun/economy.ts | 36 +++++++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/config.json.js b/config.json.js index 0a21911..237701f 100644 --- a/config.json.js +++ b/config.json.js @@ -167,7 +167,7 @@ Consider donating to one of the following people: { itemId: "beta_role_3", name: "Beta Access for 3 days", - price: 2500, + price: 500, description: "Purchase for access to beta builds!", roleId: "1510652320432521327", durationDays: 3, @@ -176,7 +176,7 @@ Consider donating to one of the following people: { itemId: "beta_role_7", name: "Beta Access for 7 days", - price: 2500, + price: 1000, description: "Purchase for access to beta builds!", roleId: "1510652320432521327", durationDays: 7, diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 68ec532..d123364 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -2,12 +2,7 @@ import { EmbedBuilder, ChatInputCommandInteraction, GuildMember, - Message, - type TextChannel, - ComponentType, - ButtonStyle, - ButtonBuilder, - ActionRowBuilder, + AutocompleteInteraction, MessageFlags } from "discord.js"; import { @@ -185,7 +180,7 @@ export default { sub .setName("buy") .setDescription("Purchase an item from the shop") - .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to buy (e.g. 'vip_role')").setRequired(true)) + .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to buy (e.g. 'vip_role')").setRequired(true).setAutocomplete(true)) .addIntegerOption((opt) => opt.setName("quantity").setDescription("How many to buy?").setMinValue(1)) ) .addSubcommand((sub) => @@ -248,6 +243,33 @@ export default { }, onInteraction: async (ctx, interaction) => { + if (interaction.isAutocomplete()) { + if (!interaction.guildId) return void await interaction.respond([]); + + const sub = interaction.options.getSubcommand(false); + if (sub === "buy") { + const focusedValue = interaction.options.getFocused().toLowerCase(); + + // Fetch the active shop products for this server + const items = await ShopItem.findAll({ where: { guildId: interaction.guildId } }); + + // Filter choices against both item name and itemId configurations + const filtered = items.filter(item => + item.name.toLowerCase().includes(focusedValue) || + item.itemId.toLowerCase().includes(focusedValue) + ); + + // Respond to Discord (capped at API maximum of 25 choices) + return void await interaction.respond( + filtered.slice(0, 25).map(item => ({ + name: `${item.name} — $${item.price}`, + value: item.itemId + })) + ); + } + return; + } + if (!interaction.isChatInputCommand()) return; const EPHEMERAL_MAPPING: Record = { From 46ea5dbd883d34ad2f52f81453bda5334ee78537 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Thu, 9 Jul 2026 01:08:23 +0800 Subject: [PATCH 21/34] other stuff --- src/commands/fun/economy.ts | 4 ++-- src/index.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index d123364..c33ed35 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -267,7 +267,7 @@ export default { })) ); } - return; + void await interaction.respond([]); } if (!interaction.isChatInputCommand()) return; @@ -967,7 +967,7 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { }); } - const USERS_PER_PAGE = 4; + const USERS_PER_PAGE = 10; const pages: EmbedBuilder[] = []; const totalPages = Math.ceil(profiles.length / USERS_PER_PAGE); diff --git a/src/index.ts b/src/index.ts index fa1af43..850f13c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,11 +201,18 @@ ctx.client.on(Events.InteractionCreate, async (interaction) => { ctx.lastUse = Date.now(); + // Handle Autocomplete interactions upfront and return early so they never fall through to chat command logic + if (interaction.isAutocomplete()) { + let handler = handlers[interaction.commandName]; + if (handler?.onInteraction) handler.onInteraction(ctx, interaction); + return; + } + let handlerId: string | undefined; if (interaction.isButton() || interaction.isModalSubmit()) { const ic = interaction.customId.indexOf(":"); handlerId = interaction.customId.substring(0, ic); - } else if (interaction.isChatInputCommand() || interaction.isAutocomplete()) { + } else if (interaction.isChatInputCommand()) { handlerId = interaction.commandName; } From 06a9a2312522e7325e17962d9d17576c1db429bf Mon Sep 17 00:00:00 2001 From: vmbbi Date: Thu, 9 Jul 2026 01:38:54 +0800 Subject: [PATCH 22/34] improved search? --- src/commands/fun/economy.ts | 2 +- src/commands/support/search.ts | 150 +++++++++++++++++++++------------ 2 files changed, 98 insertions(+), 54 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index c33ed35..7686940 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -267,7 +267,7 @@ export default { })) ); } - void await interaction.respond([]); + return void await interaction.respond([]); } if (!interaction.isChatInputCommand()) return; diff --git a/src/commands/support/search.ts b/src/commands/support/search.ts index c469a18..05ffe0b 100644 --- a/src/commands/support/search.ts +++ b/src/commands/support/search.ts @@ -1,23 +1,64 @@ -import type { - Interaction, - InteractionReplyOptions, - Message, - MessagePayload, - SendableChannels, - SharedSlashCommand, - SlashCommandBuilder, +import { + EmbedBuilder, + type Interaction, + type Message, + type SendableChannels, + type SharedSlashCommand, + type SlashCommandBuilder, + type ChatInputCommandInteraction, } from "discord.js"; import config from "config.json"; import { format, type Cmd, type CmdData, type Ctx } from "~/util/base"; -import path from "node:path"; -import { logger } from "~/util/logger"; -import { - paginate, - paginateReply, - paginateReplyMessage, -} from "~/util/paginator"; +import { paginate } from "~/util/paginator2"; import { createContentHighlighter } from "~/util/highlighter"; +function buildSearchEmbeds(query: string, body: string): EmbedBuilder[] { + const lines = body.split("\n"); + const pages: EmbedBuilder[] = []; + let currentDescription = ""; + const maxChars = 2000; + + for (const line of lines) { + if (currentDescription.length + line.length + 1 > maxChars) { + if (currentDescription.trim()) { + pages.push( + new EmbedBuilder() + .setTitle(`šŸ” Wiki Search Results: "${query}"`) + .setColor("#2B2D31") + .setDescription(currentDescription.trim()) + ); + } + currentDescription = line + "\n"; + } else { + currentDescription += line + "\n"; + } + } + + if (currentDescription.trim()) { + pages.push( + new EmbedBuilder() + .setTitle(`šŸ” Wiki Search Results: "${query}"`) + .setColor("#2B2D31") + .setDescription(currentDescription.trim()) + ); + } + + if (pages.length === 0) { + pages.push( + new EmbedBuilder() + .setTitle(`šŸ” Wiki Search Results: "${query}"`) + .setColor("#2B2D31") + .setDescription(body || "*No results found.*") + ); + } + + pages.forEach((embed, index) => { + embed.setFooter({ text: `Page ${index + 1} of ${pages.length}` }); + }); + + return pages; +} + async function printSearchResultsV2(ctx: Ctx, query: string): Promise { const result = await ctx.search.search(query); const msg = [config.wikisearch.format.header]; @@ -28,27 +69,19 @@ async function printSearchResultsV2(ctx: Ctx, query: string): Promise { } const highlighter = createContentHighlighter(query); - let pageCounter = 0; for (const res of result) { switch (res.type) { case "page": msg.push( - format(config.wikisearch.format.page, { - num: pageCounter + 1, - title: res.content, - url: config.wikisearch.baseUrl + res.url, - }), + format(config.wikisearch.format.page, { + num: pageCounter + 1, + title: res.content, + url: config.wikisearch.baseUrl + res.url, + }), ); - - msg.push( - format( - config.wikisearch.format.breadcrumbs, - res.breadcrumbs?.join(" āÆ "), - ), - ); - + msg.push(format(config.wikisearch.format.breadcrumbs, res.breadcrumbs?.join(" āÆ "))); pageCounter += 1; break; @@ -58,11 +91,10 @@ async function printSearchResultsV2(ctx: Ctx, query: string): Promise { case "text": const content = highlighter - .highlightMarkdown(res.content) - .split("\n") - .map((s) => format(config.wikisearch.format.text, s)) - .join("\n"); - + .highlightMarkdown(res.content) + .split("\n") + .map((s) => format(config.wikisearch.format.text, s)) + .join("\n"); msg.push(content); break; } @@ -74,28 +106,40 @@ async function printSearchResultsV2(ctx: Ctx, query: string): Promise { async function onInteraction(ctx: Ctx, interaction: Interaction) { if (!interaction.isChatInputCommand()) return; +await interaction.deferReply() + + // 2. Safe execution space const query = interaction.options.getString("query", true); + const body = await printSearchResultsV2(ctx, query); + const pages = buildSearchEmbeds(query, body); - await paginateReply( - interaction, - await Promise.all([ - format(config.wikisearch.format.results, query), - printSearchResultsV2(ctx, query), - ]), - ); + await paginate(interaction, pages); } async function searchByQuery(ctx: Ctx, message: Message, query: string) { const target = message.reference ? await message.fetchReference() : message; + const body = await printSearchResultsV2(ctx, query); + const pages = buildSearchEmbeds(query, body); + + const initialMessage = await target.reply({ + embeds: [pages[0]] + }); - await paginateReplyMessage(target, await printSearchResultsV2(ctx, query)); + const messageShimObject = { + user: message.author, + editReply: async (options: any) => { + return await initialMessage.edit(options); + }, + } as unknown as ChatInputCommandInteraction; + + await paginate(messageShimObject, pages); } async function execute( - ctx: Ctx, - message: Message, - channel: SendableChannels, - args: string[], + ctx: Ctx, + message: Message, + channel: SendableChannels, + args: string[], ) { const query = args.join(" "); await searchByQuery(ctx, message, query); @@ -103,10 +147,10 @@ async function execute( function slash(builder: SlashCommandBuilder): SharedSlashCommand { return builder - .setDescription("Search the wiki.") - .addStringOption((option) => - option.setName("query").setRequired(true).setDescription("Search query."), - ); + .setDescription("Search the wiki.") + .addStringOption((option) => + option.setName("query").setRequired(true).setDescription("Search query."), + ); } const data: CmdData = { @@ -117,9 +161,9 @@ export default { data, slash, onInteraction, - searchByQuery, // Added so your ThreadCreate setup can see it - printSearchResultsV2, // Added so Line 112 in support.ts can see it + searchByQuery, + printSearchResultsV2, } as Cmd & { searchByQuery: (ctx: Ctx, message: Message, query: string) => Promise; printSearchResultsV2: (ctx: Ctx, query: string) => Promise; -}; +}; \ No newline at end of file From b7525ac56981fe0a91b1266fd18649fcdb3076a9 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Thu, 9 Jul 2026 18:51:24 +0800 Subject: [PATCH 23/34] theos real final? nitpicks --- config.json.js | 31 +++++++++-- src/commands/fun/economy.ts | 105 ++++++++++++++++++------------------ src/index.ts | 9 +--- 3 files changed, 83 insertions(+), 62 deletions(-) diff --git a/config.json.js b/config.json.js index 237701f..cb61312 100644 --- a/config.json.js +++ b/config.json.js @@ -1,6 +1,6 @@ export default { guildId: ["1213989169878274068"], - clientId: "1287095017596387500", + clientId: "1520481807458504774", logging: "debug", welcome: { channel: "1213989170964340878", @@ -162,7 +162,6 @@ Consider donating to one of the following people: bypassId: "1257750834150637599" }, economy: { - currencyFormat: "{amount}$", shopItems: [ { itemId: "beta_role_3", @@ -202,7 +201,7 @@ Consider donating to one of the following people: ], teamRole: ["1262624821582364703"], gambleChannel: ["1522846518829125642"], - addMoney: "{emoji} **Transaction Complete:** Successfully added `{added}` to <@{user}>'s profile. Their new balance is `{newBalance}`.", + addMoney: "{emoji} **Transaction Complete:** Successfully added `${added}` to <@{user}>'s profile. Their new balance is `${newBalance}`.", coinEmoji: "<:al_logo:1492686347666980944>", cantAfford: "āŒ You only have \\`${userBalance}\\`. You don't have enough money to bet!", isntStaff: "āŒ You do not have a required staff role to use this command.", @@ -231,7 +230,33 @@ Consider donating to one of the following people: }, betWin: "šŸŽ° **JACKPOT!** The {thing} landed in your favor.\n{dice}\nYou won \`${betAmount}\`!\n{emoji} Your new balance is \`${balance}\`.", betLost: "šŸ“‰ **Bust!** Lady Luck was not on your side today.\n{dice}\nYou lost \`${betAmount}\`.\n{emoji} Your remaining balance is \`${balance}\`.", + roulette: { + openMessage: "šŸŽ° **<@{userId}>** opened a Roulette Table for **{seconds} seconds**! Join the thread below to place your bets.", + threadName: "šŸŽ° Roulette Table - {username}", + guideMessage: + "šŸŽ” **Roulette Table Opened!** (Closes in {seconds} seconds)\n\n" + + "To enter, type your bet choice followed by your amount. " + + "**Example: `red 250`**\n" + + "• `0-36 ` (8x payout)\n" + + "• `green ` (8x payout) 🟢\n" + + "• `red ` (2x payout) šŸ”“\n" + + "• `black ` (2x payout) ⚫\n" + + "• `even ` (2x payout)\n" + + "• `odd ` (2x payout)\n\n" + + " _The bot will react with āœ… if your bet is accepted, or āŒ if something is wrong._\n" + + "šŸ‘‘ **<@{userId}>**, type `spin` when everyone is ready!", + inactivityMessage: "ā° Table closed automatically due to inactivity.", + spinningMessage: "✨ *The wheel is spinning...* ✨", + resultHeader: "šŸ **The wheel landed on {number} {color} {emoji} !**\n\n", + betWonLine: "{betDisplay}: Won {amount}", + betLostLine: "{betDisplay}: Lost {amount}", + brokeEven: "Broke Even!", + wonNet: "Won Net {amount}!", + lostNet: "Lost Net {amount}!", + userSummaryRow: "**{user}**:\n{breakdown}\n**{netStatus}**\n" + }, wages: { + message: "{emoji} You worked a hard shift and claimed your wage of **${salary}**!\nšŸ¦ **New Balance:** ${balance}", defaultAmount: 0, roleSalaries: { "1262624821582364703": 500, diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 7686940..9efb08c 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -430,11 +430,8 @@ async function handleWage(interaction: ChatInputCommandInteraction) { profile.lastWageClaim = now; await profile.save(); - const formattedSalary = format(config.economy.currencyFormat, { amount: salaryAmount }); - const formattedBalance = format(config.economy.currencyFormat, { amount: profile.balance }); - await interaction.editReply({ - content: `šŸ’µ You worked a hard shift and claimed your wage of **${formattedSalary}**!\nšŸ¦ **New Balance:** ${formattedBalance}` + content: format(config.economy.wages.message, {emoji: config.economy.coinEmoji, salary: salaryAmount, balance: profile.balance }) }); } @@ -703,15 +700,11 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { profile.balance += amount; await profile.save(); - - // šŸ‘‡ Changed to pass the named object to format() - const formattedAmount = format(config.economy.currencyFormat, { amount: amount }); - const formattedBalance = format(config.economy.currencyFormat, { amount: profile.balance }); const replyMessage = format(config.economy.addMoney, { emoji: config.economy.coinEmoji, - added: formattedAmount, + added: amount, user: targetUser.id, - newBalance: formattedBalance + newBalance: profile.balance }); await interaction.editReply({ content: replyMessage }); @@ -792,30 +785,23 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const customSeconds = interaction.options.getInteger("seconds") || 60; const timeMs = customSeconds * 1000; + // 1. Initial opening message structured through global config formats const initialReply = await interaction.editReply({ - content: `šŸŽ° **<@${interaction.user.id}>** opened a Roulette Table for **${customSeconds} seconds**! Join the thread below to place your bets.` + content: format(config.economy.roulette.openMessage, { userId: interaction.user.id, seconds: customSeconds }) }); const thread = await initialReply.startThread({ - name: `šŸŽ° Roulette Table - <@${interaction.user.id}>`, + name: format(config.economy.roulette.threadName, { username: interaction.user.username }), autoArchiveDuration: 60, reason: "Roulette Game Room" }); const bets: RouletteBet[] = []; - await thread.send( - `šŸŽ” **Roulette Table Opened!** (Closes in ${customSeconds} seconds)\n\n` + - `To enter, type your bet choice followed by your amount. **Example: \`red 250\`**\n` + - `• \`0-36 \` (8x payout)\n` + - `• \`green \` (8x payout) 🟢\n` + // šŸ‘ˆ Added to instructions - `• \`red \` (2x payout) šŸ”“\n` + - `• \`black \` (2x payout) ⚫\n` + - `• \`even \` (2x payout)\n` + - `• \`odd \` (2x payout)\n\n` + - `šŸ‘ _The bot will react with āœ… if your bet is accepted, or āŒ if something is wrong._\n` + - `šŸ‘‘ **<@${interaction.user.id}>**, type \`spin\` when everyone is ready!` - ); + // 2. Main instructional guide announcement + await thread.send({ + content: format(config.economy.roulette.guideMessage, { seconds: customSeconds, userId: interaction.user.id }) + }); const collector = thread.createMessageCollector({ filter: (m) => !m.author.bot, time: timeMs }); @@ -830,7 +816,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { return; } - // šŸ‘ˆ Added "green" to the allowed string types here const validBetTypes = ["red", "black", "even", "odd", "green"]; const parsedNumber = parseInt(commandOrType, 10); const isNumberBet = !isNaN(parsedNumber) && parsedNumber >= 0 && parsedNumber <= 36; @@ -864,18 +849,16 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { }); collector.on("end", async (_, reason) => { - if (reason !== "spun") { - await thread.send("ā° Table closed automatically due to inactivity."); - for (const bet of bets) { - const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); - if (profile) { profile.balance += bet.amount; await profile.save(); } - } + // CHANGED: Only trigger inactivity closure if absolutely no bets were registered + if (bets.length === 0) { + await thread.send({ content: config.economy.roulette.inactivityMessage }); await thread.setLocked(true); await thread.setArchived(true); return; } - + // If there are bets, it will now automatically pass through here and spin + // whether the host typed "spin" OR the collector timer naturally ran out. const winningNumber = randomInt(0, 37); const redNumbers = [1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]; let color: "green" | "red" | "black" = "green"; @@ -884,7 +867,8 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const isEven = winningNumber > 0 && winningNumber % 2 === 0; const isOdd = winningNumber > 0 && winningNumber % 2 !== 0; - await thread.send("✨ *The wheel is spinning...* ✨"); + // 4. Send visual spin warning sequence + await thread.send({ content: config.economy.roulette.spinningMessage }); const userBreakdowns = new Map(); const userNetTotals = new Map(); @@ -892,44 +876,61 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { for (const bet of bets) { let won = bet.betType === color || (bet.betType === "even" && isEven) || (bet.betType === "odd" && isOdd) || bet.betNumber === winningNumber; - // šŸ‘ˆ Update payout check so BOTH number bets and explicit "green" bets reward 36x payout - let payoutMultiplier = (bet.betType === "number" || bet.betType === "green") ? 36 : 2; + let payoutMultiplier = (bet.betType === "number" || bet.betType === "green") ? 8 : 2; let betDisplay = bet.betType === "number" ? `Number ${bet.betNumber}` : bet.betType; const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); const currentNet = userNetTotals.get(bet.userId) ?? 0; if (!userBreakdowns.has(bet.userId)) userBreakdowns.set(bet.userId, []); - const formattedBetAmount = format(config.economy.currencyFormat, { amount: bet.amount }); + const formattedBetAmount = bet.amount.toLocaleString(); if (won && profile) { const winnings = bet.amount * payoutMultiplier; profile.balance += winnings; await profile.save(); - const formattedWinnings = format(config.economy.currencyFormat, { amount: winnings }); - userBreakdowns.get(bet.userId)!.push(`${betDisplay}: Won ${formattedWinnings}`); + + const formattedWinnings = winnings.toLocaleString(); + userBreakdowns.get(bet.userId)!.push( + format(config.economy.roulette.betWonLine, { betDisplay, amount: formattedWinnings }) + ); userNetTotals.set(bet.userId, currentNet + (winnings - bet.amount)); } else { - userBreakdowns.get(bet.userId)!.push(`${betDisplay}: Lost ${formattedBetAmount}`); + userBreakdowns.get(bet.userId)!.push( + format(config.economy.roulette.betLostLine, { betDisplay, amount: formattedBetAmount }) + ); userNetTotals.set(bet.userId, currentNet - bet.amount); } } const emoji = color === "red" ? "šŸ”“" : color === "black" ? "⚫" : "🟢"; - let outputMessage = `šŸ **The wheel landed on ${winningNumber} ${color.toUpperCase()} ${emoji} !**\n\n`; + + // 5. Build full game table data summary arrays + let outputMessage = format(config.economy.roulette.resultHeader, { + number: winningNumber, + color: color.toUpperCase(), + emoji: emoji + }); for (const [userId, breakdownArray] of userBreakdowns.entries()) { const userMention = `<@${userId}>`; const netValue = userNetTotals.get(userId) ?? 0; - let netStatus = "Broke Even!"; + let netStatus = config.economy.roulette.brokeEven; - if (netValue > 0) netStatus = `Won Net ${format(config.economy.currencyFormat, { amount: netValue })}!`; - else if (netValue < 0) netStatus = `Lost Net ${format(config.economy.currencyFormat, { amount: Math.abs(netValue) })}!`; + if (netValue > 0) { + netStatus = format(config.economy.roulette.wonNet, { amount: netValue.toLocaleString() }); + } else if (netValue < 0) { + netStatus = format(config.economy.roulette.lostNet, { amount: Math.abs(netValue).toLocaleString() }); + } - outputMessage += `**${userMention}**:\n${breakdownArray.join("\n")} | **${netStatus}**\n`; + outputMessage += format(config.economy.roulette.userSummaryRow, { + user: userMention, + breakdown: breakdownArray.join("\n"), + netStatus: netStatus + }); } - await thread.send(outputMessage); + await thread.send({ content: outputMessage }); await thread.setLocked(true); await thread.setArchived(true); }); @@ -955,7 +956,7 @@ async function seedDefaultShopItems(guildId: string) { } async function handleLeaderboard(interaction: ChatInputCommandInteraction) { - // 1. Fetch all profiles in the current guild, sorted by balance descending + // 1. Fetch all profiles in the current guild, sorted by balance descending[cite: 3] const profiles = await EconomyProfile.findAll({ where: { guildId: interaction.guildId! }, order: [["balance", "DESC"]] @@ -971,7 +972,7 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { const pages: EmbedBuilder[] = []; const totalPages = Math.ceil(profiles.length / USERS_PER_PAGE); - // 2. Loop through profiles and slice them into chunks of 10 + // 2. Loop through profiles and slice them into chunks of 10[cite: 3] for (let i = 0; i < profiles.length; i += USERS_PER_PAGE) { const chunk = profiles.slice(i, i + USERS_PER_PAGE); const currentPage = Math.floor(i / USERS_PER_PAGE) + 1; @@ -983,17 +984,19 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { let description = ""; - // 3. Build the text rows for the current page chunk + // 3. Build the text rows for the current page chunk[cite: 3] chunk.forEach((profile, index) => { const globalRank = i + index + 1; let rankDisplay = `**#${globalRank}**`; - // Style up the top 3 with shiny medals + // Style up the top 3 with shiny medals[cite: 3] if (globalRank === 1) rankDisplay = "šŸ„‡"; else if (globalRank === 2) rankDisplay = "🄈"; else if (globalRank === 3) rankDisplay = "šŸ„‰"; - const formattedBalance = format(config.economy.currencyFormat, { amount: profile.balance }); + // ABANDONED: config.economy.currencyFormat + // FIXED: Natively uses local string spacing standards with a literal string suffix instead[cite: 3]. + const formattedBalance = `${profile.balance.toLocaleString()}$`; description += `${rankDisplay} <@${profile.userId}> — **${formattedBalance}**\n`; }); @@ -1003,6 +1006,6 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { pages.push(embed); } - // 4. Pass the array of embeds into your pagination utility! + // 4. Pass the array of embeds into your pagination utility![cite: 3] await paginate(interaction, pages); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 850f13c..fa1af43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,18 +201,11 @@ ctx.client.on(Events.InteractionCreate, async (interaction) => { ctx.lastUse = Date.now(); - // Handle Autocomplete interactions upfront and return early so they never fall through to chat command logic - if (interaction.isAutocomplete()) { - let handler = handlers[interaction.commandName]; - if (handler?.onInteraction) handler.onInteraction(ctx, interaction); - return; - } - let handlerId: string | undefined; if (interaction.isButton() || interaction.isModalSubmit()) { const ic = interaction.customId.indexOf(":"); handlerId = interaction.customId.substring(0, ic); - } else if (interaction.isChatInputCommand()) { + } else if (interaction.isChatInputCommand() || interaction.isAutocomplete()) { handlerId = interaction.commandName; } From 11aeedba857c0fcb3bab06bfd424127a920f66e9 Mon Sep 17 00:00:00 2001 From: "vmbbi (Max)" Date: Thu, 9 Jul 2026 20:14:30 +0800 Subject: [PATCH 24/34] Almost fucked up the client id --- config.json.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json.js b/config.json.js index cb61312..3c16084 100644 --- a/config.json.js +++ b/config.json.js @@ -1,6 +1,6 @@ export default { guildId: ["1213989169878274068"], - clientId: "1520481807458504774", + clientId: "1287095017596387500", logging: "debug", welcome: { channel: "1213989170964340878", From 7e14c8f99a75699c7510c6de4ba85cc7a0fb4c12 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Thu, 9 Jul 2026 22:22:14 +0800 Subject: [PATCH 25/34] qol improvements + refill command --- src/commands/fun/economy.ts | 250 +++++++++++++++++++++++++++++++----- 1 file changed, 219 insertions(+), 31 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 9efb08c..d95c0a5 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -1,9 +1,15 @@ import { + ButtonBuilder, EmbedBuilder, ChatInputCommandInteraction, GuildMember, - AutocompleteInteraction, - MessageFlags + MessageFlags, + ButtonStyle, + ComponentType, + ContainerBuilder, + TextDisplayBuilder, + SeparatorBuilder, + SectionBuilder, PermissionFlagsBits } from "discord.js"; import { DataTypes, @@ -71,7 +77,6 @@ export class TempRole extends Model, InferCreationAttr declare expiresAt: Date; } const STARTING_BALANCE = 10; -const WAGE_AMOUNT = 50; const WAGE_COOLDOWN_HOURS = 24; export default { @@ -187,8 +192,24 @@ export default { sub .setName("use") .setDescription("Use a consumable item from your inventory") - .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to use").setRequired(true)) + .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to use").setRequired(true).setAutocomplete(true)) ) + .addSubcommand((sub) => + sub + .setName("refill") + .setDescription("Refill the stock of a specific shop item.") + .addStringOption(option => + option.setName("item") + .setDescription("The ID of the item to refill") + .setRequired(true) + .setAutocomplete(true) + ) + .addIntegerOption(option => + option.setName("amount") + .setDescription("How much stock to add") + .setRequired(true) + ) + ) .addSubcommand((sub) => sub.setName("inventory").setDescription("View items you currently own")) .addSubcommand((sub) => sub @@ -247,19 +268,17 @@ export default { if (!interaction.guildId) return void await interaction.respond([]); const sub = interaction.options.getSubcommand(false); - if (sub === "buy") { - const focusedValue = interaction.options.getFocused().toLowerCase(); + const focusedValue = interaction.options.getFocused().toLowerCase(); - // Fetch the active shop products for this server + // 1. Autocomplete for Shop Items (Buy & Refill) + if (sub === "buy" || sub === "refill") { const items = await ShopItem.findAll({ where: { guildId: interaction.guildId } }); - // Filter choices against both item name and itemId configurations const filtered = items.filter(item => item.name.toLowerCase().includes(focusedValue) || item.itemId.toLowerCase().includes(focusedValue) ); - // Respond to Discord (capped at API maximum of 25 choices) return void await interaction.respond( filtered.slice(0, 25).map(item => ({ name: `${item.name} — $${item.price}`, @@ -267,7 +286,35 @@ export default { })) ); } - return void await interaction.respond([]); + + // 2. Autocomplete for Inventory Items (Use) + if (sub === "use") { + // Fetch only items the user actually owns + const inventory = await Inventory.findAll({ + where: { guildId: interaction.guildId, userId: interaction.user.id } + }); + + // Fetch shop items to map the nice visual names + const shopItems = await ShopItem.findAll({ where: { guildId: interaction.guildId } }); + const itemManifest = Object.fromEntries(shopItems.map(i => [i.itemId, i.name])); + + const filtered = inventory.filter(inv => { + const name = itemManifest[inv.itemKey] || inv.itemKey; + return name.toLowerCase().includes(focusedValue) || inv.itemKey.toLowerCase().includes(focusedValue); + }); + + return void await interaction.respond( + filtered.slice(0, 25).map(inv => { + const name = itemManifest[inv.itemKey] || inv.itemKey; + return { + name: `${name} (Owned: ${inv.quantity})`, + value: inv.itemKey + }; + }) + ); + } + + return void await interaction.respond([]); } if (!interaction.isChatInputCommand()) return; @@ -281,6 +328,7 @@ export default { "add-money": true, "set-balance": true, "inflation": true, + "refill": true, // Set these to false so they are wide open to the public channel "shop": false, @@ -341,6 +389,7 @@ export default { case "inventory": return await handleInventory(interaction); case "add-money": return await handleAddMoney(interaction); case "set-balance": return await handleSetBalance(interaction); + case "refill": return await handleRefillStock(interaction) } return; } @@ -440,8 +489,9 @@ async function handleInflation(interaction: ChatInputCommandInteraction) { const percentage = interaction.options.getNumber("percentage", true); - if (percentage <= 0) { - return void await interaction.editReply({ content: "āŒ Please provide a percentage greater than 0." }); + const multiplier = 1 + (percentage / 100); + if (multiplier <= 0) { + return void await interaction.editReply({ content: "āŒ Please provide a percentage higher/lower than -/+ 100%" }); } const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); @@ -450,15 +500,16 @@ async function handleInflation(interaction: ChatInputCommandInteraction) { return void await interaction.editReply({ content: "āŒ There are no items in the shop to inflate." }); } - const multiplier = 1 + (percentage / 100); for (const item of items) { item.price = Math.round(item.price * multiplier); + if (item.price < 1) item.price = 1; await item.save(); } - + const trendEmoji = percentage > 0 ? "šŸ“ˆ" : "šŸ“‰"; + const direction = percentage > 0 ? "increased" : "decreased"; await interaction.editReply({ - content: `šŸ“ˆ **Inflation Applied!** All shop items have been increased in price by **${percentage}%**.` + content:`${trendEmoji} **Economy Updated!** All shop prices have been ${direction} by **${Math.abs(percentage)}%** (Multiplier: \`${multiplier}x\`).` }); } @@ -479,27 +530,123 @@ async function handleBalance(interaction: ChatInputCommandInteraction) { async function handleShop(interaction: ChatInputCommandInteraction) { const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); - const embed = new EmbedBuilder() - .setTitle("šŸ›’ The Server Shop") - .setDescription("Use `/economy buy ` to purchase something!") - .setColor(0x00ae86); if (items.length === 0) { - embed.setDescription("The shop is currently empty. Admins need to add items!"); - } else { - for (const item of items) { - let stockDisplay = item.stock === -1 ? "āˆž" : item.stock.toString(); - if (item.stock === 0) stockDisplay = "āŒ OUT OF STOCK"; - - embed.addFields({ - name: `${item.name} (\`${item.itemId}\`) — $${item.price}`, - value: `${item.description}\nšŸ“¦ **Stock:** ${stockDisplay}`, - inline: false, - }); + const emptyContainer = new ContainerBuilder() + .setAccentColor(0xd9534f) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("šŸ›’ **The Server Shop**\nThe shop is currently empty.") + ); + return void await interaction.reply({ + components: [emptyContainer], + flags: [MessageFlags.IsComponentsV2] // <-- Crucial flag + }); + } + + const containers: any[] = []; + + // 2. Initialize the first layout container with a header title + let currentContainer = new ContainerBuilder() + .setAccentColor(0x00ae86) // This creates that clean colored bar on the left + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("šŸ›’ **The Server Shop**\nClick the price button next to an item to purchase it instantly!") + ) + .addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + // Track component slots (Discord limits a single Container to 10 sub-components max) + let componentCount = 2; // Title + initial Divider line + + for (const item of items) { + let stockDisplay = item.stock === -1 ? "āˆž" : item.stock.toString(); + const isOutOfStock = item.stock === 0; + if (isOutOfStock) stockDisplay = "āŒ OUT OF STOCK"; + + // 3. Build a text section for the item name and description + const section = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`### ${item.name}`), + new TextDisplayBuilder().setContent(`${item.description}\nšŸ“¦ **Stock:** ${stockDisplay}`) + ); + + // 4. Create the button that will snap cleanly to the right side of the text + const button = new ButtonBuilder() + .setCustomId(`shop_buy_${item.itemId}`) + .setLabel(isOutOfStock ? "Sold Out" : `${item.price.toLocaleString()}$`) + .setEmoji(config.economy.coinEmoji) + .setStyle(isOutOfStock ? ButtonStyle.Danger : ButtonStyle.Success) + .setDisabled(isOutOfStock); + + // Pin the button to this specific text block as an accessory layout + section.setButtonAccessory(button); + + // Create a horizontal layout dividing line + const separator = new SeparatorBuilder().setDivider(true); + + // Each item consumes 2 slots (1 Section + 1 Separator line) + // If it crosses the 10-component container limit, split it cleanly into a new side-bar panel + if (componentCount + 2 > 10) { + containers.push(currentContainer); + currentContainer = new ContainerBuilder().setAccentColor(0x00ae86); + componentCount = 0; } + + currentContainer.addSectionComponents(section); + currentContainer.addSeparatorComponents(separator); + componentCount += 2; } - await interaction.editReply({ embeds: [embed] }); + if (componentCount > 0) { + containers.push(currentContainer); + } + + // 5. Send the UI layout to the user + const shopMessage = await interaction.editReply({ + components: containers, + flags: [MessageFlags.IsComponentsV2], + }); + + // 6. Hook up the collector to your existing handleBuy method + const collector = shopMessage.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 120_000 // Menu stays live for 2 minutes + }); + + collector.on("collect", async (buttonInteraction) => { + const itemId = buttonInteraction.customId.replace("shop_buy_", ""); + + await buttonInteraction.deferReply({ ephemeral: true }); + + // Build the slash-command runtime mock environment + const buyShim = Object.create(buttonInteraction); + buyShim.options = { + getString: (name: string) => name === "item" ? itemId : null, + getInteger: (name: string) => name === "quantity" ? 1 : null + }; + + // Pipe directly into your primary database purchase functions + await handleBuy(buyShim as unknown as ChatInputCommandInteraction); + }); + + // 7. Lock down the interactive elements when the browsing session ends + collector.on("end", async () => { + try { + const disabledComponents = containers.map(container => { + const json = container.toJSON(); + if (json.components) { + json.components.forEach((comp: any) => { + // Locate sections containing button accessories and mark them disabled + if (comp.type === 9 && comp.accessory && comp.accessory.type === 2) { + comp.accessory.disabled = true; + } + }); + } + return json; + }); + await interaction.editReply({ components: disabledComponents }); + } catch { + // Failsafe in case a user deletes the menu manually + } + }); } async function handleBuy(interaction: ChatInputCommandInteraction) { @@ -1008,4 +1155,45 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { // 4. Pass the array of embeds into your pagination utility![cite: 3] await paginate(interaction, pages); +} + +async function handleRefillStock(interaction: ChatInputCommandInteraction) { + if (!(await hasStaffPermission(interaction))) return; + // 1. Fetch the required options from the command + const itemKey = interaction.options.getString("item", true).toLowerCase(); + const amount = interaction.options.getInteger("amount", true); + + // 2. Validate the amount + if (amount <= 0) { + return void await interaction.editReply({ + content: "āŒ You must specify a positive amount to refill." + }); + } + + // 3. Find the item in the database + const item = await ShopItem.findOne({ + where: { guildId: interaction.guildId!, itemId: itemKey } + }); + + if (!item) { + return void await interaction.editReply({ + content: `āŒ Could not find an item with the ID \`${itemKey}\` in the shop.` + }); + } + + // 4. Check if the item has infinite stock (-1) + if (item.stock === -1) { + return void await interaction.editReply({ + content: `āš ļø **${item.name}** currently has infinite stock (āˆž), so it does not need to be refilled!` + }); + } + + // 5. Apply the stock addition and save + item.stock += amount; + await item.save(); + + // 6. Confirm the successful refill + await interaction.editReply({ + content: `šŸ“¦ Successfully added **${amount}** stock to **${item.name}**! The shop now has **${item.stock}** available.` + }); } \ No newline at end of file From 3f1d615d1b7e8be6ba9940926af5f0bc919029d8 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Fri, 10 Jul 2026 00:37:17 +0800 Subject: [PATCH 26/34] made the shop items only config --- config.json.js | 12 +- src/commands/fun/economy.ts | 429 +++++++++++------------------------- 2 files changed, 135 insertions(+), 306 deletions(-) diff --git a/config.json.js b/config.json.js index 3c16084..0c3aea9 100644 --- a/config.json.js +++ b/config.json.js @@ -1,6 +1,6 @@ export default { guildId: ["1213989169878274068"], - clientId: "1287095017596387500", + clientId: "1520481807458504774", logging: "debug", welcome: { channel: "1213989170964340878", @@ -191,12 +191,12 @@ Consider donating to one of the following people: stock: -1 }, { - itemId: "cigarette", - name: "cigarette", + itemId: "candy", + name: "candy", price: 20, - description: "Purchase 500!", - useMessage: "The aroma is most pleasing", - stock: -1 + description: "Purchase many!", + useMessage: "The taste is most pleasing", + stock: 20 }, ], teamRole: ["1262624821582364703"], diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index d95c0a5..f4bb6c2 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -9,7 +9,7 @@ import { ContainerBuilder, TextDisplayBuilder, SeparatorBuilder, - SectionBuilder, PermissionFlagsBits + SectionBuilder } from "discord.js"; import { DataTypes, @@ -17,16 +17,14 @@ import { type CreationOptional, type InferAttributes, type InferCreationAttributes, - Op, Sequelize + Op } from "sequelize"; import type { Cmd } from "~/util/base"; import { format } from "~/util/base"; -import rnd from "~/util/rnd"; import config from "config.json"; import { randomInt } from "crypto"; -import { paginate } from "../../util/paginator2.ts"; +import { paginate } from "~/util/paginator2"; -// 2. Define the Database Models export class EconomyProfile extends Model< InferAttributes, InferCreationAttributes @@ -58,13 +56,7 @@ export class ShopItem extends Model< > { declare guildId: string; declare itemId: string; - declare name: string; - declare description: string; - declare price: number; - declare roleId: CreationOptional; - declare stock: CreationOptional; - declare durationDays: CreationOptional; - declare useMessage: CreationOptional; + declare stock: number; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; } @@ -76,6 +68,7 @@ export class TempRole extends Model, InferCreationAttr declare roleId: string; declare expiresAt: Date; } + const STARTING_BALANCE = 10; const WAGE_COOLDOWN_HOURS = 24; @@ -112,13 +105,7 @@ export default { { guildId: { type: DataTypes.STRING, primaryKey: true }, itemId: { type: DataTypes.STRING, primaryKey: true }, - name: { type: DataTypes.STRING, allowNull: false }, - description: { type: DataTypes.STRING, allowNull: false }, - price: { type: DataTypes.INTEGER, allowNull: false }, - roleId: { type: DataTypes.STRING, allowNull: true }, - stock: { type: DataTypes.INTEGER, defaultValue: -1 }, - durationDays: { type: DataTypes.INTEGER, allowNull: true }, - useMessage: { type: DataTypes.STRING, allowNull: true }, + stock: { type: DataTypes.INTEGER, allowNull: false }, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE, }, @@ -141,10 +128,6 @@ export default { await ctx.sql.sync(); - const guilds = ctx.client.guilds.cache; - for (const [guildId, guild] of guilds) { - await seedDefaultShopItems(guildId); - } setInterval(async () => { try { const now = new Date(); @@ -160,7 +143,7 @@ export default { await member.roles.remove(record.roleId, "šŸ•’ Temporary shop item duration expired."); } } - await record.destroy(); // Purge record from DB + await record.destroy(); } } catch (err) { console.error("[Sweeper Worker Error]:", err); @@ -168,113 +151,16 @@ export default { }, 3600000); }, - slash: (builder) => { - return builder - .setName("economy") - .setDescription("Manage your pocket change and inventory") - .addSubcommand((sub) => - sub - .setName("balance") - .setDescription("Check your current balance or another user's balance") - .addUserOption((opt) => opt.setName("user").setDescription("The user to check").setRequired(false)), - ) - .addSubcommand(sub => sub.setName("wage").setDescription("Collect your regular salary!")) - .addSubcommand(sub => sub.setName("leaderboard").setDescription("View the leaderboard")) - .addSubcommand((sub) => sub.setName("shop").setDescription("View available items for purchase")) - .addSubcommand((sub) => - sub - .setName("buy") - .setDescription("Purchase an item from the shop") - .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to buy (e.g. 'vip_role')").setRequired(true).setAutocomplete(true)) - .addIntegerOption((opt) => opt.setName("quantity").setDescription("How many to buy?").setMinValue(1)) - ) - .addSubcommand((sub) => - sub - .setName("use") - .setDescription("Use a consumable item from your inventory") - .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to use").setRequired(true).setAutocomplete(true)) - ) - .addSubcommand((sub) => - sub - .setName("refill") - .setDescription("Refill the stock of a specific shop item.") - .addStringOption(option => - option.setName("item") - .setDescription("The ID of the item to refill") - .setRequired(true) - .setAutocomplete(true) - ) - .addIntegerOption(option => - option.setName("amount") - .setDescription("How much stock to add") - .setRequired(true) - ) - ) - .addSubcommand((sub) => sub.setName("inventory").setDescription("View items you currently own")) - .addSubcommand((sub) => - sub - .setName("add-money") - .setDescription("Add money to a user's balance (Admin/Staff Only)") - .addUserOption((opt) => opt.setName("user").setDescription("The user receiving the money").setRequired(true)) - .addIntegerOption((opt) => opt.setName("amount").setDescription("The amount of money to add").setRequired(true)), - ) - .addSubcommand((sub) => - sub - .setName("set-balance") - .setDescription("Forcefully set a user's balance to a specific amount (Staff Only)") - .addUserOption((opt) => opt.setName("user").setDescription("The target user").setRequired(true)) - .addIntegerOption((opt) => opt.setName("amount").setDescription("The exact balance to set").setRequired(true)), - ) - .addSubcommand((sub) => - sub - .setName("inflation") - .setDescription("Increase all shop prices by a percentage to combat wealth (Staff Only)") - .addNumberOption((opt) => opt.setName("percentage").setDescription("Percentage to increase (e.g. 10 for 10%)").setRequired(true)) - ) - .addSubcommandGroup((group) => - group - .setName("gamble") - .setDescription("Risk your money on different casino games!") - .addSubcommand((sub) => - sub - .setName("coinflip") - .setDescription("A 50/50 chance to double your money!") - .addIntegerOption((opt) => opt.setName("amount").setDescription("How much to bet").setRequired(true).setMinValue(1)) - ) - .addSubcommand((sub) => - sub - .setName("dice") - .setDescription("Guess a 6-sided die roll. Win 5x your bet!") - .addIntegerOption((opt) => opt.setName("amount").setDescription("How much to bet").setRequired(true).setMinValue(1)) - .addIntegerOption((opt) => opt.setName("guess").setDescription("Your guess (1-6)").setRequired(true).setMinValue(1).setMaxValue(6)) - ) - .addSubcommand((sub) => - sub - .setName("roulette") - .setDescription("Open a roulette table and place multiple bets! (1-24, Red/Black, Even/Odd)") - .addIntegerOption(option => - option.setName("seconds") - .setDescription("How many seconds should the table stay open? (Default: 60)") - .setRequired(false) - .setMinValue(15) - .setMaxValue(1800) - ) - ) - ); - }, - onInteraction: async (ctx, interaction) => { if (interaction.isAutocomplete()) { if (!interaction.guildId) return void await interaction.respond([]); const sub = interaction.options.getSubcommand(false); const focusedValue = interaction.options.getFocused().toLowerCase(); + const shopItems = config.economy.shopItems || []; - // 1. Autocomplete for Shop Items (Buy & Refill) if (sub === "buy" || sub === "refill") { - const items = await ShopItem.findAll({ where: { guildId: interaction.guildId } }); - - const filtered = items.filter(item => + const filtered = shopItems.filter(item => item.name.toLowerCase().includes(focusedValue) || item.itemId.toLowerCase().includes(focusedValue) ); @@ -287,30 +173,32 @@ export default { ); } - // 2. Autocomplete for Inventory Items (Use) if (sub === "use") { - // Fetch only items the user actually owns const inventory = await Inventory.findAll({ where: { guildId: interaction.guildId, userId: interaction.user.id } }); - // Fetch shop items to map the nice visual names - const shopItems = await ShopItem.findAll({ where: { guildId: interaction.guildId } }); - const itemManifest = Object.fromEntries(shopItems.map(i => [i.itemId, i.name])); + const itemMap = new Map(shopItems.map(i => [i.itemId, i.name])); + const validInventory = []; - const filtered = inventory.filter(inv => { - const name = itemManifest[inv.itemKey] || inv.itemKey; + for (const inv of inventory) { + if (!itemMap.has(inv.itemKey)) { + await inv.destroy(); + } else { + validInventory.push(inv); + } + } + + const filtered = validInventory.filter(inv => { + const name = itemMap.get(inv.itemKey) || inv.itemKey; return name.toLowerCase().includes(focusedValue) || inv.itemKey.toLowerCase().includes(focusedValue); }); return void await interaction.respond( - filtered.slice(0, 25).map(inv => { - const name = itemManifest[inv.itemKey] || inv.itemKey; - return { - name: `${name} (Owned: ${inv.quantity})`, - value: inv.itemKey - }; - }) + filtered.slice(0, 25).map(inv => ({ + name: `${itemMap.get(inv.itemKey)} (Owned: ${inv.quantity})`, + value: inv.itemKey + })) ); } @@ -327,10 +215,7 @@ export default { "buy": false, "add-money": true, "set-balance": true, - "inflation": true, "refill": true, - - // Set these to false so they are wide open to the public channel "shop": false, "coinflip": false, "dice": false, @@ -338,10 +223,9 @@ export default { "use": false, }; - const sub = interaction.options.getSubcommand(true); // Changed to true because a subcommand is guaranteed + const sub = interaction.options.getSubcommand(true); const group = interaction.options.getSubcommandGroup(false); - // 1. FAST SYNC CHECK: Check gambling permissions BEFORE deferring anything if (group === "gamble") { const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) @@ -357,15 +241,18 @@ export default { } } - // 2. INDIVIDUAL VISIBILITY LOOKUP: Safely uses the guaranteed sub string const isEphemeral = EPHEMERAL_MAPPING[sub] ?? false; - // 3. SAFE DEFER: Instantly secure the connection before any heavy logic - await interaction.deferReply({ - flags: isEphemeral ? MessageFlags.Ephemeral : undefined - }); + // SAFE GUARD: Wrapped inside a try-catch to absorb 10062 Unknown Interaction token expirations + try { + await interaction.deferReply({ + flags: isEphemeral ? MessageFlags.Ephemeral : undefined + }); + } catch (error) { + console.warn(`[Economy Server] Interaction expired before deferral response could reach Discord Gateway for command: ${sub}.`); + return; + } - // 4. ROUTE SAFELY TO HANDLERS switch (group) { case "gamble": { switch (sub) { @@ -379,7 +266,6 @@ export default { case null: default: { switch (sub) { - case "inflation": return await handleInflation(interaction); case "leaderboard": return await handleLeaderboard(interaction); case "wage": return await handleWage(interaction) case "balance": return await handleBalance(interaction); @@ -415,25 +301,17 @@ async function hasSufficientFunds( function calculateWage(member: GuildMember): number { const wageConfig = config.economy.wages; - - // 1. Start with an array containing just the baseline default wage const matchingSalaries: number[] = [wageConfig.defaultAmount]; - // 2. Map through the config roles. If the member has the role, push its salary to the array for (const [roleId, salary] of Object.entries(wageConfig.roleSalaries)) { if (member.roles.cache.has(roleId)) { matchingSalaries.push(salary as number); } } - // 3. Return the absolute highest value found. - // If they have no special roles, Math.max(100) safely returns 100! return Math.max(...matchingSalaries); } -/** - * Checks if the user has a required staff role. If not, it replies with an error and returns false. - */ async function hasStaffPermission(interaction: ChatInputCommandInteraction): Promise { const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) @@ -446,6 +324,7 @@ async function hasStaffPermission(interaction: ChatInputCommandInteraction): Pro return true; } + // ── SUBCOMMAND HANDLERS ────────────────────────────────────────────────── async function handleWage(interaction: ChatInputCommandInteraction) { @@ -459,7 +338,6 @@ async function handleWage(interaction: ChatInputCommandInteraction) { const now = new Date(); const cooldownMs = WAGE_COOLDOWN_HOURS * 60 * 60 * 1000; - // 1. Check Cooldown if (profile.lastWageClaim) { const timeSinceLastClaim = now.getTime() - profile.lastWageClaim.getTime(); if (timeSinceLastClaim < cooldownMs) { @@ -471,10 +349,8 @@ async function handleWage(interaction: ChatInputCommandInteraction) { } } - // 2. THIS IS WHERE calculateWage IS USED! šŸš€ const salaryAmount = calculateWage(interaction.member); - // 3. Apply the money and reset the cooldown timer profile.balance += salaryAmount; profile.lastWageClaim = now; await profile.save(); @@ -484,35 +360,6 @@ async function handleWage(interaction: ChatInputCommandInteraction) { }); } -async function handleInflation(interaction: ChatInputCommandInteraction) { - if (!(await hasStaffPermission(interaction))) return; - - const percentage = interaction.options.getNumber("percentage", true); - - const multiplier = 1 + (percentage / 100); - if (multiplier <= 0) { - return void await interaction.editReply({ content: "āŒ Please provide a percentage higher/lower than -/+ 100%" }); - } - - const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); - - if (items.length === 0) { - return void await interaction.editReply({ content: "āŒ There are no items in the shop to inflate." }); - } - - - for (const item of items) { - item.price = Math.round(item.price * multiplier); - if (item.price < 1) item.price = 1; - await item.save(); - } - const trendEmoji = percentage > 0 ? "šŸ“ˆ" : "šŸ“‰"; - const direction = percentage > 0 ? "increased" : "decreased"; - await interaction.editReply({ - content:`${trendEmoji} **Economy Updated!** All shop prices have been ${direction} by **${Math.abs(percentage)}%** (Multiplier: \`${multiplier}x\`).` - }); -} - async function handleBalance(interaction: ChatInputCommandInteraction) { const targetUser = interaction.options.getUser("user") || interaction.user; @@ -529,9 +376,9 @@ async function handleBalance(interaction: ChatInputCommandInteraction) { } async function handleShop(interaction: ChatInputCommandInteraction) { - const items = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); + const shopItems = config.economy.shopItems || []; - if (items.length === 0) { + if (shopItems.length === 0) { const emptyContainer = new ContainerBuilder() .setAccentColor(0xd9534f) .addTextDisplayComponents( @@ -539,36 +386,43 @@ async function handleShop(interaction: ChatInputCommandInteraction) { ); return void await interaction.reply({ components: [emptyContainer], - flags: [MessageFlags.IsComponentsV2] // <-- Crucial flag + flags: [MessageFlags.IsComponentsV2] }); } + const limitedItemIds = shopItems.filter(i => i.stock !== -1).map(i => i.itemId); + const stockTrackers = await ShopItem.findAll({ + where: { guildId: interaction.guildId!, itemId: limitedItemIds } + }); + const stockMap = new Map(stockTrackers.map(s => [s.itemId, s.stock])); + const containers: any[] = []; - // 2. Initialize the first layout container with a header title let currentContainer = new ContainerBuilder() - .setAccentColor(0x00ae86) // This creates that clean colored bar on the left + .setAccentColor(0x00ae86) .addTextDisplayComponents( new TextDisplayBuilder().setContent("šŸ›’ **The Server Shop**\nClick the price button next to an item to purchase it instantly!") ) .addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - // Track component slots (Discord limits a single Container to 10 sub-components max) - let componentCount = 2; // Title + initial Divider line + let componentCount = 2; - for (const item of items) { - let stockDisplay = item.stock === -1 ? "āˆž" : item.stock.toString(); - const isOutOfStock = item.stock === 0; + for (const item of shopItems) { + let currentStock = item.stock; + if (item.stock !== -1) { + currentStock = stockMap.has(item.itemId) ? stockMap.get(item.itemId)! : item.stock; + } + + let stockDisplay = item.stock === -1 ? "āˆž" : currentStock.toString(); + const isOutOfStock = item.stock !== -1 && currentStock === 0; if (isOutOfStock) stockDisplay = "āŒ OUT OF STOCK"; - // 3. Build a text section for the item name and description const section = new SectionBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent(`### ${item.name}`), new TextDisplayBuilder().setContent(`${item.description}\nšŸ“¦ **Stock:** ${stockDisplay}`) ); - // 4. Create the button that will snap cleanly to the right side of the text const button = new ButtonBuilder() .setCustomId(`shop_buy_${item.itemId}`) .setLabel(isOutOfStock ? "Sold Out" : `${item.price.toLocaleString()}$`) @@ -576,14 +430,10 @@ async function handleShop(interaction: ChatInputCommandInteraction) { .setStyle(isOutOfStock ? ButtonStyle.Danger : ButtonStyle.Success) .setDisabled(isOutOfStock); - // Pin the button to this specific text block as an accessory layout section.setButtonAccessory(button); - // Create a horizontal layout dividing line const separator = new SeparatorBuilder().setDivider(true); - // Each item consumes 2 slots (1 Section + 1 Separator line) - // If it crosses the 10-component container limit, split it cleanly into a new side-bar panel if (componentCount + 2 > 10) { containers.push(currentContainer); currentContainer = new ContainerBuilder().setAccentColor(0x00ae86); @@ -599,42 +449,42 @@ async function handleShop(interaction: ChatInputCommandInteraction) { containers.push(currentContainer); } - // 5. Send the UI layout to the user const shopMessage = await interaction.editReply({ components: containers, flags: [MessageFlags.IsComponentsV2], }); - // 6. Hook up the collector to your existing handleBuy method const collector = shopMessage.createMessageComponentCollector({ componentType: ComponentType.Button, - time: 120_000 // Menu stays live for 2 minutes + time: 120_000 }); collector.on("collect", async (buttonInteraction) => { const itemId = buttonInteraction.customId.replace("shop_buy_", ""); - await buttonInteraction.deferReply({ ephemeral: true }); + // SAFE GUARD: Wrap button component interaction deferral inside try-catch to prevent crashes on latency spikes + try { + await buttonInteraction.deferReply({ ephemeral: true }); + } catch (error) { + console.warn("[Economy Shop] Button component interaction expired before defer reply completed execution."); + return; + } - // Build the slash-command runtime mock environment const buyShim = Object.create(buttonInteraction); buyShim.options = { getString: (name: string) => name === "item" ? itemId : null, getInteger: (name: string) => name === "quantity" ? 1 : null }; - // Pipe directly into your primary database purchase functions await handleBuy(buyShim as unknown as ChatInputCommandInteraction); }); - // 7. Lock down the interactive elements when the browsing session ends collector.on("end", async () => { try { const disabledComponents = containers.map(container => { const json = container.toJSON(); if (json.components) { json.components.forEach((comp: any) => { - // Locate sections containing button accessories and mark them disabled if (comp.type === 9 && comp.accessory && comp.accessory.type === 2) { comp.accessory.disabled = true; } @@ -644,28 +494,33 @@ async function handleShop(interaction: ChatInputCommandInteraction) { }); await interaction.editReply({ components: disabledComponents }); } catch { - // Failsafe in case a user deletes the menu manually } }); } async function handleBuy(interaction: ChatInputCommandInteraction) { const itemKey = interaction.options.getString("item", true).toLowerCase(); - // 1. Fetch the quantity option from the command (defaults to 1 if empty) const quantity = interaction.options.getInteger("quantity") ?? 1; - const item = await ShopItem.findOne({ where: { guildId: interaction.guildId!, itemId: itemKey } }); + const shopItems = config.economy.shopItems || []; + const item = shopItems.find(i => i.itemId === itemKey); if (!item) return void await interaction.editReply({ content: config.economy.shop.notItem }); - // 2. Check if the shop has enough stock for the requested quantity - if (item.stock !== -1 && item.stock < quantity) { - if (item.stock === 0) { + let currentStock = item.stock; + let stockTracker = null; + + if (item.stock !== -1) { + stockTracker = await ShopItem.findOne({ where: { guildId: interaction.guildId!, itemId: itemKey } }); + currentStock = stockTracker ? stockTracker.stock : item.stock; + } + + if (item.stock !== -1 && currentStock < quantity) { + if (currentStock === 0) { return void await interaction.editReply({ content: format(config.economy.shop.soldOut, {name: item.name}) }); } - return void await interaction.editReply({ content: format(config.economy.shop.notEnough, {stock: item.stock} )}); + return void await interaction.editReply({ content: format(config.economy.shop.notEnough, {stock: currentStock} )}); } - // 3. Safeguard: Prevent ordering multiples of items that immediately grant roles if (item.roleId && quantity > 1) { return void await interaction.editReply({ content: config.economy.shop.notMultiple }); } @@ -673,7 +528,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); const currentBalance = profile?.balance ?? STARTING_BALANCE; - // 4. Calculate total cost for the order const totalCost = item.price * quantity; const shopErrorMessage = format(config.economy.shop.cantAfford,{ @@ -689,14 +543,16 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { let roleGrantedMessage = ""; - // 5. Apply the correct stock deductions - if (item.stock > 0) { - item.stock -= quantity; - await item.save(); + if (item.stock !== -1) { + if (!stockTracker) { + stockTracker = await ShopItem.create({ guildId: interaction.guildId!, itemId: itemKey, stock: item.stock - quantity }); + } else { + stockTracker.stock -= quantity; + await stockTracker.save(); + } } profile.balance -= totalCost; - // 6. Role Assignment Logic (Safe because quantity is guaranteed to be 1 here) if (item.roleId && interaction.member instanceof GuildMember) { try { if (item.durationDays) { @@ -716,7 +572,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { await interaction.member.roles.add(item.roleId, `Purchased ${item.durationDays} day pass.`); roleGrantedMessage = format(config.economy.shop.tempRole, {roleId: item.roleId, durationDays: item.durationDays}); - // INSTANT REMOVAL TIMER const msRemaining = item.durationDays * 24 * 60 * 60 * 1000; const memberRef = interaction.member; const targetRoleId = item.roleId; @@ -751,7 +606,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { await profile.save(); - // 7. Save the bulk amount into the inventory cleanly const [invItem, created] = await Inventory.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: quantity } @@ -762,7 +616,6 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { await invItem.save(); } - // 8. Inform the user with total price breakdown await interaction.editReply({ content: format(config.economy.shop.successBuy, { name: quantity > 1 ? `${quantity}x ${item.name}` : item.name, price: totalCost, @@ -774,13 +627,23 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { async function handleInventory(interaction: ChatInputCommandInteraction) { const items = await Inventory.findAll({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); - if (items.length === 0) return void await interaction.editReply({ content: config.economy.inv.empty }); - const allShopItems = await ShopItem.findAll({ where: { guildId: interaction.guildId! } }); - const itemManifest = Object.fromEntries(allShopItems.map((i) => [i.itemId, i.name])); + const shopItems = config.economy.shopItems || []; + const itemMap = new Map(shopItems.map(i => [i.itemId, i.name])); + const validInventory = []; + + for (const item of items) { + if (!itemMap.has(item.itemKey)) { + await item.destroy(); + } else { + validInventory.push(item); + } + } + + if (validInventory.length === 0) return void await interaction.editReply({ content: config.economy.inv.empty }); - const inventoryList = items.map((item) => { - const visualName = itemManifest[item.itemKey] || `āš™ļø Unknown Item (${item.itemKey})`; + const inventoryList = validInventory.map((item) => { + const visualName = itemMap.get(item.itemKey) || `āš™ļø Unknown Item (${item.itemKey})`; return `${visualName} x\`${item.quantity}\``; }).join("\n"); @@ -791,43 +654,37 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { async function handleUse(interaction: ChatInputCommandInteraction) { const itemKey = interaction.options.getString("item", true).toLowerCase(); - // 1. Check if the user actually owns the item + const shopItems = config.economy.shopItems || []; + const shopItem = shopItems.find(i => i.itemId === itemKey); + const invItem = await Inventory.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey } }); + if (!shopItem) { + if (invItem) await invItem.destroy(); + return void await interaction.editReply({ content: config.economy.inv.nonexistent }); + } + if (!invItem || invItem.quantity <= 0) { return void await interaction.editReply({ - content: format(config.economy.inv.lack, {item: itemKey}) + content: format(config.economy.inv.lack, {item: shopItem.name}) }); } - // 2. Fetch the item's data to get the custom useMessage - const shopItem = await ShopItem.findOne({ - where: { guildId: interaction.guildId!, itemId: itemKey } - }); - - if (!shopItem) { - return void await interaction.editReply({ content: config.economy.inv.nonexistent }); - } - - // 3. Check if it's actually a usable item if (!shopItem.useMessage) { return void await interaction.editReply({ content: format(config.economy.inv.nonconsumable, {name: shopItem.name}) }); } - // 4. Consume the item from their inventory invItem.quantity -= 1; if (invItem.quantity <= 0) { - await invItem.destroy(); // Remove the row completely if they are out + await invItem.destroy(); } else { - await invItem.save(); // Otherwise just save the lowered quantity + await invItem.save(); } - // 5. Send the custom message! - // (Bonus: We replace "{user}" so you can dynamically ping the user in the custom message!) const customReply = shopItem.useMessage.replace(/{user}/g, `<@${interaction.user.id}>`); await interaction.editReply({ @@ -932,7 +789,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const customSeconds = interaction.options.getInteger("seconds") || 60; const timeMs = customSeconds * 1000; - // 1. Initial opening message structured through global config formats const initialReply = await interaction.editReply({ content: format(config.economy.roulette.openMessage, { userId: interaction.user.id, seconds: customSeconds }) }); @@ -945,7 +801,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const bets: RouletteBet[] = []; - // 2. Main instructional guide announcement await thread.send({ content: format(config.economy.roulette.guideMessage, { seconds: customSeconds, userId: interaction.user.id }) }); @@ -979,7 +834,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { if (currentBalance < amount) return void await message.react("āŒ"); - if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: message.author.id, balance: STARTING_BALANCE }); + if (!profile) profile = await EconomyProfile.create({ guildId: message.author.id, userId: message.author.id, balance: STARTING_BALANCE }); profile.balance -= amount; await profile.save(); @@ -996,7 +851,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { }); collector.on("end", async (_, reason) => { - // CHANGED: Only trigger inactivity closure if absolutely no bets were registered if (bets.length === 0) { await thread.send({ content: config.economy.roulette.inactivityMessage }); await thread.setLocked(true); @@ -1004,8 +858,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { return; } - // If there are bets, it will now automatically pass through here and spin - // whether the host typed "spin" OR the collector timer naturally ran out. const winningNumber = randomInt(0, 37); const redNumbers = [1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]; let color: "green" | "red" | "black" = "green"; @@ -1014,7 +866,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const isEven = winningNumber > 0 && winningNumber % 2 === 0; const isOdd = winningNumber > 0 && winningNumber % 2 !== 0; - // 4. Send visual spin warning sequence await thread.send({ content: config.economy.roulette.spinningMessage }); const userBreakdowns = new Map(); @@ -1052,7 +903,6 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const emoji = color === "red" ? "šŸ”“" : color === "black" ? "⚫" : "🟢"; - // 5. Build full game table data summary arrays let outputMessage = format(config.economy.roulette.resultHeader, { number: winningNumber, color: color.toUpperCase(), @@ -1083,27 +933,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { }); } -async function seedDefaultShopItems(guildId: string) { - for (const item of config.economy.shopItems) { - await ShopItem.findOrCreate({ - where: {guildId: guildId, name: item.name}, - defaults: { - guildId: guildId, - itemId: item.itemId, - name: item.name, - price: item.price, - description: item.description, - roleId: item.roleId || null, - durationDays: item.durationDays || null, - useMessage: item.useMessage || null, - stock: item.stock - } - }); - } -} - async function handleLeaderboard(interaction: ChatInputCommandInteraction) { - // 1. Fetch all profiles in the current guild, sorted by balance descending[cite: 3] const profiles = await EconomyProfile.findAll({ where: { guildId: interaction.guildId! }, order: [["balance", "DESC"]] @@ -1119,30 +949,25 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { const pages: EmbedBuilder[] = []; const totalPages = Math.ceil(profiles.length / USERS_PER_PAGE); - // 2. Loop through profiles and slice them into chunks of 10[cite: 3] for (let i = 0; i < profiles.length; i += USERS_PER_PAGE) { const chunk = profiles.slice(i, i + USERS_PER_PAGE); const currentPage = Math.floor(i / USERS_PER_PAGE) + 1; const embed = new EmbedBuilder() .setTitle(`šŸ† ${interaction.guild?.name || "Server"} Wealth Leaderboard`) - .setColor("#F1C40F") // Clean Gold Color + .setColor("#F1C40F") .setTimestamp(); let description = ""; - // 3. Build the text rows for the current page chunk[cite: 3] chunk.forEach((profile, index) => { const globalRank = i + index + 1; let rankDisplay = `**#${globalRank}**`; - // Style up the top 3 with shiny medals[cite: 3] if (globalRank === 1) rankDisplay = "šŸ„‡"; else if (globalRank === 2) rankDisplay = "🄈"; else if (globalRank === 3) rankDisplay = "šŸ„‰"; - // ABANDONED: config.economy.currencyFormat - // FIXED: Natively uses local string spacing standards with a literal string suffix instead[cite: 3]. const formattedBalance = `${profile.balance.toLocaleString()}$`; description += `${rankDisplay} <@${profile.userId}> — **${formattedBalance}**\n`; }); @@ -1153,47 +978,51 @@ async function handleLeaderboard(interaction: ChatInputCommandInteraction) { pages.push(embed); } - // 4. Pass the array of embeds into your pagination utility![cite: 3] await paginate(interaction, pages); } async function handleRefillStock(interaction: ChatInputCommandInteraction) { if (!(await hasStaffPermission(interaction))) return; - // 1. Fetch the required options from the command const itemKey = interaction.options.getString("item", true).toLowerCase(); const amount = interaction.options.getInteger("amount", true); - // 2. Validate the amount if (amount <= 0) { return void await interaction.editReply({ content: "āŒ You must specify a positive amount to refill." }); } - // 3. Find the item in the database - const item = await ShopItem.findOne({ - where: { guildId: interaction.guildId!, itemId: itemKey } - }); + const shopItems = config.economy.shopItems || []; + const item = shopItems.find(i => i.itemId === itemKey); if (!item) { return void await interaction.editReply({ - content: `āŒ Could not find an item with the ID \`${itemKey}\` in the shop.` + content: `āŒ Could not find an item with the ID \`${itemKey}\` in the shop configuration.` }); } - // 4. Check if the item has infinite stock (-1) if (item.stock === -1) { return void await interaction.editReply({ content: `āš ļø **${item.name}** currently has infinite stock (āˆž), so it does not need to be refilled!` }); } - // 5. Apply the stock addition and save - item.stock += amount; - await item.save(); + let stockTracker = await ShopItem.findOne({ + where: { guildId: interaction.guildId!, itemId: itemKey } + }); + + if (!stockTracker) { + stockTracker = await ShopItem.create({ + guildId: interaction.guildId!, + itemId: itemKey, + stock: item.stock + amount + }); + } else { + stockTracker.stock += amount; + await stockTracker.save(); + } - // 6. Confirm the successful refill await interaction.editReply({ - content: `šŸ“¦ Successfully added **${amount}** stock to **${item.name}**! The shop now has **${item.stock}** available.` + content: `šŸ“¦ Successfully added **${amount}** stock to **${item.name}**! The shop now has **${stockTracker.stock}** available.` }); } \ No newline at end of file From 27ab784fc079385c8c50ef3b4372a26db3ee1324 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Fri, 10 Jul 2026 01:00:27 +0800 Subject: [PATCH 27/34] other fix --- src/commands/fun/economy.ts | 106 +++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 8 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index f4bb6c2..2539de5 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -17,13 +17,14 @@ import { type CreationOptional, type InferAttributes, type InferCreationAttributes, - Op + Op, Sequelize } from "sequelize"; import type { Cmd } from "~/util/base"; import { format } from "~/util/base"; -import config from "config.json"; +import rnd from "~/util/rnd"; +import config from "config.json"; // Update to "../config.json.js" or "~/config.json" if your compiler requires it import { randomInt } from "crypto"; -import { paginate } from "~/util/paginator2"; +import { paginate } from "~/util/paginator2"; // FIX: Removed .ts and updated to project path mapping alias export class EconomyProfile extends Model< InferAttributes, @@ -151,6 +152,95 @@ export default { }, 3600000); }, + slash: (builder) => { + return builder + .setName("economy") + .setDescription("Manage your pocket change and inventory") + .addSubcommand((sub) => + sub + .setName("balance") + .setDescription("Check your current balance or another user's balance") + .addUserOption((opt) => opt.setName("user").setDescription("The user to check").setRequired(false)), + ) + .addSubcommand(sub => sub.setName("wage").setDescription("Collect your regular salary!")) + .addSubcommand(sub => sub.setName("leaderboard").setDescription("View the leaderboard")) + .addSubcommand((sub) => sub.setName("shop").setDescription("View available items for purchase")) + .addSubcommand((sub) => + sub + .setName("buy") + .setDescription("Purchase an item from the shop") + .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to buy (e.g. 'vip_role')").setRequired(true).setAutocomplete(true)) + .addIntegerOption((opt) => opt.setName("quantity").setDescription("How many to buy?").setMinValue(1)) + ) + .addSubcommand((sub) => + sub + .setName("use") + .setDescription("Use a consumable item from your inventory") + .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to use").setRequired(true).setAutocomplete(true)) + ) + .addSubcommand((sub) => + sub + .setName("refill") + .setDescription("Refill the stock of a specific shop item.") + .addStringOption(option => + option.setName("item") + .setDescription("The ID of the item to refill") + .setRequired(true) + .setAutocomplete(true) + ) + .addIntegerOption(option => + option.setName("amount") + .setDescription("How much stock to add") + .setRequired(true) + ) + ) + .addSubcommand((sub) => sub.setName("inventory").setDescription("View items you currently own")) + .addSubcommand((sub) => + sub + .setName("add-money") + .setDescription("Add money to a user's balance (Admin/Staff Only)") + .addUserOption((opt) => opt.setName("user").setDescription("The user receiving the money").setRequired(true)) + .addIntegerOption((opt) => opt.setName("amount").setDescription("The amount of money to add").setRequired(true)), + ) + .addSubcommand((sub) => + sub + .setName("set-balance") + .setDescription("Forcefully set a user's balance to a specific amount (Staff Only)") + .addUserOption((opt) => opt.setName("user").setDescription("The target user").setRequired(true)) + .addIntegerOption((opt) => opt.setName("amount").setDescription("The exact balance to set").setRequired(true)), + ) + .addSubcommandGroup((group) => + group + .setName("gamble") + .setDescription("Risk your money on different casino games!") + .addSubcommand((sub) => + sub + .setName("coinflip") + .setDescription("A 50/50 chance to double your money!") + .addIntegerOption((opt) => opt.setName("amount").setDescription("How much to bet").setRequired(true).setMinValue(1)) + ) + .addSubcommand((sub) => + sub + .setName("dice") + .setDescription("Guess a 6-sided die roll. Win 5x your bet!") + .addIntegerOption((opt) => opt.setName("amount").setDescription("How much to bet").setRequired(true).setMinValue(1)) + .addIntegerOption((opt) => opt.setName("guess").setDescription("Your guess (1-6)").setRequired(true).setMinValue(1).setMaxValue(6)) + ) + .addSubcommand((sub) => + sub + .setName("roulette") + .setDescription("Open a roulette table and place multiple bets! (1-24, Red/Black, Even/Odd)") + .addIntegerOption(option => + option.setName("seconds") + .setDescription("How many seconds should the table stay open? (Default: 60)") + .setRequired(false) + .setMinValue(15) + .setMaxValue(1800) + ) + ) + ); + }, + onInteraction: async (ctx, interaction) => { if (interaction.isAutocomplete()) { if (!interaction.guildId) return void await interaction.respond([]); @@ -243,7 +333,6 @@ export default { const isEphemeral = EPHEMERAL_MAPPING[sub] ?? false; - // SAFE GUARD: Wrapped inside a try-catch to absorb 10062 Unknown Interaction token expirations try { await interaction.deferReply({ flags: isEphemeral ? MessageFlags.Ephemeral : undefined @@ -462,7 +551,6 @@ async function handleShop(interaction: ChatInputCommandInteraction) { collector.on("collect", async (buttonInteraction) => { const itemId = buttonInteraction.customId.replace("shop_buy_", ""); - // SAFE GUARD: Wrap button component interaction deferral inside try-catch to prevent crashes on latency spikes try { await buttonInteraction.deferReply({ ephemeral: true }); } catch (error) { @@ -647,8 +735,10 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { return `${visualName} x\`${item.quantity}\``; }).join("\n"); - const embed = new EmbedBuilder().setTitle(`šŸŽ’ <@${interaction.user.id}>'s Inventory`).setDescription(inventoryList).setColor(0x00ae86); - await interaction.editReply({ embeds: [embed] }); + // FIX: Combined everything into a clean, markdown-formatted plain text string + const responseMessage = `šŸŽ’ **<@${interaction.user.id}>'s Inventory**\n\n${inventoryList}`; + + await interaction.editReply({ content: responseMessage }); } async function handleUse(interaction: ChatInputCommandInteraction) { @@ -834,7 +924,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { if (currentBalance < amount) return void await message.react("āŒ"); - if (!profile) profile = await EconomyProfile.create({ guildId: message.author.id, userId: message.author.id, balance: STARTING_BALANCE }); + if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: message.author.id, balance: STARTING_BALANCE }); profile.balance -= amount; await profile.save(); From 0574e0444284e982276dcb67d82b3964bde88997 Mon Sep 17 00:00:00 2001 From: "vmbbi (Max)" Date: Fri, 10 Jul 2026 01:03:58 +0800 Subject: [PATCH 28/34] Update config.json.js --- config.json.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json.js b/config.json.js index 0c3aea9..8752d42 100644 --- a/config.json.js +++ b/config.json.js @@ -1,6 +1,6 @@ export default { guildId: ["1213989169878274068"], - clientId: "1520481807458504774", + clientId: "1287095017596387500", logging: "debug", welcome: { channel: "1213989170964340878", From 05bc87013e2d8d33c955d1bf31bf7dec869b65a9 Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 25 Jul 2026 01:19:28 +0800 Subject: [PATCH 29/34] monke nitpicks --- config.json.js | 4 +- src/commands/fun/economy.ts | 218 ++++++++++++++++++++++-------------- 2 files changed, 136 insertions(+), 86 deletions(-) diff --git a/config.json.js b/config.json.js index 8752d42..dc795f4 100644 --- a/config.json.js +++ b/config.json.js @@ -237,8 +237,8 @@ Consider donating to one of the following people: "šŸŽ” **Roulette Table Opened!** (Closes in {seconds} seconds)\n\n" + "To enter, type your bet choice followed by your amount. " + "**Example: `red 250`**\n" + - "• `0-36 ` (8x payout)\n" + - "• `green ` (8x payout) 🟢\n" + + "• `0-36 ` (35x payout)\n" + + "• `green ` (35x payout) 🟢\n" + "• `red ` (2x payout) šŸ”“\n" + "• `black ` (2x payout) ⚫\n" + "• `even ` (2x payout)\n" + diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 2539de5..5d88155 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -9,7 +9,8 @@ import { ContainerBuilder, TextDisplayBuilder, SeparatorBuilder, - SectionBuilder + SectionBuilder, + InteractionContextType, PermissionFlagsBits } from "discord.js"; import { DataTypes, @@ -21,10 +22,9 @@ import { } from "sequelize"; import type { Cmd } from "~/util/base"; import { format } from "~/util/base"; -import rnd from "~/util/rnd"; -import config from "config.json"; // Update to "../config.json.js" or "~/config.json" if your compiler requires it +import config from "config.json"; import { randomInt } from "crypto"; -import { paginate } from "~/util/paginator2"; // FIX: Removed .ts and updated to project path mapping alias +import { paginate } from "~/util/paginator2"; export class EconomyProfile extends Model< InferAttributes, @@ -149,13 +149,14 @@ export default { } catch (err) { console.error("[Sweeper Worker Error]:", err); } - }, 3600000); + }, 10000); }, slash: (builder) => { return builder .setName("economy") .setDescription("Manage your pocket change and inventory") + .setContexts(InteractionContextType.Guild) .addSubcommand((sub) => sub .setName("balance") @@ -170,7 +171,7 @@ export default { .setName("buy") .setDescription("Purchase an item from the shop") .addStringOption((opt) => opt.setName("item").setDescription("The ID of the item you want to buy (e.g. 'vip_role')").setRequired(true).setAutocomplete(true)) - .addIntegerOption((opt) => opt.setName("quantity").setDescription("How many to buy?").setMinValue(1)) + .addIntegerOption((opt) => opt.setName("quantity").setDescription("How many to buy?").setMinValue(1).setMaxValue(10000000)) ) .addSubcommand((sub) => sub @@ -229,7 +230,7 @@ export default { .addSubcommand((sub) => sub .setName("roulette") - .setDescription("Open a roulette table and place multiple bets! (1-24, Red/Black, Even/Odd)") + .setDescription("Open a roulette table and place multiple bets! (0-36, Red/Black, Even/Odd)") .addIntegerOption(option => option.setName("seconds") .setDescription("How many seconds should the table stay open? (Default: 60)") @@ -250,17 +251,33 @@ export default { const shopItems = config.economy.shopItems || []; if (sub === "buy" || sub === "refill") { - const filtered = shopItems.filter(item => - item.name.toLowerCase().includes(focusedValue) || - item.itemId.toLowerCase().includes(focusedValue) - ); + try { + const dbStockRecords = await ShopItem.findAll({ where: { guildId: interaction.guildId } }); + const stockMap = new Map(dbStockRecords.map(s => [s.itemId, s.stock])); - return void await interaction.respond( - filtered.slice(0, 25).map(item => ({ - name: `${item.name} — $${item.price}`, - value: item.itemId - })) - ); + const filtered = shopItems.filter(item => { + const matchesFocus = item.name.toLowerCase().includes(focusedValue) || + item.itemId.toLowerCase().includes(focusedValue); + if (!matchesFocus) return false; + + const currentStock = item.stock === -1 + ? -1 + : (stockMap.get(item.itemId) ?? item.stock); + + return currentStock === -1 || currentStock > 0; + }); + + return void await interaction.respond( + filtered.slice(0, 25).map(item => ({ + name: `${item.name} — $${item.price}`, + value: item.itemId + })) + ); + } catch (error: any) { + if (error?.code !== 10062) { + console.error("Autocomplete execution error:", error); + } + } } if (sub === "use") { @@ -269,15 +286,7 @@ export default { }); const itemMap = new Map(shopItems.map(i => [i.itemId, i.name])); - const validInventory = []; - - for (const inv of inventory) { - if (!itemMap.has(inv.itemKey)) { - await inv.destroy(); - } else { - validInventory.push(inv); - } - } + const validInventory = inventory.filter(inv => itemMap.has(inv.itemKey)); const filtered = validInventory.filter(inv => { const name = itemMap.get(inv.itemKey) || inv.itemKey; @@ -369,7 +378,7 @@ export default { return; } } - }, + } } as Cmd; // ── UTILITY ────────────────────────────────────────────────────────────── @@ -473,7 +482,7 @@ async function handleShop(interaction: ChatInputCommandInteraction) { .addTextDisplayComponents( new TextDisplayBuilder().setContent("šŸ›’ **The Server Shop**\nThe shop is currently empty.") ); - return void await interaction.reply({ + return void await interaction.editReply({ components: [emptyContainer], flags: [MessageFlags.IsComponentsV2] }); @@ -573,7 +582,8 @@ async function handleShop(interaction: ChatInputCommandInteraction) { const json = container.toJSON(); if (json.components) { json.components.forEach((comp: any) => { - if (comp.type === 9 && comp.accessory && comp.accessory.type === 2) { + if (comp.type === ComponentType.Section && + comp.accessory?.type === ComponentType.Button) { comp.accessory.disabled = true; } }); @@ -587,12 +597,30 @@ async function handleShop(interaction: ChatInputCommandInteraction) { } async function handleBuy(interaction: ChatInputCommandInteraction) { + const sequelize = EconomyProfile.sequelize; + if (!sequelize) { + return void await interaction.editReply({ content: "āŒ Database connection error." }); + } + const itemKey = interaction.options.getString("item", true).toLowerCase(); const quantity = interaction.options.getInteger("quantity") ?? 1; const shopItems = config.economy.shopItems || []; const item = shopItems.find(i => i.itemId === itemKey); - if (!item) return void await interaction.editReply({ content: config.economy.shop.notItem }); + + if (!item) { + return void await interaction.editReply({ content: config.economy.shop.notItem }); + } + + if (item.roleId && quantity > 1) { + return void await interaction.editReply({ content: config.economy.shop.notMultiple }); + } + + if (item.roleId && !item.durationDays && interaction.member instanceof GuildMember) { + if (interaction.member.roles.cache.has(item.roleId)) { + return void await interaction.editReply({ content: config.economy.shop.permRoleOwned }); + } + } let currentStock = item.stock; let stockTracker = null; @@ -609,13 +637,8 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { return void await interaction.editReply({ content: format(config.economy.shop.notEnough, {stock: currentStock} )}); } - if (item.roleId && quantity > 1) { - return void await interaction.editReply({ content: config.economy.shop.notMultiple }); - } - - let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); + const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); const currentBalance = profile?.balance ?? STARTING_BALANCE; - const totalCost = item.price * quantity; const shopErrorMessage = format(config.economy.shop.cantAfford,{ @@ -625,40 +648,69 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { }); if (!(await hasSufficientFunds(interaction, currentBalance, totalCost, shopErrorMessage))) return; - if (!profile) { - profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); - } - + let newBalance = currentBalance - totalCost; let roleGrantedMessage = ""; - if (item.stock !== -1) { - if (!stockTracker) { - stockTracker = await ShopItem.create({ guildId: interaction.guildId!, itemId: itemKey, stock: item.stock - quantity }); - } else { - stockTracker.stock -= quantity; - await stockTracker.save(); - } - } - profile.balance -= totalCost; + try { + await sequelize.transaction(async (t) => { + const [userProfile] = await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: interaction.user.id }, + defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }, + transaction: t + }); - if (item.roleId && interaction.member instanceof GuildMember) { - try { - if (item.durationDays) { + await userProfile.decrement({ balance: totalCost }, { transaction: t }); + newBalance = userProfile.balance - totalCost; + + if (item.stock !== -1) { + const [itemStock] = await ShopItem.findOrCreate({ + where: { guildId: interaction.guildId!, itemId: itemKey }, + defaults: { guildId: interaction.guildId!, itemId: itemKey, stock: item.stock }, + transaction: t + }); + await itemStock.decrement({ stock: quantity }, { transaction: t }); + } + + const [invItem, created] = await Inventory.findOrCreate({ + where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, + defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: quantity }, + transaction: t + }); + + if (!created) { + await invItem.increment({ quantity: quantity }, { transaction: t }); + } + + if (item.roleId && item.durationDays) { const timeToAdd = item.durationDays * 24 * 60 * 60 * 1000; - let tempRole = await TempRole.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId } }); + const tempRole = await TempRole.findOne({ + where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId }, + transaction: t + }); if (tempRole) { tempRole.expiresAt = new Date(tempRole.expiresAt.getTime() + timeToAdd); - await tempRole.save(); + await tempRole.save({ transaction: t }); } else { await TempRole.create({ - guildId: interaction.guildId!, userId: interaction.user.id, - roleId: item.roleId, expiresAt: new Date(Date.now() + timeToAdd) - }); + guildId: interaction.guildId!, + userId: interaction.user.id, + roleId: item.roleId, + expiresAt: new Date(Date.now() + timeToAdd) + }, { transaction: t }); } + } + }); + } catch (error) { + console.error("Buy Transaction Error:", error); + return void await interaction.editReply({ content: "āŒ Transaction failed. Please try again." }); + } + if (item.roleId && interaction.member instanceof GuildMember) { + try { + if (item.durationDays) { await interaction.member.roles.add(item.roleId, `Purchased ${item.durationDays} day pass.`); - roleGrantedMessage = format(config.economy.shop.tempRole, {roleId: item.roleId, durationDays: item.durationDays}); + roleGrantedMessage = format(config.economy.shop.tempRole, { roleId: item.roleId, durationDays: item.durationDays }); const msRemaining = item.durationDays * 24 * 60 * 60 * 1000; const memberRef = interaction.member; @@ -679,11 +731,7 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { console.error("[Instant Timer Error] Failed to remove role:", err); } }, msRemaining); - } else { - if (interaction.member.roles.cache.has(item.roleId)) { - return void await interaction.editReply({ content: config.economy.shop.permRoleOwned }); - } await interaction.member.roles.add(item.roleId, `Purchased permanent role.`); roleGrantedMessage = format(config.economy.shop.permaRole, {roleId: item.roleId}); } @@ -692,23 +740,12 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } } - await profile.save(); - - const [invItem, created] = await Inventory.findOrCreate({ - where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, - defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: quantity } - }); - - if (!created) { - invItem.quantity += quantity; - await invItem.save(); - } - - await interaction.editReply({ content: format(config.economy.shop.successBuy, { + await interaction.editReply({ + content: format(config.economy.shop.successBuy, { name: quantity > 1 ? `${quantity}x ${item.name}` : item.name, price: totalCost, message: roleGrantedMessage, - balance: profile.balance + balance: newBalance }) }); } @@ -734,8 +771,6 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { const visualName = itemMap.get(item.itemKey) || `āš™ļø Unknown Item (${item.itemKey})`; return `${visualName} x\`${item.quantity}\``; }).join("\n"); - - // FIX: Combined everything into a clean, markdown-formatted plain text string const responseMessage = `šŸŽ’ **<@${interaction.user.id}>'s Inventory**\n\n${inventoryList}`; await interaction.editReply({ content: responseMessage }); @@ -876,6 +911,13 @@ interface RouletteBet { } async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { + if (interaction.channel?.isThread() || !interaction.appPermissions?.has(PermissionFlagsBits.CreatePublicThreads)) { + await interaction.editReply({ + content: "āŒ Roulette cannot be started inside a thread or without thread creation permissions." + }); + return; + } + const customSeconds = interaction.options.getInteger("seconds") || 60; const timeMs = customSeconds * 1000; @@ -883,11 +925,19 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { content: format(config.economy.roulette.openMessage, { userId: interaction.user.id, seconds: customSeconds }) }); - const thread = await initialReply.startThread({ - name: format(config.economy.roulette.threadName, { username: interaction.user.username }), - autoArchiveDuration: 60, - reason: "Roulette Game Room" - }); + let thread; + try { + thread = await initialReply.startThread({ + name: format(config.economy.roulette.threadName, { username: interaction.user.username }), + autoArchiveDuration: 60, + reason: "Roulette Game Room" + }); + } catch { + await interaction.editReply({ + content: "āŒ Failed to create the game thread. Please ensure I have proper permissions." + }); + return; + } const bets: RouletteBet[] = []; @@ -964,7 +1014,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { for (const bet of bets) { let won = bet.betType === color || (bet.betType === "even" && isEven) || (bet.betType === "odd" && isOdd) || bet.betNumber === winningNumber; - let payoutMultiplier = (bet.betType === "number" || bet.betType === "green") ? 8 : 2; + let payoutMultiplier = (bet.betType === "number" || bet.betType === "green") ? 35 : 2; let betDisplay = bet.betType === "number" ? `Number ${bet.betNumber}` : bet.betType; const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); From bac99e9d02ca894dd934d9c9b530a7814cf2129e Mon Sep 17 00:00:00 2001 From: vmbbi Date: Sat, 25 Jul 2026 19:02:16 +0800 Subject: [PATCH 30/34] monke nitpicks 2 --- src/commands/fun/economy.ts | 139 ++++++++++++++++++++++++------------ 1 file changed, 92 insertions(+), 47 deletions(-) diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 5d88155..24351ee 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -149,7 +149,7 @@ export default { } catch (err) { console.error("[Sweeper Worker Error]:", err); } - }, 10000); + }, 10 * 1000); }, slash: (builder) => { @@ -277,6 +277,7 @@ export default { if (error?.code !== 10062) { console.error("Autocomplete execution error:", error); } + return; } } @@ -656,21 +657,32 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { const [userProfile] = await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }, - transaction: t + transaction: t, + lock: t.LOCK.UPDATE }); - await userProfile.decrement({ balance: totalCost }, { transaction: t }); - newBalance = userProfile.balance - totalCost; + if (userProfile.balance < totalCost) { + throw new Error("INSUFFICIENT_FUNDS"); + } if (item.stock !== -1) { const [itemStock] = await ShopItem.findOrCreate({ where: { guildId: interaction.guildId!, itemId: itemKey }, defaults: { guildId: interaction.guildId!, itemId: itemKey, stock: item.stock }, - transaction: t + transaction: t, + lock: t.LOCK.UPDATE }); + + if (itemStock.stock < quantity) { + throw new Error("INSUFFICIENT_STOCK"); + } + await itemStock.decrement({ stock: quantity }, { transaction: t }); } + await userProfile.decrement({ balance: totalCost }, { transaction: t }); + newBalance = userProfile.balance - totalCost; + const [invItem, created] = await Inventory.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: quantity }, @@ -701,7 +713,13 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } } }); - } catch (error) { + } catch (error: any) { + if (error?.message === "INSUFFICIENT_FUNDS") { + return void await interaction.editReply({ content: shopErrorMessage }); + } + if (error?.message === "INSUFFICIENT_STOCK") { + return void await interaction.editReply({ content: format(config.economy.shop.soldOut, { name: item.name }) }); + } console.error("Buy Transaction Error:", error); return void await interaction.editReply({ content: "āŒ Transaction failed. Please try again." }); } @@ -737,6 +755,15 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { } } catch (error) { console.error("Failed to assign shop role:", error); + if (profile) { + profile.balance += totalCost; + await profile.save(); + } + + // Stop execution and inform the user of the failure and refund + return void await interaction.editReply({ + content: "āŒ Failed to grant the role, refunded." + }).catch(() => {}); } } @@ -755,15 +782,8 @@ async function handleInventory(interaction: ChatInputCommandInteraction) { const shopItems = config.economy.shopItems || []; const itemMap = new Map(shopItems.map(i => [i.itemId, i.name])); - const validInventory = []; - for (const item of items) { - if (!itemMap.has(item.itemKey)) { - await item.destroy(); - } else { - validInventory.push(item); - } - } + const validInventory = items.filter(item => itemMap.has(item.itemKey)); if (validInventory.length === 0) return void await interaction.editReply({ content: config.economy.inv.empty }); @@ -782,25 +802,24 @@ async function handleUse(interaction: ChatInputCommandInteraction) { const shopItems = config.economy.shopItems || []; const shopItem = shopItems.find(i => i.itemId === itemKey); + if (!shopItem) { + return void await interaction.editReply({ content: config.economy.inv.nonexistent }).catch(() => {}); + } + const invItem = await Inventory.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey } }); - if (!shopItem) { - if (invItem) await invItem.destroy(); - return void await interaction.editReply({ content: config.economy.inv.nonexistent }); - } - if (!invItem || invItem.quantity <= 0) { return void await interaction.editReply({ - content: format(config.economy.inv.lack, {item: shopItem.name}) - }); + content: format(config.economy.inv.lack, { item: shopItem.name }) + }).catch(() => {}); } if (!shopItem.useMessage) { return void await interaction.editReply({ - content: format(config.economy.inv.nonconsumable, {name: shopItem.name}) - }); + content: format(config.economy.inv.nonconsumable, { name: shopItem.name }) + }).catch(() => {}); } invItem.quantity -= 1; @@ -814,7 +833,7 @@ async function handleUse(interaction: ChatInputCommandInteraction) { await interaction.editReply({ content: `šŸ“¦ **<@${interaction.user.id}>** used a **${shopItem.name}**!\n\n${customReply}` - }); + }).catch(() => {}); } async function handleAddMoney(interaction: ChatInputCommandInteraction) { @@ -914,16 +933,21 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { if (interaction.channel?.isThread() || !interaction.appPermissions?.has(PermissionFlagsBits.CreatePublicThreads)) { await interaction.editReply({ content: "āŒ Roulette cannot be started inside a thread or without thread creation permissions." - }); + }).catch(() => {}); return; } const customSeconds = interaction.options.getInteger("seconds") || 60; const timeMs = customSeconds * 1000; - const initialReply = await interaction.editReply({ - content: format(config.economy.roulette.openMessage, { userId: interaction.user.id, seconds: customSeconds }) - }); + let initialReply; + try { + initialReply = await interaction.editReply({ + content: format(config.economy.roulette.openMessage, { userId: interaction.user.id, seconds: customSeconds }) + }); + } catch { + return; + } let thread; try { @@ -935,7 +959,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { } catch { await interaction.editReply({ content: "āŒ Failed to create the game thread. Please ensure I have proper permissions." - }); + }).catch(() => {}); return; } @@ -943,7 +967,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { await thread.send({ content: format(config.economy.roulette.guideMessage, { seconds: customSeconds, userId: interaction.user.id }) - }); + }).catch(() => {}); const collector = thread.createMessageCollector({ filter: (m) => !m.author.bot, time: timeMs }); @@ -952,8 +976,8 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const commandOrType = args[0]; if (commandOrType === "spin") { - if (message.author.id !== interaction.user.id) return void await message.react("āŒ"); - if (bets.length === 0) return void await message.react("āŒ"); + if (message.author.id !== interaction.user.id) return void await message.react("āŒ").catch(() => {}); + if (bets.length === 0) return void await message.react("āŒ").catch(() => {}); collector.stop("spun"); return; } @@ -964,20 +988,19 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { if (validBetTypes.includes(commandOrType) || isNumberBet) { const amountStr = args[1]; - if (!amountStr) return void await message.react("āŒ"); + if (!amountStr) return void await message.react("āŒ").catch(() => {}); const amount = parseInt(amountStr, 10); - if (isNaN(amount) || amount <= 0) return void await message.react("āŒ"); + if (isNaN(amount) || amount <= 0) return void await message.react("āŒ").catch(() => {}); let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: message.author.id } }); const currentBalance = profile?.balance ?? STARTING_BALANCE; - if (currentBalance < amount) return void await message.react("āŒ"); + if (currentBalance < amount) return void await message.react("āŒ").catch(() => {}); if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: message.author.id, balance: STARTING_BALANCE }); - profile.balance -= amount; - await profile.save(); + await profile.decrement('balance', {by: amount}); bets.push({ userId: message.author.id, @@ -986,15 +1009,17 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { betType: isNumberBet ? "number" : (commandOrType as any), betNumber: isNumberBet ? parsedNumber : undefined }); - await message.react("āœ…"); + await message.react("āœ…").catch(() => {}); } }); collector.on("end", async (_, reason) => { if (bets.length === 0) { - await thread.send({ content: config.economy.roulette.inactivityMessage }); - await thread.setLocked(true); - await thread.setArchived(true); + await thread.send({ content: config.economy.roulette.inactivityMessage }).catch(() => {}); + try { + await thread.setLocked(true); + await thread.setArchived(true); + } catch {} return; } @@ -1006,7 +1031,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const isEven = winningNumber > 0 && winningNumber % 2 === 0; const isOdd = winningNumber > 0 && winningNumber % 2 !== 0; - await thread.send({ content: config.economy.roulette.spinningMessage }); + await thread.send({ content: config.economy.roulette.spinningMessage }).catch(() => {}); const userBreakdowns = new Map(); const userNetTotals = new Map(); @@ -1025,8 +1050,7 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { if (won && profile) { const winnings = bet.amount * payoutMultiplier; - profile.balance += winnings; - await profile.save(); + await profile.increment('balance', { by: winnings }); const formattedWinnings = winnings.toLocaleString(); userBreakdowns.get(bet.userId)!.push( @@ -1067,9 +1091,30 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { }); } - await thread.send({ content: outputMessage }); - await thread.setLocked(true); - await thread.setArchived(true); + // Chunk and send outputMessage if it exceeds 1900 characters + const CHUNK_LIMIT = 1900; + const lines = outputMessage.split("\n"); + let currentChunk = ""; + + for (const line of lines) { + if ((currentChunk + "\n" + line).length > CHUNK_LIMIT) { + if (currentChunk.trim()) { + await thread.send({ content: currentChunk }).catch(() => {}); + } + currentChunk = line; + } else { + currentChunk = currentChunk ? `${currentChunk}\n${line}` : line; + } + } + + if (currentChunk.trim()) { + await thread.send({ content: currentChunk }).catch(() => {}); + } + + try { + await thread.setLocked(true); + await thread.setArchived(true); + } catch {} }); } From 6ef97f73a188dea880be48c63e50d2a95e39a4d1 Mon Sep 17 00:00:00 2001 From: alex <70163067+rapbattlegod32@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:08:34 +0100 Subject: [PATCH 31/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/util/paginator2.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/util/paginator2.ts b/src/util/paginator2.ts index 88d9915..1cf1a51 100644 --- a/src/util/paginator2.ts +++ b/src/util/paginator2.ts @@ -54,8 +54,9 @@ export async function paginate( }); collector.on("collect", async (i) => { - if (i.customId === "prev") index--; - else if (i.customId === "next") index++; + if (i.customId === "prev") index = Math.max(0, index - 1); + else if (i.customId === "next") index = Math.min(pages.length - 1, index + 1); + else return; // Dynamically disable buttons based on the new index prevButton.setDisabled(index === 0); From 03b74533ef13bf407e3cd1877ea86c25b9d31262 Mon Sep 17 00:00:00 2001 From: alex <70163067+rapbattlegod32@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:08:51 +0100 Subject: [PATCH 32/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/commands/support/search.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/support/search.ts b/src/commands/support/search.ts index 05ffe0b..26cd3b9 100644 --- a/src/commands/support/search.ts +++ b/src/commands/support/search.ts @@ -106,10 +106,9 @@ async function printSearchResultsV2(ctx: Ctx, query: string): Promise { async function onInteraction(ctx: Ctx, interaction: Interaction) { if (!interaction.isChatInputCommand()) return; -await interaction.deferReply() + await interaction.deferReply(); // 2. Safe execution space - const query = interaction.options.getString("query", true); const body = await printSearchResultsV2(ctx, query); const pages = buildSearchEmbeds(query, body); From de33f77c9a0d3478847e24a7851d8875ce9e8a6d Mon Sep 17 00:00:00 2001 From: alex <70163067+rapbattlegod32@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:52:40 +0100 Subject: [PATCH 33/34] economy: atomic balance writes, fix double refund, restore lost query line --- config.json.js | 8 +- src/commands/fun/economy.ts | 172 +++++++++++++++--------- src/commands/support/search.ts | 239 +++++++++++++++++---------------- src/util/paginator2.ts | 3 +- 4 files changed, 234 insertions(+), 188 deletions(-) diff --git a/config.json.js b/config.json.js index dc795f4..647ce7e 100644 --- a/config.json.js +++ b/config.json.js @@ -203,14 +203,14 @@ Consider donating to one of the following people: gambleChannel: ["1522846518829125642"], addMoney: "{emoji} **Transaction Complete:** Successfully added `${added}` to <@{user}>'s profile. Their new balance is `${newBalance}`.", coinEmoji: "<:al_logo:1492686347666980944>", - cantAfford: "āŒ You only have \\`${userBalance}\\`. You don't have enough money to bet!", + cantAfford: "āŒ You only have `${userBalance}`. You don't have enough money to bet!", isntStaff: "āŒ You do not have a required staff role to use this command.", balanceMessage: "{emoji} <@{targetUser}> currently has **${balance}**.", shop:{ notItem: "That item doesn't exist in our shop.", - soldOut: "āŒ Sorry, **${name}** is completely sold out!", - cantAfford: "`āŒ You can't afford that! **{name}** costs \`${price}\`, but you only have \`${balance}\`.", - successBuy: "šŸŽ‰ Successfully bought **{name}** for \`${price}\`{message}. Your remaining balance is \`$${balance}\`.", + soldOut: "āŒ Sorry, **{name}** is completely sold out!", + cantAfford: "āŒ You can't afford that! **{name}** costs \`${price}\`, but you only have \`${balance}\`.", + successBuy: "šŸŽ‰ Successfully bought **{name}** for \`${price}\`{message}. Your remaining balance is \`${balance}\`.", permaRole: " and granted you the <@&{roleId}> role permanently!", tempRole: ` and granted you the <@&{roleId}> role for **{durationDays} days**!`, permRoleOwned: "āŒ You already have this permanent role!", diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 24351ee..156d381 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -18,7 +18,7 @@ import { type CreationOptional, type InferAttributes, type InferCreationAttributes, - Op, Sequelize + Op, Sequelize, Transaction } from "sequelize"; import type { Cmd } from "~/util/base"; import { format } from "~/util/base"; @@ -260,6 +260,8 @@ export default { item.itemId.toLowerCase().includes(focusedValue); if (!matchesFocus) return false; + if (sub === "refill") return item.stock !== -1; + const currentStock = item.stock === -1 ? -1 : (stockMap.get(item.itemId) ?? item.stock); @@ -450,12 +452,12 @@ async function handleWage(interaction: ChatInputCommandInteraction) { const salaryAmount = calculateWage(interaction.member); - profile.balance += salaryAmount; + await profile.increment({ balance: salaryAmount }); profile.lastWageClaim = now; await profile.save(); await interaction.editReply({ - content: format(config.economy.wages.message, {emoji: config.economy.coinEmoji, salary: salaryAmount, balance: profile.balance }) + content: format(config.economy.wages.message, {emoji: config.economy.coinEmoji, salary: salaryAmount, balance: profile.balance + salaryAmount }) }); } @@ -653,35 +655,44 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { let roleGrantedMessage = ""; try { - await sequelize.transaction(async (t) => { - const [userProfile] = await EconomyProfile.findOrCreate({ + await sequelize.transaction({ type: Transaction.TYPES.IMMEDIATE }, async (t) => { + await EconomyProfile.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id }, defaults: { guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }, - transaction: t, - lock: t.LOCK.UPDATE + transaction: t }); - if (userProfile.balance < totalCost) { - throw new Error("INSUFFICIENT_FUNDS"); - } - if (item.stock !== -1) { - const [itemStock] = await ShopItem.findOrCreate({ + await ShopItem.findOrCreate({ where: { guildId: interaction.guildId!, itemId: itemKey }, defaults: { guildId: interaction.guildId!, itemId: itemKey, stock: item.stock }, - transaction: t, - lock: t.LOCK.UPDATE + transaction: t }); - if (itemStock.stock < quantity) { - throw new Error("INSUFFICIENT_STOCK"); - } + const [stockTaken] = await ShopItem.update( + { stock: Sequelize.literal(`stock - ${quantity}`) as any }, + { + where: { guildId: interaction.guildId!, itemId: itemKey, stock: { [Op.gte]: quantity } }, + transaction: t + } + ); - await itemStock.decrement({ stock: quantity }, { transaction: t }); + if (stockTaken === 0) throw new Error("INSUFFICIENT_STOCK"); } - await userProfile.decrement({ balance: totalCost }, { transaction: t }); - newBalance = userProfile.balance - totalCost; + const [debited] = await EconomyProfile.update( + { balance: Sequelize.literal(`balance - ${totalCost}`) as any }, + { + where: { + guildId: interaction.guildId!, + userId: interaction.user.id, + balance: { [Op.gte]: totalCost } + }, + transaction: t + } + ); + + if (debited === 0) throw new Error("INSUFFICIENT_FUNDS"); const [invItem, created] = await Inventory.findOrCreate({ where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, @@ -724,43 +735,66 @@ async function handleBuy(interaction: ChatInputCommandInteraction) { return void await interaction.editReply({ content: "āŒ Transaction failed. Please try again." }); } + newBalance = (await EconomyProfile.findOne({ + where: { guildId: interaction.guildId!, userId: interaction.user.id } + }))?.balance ?? newBalance; + if (item.roleId && interaction.member instanceof GuildMember) { try { if (item.durationDays) { await interaction.member.roles.add(item.roleId, `Purchased ${item.durationDays} day pass.`); roleGrantedMessage = format(config.economy.shop.tempRole, { roleId: item.roleId, durationDays: item.durationDays }); - - const msRemaining = item.durationDays * 24 * 60 * 60 * 1000; - const memberRef = interaction.member; - const targetRoleId = item.roleId; - const targetGuildId = interaction.guildId!; - const targetUserId = interaction.user.id; - - setTimeout(async () => { - try { - const currentRecord = await TempRole.findOne({ where: { guildId: targetGuildId, userId: targetUserId, roleId: targetRoleId } }); - if (currentRecord && currentRecord.expiresAt <= new Date()) { - if (memberRef.roles.cache.has(targetRoleId)) { - await memberRef.roles.remove(targetRoleId, "šŸ•’ Temporary shop item duration expired."); - } - await currentRecord.destroy(); - } - } catch (err) { - console.error("[Instant Timer Error] Failed to remove role:", err); - } - }, msRemaining); } else { await interaction.member.roles.add(item.roleId, `Purchased permanent role.`); roleGrantedMessage = format(config.economy.shop.permaRole, {roleId: item.roleId}); } } catch (error) { console.error("Failed to assign shop role:", error); - if (profile) { - profile.balance += totalCost; - await profile.save(); + + try { + await sequelize.transaction({ type: Transaction.TYPES.IMMEDIATE }, async (t) => { + await EconomyProfile.increment( + { balance: totalCost }, + { where: { guildId: interaction.guildId!, userId: interaction.user.id }, transaction: t } + ); + + const invItem = await Inventory.findOne({ + where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey }, + transaction: t + }); + + if (invItem) { + if (invItem.quantity <= quantity) await invItem.destroy({ transaction: t }); + else await invItem.decrement({ quantity }, { transaction: t }); + } + + if (item.stock !== -1) { + await ShopItem.increment( + { stock: quantity }, + { where: { guildId: interaction.guildId!, itemId: itemKey }, transaction: t } + ); + } + + if (item.roleId && item.durationDays) { + const tempRole = await TempRole.findOne({ + where: { guildId: interaction.guildId!, userId: interaction.user.id, roleId: item.roleId }, + transaction: t + }); + + if (tempRole) { + const rewound = tempRole.expiresAt.getTime() - item.durationDays * 24 * 60 * 60 * 1000; + if (rewound <= Date.now()) await tempRole.destroy({ transaction: t }); + else { + tempRole.expiresAt = new Date(rewound); + await tempRole.save({ transaction: t }); + } + } + } + }); + } catch (rollbackError) { + console.error("Refund rollback failed:", rollbackError); } - // Stop execution and inform the user of the failure and refund return void await interaction.editReply({ content: "āŒ Failed to grant the role, refunded." }).catch(() => {}); @@ -822,13 +856,28 @@ async function handleUse(interaction: ChatInputCommandInteraction) { }).catch(() => {}); } - invItem.quantity -= 1; - if (invItem.quantity <= 0) { - await invItem.destroy(); - } else { - await invItem.save(); + const [consumed] = await Inventory.update( + { quantity: Sequelize.literal("quantity - 1") as any }, + { + where: { + guildId: interaction.guildId!, + userId: interaction.user.id, + itemKey, + quantity: { [Op.gt]: 0 } + } + } + ); + + if (consumed === 0) { + return void await interaction.editReply({ + content: format(config.economy.inv.lack, { item: shopItem.name }) + }).catch(() => {}); } + await Inventory.destroy({ + where: { guildId: interaction.guildId!, userId: interaction.user.id, itemKey, quantity: { [Op.lte]: 0 } } + }); + const customReply = shopItem.useMessage.replace(/{user}/g, `<@${interaction.user.id}>`); await interaction.editReply({ @@ -846,13 +895,12 @@ async function handleAddMoney(interaction: ChatInputCommandInteraction) { let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: targetUser.id } }); if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: targetUser.id, balance: STARTING_BALANCE }); - profile.balance += amount; - await profile.save(); + await profile.increment({ balance: amount }); const replyMessage = format(config.economy.addMoney, { emoji: config.economy.coinEmoji, added: amount, user: targetUser.id, - newBalance: profile.balance + newBalance: profile.balance + amount }); await interaction.editReply({ content: replyMessage }); @@ -886,13 +934,11 @@ async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { const isWinner = randomInt(0,2); if (isWinner == 1) { - profile.balance += betAmount; - await profile.save(); - await interaction.editReply({ content: format(config.economy.betWin, {thing: "coin", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance, dice: ""}) }); + await profile.increment({ balance: betAmount }); + await interaction.editReply({ content: format(config.economy.betWin, {thing: "coin", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: currentBalance + betAmount, dice: ""}) }); } else { - profile.balance -= betAmount; - await profile.save(); - await interaction.editReply({ content: format(config.economy.betLost, {dice: "", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance}) }); + await profile.decrement({ balance: betAmount }); + await interaction.editReply({ content: format(config.economy.betLost, {dice: "", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: currentBalance - betAmount}) }); } } @@ -911,13 +957,11 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { if (guess === diceRoll) { const winnings = betAmount * 5; - profile.balance += winnings; - await profile.save(); - await interaction.editReply({ content: format(config.economy.betWin, {thing: "dice", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance, dice: `It rolled a ${diceRoll}`}) }); + await profile.increment({ balance: winnings }); + await interaction.editReply({ content: format(config.economy.betWin, {thing: "dice", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: currentBalance + winnings, dice: `It rolled a ${diceRoll}`}) }); } else { - profile.balance -= betAmount; - await profile.save(); - await interaction.editReply({ content: format(config.economy.betLost, {dice: `The dice rolled ${diceRoll} while you guessed ${guess}`, betAmount: betAmount, emoji: config.economy.coinEmoji, balance: profile.balance}) }); + await profile.decrement({ balance: betAmount }); + await interaction.editReply({ content: format(config.economy.betLost, {dice: `The dice rolled ${diceRoll} while you guessed ${guess}`, betAmount: betAmount, emoji: config.economy.coinEmoji, balance: currentBalance - betAmount}) }); } } diff --git a/src/commands/support/search.ts b/src/commands/support/search.ts index 26cd3b9..641e08b 100644 --- a/src/commands/support/search.ts +++ b/src/commands/support/search.ts @@ -1,11 +1,11 @@ import { - EmbedBuilder, - type Interaction, - type Message, - type SendableChannels, - type SharedSlashCommand, - type SlashCommandBuilder, - type ChatInputCommandInteraction, + EmbedBuilder, + type Interaction, + type Message, + type SendableChannels, + type SharedSlashCommand, + type SlashCommandBuilder, + type ChatInputCommandInteraction, } from "discord.js"; import config from "config.json"; import { format, type Cmd, type CmdData, type Ctx } from "~/util/base"; @@ -13,125 +13,128 @@ import { paginate } from "~/util/paginator2"; import { createContentHighlighter } from "~/util/highlighter"; function buildSearchEmbeds(query: string, body: string): EmbedBuilder[] { - const lines = body.split("\n"); - const pages: EmbedBuilder[] = []; - let currentDescription = ""; - const maxChars = 2000; - - for (const line of lines) { - if (currentDescription.length + line.length + 1 > maxChars) { - if (currentDescription.trim()) { + const maxChars = 2000; + const safeQuery = query.length > 200 ? `${query.slice(0, 197)}...` : query; + const lines = body.split("\n").flatMap((line) => + line.length <= maxChars ? [line] : (line.match(new RegExp(`.{1,${maxChars}}`, "g")) ?? [line]) + ); + const pages: EmbedBuilder[] = []; + let currentDescription = ""; + + for (const line of lines) { + if (currentDescription.length + line.length + 1 > maxChars) { + if (currentDescription.trim()) { + pages.push( + new EmbedBuilder() + .setTitle(`šŸ” Wiki Search Results: "${safeQuery}"`) + .setColor("#2B2D31") + .setDescription(currentDescription.trim()) + ); + } + currentDescription = line + "\n"; + } else { + currentDescription += line + "\n"; + } + } + + if (currentDescription.trim()) { pages.push( new EmbedBuilder() - .setTitle(`šŸ” Wiki Search Results: "${query}"`) + .setTitle(`šŸ” Wiki Search Results: "${safeQuery}"`) .setColor("#2B2D31") .setDescription(currentDescription.trim()) ); - } - currentDescription = line + "\n"; - } else { - currentDescription += line + "\n"; } - } - - if (currentDescription.trim()) { - pages.push( - new EmbedBuilder() - .setTitle(`šŸ” Wiki Search Results: "${query}"`) - .setColor("#2B2D31") - .setDescription(currentDescription.trim()) - ); - } - - if (pages.length === 0) { - pages.push( - new EmbedBuilder() - .setTitle(`šŸ” Wiki Search Results: "${query}"`) - .setColor("#2B2D31") - .setDescription(body || "*No results found.*") - ); - } - pages.forEach((embed, index) => { - embed.setFooter({ text: `Page ${index + 1} of ${pages.length}` }); - }); + if (pages.length === 0) { + pages.push( + new EmbedBuilder() + .setTitle(`šŸ” Wiki Search Results: "${safeQuery}"`) + .setColor("#2B2D31") + .setDescription(body || "*No results found.*") + ); + } + + pages.forEach((embed, index) => { + embed.setFooter({ text: `Page ${index + 1} of ${pages.length}` }); + }); - return pages; + return pages; } async function printSearchResultsV2(ctx: Ctx, query: string): Promise { - const result = await ctx.search.search(query); - const msg = [config.wikisearch.format.header]; + const result = await ctx.search.search(query); + const msg = [config.wikisearch.format.header]; - if (!result.length) { - msg.push(config.wikisearch.format.empty); - return msg.join(config.wikisearch.format.sep); - } - - const highlighter = createContentHighlighter(query); - let pageCounter = 0; - - for (const res of result) { - switch (res.type) { - case "page": - msg.push( - format(config.wikisearch.format.page, { - num: pageCounter + 1, - title: res.content, - url: config.wikisearch.baseUrl + res.url, - }), - ); - msg.push(format(config.wikisearch.format.breadcrumbs, res.breadcrumbs?.join(" āÆ "))); - pageCounter += 1; - break; - - case "heading": - msg.push(format(config.wikisearch.format.header, res.content)); - break; - - case "text": - const content = highlighter - .highlightMarkdown(res.content) - .split("\n") - .map((s) => format(config.wikisearch.format.text, s)) - .join("\n"); - msg.push(content); - break; + if (!result.length) { + msg.push(config.wikisearch.format.empty); + return msg.join(config.wikisearch.format.sep); } - } - return msg.join(config.wikisearch.format.sep); + const highlighter = createContentHighlighter(query); + let pageCounter = 0; + + for (const res of result) { + switch (res.type) { + case "page": + msg.push( + format(config.wikisearch.format.page, { + num: pageCounter + 1, + title: res.content, + url: config.wikisearch.baseUrl + res.url, + }), + ); + msg.push(format(config.wikisearch.format.breadcrumbs, res.breadcrumbs?.join(" āÆ "))); + pageCounter += 1; + break; + + case "heading": + msg.push(format(config.wikisearch.format.header, res.content)); + break; + + case "text": + const content = highlighter + .highlightMarkdown(res.content) + .split("\n") + .map((s) => format(config.wikisearch.format.text, s)) + .join("\n"); + msg.push(content); + break; + } + } + + return msg.join(config.wikisearch.format.sep); } async function onInteraction(ctx: Ctx, interaction: Interaction) { - if (!interaction.isChatInputCommand()) return; + if (!interaction.isChatInputCommand()) return; - await interaction.deferReply(); + await interaction.deferReply(); - // 2. Safe execution space - const body = await printSearchResultsV2(ctx, query); - const pages = buildSearchEmbeds(query, body); + const query = interaction.options.getString("query", true); + const body = await printSearchResultsV2(ctx, query); + const pages = buildSearchEmbeds(query, body); - await paginate(interaction, pages); + await paginate(interaction, pages); } async function searchByQuery(ctx: Ctx, message: Message, query: string) { - const target = message.reference ? await message.fetchReference() : message; - const body = await printSearchResultsV2(ctx, query); - const pages = buildSearchEmbeds(query, body); - - const initialMessage = await target.reply({ - embeds: [pages[0]] - }); - - const messageShimObject = { - user: message.author, - editReply: async (options: any) => { - return await initialMessage.edit(options); - }, - } as unknown as ChatInputCommandInteraction; - - await paginate(messageShimObject, pages); + const target = message.reference ? await message.fetchReference() : message; + const body = await printSearchResultsV2(ctx, query); + const pages = buildSearchEmbeds(query, body); + + const initialMessage = await target.reply({ + embeds: [pages[0]] + }); + + const messageShimObject = { + user: message.author, + editReply: async (options: any) => { + return await initialMessage.edit(options); + }, + } as unknown as ChatInputCommandInteraction; + + await paginate(messageShimObject, pages); } async function execute( @@ -140,29 +143,29 @@ async function execute( channel: SendableChannels, args: string[], ) { - const query = args.join(" "); - await searchByQuery(ctx, message, query); + const query = args.join(" "); + await searchByQuery(ctx, message, query); } function slash(builder: SlashCommandBuilder): SharedSlashCommand { - return builder - .setDescription("Search the wiki.") - .addStringOption((option) => - option.setName("query").setRequired(true).setDescription("Search query."), - ); + return builder + .setDescription("Search the wiki.") + .addStringOption((option) => + option.setName("query").setRequired(true).setDescription("Search query."), + ); } const data: CmdData = { - name: "search", + name: "search", }; export default { - data, - slash, - onInteraction, - searchByQuery, - printSearchResultsV2, + data, + slash, + onInteraction, + searchByQuery, + printSearchResultsV2, } as Cmd & { - searchByQuery: (ctx: Ctx, message: Message, query: string) => Promise; - printSearchResultsV2: (ctx: Ctx, query: string) => Promise; + searchByQuery: (ctx: Ctx, message: Message, query: string) => Promise; + printSearchResultsV2: (ctx: Ctx, query: string) => Promise; }; \ No newline at end of file diff --git a/src/util/paginator2.ts b/src/util/paginator2.ts index 1cf1a51..a796529 100644 --- a/src/util/paginator2.ts +++ b/src/util/paginator2.ts @@ -73,8 +73,7 @@ export async function paginate( prevButton.setDisabled(true); nextButton.setDisabled(true); - await message.edit({ components: [getRow()] }).catch(() => { - // Catch error in case the message was deleted before the timer ended + await interaction.editReply({ components: [getRow()] }).catch(() => { console.warn("Could not disable pagination buttons (message deleted)."); }); }); From 1e779bdacdc6fec80770688ca5bef182e421c1dc Mon Sep 17 00:00:00 2001 From: alex <70163067+rapbattlegod32@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:06:08 +0100 Subject: [PATCH 34/34] e --- config.json.js | 2 +- src/commands/fun/economy.ts | 478 +++++++++++++++++++-------------- src/commands/support/search.ts | 54 ++-- src/util/paginator2.ts | 24 +- 4 files changed, 319 insertions(+), 239 deletions(-) diff --git a/config.json.js b/config.json.js index 647ce7e..ec7e06b 100644 --- a/config.json.js +++ b/config.json.js @@ -257,7 +257,7 @@ Consider donating to one of the following people: }, wages: { message: "{emoji} You worked a hard shift and claimed your wage of **${salary}**!\nšŸ¦ **New Balance:** ${balance}", - defaultAmount: 0, + defaultAmount: 50, roleSalaries: { "1262624821582364703": 500, } diff --git a/src/commands/fun/economy.ts b/src/commands/fun/economy.ts index 156d381..43b8f8f 100644 --- a/src/commands/fun/economy.ts +++ b/src/commands/fun/economy.ts @@ -149,7 +149,7 @@ export default { } catch (err) { console.error("[Sweeper Worker Error]:", err); } - }, 10 * 1000); + }, 60 * 1000); }, slash: (builder) => { @@ -244,7 +244,7 @@ export default { onInteraction: async (ctx, interaction) => { if (interaction.isAutocomplete()) { - if (!interaction.guildId) return void await interaction.respond([]); + if (!interaction.guildId) return void await interaction.respond([]).catch(() => {}); const sub = interaction.options.getSubcommand(false); const focusedValue = interaction.options.getFocused().toLowerCase(); @@ -284,27 +284,34 @@ export default { } if (sub === "use") { - const inventory = await Inventory.findAll({ - where: { guildId: interaction.guildId, userId: interaction.user.id } - }); + try { + const inventory = await Inventory.findAll({ + where: { guildId: interaction.guildId, userId: interaction.user.id } + }); - const itemMap = new Map(shopItems.map(i => [i.itemId, i.name])); - const validInventory = inventory.filter(inv => itemMap.has(inv.itemKey)); + const itemMap = new Map(shopItems.map(i => [i.itemId, i.name])); + const validInventory = inventory.filter(inv => itemMap.has(inv.itemKey)); - const filtered = validInventory.filter(inv => { - const name = itemMap.get(inv.itemKey) || inv.itemKey; - return name.toLowerCase().includes(focusedValue) || inv.itemKey.toLowerCase().includes(focusedValue); - }); + const filtered = validInventory.filter(inv => { + const name = itemMap.get(inv.itemKey) || inv.itemKey; + return name.toLowerCase().includes(focusedValue) || inv.itemKey.toLowerCase().includes(focusedValue); + }); - return void await interaction.respond( - filtered.slice(0, 25).map(inv => ({ - name: `${itemMap.get(inv.itemKey)} (Owned: ${inv.quantity})`, - value: inv.itemKey - })) - ); + return void await interaction.respond( + filtered.slice(0, 25).map(inv => ({ + name: `${itemMap.get(inv.itemKey)} (Owned: ${inv.quantity})`, + value: inv.itemKey + })) + ); + } catch (error: any) { + if (error?.code !== 10062) { + console.error("Autocomplete execution error:", error); + } + return; + } } - return void await interaction.respond([]); + return void await interaction.respond([]).catch(() => {}); } if (!interaction.isChatInputCommand()) return; @@ -328,57 +335,68 @@ export default { const sub = interaction.options.getSubcommand(true); const group = interaction.options.getSubcommandGroup(false); - if (group === "gamble") { - const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => - interaction.member.roles.cache.has(roleId) - ); - const isExplicitAdmin = interaction.inCachedGuild() && interaction.member.permissions.has("Administrator"); + try { + if (group === "gamble") { + const hasBypassRole = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => + interaction.member.roles.cache.has(roleId) + ); + const isExplicitAdmin = interaction.inCachedGuild() && interaction.member.permissions.has("Administrator"); - if (!hasBypassRole && !isExplicitAdmin && !config.economy.gambleChannel.includes(interaction.channelId)) { - const allowedList = config.economy.gambleChannel.map((id: string) => `<#${id}>`).join(", "); - return void await interaction.reply({ - content: `āŒ Gambling commands can only be used in ${allowedList}`, - flags: MessageFlags.Ephemeral - }); + if (!hasBypassRole && !isExplicitAdmin && !config.economy.gambleChannel.includes(interaction.channelId)) { + const allowedList = config.economy.gambleChannel.map((id: string) => `<#${id}>`).join(", "); + return void await interaction.reply({ + content: `āŒ Gambling commands can only be used in ${allowedList}`, + flags: MessageFlags.Ephemeral + }); + } } - } - const isEphemeral = EPHEMERAL_MAPPING[sub] ?? false; - - try { - await interaction.deferReply({ - flags: isEphemeral ? MessageFlags.Ephemeral : undefined - }); - } catch (error) { - console.warn(`[Economy Server] Interaction expired before deferral response could reach Discord Gateway for command: ${sub}.`); - return; - } + const isEphemeral = EPHEMERAL_MAPPING[sub] ?? false; - switch (group) { - case "gamble": { - switch (sub) { - case "coinflip": return await handleGambleCoinflip(interaction); - case "dice": return await handleGambleDice(interaction); - case "roulette": return await handleGambleRoulette(interaction); - } + try { + await interaction.deferReply({ + flags: isEphemeral ? MessageFlags.Ephemeral : undefined + }); + } catch (error) { + console.warn(`[Economy Server] Interaction expired before deferral response could reach Discord Gateway for command: ${sub}.`); return; } - case null: - default: { - switch (sub) { - case "leaderboard": return await handleLeaderboard(interaction); - case "wage": return await handleWage(interaction) - case "balance": return await handleBalance(interaction); - case "shop": return await handleShop(interaction); - case "buy": return await handleBuy(interaction); - case "use": return await handleUse(interaction); - case "inventory": return await handleInventory(interaction); - case "add-money": return await handleAddMoney(interaction); - case "set-balance": return await handleSetBalance(interaction); - case "refill": return await handleRefillStock(interaction) + switch (group) { + case "gamble": { + switch (sub) { + case "coinflip": return await handleGambleCoinflip(interaction); + case "dice": return await handleGambleDice(interaction); + case "roulette": return await handleGambleRoulette(interaction); + } + return; + } + + case null: + default: { + switch (sub) { + case "leaderboard": return await handleLeaderboard(interaction); + case "wage": return await handleWage(interaction) + case "balance": return await handleBalance(interaction); + case "shop": return await handleShop(interaction); + case "buy": return await handleBuy(interaction); + case "use": return await handleUse(interaction); + case "inventory": return await handleInventory(interaction); + case "add-money": return await handleAddMoney(interaction); + case "set-balance": return await handleSetBalance(interaction); + case "refill": return await handleRefillStock(interaction) + } + return; } - return; + } + } catch (error) { + console.error(`[Economy] Handler for "${sub}" failed:`, error); + + const failureMessage = { content: "āŒ Something went wrong running that command. Please try again." }; + if (interaction.deferred || interaction.replied) { + await interaction.editReply(failureMessage).catch(() => {}); + } else { + await interaction.reply({ ...failureMessage, flags: MessageFlags.Ephemeral }).catch(() => {}); } } } @@ -413,6 +431,34 @@ function calculateWage(member: GuildMember): number { return Math.max(...matchingSalaries); } +async function fetchBalance(guildId: string, userId: string): Promise { + return (await EconomyProfile.findOne({ where: { guildId, userId } }))?.balance ?? STARTING_BALANCE; +} + +async function stakeBet(interaction: ChatInputCommandInteraction, betAmount: number): Promise { + const guildId = interaction.guildId!; + const userId = interaction.user.id; + + await EconomyProfile.findOrCreate({ + where: { guildId, userId }, + defaults: { guildId, userId, balance: STARTING_BALANCE } + }); + + const [staked] = await EconomyProfile.update( + { balance: Sequelize.literal(`balance - ${betAmount}`) as any }, + { where: { guildId, userId, balance: { [Op.gte]: betAmount } } } + ); + + if (staked === 0) { + await interaction.editReply({ + content: format(config.economy.cantAfford, { userBalance: await fetchBalance(guildId, userId) }) + }); + return false; + } + + return true; +} + async function hasStaffPermission(interaction: ChatInputCommandInteraction): Promise { const isStaff = interaction.inCachedGuild() && config.economy.teamRole.some((roleId: string) => interaction.member.roles.cache.has(roleId) @@ -452,9 +498,25 @@ async function handleWage(interaction: ChatInputCommandInteraction) { const salaryAmount = calculateWage(interaction.member); - await profile.increment({ balance: salaryAmount }); - profile.lastWageClaim = now; - await profile.save(); + const [claimed] = await EconomyProfile.update( + { balance: Sequelize.literal(`balance + ${salaryAmount}`) as any, lastWageClaim: now }, + { + where: { + guildId: interaction.guildId, + userId: interaction.user.id, + [Op.or]: [ + { lastWageClaim: null }, + { lastWageClaim: { [Op.lte]: new Date(now.getTime() - cooldownMs) } } + ] + } + } + ); + + if (claimed === 0) { + return void await interaction.editReply({ + content: "ā³ You are still on cooldown! Please wait before claiming your next wage." + }); + } await interaction.editReply({ content: format(config.economy.wages.message, {emoji: config.economy.coinEmoji, salary: salaryAmount, balance: profile.balance + salaryAmount }) @@ -576,7 +638,12 @@ async function handleShop(interaction: ChatInputCommandInteraction) { getInteger: (name: string) => name === "quantity" ? 1 : null }; - await handleBuy(buyShim as unknown as ChatInputCommandInteraction); + try { + await handleBuy(buyShim as unknown as ChatInputCommandInteraction); + } catch (err) { + console.error("[Economy Shop] Buy from shop button failed:", err); + await buttonInteraction.editReply({ content: "āŒ Purchase failed. Please try again." }).catch(() => {}); + } }); collector.on("end", async () => { @@ -924,21 +991,18 @@ async function handleSetBalance(interaction: ChatInputCommandInteraction) { async function handleGambleCoinflip(interaction: ChatInputCommandInteraction) { const betAmount = interaction.options.getInteger("amount", true); - let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); - const currentBalance = profile?.balance ?? STARTING_BALANCE; - - if (!(await hasSufficientFunds(interaction, currentBalance, betAmount))) return; + const guildId = interaction.guildId!; + const userId = interaction.user.id; - if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); + if (!(await stakeBet(interaction, betAmount))) return; const isWinner = randomInt(0,2); if (isWinner == 1) { - await profile.increment({ balance: betAmount }); - await interaction.editReply({ content: format(config.economy.betWin, {thing: "coin", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: currentBalance + betAmount, dice: ""}) }); + await EconomyProfile.increment({ balance: betAmount * 2 }, { where: { guildId, userId } }); + await interaction.editReply({ content: format(config.economy.betWin, {thing: "coin", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: await fetchBalance(guildId, userId), dice: ""}) }); } else { - await profile.decrement({ balance: betAmount }); - await interaction.editReply({ content: format(config.economy.betLost, {dice: "", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: currentBalance - betAmount}) }); + await interaction.editReply({ content: format(config.economy.betLost, {dice: "", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: await fetchBalance(guildId, userId)}) }); } } @@ -946,22 +1010,18 @@ async function handleGambleDice(interaction: ChatInputCommandInteraction) { const betAmount = interaction.options.getInteger("amount", true); const guess = interaction.options.getInteger("guess", true); - let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: interaction.user.id } }); - const currentBalance = profile?.balance ?? STARTING_BALANCE; - - if (!(await hasSufficientFunds(interaction, currentBalance, betAmount))) return; + const guildId = interaction.guildId!; + const userId = interaction.user.id; - if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: interaction.user.id, balance: STARTING_BALANCE }); + if (!(await stakeBet(interaction, betAmount))) return; const diceRoll = randomInt(1, 7); if (guess === diceRoll) { - const winnings = betAmount * 5; - await profile.increment({ balance: winnings }); - await interaction.editReply({ content: format(config.economy.betWin, {thing: "dice", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: currentBalance + winnings, dice: `It rolled a ${diceRoll}`}) }); + await EconomyProfile.increment({ balance: betAmount * 6 }, { where: { guildId, userId } }); + await interaction.editReply({ content: format(config.economy.betWin, {thing: "dice", betAmount: betAmount, emoji: config.economy.coinEmoji, balance: await fetchBalance(guildId, userId), dice: `It rolled a ${diceRoll}`}) }); } else { - await profile.decrement({ balance: betAmount }); - await interaction.editReply({ content: format(config.economy.betLost, {dice: `The dice rolled ${diceRoll} while you guessed ${guess}`, betAmount: betAmount, emoji: config.economy.coinEmoji, balance: currentBalance - betAmount}) }); + await interaction.editReply({ content: format(config.economy.betLost, {dice: `The dice rolled ${diceRoll} while you guessed ${guess}`, betAmount: betAmount, emoji: config.economy.coinEmoji, balance: await fetchBalance(guildId, userId)}) }); } } @@ -1016,149 +1076,160 @@ async function handleGambleRoulette(interaction: ChatInputCommandInteraction) { const collector = thread.createMessageCollector({ filter: (m) => !m.author.bot, time: timeMs }); collector.on("collect", async (message) => { - const args = message.content.trim().toLowerCase().split(/\s+/); - const commandOrType = args[0]; - - if (commandOrType === "spin") { - if (message.author.id !== interaction.user.id) return void await message.react("āŒ").catch(() => {}); - if (bets.length === 0) return void await message.react("āŒ").catch(() => {}); - collector.stop("spun"); - return; - } + try { + const args = message.content.trim().toLowerCase().split(/\s+/); + const commandOrType = args[0]; - const validBetTypes = ["red", "black", "even", "odd", "green"]; - const parsedNumber = parseInt(commandOrType, 10); - const isNumberBet = !isNaN(parsedNumber) && parsedNumber >= 0 && parsedNumber <= 36; + if (commandOrType === "spin") { + if (message.author.id !== interaction.user.id) return void await message.react("āŒ").catch(() => {}); + if (bets.length === 0) return void await message.react("āŒ").catch(() => {}); + collector.stop("spun"); + return; + } - if (validBetTypes.includes(commandOrType) || isNumberBet) { - const amountStr = args[1]; - if (!amountStr) return void await message.react("āŒ").catch(() => {}); + const validBetTypes = ["red", "black", "even", "odd", "green"]; + const parsedNumber = parseInt(commandOrType, 10); + const isNumberBet = !isNaN(parsedNumber) && parsedNumber >= 0 && parsedNumber <= 36; - const amount = parseInt(amountStr, 10); - if (isNaN(amount) || amount <= 0) return void await message.react("āŒ").catch(() => {}); + if (validBetTypes.includes(commandOrType) || isNumberBet) { + const amountStr = args[1]; + if (!amountStr) return void await message.react("āŒ").catch(() => {}); - let profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: message.author.id } }); - const currentBalance = profile?.balance ?? STARTING_BALANCE; + const amount = parseInt(amountStr, 10); + if (isNaN(amount) || amount <= 0) return void await message.react("āŒ").catch(() => {}); - if (currentBalance < amount) return void await message.react("āŒ").catch(() => {}); + await EconomyProfile.findOrCreate({ + where: { guildId: interaction.guildId!, userId: message.author.id }, + defaults: { guildId: interaction.guildId!, userId: message.author.id, balance: STARTING_BALANCE } + }); - if (!profile) profile = await EconomyProfile.create({ guildId: interaction.guildId!, userId: message.author.id, balance: STARTING_BALANCE }); + const [staked] = await EconomyProfile.update( + { balance: Sequelize.literal(`balance - ${amount}`) as any }, + { where: { guildId: interaction.guildId!, userId: message.author.id, balance: { [Op.gte]: amount } } } + ); - await profile.decrement('balance', {by: amount}); + if (staked === 0) return void await message.react("āŒ").catch(() => {}); - bets.push({ - userId: message.author.id, - username: message.author.username, - amount: amount, - betType: isNumberBet ? "number" : (commandOrType as any), - betNumber: isNumberBet ? parsedNumber : undefined - }); - await message.react("āœ…").catch(() => {}); + bets.push({ + userId: message.author.id, + username: message.author.username, + amount: amount, + betType: isNumberBet ? "number" : (commandOrType as any), + betNumber: isNumberBet ? parsedNumber : undefined + }); + await message.react("āœ…").catch(() => {}); + } + } catch (err) { + console.error("[Roulette Bet]", err); } }); - collector.on("end", async (_, reason) => { - if (bets.length === 0) { - await thread.send({ content: config.economy.roulette.inactivityMessage }).catch(() => {}); - try { - await thread.setLocked(true); - await thread.setArchived(true); - } catch {} - return; - } + collector.on("end", async () => { + try { + if (bets.length === 0) { + await thread.send({ content: config.economy.roulette.inactivityMessage }).catch(() => {}); + try { + await thread.setLocked(true); + await thread.setArchived(true); + } catch {} + return; + } - const winningNumber = randomInt(0, 37); - const redNumbers = [1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]; - let color: "green" | "red" | "black" = "green"; - if (winningNumber > 0) color = redNumbers.includes(winningNumber) ? "red" : "black"; + const winningNumber = randomInt(0, 37); + const redNumbers = [1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]; + let color: "green" | "red" | "black" = "green"; + if (winningNumber > 0) color = redNumbers.includes(winningNumber) ? "red" : "black"; - const isEven = winningNumber > 0 && winningNumber % 2 === 0; - const isOdd = winningNumber > 0 && winningNumber % 2 !== 0; + const isEven = winningNumber > 0 && winningNumber % 2 === 0; + const isOdd = winningNumber > 0 && winningNumber % 2 !== 0; - await thread.send({ content: config.economy.roulette.spinningMessage }).catch(() => {}); + await thread.send({ content: config.economy.roulette.spinningMessage }).catch(() => {}); - const userBreakdowns = new Map(); - const userNetTotals = new Map(); + const userBreakdowns = new Map(); + const userNetTotals = new Map(); - for (const bet of bets) { - let won = bet.betType === color || (bet.betType === "even" && isEven) || (bet.betType === "odd" && isOdd) || bet.betNumber === winningNumber; + for (const bet of bets) { + let won = bet.betType === color || (bet.betType === "even" && isEven) || (bet.betType === "odd" && isOdd) || bet.betNumber === winningNumber; - let payoutMultiplier = (bet.betType === "number" || bet.betType === "green") ? 35 : 2; - let betDisplay = bet.betType === "number" ? `Number ${bet.betNumber}` : bet.betType; + let payoutMultiplier = (bet.betType === "number" || bet.betType === "green") ? 35 : 2; + let betDisplay = bet.betType === "number" ? `Number ${bet.betNumber}` : bet.betType; - const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); - const currentNet = userNetTotals.get(bet.userId) ?? 0; - if (!userBreakdowns.has(bet.userId)) userBreakdowns.set(bet.userId, []); + const profile = await EconomyProfile.findOne({ where: { guildId: interaction.guildId!, userId: bet.userId } }); + const currentNet = userNetTotals.get(bet.userId) ?? 0; + if (!userBreakdowns.has(bet.userId)) userBreakdowns.set(bet.userId, []); - const formattedBetAmount = bet.amount.toLocaleString(); + const formattedBetAmount = bet.amount.toLocaleString(); - if (won && profile) { - const winnings = bet.amount * payoutMultiplier; - await profile.increment('balance', { by: winnings }); + if (won && profile) { + const winnings = bet.amount * payoutMultiplier; + await profile.increment('balance', { by: winnings }); - const formattedWinnings = winnings.toLocaleString(); - userBreakdowns.get(bet.userId)!.push( - format(config.economy.roulette.betWonLine, { betDisplay, amount: formattedWinnings }) - ); - userNetTotals.set(bet.userId, currentNet + (winnings - bet.amount)); - } else { - userBreakdowns.get(bet.userId)!.push( - format(config.economy.roulette.betLostLine, { betDisplay, amount: formattedBetAmount }) - ); - userNetTotals.set(bet.userId, currentNet - bet.amount); + const formattedWinnings = winnings.toLocaleString(); + userBreakdowns.get(bet.userId)!.push( + format(config.economy.roulette.betWonLine, { betDisplay, amount: formattedWinnings }) + ); + userNetTotals.set(bet.userId, currentNet + (winnings - bet.amount)); + } else { + userBreakdowns.get(bet.userId)!.push( + format(config.economy.roulette.betLostLine, { betDisplay, amount: formattedBetAmount }) + ); + userNetTotals.set(bet.userId, currentNet - bet.amount); + } } - } - const emoji = color === "red" ? "šŸ”“" : color === "black" ? "⚫" : "🟢"; + const emoji = color === "red" ? "šŸ”“" : color === "black" ? "⚫" : "🟢"; - let outputMessage = format(config.economy.roulette.resultHeader, { - number: winningNumber, - color: color.toUpperCase(), - emoji: emoji - }); + let outputMessage = format(config.economy.roulette.resultHeader, { + number: winningNumber, + color: color.toUpperCase(), + emoji: emoji + }); - for (const [userId, breakdownArray] of userBreakdowns.entries()) { - const userMention = `<@${userId}>`; - const netValue = userNetTotals.get(userId) ?? 0; - let netStatus = config.economy.roulette.brokeEven; + for (const [userId, breakdownArray] of userBreakdowns.entries()) { + const userMention = `<@${userId}>`; + const netValue = userNetTotals.get(userId) ?? 0; + let netStatus = config.economy.roulette.brokeEven; - if (netValue > 0) { - netStatus = format(config.economy.roulette.wonNet, { amount: netValue.toLocaleString() }); - } else if (netValue < 0) { - netStatus = format(config.economy.roulette.lostNet, { amount: Math.abs(netValue).toLocaleString() }); - } + if (netValue > 0) { + netStatus = format(config.economy.roulette.wonNet, { amount: netValue.toLocaleString() }); + } else if (netValue < 0) { + netStatus = format(config.economy.roulette.lostNet, { amount: Math.abs(netValue).toLocaleString() }); + } - outputMessage += format(config.economy.roulette.userSummaryRow, { - user: userMention, - breakdown: breakdownArray.join("\n"), - netStatus: netStatus - }); - } + outputMessage += format(config.economy.roulette.userSummaryRow, { + user: userMention, + breakdown: breakdownArray.join("\n"), + netStatus: netStatus + }); + } - // Chunk and send outputMessage if it exceeds 1900 characters - const CHUNK_LIMIT = 1900; - const lines = outputMessage.split("\n"); - let currentChunk = ""; + // Chunk and send outputMessage if it exceeds 1900 characters + const CHUNK_LIMIT = 1900; + const lines = outputMessage.split("\n"); + let currentChunk = ""; - for (const line of lines) { - if ((currentChunk + "\n" + line).length > CHUNK_LIMIT) { - if (currentChunk.trim()) { - await thread.send({ content: currentChunk }).catch(() => {}); + for (const line of lines) { + if ((currentChunk + "\n" + line).length > CHUNK_LIMIT) { + if (currentChunk.trim()) { + await thread.send({ content: currentChunk }).catch(() => {}); + } + currentChunk = line; + } else { + currentChunk = currentChunk ? `${currentChunk}\n${line}` : line; } - currentChunk = line; - } else { - currentChunk = currentChunk ? `${currentChunk}\n${line}` : line; } - } - if (currentChunk.trim()) { - await thread.send({ content: currentChunk }).catch(() => {}); - } + if (currentChunk.trim()) { + await thread.send({ content: currentChunk }).catch(() => {}); + } - try { - await thread.setLocked(true); - await thread.setArchived(true); - } catch {} + try { + await thread.setLocked(true); + await thread.setArchived(true); + } catch {} + } catch (err) { + console.error("[Roulette Result]", err); + } }); } @@ -1236,22 +1307,15 @@ async function handleRefillStock(interaction: ChatInputCommandInteraction) { }); } - let stockTracker = await ShopItem.findOne({ - where: { guildId: interaction.guildId!, itemId: itemKey } + const [tracker] = await ShopItem.findOrCreate({ + where: { guildId: interaction.guildId!, itemId: itemKey }, + defaults: { guildId: interaction.guildId!, itemId: itemKey, stock: item.stock } }); - if (!stockTracker) { - stockTracker = await ShopItem.create({ - guildId: interaction.guildId!, - itemId: itemKey, - stock: item.stock + amount - }); - } else { - stockTracker.stock += amount; - await stockTracker.save(); - } + await tracker.increment({ stock: amount }); + await tracker.reload(); await interaction.editReply({ - content: `šŸ“¦ Successfully added **${amount}** stock to **${item.name}**! The shop now has **${stockTracker.stock}** available.` + content: `šŸ“¦ Successfully added **${amount}** stock to **${item.name}**! The shop now has **${tracker.stock}** available.` }); } \ No newline at end of file diff --git a/src/commands/support/search.ts b/src/commands/support/search.ts index 641e08b..962659e 100644 --- a/src/commands/support/search.ts +++ b/src/commands/support/search.ts @@ -109,32 +109,44 @@ async function printSearchResultsV2(ctx: Ctx, query: string): Promise { async function onInteraction(ctx: Ctx, interaction: Interaction) { if (!interaction.isChatInputCommand()) return; - await interaction.deferReply(); + try { + await interaction.deferReply(); - const query = interaction.options.getString("query", true); - const body = await printSearchResultsV2(ctx, query); - const pages = buildSearchEmbeds(query, body); + const query = interaction.options.getString("query", true); + const body = await printSearchResultsV2(ctx, query); + const pages = buildSearchEmbeds(query, body); - await paginate(interaction, pages); + await paginate(interaction, pages); + } catch (error) { + console.error("[Search] Wiki search failed:", error); + + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ content: "āŒ The wiki search failed. Please try again." }).catch(() => {}); + } + } } async function searchByQuery(ctx: Ctx, message: Message, query: string) { - const target = message.reference ? await message.fetchReference() : message; - const body = await printSearchResultsV2(ctx, query); - const pages = buildSearchEmbeds(query, body); - - const initialMessage = await target.reply({ - embeds: [pages[0]] - }); - - const messageShimObject = { - user: message.author, - editReply: async (options: any) => { - return await initialMessage.edit(options); - }, - } as unknown as ChatInputCommandInteraction; - - await paginate(messageShimObject, pages); + try { + const target = message.reference ? await message.fetchReference() : message; + const body = await printSearchResultsV2(ctx, query); + const pages = buildSearchEmbeds(query, body); + + const initialMessage = await target.reply({ + embeds: [pages[0]] + }); + + const messageShimObject = { + user: message.author, + editReply: async (options: any) => { + return await initialMessage.edit(options); + }, + } as unknown as ChatInputCommandInteraction; + + await paginate(messageShimObject, pages); + } catch (error) { + console.error("[Search] Wiki search failed:", error); + } } async function execute( diff --git a/src/util/paginator2.ts b/src/util/paginator2.ts index a796529..b5b4518 100644 --- a/src/util/paginator2.ts +++ b/src/util/paginator2.ts @@ -54,18 +54,22 @@ export async function paginate( }); collector.on("collect", async (i) => { - if (i.customId === "prev") index = Math.max(0, index - 1); - else if (i.customId === "next") index = Math.min(pages.length - 1, index + 1); - else return; + try { + if (i.customId === "prev") index = Math.max(0, index - 1); + else if (i.customId === "next") index = Math.min(pages.length - 1, index + 1); + else return; - // Dynamically disable buttons based on the new index - prevButton.setDisabled(index === 0); - nextButton.setDisabled(index === pages.length - 1); + // Dynamically disable buttons based on the new index + prevButton.setDisabled(index === 0); + nextButton.setDisabled(index === pages.length - 1); - await i.update({ - embeds: [pages[index]], - components: [getRow()] - }); + await i.update({ + embeds: [pages[index]], + components: [getRow()] + }); + } catch (error) { + console.warn("Could not update pagination message:", error); + } }); collector.on("end", async () => {