From 421bb26ceb943bc9f0feed67a2564c45f1b8d486 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 13 Dec 2025 12:43:27 +0100 Subject: [PATCH] feat: add trading system with dedicated modules and centralize embed creation for commands --- src/commands/economy/daily.ts | 10 +- src/commands/economy/pay.ts | 9 +- src/commands/economy/sell.ts | 19 +- src/commands/economy/trade.ts | 87 ++++++++ src/commands/inventory/inventory.ts | 8 +- src/commands/leveling/leaderboard.ts | 3 +- src/commands/quest/quests.ts | 8 +- src/commands/system/reload.ts | 8 +- src/index.ts | 18 +- src/lib/embeds.ts | 29 +++ src/modules/trade/trade.interaction.ts | 294 +++++++++++++++++++++++++ src/modules/trade/trade.service.ts | 187 ++++++++++++++++ src/modules/trade/trade.types.ts | 28 +++ 13 files changed, 667 insertions(+), 41 deletions(-) create mode 100644 src/commands/economy/trade.ts create mode 100644 src/lib/embeds.ts create mode 100644 src/modules/trade/trade.interaction.ts create mode 100644 src/modules/trade/trade.service.ts create mode 100644 src/modules/trade/trade.types.ts diff --git a/src/commands/economy/daily.ts b/src/commands/economy/daily.ts index b698042..fa3687b 100644 --- a/src/commands/economy/daily.ts +++ b/src/commands/economy/daily.ts @@ -2,6 +2,7 @@ import { createCommand } from "@/lib/utils"; import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { economyService } from "@/modules/economy/economy.service"; import { userService } from "@/modules/user/user.service"; +import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; export const daily = createCommand({ data: new SlashCommandBuilder() @@ -27,17 +28,12 @@ export const daily = createCommand({ } catch (error: any) { if (error.message.includes("Daily already claimed")) { - const embed = new EmbedBuilder() - .setTitle("⏳ Cooldown") - .setDescription(error.message) - .setColor("Orange"); - - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ embeds: [createWarningEmbed(error.message, "Cooldown")] }); return; } console.error(error); - await interaction.editReply({ content: "❌ An error occurred while claiming your daily reward." }); + await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while claiming your daily reward.")] }); } } }); diff --git a/src/commands/economy/pay.ts b/src/commands/economy/pay.ts index 77aebdf..7a6dec1 100644 --- a/src/commands/economy/pay.ts +++ b/src/commands/economy/pay.ts @@ -3,6 +3,7 @@ import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { economyService } from "@/modules/economy/economy.service"; import { userService } from "@/modules/user/user.service"; import { GameConfig } from "@/config/game"; +import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; export const pay = createCommand({ data: new SlashCommandBuilder() @@ -28,12 +29,12 @@ export const pay = createCommand({ const receiverId = targetUser.id; if (amount < GameConfig.economy.transfers.minAmount) { - await interaction.editReply({ content: `❌ Amount must be at least ${GameConfig.economy.transfers.minAmount}.` }); + await interaction.editReply({ embeds: [createWarningEmbed(`Amount must be at least ${GameConfig.economy.transfers.minAmount}.`)] }); return; } if (senderId === receiverId) { - await interaction.editReply({ content: "❌ You cannot pay yourself." }); + await interaction.editReply({ embeds: [createWarningEmbed("You cannot pay yourself.")] }); return; } @@ -50,11 +51,11 @@ export const pay = createCommand({ } catch (error: any) { if (error.message.includes("Insufficient funds")) { - await interaction.editReply({ content: "❌ Insufficient funds." }); + await interaction.editReply({ embeds: [createWarningEmbed("Insufficient funds.")] }); return; } console.error(error); - await interaction.editReply({ content: "❌ Transfer failed." }); + await interaction.editReply({ embeds: [createErrorEmbed("Transfer failed.")] }); } } }); diff --git a/src/commands/economy/sell.ts b/src/commands/economy/sell.ts index 930ca7d..c14bfa1 100644 --- a/src/commands/economy/sell.ts +++ b/src/commands/economy/sell.ts @@ -13,6 +13,7 @@ import { import { userService } from "@/modules/user/user.service"; import { inventoryService } from "@/modules/inventory/inventory.service"; import type { items } from "@db/schema"; +import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; export const sell = createCommand({ data: new SlashCommandBuilder() @@ -36,18 +37,18 @@ export const sell = createCommand({ const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel; if (!targetChannel || !targetChannel.isSendable()) { - await interaction.editReply({ content: "Target channel is invalid or not sendable." }); + await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] }); return; } const item = await inventoryService.getItem(itemId); if (!item) { - await interaction.editReply({ content: `Item with ID ${itemId} not found.` }); + await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] }); return; } if (!item.price) { - await interaction.editReply({ content: `Item "${item.name}" is not for sale (no price set).` }); + await interaction.editReply({ content: "", embeds: [createWarningEmbed(`Item "${item.name}" is not for sale (no price set).`)] }); return; } @@ -82,7 +83,7 @@ export const sell = createCommand({ } catch (error) { console.error("Failed to send sell message:", error); - await interaction.editReply({ content: "Failed to post the item for sale." }); + await interaction.editReply({ content: "", embeds: [createErrorEmbed("Failed to post the item for sale.")] }); } } }); @@ -95,19 +96,19 @@ async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof const user = await userService.getUserById(userId); if (!user) { - await interaction.editReply({ content: "User profile not found." }); + await interaction.editReply({ content: "", embeds: [createErrorEmbed("User profile not found.")] }); return; } if ((user.balance ?? 0n) < (item.price ?? 0n)) { - await interaction.editReply({ content: `You don't have enough money! You need ${item.price} 🪙.` }); + await interaction.editReply({ content: "", embeds: [createWarningEmbed(`You don't have enough money! You need ${item.price} 🪙.`)] }); return; } const result = await inventoryService.buyItem(userId, item.id, 1n); if (!result.success) { - await interaction.editReply({ content: "Transaction failed. Please try again." }); + await interaction.editReply({ content: "", embeds: [createErrorEmbed("Transaction failed. Please try again.")] }); return; } @@ -115,9 +116,9 @@ async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof } catch (error) { console.error("Error processing purchase:", error); if (interaction.deferred || interaction.replied) { - await interaction.editReply({ content: "An error occurred while processing your purchase." }); + await interaction.editReply({ content: "", embeds: [createErrorEmbed("An error occurred while processing your purchase.")] }); } else { - await interaction.reply({ content: "An error occurred while processing your purchase.", ephemeral: true }); + await interaction.reply({ embeds: [createErrorEmbed("An error occurred while processing your purchase.")], ephemeral: true }); } } } diff --git a/src/commands/economy/trade.ts b/src/commands/economy/trade.ts new file mode 100644 index 0000000..61f3793 --- /dev/null +++ b/src/commands/economy/trade.ts @@ -0,0 +1,87 @@ +import { createCommand } from "@/lib/utils"; +import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration } from "discord.js"; +import { TradeService } from "@/modules/trade/trade.service"; +import { updateTradeDashboard } from "@/modules/trade/trade.interaction"; +import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; + +export const trade = createCommand({ + data: new SlashCommandBuilder() + .setName("trade") + .setDescription("Start a trade with another player") + .addUserOption(option => + option.setName("user") + .setDescription("The user to trade with") + .setRequired(true) + ), + execute: async (interaction) => { + const targetUser = interaction.options.getUser("user", true); + + if (targetUser.id === interaction.user.id) { + await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], ephemeral: true }); + return; + } + + if (targetUser.bot) { + await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], ephemeral: true }); + return; + } + + // Create Thread + const channel = interaction.channel; + if (!channel || channel.type === ChannelType.DM) { + await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], ephemeral: true }); + return; + } + + // Check if we can create threads + // Assuming permissions are fine. + + await interaction.reply({ content: `🔄 Setting up trade with ${targetUser}...` }); + const message = await interaction.fetchReply(); + + let thread; + try { + thread = await message.startThread({ + name: `trade-${interaction.user.username}-${targetUser.username}`, + autoArchiveDuration: ThreadAutoArchiveDuration.OneHour, + reason: "Trading Session" + }); + } catch (e) { + // Fallback if message threads fail, try channel threads (private preferred) + // But startThread on message is usually easiest. + await interaction.editReply({ content: "", embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")] }); + return; + } + + // Setup Session + TradeService.createSession(thread.id, + { id: interaction.user.id, username: interaction.user.username }, + { id: targetUser.id, username: targetUser.username } + ); + + // Send Dashboard to Thread + const embed = new EmbedBuilder() + .setTitle("🤝 Trading Session") + .setDescription(`Trade started between ${interaction.user} and ${targetUser}.\nUse the controls below to build your offer.`) + .setColor(0xFFD700) + .addFields( + { name: interaction.user.username, value: "*Empty Offer*", inline: true }, + { name: targetUser.username, value: "*Empty Offer*", inline: true } + ) + .setFooter({ text: "Both parties must click Lock to confirm trade." }); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger), + ); + + await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, embeds: [embed], components: [row] }); + + // Update original reply + await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` }); + } +}); diff --git a/src/commands/inventory/inventory.ts b/src/commands/inventory/inventory.ts index 93bd98b..fe6412e 100644 --- a/src/commands/inventory/inventory.ts +++ b/src/commands/inventory/inventory.ts @@ -2,6 +2,7 @@ import { createCommand } from "@/lib/utils"; import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { inventoryService } from "@/modules/inventory/inventory.service"; import { userService } from "@/modules/user/user.service"; +import { createWarningEmbed } from "@lib/embeds"; export const inventory = createCommand({ data: new SlashCommandBuilder() @@ -20,12 +21,7 @@ export const inventory = createCommand({ const items = await inventoryService.getInventory(user.id); if (!items || items.length === 0) { - const embed = new EmbedBuilder() - .setTitle(`${user.username}'s Inventory`) - .setDescription("Inventory is empty.") - .setColor("Blue"); - - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] }); return; } diff --git a/src/commands/leveling/leaderboard.ts b/src/commands/leveling/leaderboard.ts index 5b0d6a0..62d82e1 100644 --- a/src/commands/leveling/leaderboard.ts +++ b/src/commands/leveling/leaderboard.ts @@ -3,6 +3,7 @@ import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { DrizzleClient } from "@/lib/DrizzleClient"; import { users } from "@/db/schema"; import { desc } from "drizzle-orm"; +import { createWarningEmbed } from "@lib/embeds"; export const leaderboard = createCommand({ data: new SlashCommandBuilder() @@ -29,7 +30,7 @@ export const leaderboard = createCommand({ }); if (leaders.length === 0) { - await interaction.editReply({ content: "❌ No users found." }); + await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] }); return; } diff --git a/src/commands/quest/quests.ts b/src/commands/quest/quests.ts index bf946e2..4962640 100644 --- a/src/commands/quest/quests.ts +++ b/src/commands/quest/quests.ts @@ -1,6 +1,7 @@ import { createCommand } from "@/lib/utils"; import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { questService } from "@/modules/quest/quest.service"; +import { createWarningEmbed } from "@lib/embeds"; export const quests = createCommand({ data: new SlashCommandBuilder() @@ -12,12 +13,7 @@ export const quests = createCommand({ const userQuests = await questService.getUserQuests(interaction.user.id); if (!userQuests || userQuests.length === 0) { - const embed = new EmbedBuilder() - .setTitle("📜 Quest Log") - .setDescription("You have no active quests.") - .setColor("Grey"); - - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] }); return; } diff --git a/src/commands/system/reload.ts b/src/commands/system/reload.ts index e792258..d2979bf 100644 --- a/src/commands/system/reload.ts +++ b/src/commands/system/reload.ts @@ -1,6 +1,7 @@ import { createCommand } from "@lib/utils"; import { KyokoClient } from "@lib/KyokoClient"; import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from "discord.js"; +import { createErrorEmbed } from "@lib/embeds"; export const reload = createCommand({ data: new SlashCommandBuilder() @@ -23,12 +24,7 @@ export const reload = createCommand({ await interaction.editReply({ embeds: [embed] }); } catch (error) { console.error(error); - const embed = new EmbedBuilder() - .setTitle("❌ Reload Failed") - .setDescription("An error occurred while reloading commands. Check console for details.") - .setColor("Red"); - - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while reloading commands. Check console for details.", "Reload Failed")] }); } } }); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7909df1..0e681ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { KyokoClient } from "@lib/KyokoClient"; import { env } from "@lib/env"; import { userService } from "@/modules/user/user.service"; import { levelingService } from "@/modules/leveling/leveling.service"; +import { createErrorEmbed } from "@lib/embeds"; // Load commands await KyokoClient.loadCommands(); @@ -25,6 +26,17 @@ KyokoClient.on(Events.MessageCreate, async message => { // handle commands KyokoClient.on(Events.InteractionCreate, async interaction => { + // Handle Trade Interactions + if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) { + if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") { // "amount" is the text input id, usually interaction wrapper keeps customId of modal? + // Wait, ModalSubmitInteraction customId IS likely 'trade_money_modal'. + // The components INSIDE have IDs. The Interaction has the Modal ID. + // So checking startWith("trade_") is correct for the modal itself. + await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction)); + return; + } + } + if (!interaction.isChatInputCommand()) return; const command = KyokoClient.commands.get(interaction.commandName); @@ -50,14 +62,16 @@ KyokoClient.on(Events.InteractionCreate, async interaction => { await command.execute(interaction); } catch (error) { console.error(error); + const errorEmbed = createErrorEmbed('There was an error while executing this command!'); if (interaction.replied || interaction.deferred) { - await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true }); + await interaction.followUp({ embeds: [errorEmbed], ephemeral: true }); } else { - await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true }); + await interaction.reply({ embeds: [errorEmbed], ephemeral: true }); } } }); + // login with the token from .env if (!env.DISCORD_BOT_TOKEN) { throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables."); diff --git a/src/lib/embeds.ts b/src/lib/embeds.ts new file mode 100644 index 0000000..36f1ed7 --- /dev/null +++ b/src/lib/embeds.ts @@ -0,0 +1,29 @@ +import { EmbedBuilder, Colors } from "discord.js"; + +/** + * Creates a standardized error embed. + * @param message The error message to display. + * @param title Optional title for the embed. Defaults to "Error". + * @returns An EmbedBuilder instance configured as an error. + */ +export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder { + return new EmbedBuilder() + .setTitle(`❌ ${title}`) + .setDescription(message) + .setColor(Colors.Red) + .setTimestamp(); +} + +/** + * Creates a standardized warning embed. + * @param message The warning message to display. + * @param title Optional title for the embed. Defaults to "Warning". + * @returns An EmbedBuilder instance configured as a warning. + */ +export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder { + return new EmbedBuilder() + .setTitle(`⚠️ ${title}`) + .setDescription(message) + .setColor(Colors.Yellow) + .setTimestamp(); +} diff --git a/src/modules/trade/trade.interaction.ts b/src/modules/trade/trade.interaction.ts new file mode 100644 index 0000000..4609608 --- /dev/null +++ b/src/modules/trade/trade.interaction.ts @@ -0,0 +1,294 @@ +import { + ButtonInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, + type Interaction, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + StringSelectMenuBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ThreadChannel, + TextChannel, + Colors +} from "discord.js"; +import { TradeService } from "./trade.service"; +import { inventoryService } from "@/modules/inventory/inventory.service"; +import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; + +const EMBED_COLOR = 0xFFD700; // Gold + +export async function handleTradeInteraction(interaction: Interaction) { + if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return; + + const { customId } = interaction; + const threadId = interaction.channelId; + + if (!threadId) return; + + try { + if (customId === 'trade_cancel') { + await handleCancel(interaction, threadId); + } else if (customId === 'trade_lock') { + await handleLock(interaction, threadId); + } else if (customId === 'trade_confirm') { + // Confirm logic is handled implicitly by both locking or explicitly if needed. + // For now, locking both triggers execution, so no separate confirm handler is actively used + // unless we re-introduce a specific button. keeping basic handler stub if needed. + } else if (customId === 'trade_add_money') { + await handleAddMoneyClick(interaction); + } else if (customId === 'trade_money_modal') { + await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId); + } else if (customId === 'trade_add_item') { + await handleAddItemClick(interaction as ButtonInteraction, threadId); + } else if (customId === 'trade_select_item') { + await handleItemSelect(interaction as StringSelectMenuInteraction, threadId); + } else if (customId === 'trade_remove_item') { + await handleRemoveItemClick(interaction as ButtonInteraction, threadId); + } else if (customId === 'trade_remove_item_select') { + await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId); + } + } catch (error: any) { + const errorEmbed = createErrorEmbed(error.message); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ embeds: [errorEmbed], ephemeral: true }); + } else { + await interaction.reply({ embeds: [errorEmbed], ephemeral: true }); + } + } +} + +async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) { + TradeService.endSession(threadId); + await interaction.reply({ content: "🛑 Trade cancelled. Deleting thread in 5 seconds..." }); + setTimeout(async () => { + try { + await interaction.channel?.delete(); + } catch (e) { + console.error("Failed to delete thread", e); + } + }, 5000); +} + +async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) { + const isLocked = TradeService.toggleLock(threadId, interaction.user.id); + await updateTradeDashboard(interaction, threadId); + + // Check if trade executed (both locked) + const session = TradeService.getSession(threadId); + if (session && session.state === 'COMPLETED') { + // Trade executed during updateTradeDashboard + return; + } + + await interaction.followUp({ content: isLocked ? "🔒 You locked your offer." : "� You unlocked your offer.", ephemeral: true }); +} + +async function handleAddMoneyClick(interaction: Interaction) { + if (!interaction.isButton()) return; + const modal = new ModalBuilder() + .setCustomId('trade_money_modal') + .setTitle('Add Money'); + + const input = new TextInputBuilder() + .setCustomId('amount') + .setLabel("Amount to trade") + .setStyle(TextInputStyle.Short) + .setPlaceholder("100") + .setRequired(true); + + const row = new ActionRowBuilder().addComponents(input); + modal.addComponents(row); + + await interaction.showModal(modal); +} + +async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId: string) { + const amountStr = interaction.fields.getTextInputValue('amount'); + const amount = BigInt(amountStr); + + if (amount < 0n) throw new Error("Amount must be positive"); + + TradeService.updateMoney(threadId, interaction.user.id, amount); + await interaction.deferUpdate(); // Acknowledge modal + await updateTradeDashboard(interaction, threadId); +} + +async function handleAddItemClick(interaction: ButtonInteraction, threadId: string) { + const inventory = await inventoryService.getInventory(interaction.user.id); + + if (inventory.length === 0) { + await interaction.reply({ embeds: [createWarningEmbed("Your inventory is empty.")], ephemeral: true }); + return; + } + + // Slice top 25 for select menu + const options = inventory.slice(0, 25).map(entry => ({ + label: `${entry.item.name} (${entry.quantity})`, + value: entry.item.id.toString(), + description: `Rarity: ${entry.item.rarity}` + })); + + const select = new StringSelectMenuBuilder() + .setCustomId('trade_select_item') + .setPlaceholder('Select an item to add') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(select); + + await interaction.reply({ content: "Select an item to add:", components: [row], ephemeral: true }); +} + +async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) { + const value = interaction.values[0]; + if (!value) return; + const itemId = parseInt(value); + + // Assuming implementation implies adding 1 item for now + const item = await inventoryService.getItem(itemId); + if (!item) throw new Error("Item not found"); + + TradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n); + + await interaction.update({ content: `Added ${item.name} x1`, components: [] }); + await updateTradeDashboard(interaction, threadId); +} + +async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) { + const session = TradeService.getSession(threadId); + if (!session) return; + + const participant = session.userA.id === interaction.user.id ? session.userA : session.userB; + + if (participant.offer.items.length === 0) { + await interaction.reply({ embeds: [createWarningEmbed("No items in offer to remove.")], ephemeral: true }); + return; + } + + const options = participant.offer.items.slice(0, 25).map(i => ({ + label: `${i.name} (${i.quantity})`, + value: i.id.toString(), + })); + + const select = new StringSelectMenuBuilder() + .setCustomId('trade_remove_item_select') + .setPlaceholder('Select an item to remove') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(select); + + await interaction.reply({ content: "Select an item to remove:", components: [row], ephemeral: true }); +} + +async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) { + const value = interaction.values[0]; + if (!value) return; + const itemId = parseInt(value); + TradeService.removeItem(threadId, interaction.user.id, itemId); + + await interaction.update({ content: `Removed item.`, components: [] }); + await updateTradeDashboard(interaction, threadId); +} + + +// --- DASHBOARD UPDATER --- + +export async function updateTradeDashboard(interaction: Interaction, threadId: string) { + const session = TradeService.getSession(threadId); + if (!session) return; + + // Check Auto-Execute (If both locked) + if (session.userA.locked && session.userB.locked) { + // Execute Trade + try { + await TradeService.executeTrade(threadId); + const embed = new EmbedBuilder() + .setTitle("✅ Trade Completed") + .setColor("Green") + .addFields( + { name: session.userA.username, value: formatOffer(session.userA), inline: true }, + { name: session.userB.username, value: formatOffer(session.userB), inline: true } + ) + .setTimestamp(); + + await updateDashboardMessage(interaction, { embeds: [embed], components: [] }); + return; + } catch (e: any) { + const embed = createErrorEmbed(e.message, "Trade Failed"); + + if (interaction.channel && (interaction.channel.isThread() || interaction.channel instanceof TextChannel)) { + await interaction.channel.send({ embeds: [embed] }); + } + return; + } + } + + // Build Status Embed + const embed = new EmbedBuilder() + .setTitle("🤝 Trading Session") + .setColor(EMBED_COLOR) + .addFields( + { + name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`, + value: formatOffer(session.userA), + inline: true + }, + { + name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`, + value: formatOffer(session.userB), + inline: true + } + ) + .setFooter({ text: "Both parties must click Lock to confirm trade." }); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger), + ); + + await updateDashboardMessage(interaction, { embeds: [embed], components: [row] }); +} + +async function updateDashboardMessage(interaction: Interaction, payload: any) { + if (interaction.isButton() && interaction.message) { + // If interaction came from the dashboard itself, we can edit directly + try { + await interaction.message.edit(payload); + } catch (e) { + console.error("Failed to edit message directly", e); + } + } else { + // Find dashboard in channel + const channel = interaction.channel as ThreadChannel; + if (channel && channel.isThread()) { + try { + const messages = await channel.messages.fetch({ limit: 10 }); + const dashboardFn = messages.find(m => m.embeds[0]?.title === "🤝 Trading Session"); + if (dashboardFn) { + await dashboardFn.edit(payload); + } + } catch (e) { + console.error("Failed to fetch/edit dashboard", e); + } + } + } +} + +function formatOffer(participant: any) { + let text = ""; + if (participant.offer.money > 0n) { + text += `💰 ${participant.offer.money} 🪙\n`; + } + if (participant.offer.items.length > 0) { + text += participant.offer.items.map((i: any) => `- ${i.name} (x${i.quantity})`).join("\n"); + } + if (text === "") text = "*Empty Offer*"; + return text; +} diff --git a/src/modules/trade/trade.service.ts b/src/modules/trade/trade.service.ts new file mode 100644 index 0000000..11aed33 --- /dev/null +++ b/src/modules/trade/trade.service.ts @@ -0,0 +1,187 @@ +import type { TradeSession, TradeParticipant, TradeState } from "./trade.types"; +import { DrizzleClient } from "@/lib/DrizzleClient"; +import { economyService } from "@/modules/economy/economy.service"; +import { inventoryService } from "@/modules/inventory/inventory.service"; +import { itemTransactions, transactions } from "@/db/schema"; + +export class TradeService { + private static sessions = new Map(); + + /** + * Creates a new trade session + */ + static createSession(threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession { + const session: TradeSession = { + threadId, + userA: { + id: userA.id, + username: userA.username, + locked: false, + offer: { money: 0n, items: [] } + }, + userB: { + id: userB.id, + username: userB.username, + locked: false, + offer: { money: 0n, items: [] } + }, + state: 'NEGOTIATING', + lastInteraction: Date.now() + }; + + this.sessions.set(threadId, session); + return session; + } + + static getSession(threadId: string): TradeSession | undefined { + return this.sessions.get(threadId); + } + + static endSession(threadId: string) { + this.sessions.delete(threadId); + } + + /** + * Updates an offer. If allowed, validation checks should be done BEFORE calling this. + * unlocking logic is handled here (if offer changes, unlock both). + */ + static updateMoney(threadId: string, userId: string, amount: bigint) { + const session = this.getSession(threadId); + if (!session) throw new Error("Session not found"); + if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active"); + + const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; + if (!participant) throw new Error("User not in trade"); + + participant.offer.money = amount; + this.unlockAll(session); + session.lastInteraction = Date.now(); + } + + static addItem(threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) { + const session = this.getSession(threadId); + if (!session) throw new Error("Session not found"); + if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active"); + + const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; + if (!participant) throw new Error("User not in trade"); + + const existing = participant.offer.items.find(i => i.id === item.id); + if (existing) { + existing.quantity += quantity; + } else { + participant.offer.items.push({ id: item.id, name: item.name, quantity }); + } + + this.unlockAll(session); + session.lastInteraction = Date.now(); + } + + static removeItem(threadId: string, userId: string, itemId: number) { + const session = this.getSession(threadId); + if (!session) throw new Error("Session not found"); + + const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; + if (!participant) throw new Error("User not in trade"); + + participant.offer.items = participant.offer.items.filter(i => i.id !== itemId); + + this.unlockAll(session); + session.lastInteraction = Date.now(); + } + + static toggleLock(threadId: string, userId: string): boolean { + const session = this.getSession(threadId); + if (!session) throw new Error("Session not found"); + + const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; + if (!participant) throw new Error("User not in trade"); + + participant.locked = !participant.locked; + session.lastInteraction = Date.now(); + + return participant.locked; + } + + private static unlockAll(session: TradeSession) { + session.userA.locked = false; + session.userB.locked = false; + } + + /** + * Executes the trade atomically. + * 1. Validates balances/inventory for both users. + * 2. Swaps money. + * 3. Swaps items. + * 4. Logs transactions. + */ + static async executeTrade(threadId: string): Promise { + const session = this.getSession(threadId); + if (!session) throw new Error("Session not found"); + + if (!session.userA.locked || !session.userB.locked) { + throw new Error("Both players must accept the trade first."); + } + + session.state = 'COMPLETED'; // Prevent double execution + + await DrizzleClient.transaction(async (tx) => { + // -- Validate & Execute User A -> User B -- + await this.processTransfer(tx, session.userA, session.userB, session.threadId); + + // -- Validate & Execute User B -> User A -- + await this.processTransfer(tx, session.userB, session.userA, session.threadId); + }); + + this.endSession(threadId); + } + + private static async processTransfer(tx: any, from: TradeParticipant, to: TradeParticipant, threadId: string) { + // 1. Money + if (from.offer.money > 0n) { + await economyService.modifyUserBalance( + from.id, + -from.offer.money, + 'TRADE_OUT', + `Trade with ${to.username} (Thread: ${threadId})`, + tx + ); + await economyService.modifyUserBalance( + to.id, + from.offer.money, + 'TRADE_IN', + `Trade with ${from.username} (Thread: ${threadId})`, + tx + ); + } + + // 2. Items + for (const item of from.offer.items) { + // Remove from sender + await inventoryService.removeItem(from.id, item.id, item.quantity, tx); + + // Add to receiver + await inventoryService.addItem(to.id, item.id, item.quantity, tx); + + // Log Item Transaction (Sender) + await tx.insert(itemTransactions).values({ + userId: BigInt(from.id), + relatedUserId: BigInt(to.id), + itemId: item.id, + quantity: -item.quantity, + type: 'TRADE_OUT', + description: `Traded to ${to.username}`, + }); + + // Log Item Transaction (Receiver) + await tx.insert(itemTransactions).values({ + userId: BigInt(to.id), + relatedUserId: BigInt(from.id), + itemId: item.id, + quantity: item.quantity, + type: 'TRADE_IN', + description: `Received from ${from.username}`, + }); + } + } +} diff --git a/src/modules/trade/trade.types.ts b/src/modules/trade/trade.types.ts new file mode 100644 index 0000000..5246710 --- /dev/null +++ b/src/modules/trade/trade.types.ts @@ -0,0 +1,28 @@ + +export interface TradeItem { + id: number; + name: string; // Cache name for UI display + quantity: bigint; +} + +export interface TradeOffer { + money: bigint; + items: TradeItem[]; // easier to iterate for UI than Map +} + +export interface TradeParticipant { + id: string; + username: string; + locked: boolean; + offer: TradeOffer; +} + +export type TradeState = 'NEGOTIATING' | 'COMPLETED' | 'CANCELLED'; + +export interface TradeSession { + threadId: string; + userA: TradeParticipant; + userB: TradeParticipant; + state: TradeState; + lastInteraction: number; +}