Compare commits
7 Commits
5420653b2b
...
1523a392c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1523a392c2 | ||
|
|
7d6912cdee | ||
|
|
947bbc10d6 | ||
|
|
2933eaeafc | ||
|
|
77d3fafdce | ||
|
|
10a760edf4 | ||
|
|
a53d30a0b3 |
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@/lib/utils";
|
||||||
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||||
import { TradeService } from "@/modules/trade/trade.service";
|
import { tradeService } from "@/modules/trade/trade.service";
|
||||||
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export const trade = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup Session
|
// Setup Session
|
||||||
const session = TradeService.createSession(thread.id,
|
const session = tradeService.createSession(thread.id,
|
||||||
{ id: interaction.user.id, username: interaction.user.username },
|
{ id: interaction.user.id, username: interaction.user.username },
|
||||||
{ id: targetUser.id, username: targetUser.username }
|
{ id: targetUser.id, username: targetUser.username }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createCommand } from "@/lib/utils";
|
|||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { createWarningEmbed, createBaseEmbed } from "@lib/embeds";
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
|
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||||
|
|
||||||
export const inventory = createCommand({
|
export const inventory = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -24,18 +25,19 @@ export const inventory = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
const items = await inventoryService.getInventory(user.id);
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await inventoryService.getInventory(user.id.toString());
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = items.map(entry => {
|
const embed = getInventoryEmbed(items, user.username);
|
||||||
return `**${entry.item.name}** x${entry.quantity}`;
|
|
||||||
}).join("\n");
|
|
||||||
|
|
||||||
const embed = createBaseEmbed(`${user.username}'s Inventory`, description, "Blue");
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createCommand } from "@/lib/utils";
|
|||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||||
import { inventory, items } from "@/db/schema";
|
import { inventory, items } from "@/db/schema";
|
||||||
import { eq, and, like } from "drizzle-orm";
|
import { eq, and, like } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
@@ -25,9 +26,13 @@ export const use = createCommand({
|
|||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await inventoryService.useItem(user.id, itemId);
|
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||||
|
|
||||||
const usageData = result.usageData;
|
const usageData = result.usageData;
|
||||||
if (usageData) {
|
if (usageData) {
|
||||||
@@ -53,11 +58,7 @@ export const use = createCommand({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = createSuccessEmbed(
|
const embed = getItemUseResultEmbed(result.results);
|
||||||
result.results.map(r => `• ${r}`).join("\n"),
|
|
||||||
`Used ${result.usageData.effects.length > 0 ? 'Item' : 'Item'}` // Generic title, improves below
|
|
||||||
);
|
|
||||||
embed.setTitle("Item Used!");
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { SlashCommandBuilder } from "discord.js";
|
|||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { users } from "@/db/schema";
|
import { users } from "@/db/schema";
|
||||||
import { desc } from "drizzle-orm";
|
import { desc } from "drizzle-orm";
|
||||||
import { createWarningEmbed, createBaseEmbed } from "@lib/embeds";
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
|
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
||||||
|
|
||||||
export const leaderboard = createCommand({
|
export const leaderboard = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -34,13 +35,7 @@ export const leaderboard = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = leaders.map((user, index) => {
|
const embed = getLeaderboardEmbed(leaders, isXp ? 'xp' : 'balance');
|
||||||
const medal = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`;
|
|
||||||
const value = isXp ? `Lvl ${user.level} (${user.xp} XP)` : `${user.balance} 🪙`;
|
|
||||||
return `${medal} **${user.username}** — ${value}`;
|
|
||||||
}).join("\n");
|
|
||||||
|
|
||||||
const embed = createBaseEmbed(isXp ? "🏆 XP Leaderboard" : "💰 Richest Players", description, "Gold");
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@/lib/utils";
|
||||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||||
import { questService } from "@/modules/quest/quest.service";
|
import { questService } from "@/modules/quest/quest.service";
|
||||||
import { createWarningEmbed, createBaseEmbed } from "@lib/embeds";
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
|
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
||||||
|
|
||||||
export const quests = createCommand({
|
export const quests = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -17,21 +18,7 @@ export const quests = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = createBaseEmbed("📜 Quest Log", undefined, "Blue");
|
const embed = getQuestListEmbed(userQuests);
|
||||||
|
|
||||||
userQuests.forEach(entry => {
|
|
||||||
const status = entry.completedAt ? "✅ Completed" : "In Progress";
|
|
||||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
|
||||||
const rewardStr = [];
|
|
||||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
|
||||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
|
||||||
|
|
||||||
embed.addFields({
|
|
||||||
name: `${entry.quest.name} (${status})`,
|
|
||||||
value: `${entry.quest.description}\n**Rewards:** ${rewardStr.join(", ")}\n**Progress:** ${entry.progress}%`,
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Command } from "@lib/types";
|
|||||||
import { env } from "@lib/env";
|
import { env } from "@lib/env";
|
||||||
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
||||||
import { EventLoader } from "@lib/loaders/EventLoader";
|
import { EventLoader } from "@lib/loaders/EventLoader";
|
||||||
|
import { logger } from "@lib/logger";
|
||||||
|
|
||||||
export class Client extends DiscordClient {
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
@@ -21,25 +22,25 @@ export class Client extends DiscordClient {
|
|||||||
async loadCommands(reload: boolean = false) {
|
async loadCommands(reload: boolean = false) {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
this.commands.clear();
|
this.commands.clear();
|
||||||
console.log("♻️ Reloading commands...");
|
logger.info("♻️ Reloading commands...");
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandsPath = join(import.meta.dir, '../commands');
|
const commandsPath = join(import.meta.dir, '../commands');
|
||||||
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
||||||
|
|
||||||
console.log(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
logger.info(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadEvents(reload: boolean = false) {
|
async loadEvents(reload: boolean = false) {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
this.removeAllListeners();
|
this.removeAllListeners();
|
||||||
console.log("♻️ Reloading events...");
|
logger.info("♻️ Reloading events...");
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsPath = join(import.meta.dir, '../events');
|
const eventsPath = join(import.meta.dir, '../events');
|
||||||
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
||||||
|
|
||||||
console.log(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
logger.info(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ export class Client extends DiscordClient {
|
|||||||
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
||||||
const token = env.DISCORD_BOT_TOKEN;
|
const token = env.DISCORD_BOT_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.error("❌ DISCORD_BOT_TOKEN is not set.");
|
logger.error("DISCORD_BOT_TOKEN is not set.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +59,16 @@ export class Client extends DiscordClient {
|
|||||||
const clientId = env.DISCORD_CLIENT_ID;
|
const clientId = env.DISCORD_CLIENT_ID;
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
console.error("❌ DISCORD_CLIENT_ID is not set.");
|
logger.error("DISCORD_CLIENT_ID is not set.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
|
logger.info(`Started refreshing ${commandsData.length} application (/) commands.`);
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
if (guildId) {
|
if (guildId) {
|
||||||
console.log(`Registering commands to guild: ${guildId}`);
|
logger.info(`Registering commands to guild: ${guildId}`);
|
||||||
data = await rest.put(
|
data = await rest.put(
|
||||||
Routes.applicationGuildCommands(clientId, guildId),
|
Routes.applicationGuildCommands(clientId, guildId),
|
||||||
{ body: commandsData },
|
{ body: commandsData },
|
||||||
@@ -75,20 +76,20 @@ export class Client extends DiscordClient {
|
|||||||
// Clear global commands to avoid duplicates
|
// Clear global commands to avoid duplicates
|
||||||
await rest.put(Routes.applicationCommands(clientId), { body: [] });
|
await rest.put(Routes.applicationCommands(clientId), { body: [] });
|
||||||
} else {
|
} else {
|
||||||
console.log('Registering commands globally');
|
logger.info('Registering commands globally');
|
||||||
data = await rest.put(
|
data = await rest.put(
|
||||||
Routes.applicationCommands(clientId),
|
Routes.applicationCommands(clientId),
|
||||||
{ body: commandsData },
|
{ body: commandsData },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Successfully reloaded ${(data as any).length} application (/) commands.`);
|
logger.success(`Successfully reloaded ${(data as any).length} application (/) commands.`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code === 50001) {
|
if (error.code === 50001) {
|
||||||
console.warn("⚠️ Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
|
logger.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
|
||||||
console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
|
logger.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
|
||||||
} else {
|
} else {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AutocompleteInteraction } from "discord.js";
|
import { AutocompleteInteraction } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { logger } from "@lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles autocomplete interactions for slash commands
|
* Handles autocomplete interactions for slash commands
|
||||||
@@ -15,7 +16,7 @@ export class AutocompleteHandler {
|
|||||||
try {
|
try {
|
||||||
await command.autocomplete(interaction);
|
await command.autocomplete(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
logger.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { logger } from "@lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles slash command execution
|
* Handles slash command execution
|
||||||
@@ -12,7 +13,7 @@ export class CommandHandler {
|
|||||||
const command = AuroraClient.commands.get(interaction.commandName);
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
logger.error(`No command matching ${interaction.commandName} was found.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,13 +21,13 @@ export class CommandHandler {
|
|||||||
try {
|
try {
|
||||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to ensure user exists:", error);
|
logger.error("Failed to ensure user exists:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error(String(error));
|
||||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction } from "discord.js";
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction } from "discord.js";
|
||||||
|
import { logger } from "@lib/logger";
|
||||||
|
|
||||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export class ComponentInteractionHandler {
|
|||||||
await handlerMethod(interaction);
|
await handlerMethod(interaction);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.error(`Handler method ${route.method} not found in module`);
|
logger.error(`Handler method ${route.method} not found in module`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Command } from "@lib/types";
|
|||||||
import { config } from "@lib/config";
|
import { config } from "@lib/config";
|
||||||
import type { LoadResult, LoadError } from "./types";
|
import type { LoadResult, LoadError } from "./types";
|
||||||
import type { Client } from "../BotClient";
|
import type { Client } from "../BotClient";
|
||||||
|
import { logger } from "@lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles loading commands from the file system
|
* Handles loading commands from the file system
|
||||||
@@ -44,7 +45,7 @@ export class CommandLoader {
|
|||||||
await this.loadCommandFile(filePath, reload, result);
|
await this.loadCommandFile(filePath, reload, result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading directory ${dir}:`, error);
|
logger.error(`Error reading directory ${dir}:`, error);
|
||||||
result.errors.push({ file: dir, error });
|
result.errors.push({ file: dir, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,7 @@ export class CommandLoader {
|
|||||||
const commands = Object.values(commandModule);
|
const commands = Object.values(commandModule);
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
console.warn(`⚠️ No commands found in ${filePath}`);
|
logger.warn(`No commands found in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,21 +74,21 @@ export class CommandLoader {
|
|||||||
const isEnabled = config.commands[command.data.name] !== false;
|
const isEnabled = config.commands[command.data.name] !== false;
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
logger.info(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.client.commands.set(command.data.name, command);
|
this.client.commands.set(command.data.name, command);
|
||||||
console.log(`✅ Loaded command: ${command.data.name}`);
|
logger.success(`Loaded command: ${command.data.name}`);
|
||||||
result.loaded++;
|
result.loaded++;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`⚠️ Skipping invalid command in ${filePath}`);
|
logger.warn(`Skipping invalid command in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Failed to load command from ${filePath}:`, error);
|
logger.error(`Failed to load command from ${filePath}:`, error);
|
||||||
result.errors.push({ file: filePath, error });
|
result.errors.push({ file: filePath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@lib/types";
|
||||||
import type { LoadResult } from "./types";
|
import type { LoadResult } from "./types";
|
||||||
import type { Client } from "../BotClient";
|
import type { Client } from "../BotClient";
|
||||||
|
import { logger } from "@lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles loading events from the file system
|
* Handles loading events from the file system
|
||||||
@@ -43,7 +44,7 @@ export class EventLoader {
|
|||||||
await this.loadEventFile(filePath, reload, result);
|
await this.loadEventFile(filePath, reload, result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading directory ${dir}:`, error);
|
logger.error(`Error reading directory ${dir}:`, error);
|
||||||
result.errors.push({ file: dir, error });
|
result.errors.push({ file: dir, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,14 +64,14 @@ export class EventLoader {
|
|||||||
} else {
|
} else {
|
||||||
this.client.on(event.name, (...args) => event.execute(...args));
|
this.client.on(event.name, (...args) => event.execute(...args));
|
||||||
}
|
}
|
||||||
console.log(`✅ Loaded event: ${event.name}`);
|
logger.success(`Loaded event: ${event.name}`);
|
||||||
result.loaded++;
|
result.loaded++;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`⚠️ Skipping invalid event in ${filePath}`);
|
logger.warn(`Skipping invalid event in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Failed to load event from ${filePath}:`, error);
|
logger.error(`Failed to load event from ${filePath}:`, error);
|
||||||
result.errors.push({ file: filePath, error });
|
result.errors.push({ file: filePath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/lib/logger.ts
Normal file
39
src/lib/logger.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Centralized logging utility with consistent formatting
|
||||||
|
*/
|
||||||
|
export const logger = {
|
||||||
|
/**
|
||||||
|
* General information message
|
||||||
|
*/
|
||||||
|
info: (message: string, ...args: any[]) => {
|
||||||
|
console.log(`ℹ️ ${message}`, ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success message
|
||||||
|
*/
|
||||||
|
success: (message: string, ...args: any[]) => {
|
||||||
|
console.log(`✅ ${message}`, ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warning message
|
||||||
|
*/
|
||||||
|
warn: (message: string, ...args: any[]) => {
|
||||||
|
console.warn(`⚠️ ${message}`, ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message
|
||||||
|
*/
|
||||||
|
error: (message: string, ...args: any[]) => {
|
||||||
|
console.error(`❌ ${message}`, ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug message
|
||||||
|
*/
|
||||||
|
debug: (message: string, ...args: any[]) => {
|
||||||
|
console.log(`🔍 ${message}`, ...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,14 +2,16 @@ import { classes, users } from "@/db/schema";
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { withTransaction } from "@/lib/db";
|
||||||
|
import type { Transaction } from "@/lib/types";
|
||||||
|
|
||||||
export const classService = {
|
export const classService = {
|
||||||
getAllClasses: async () => {
|
getAllClasses: async () => {
|
||||||
return await DrizzleClient.query.classes.findMany();
|
return await DrizzleClient.query.classes.findMany();
|
||||||
},
|
},
|
||||||
|
|
||||||
assignClass: async (userId: string, classId: bigint, tx?: any) => {
|
assignClass: async (userId: string, classId: bigint, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const cls = await txFn.query.classes.findFirst({
|
const cls = await txFn.query.classes.findFirst({
|
||||||
where: eq(classes.id, classId),
|
where: eq(classes.id, classId),
|
||||||
});
|
});
|
||||||
@@ -22,8 +24,7 @@ export const classService = {
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
},
|
},
|
||||||
getClassBalance: async (classId: bigint) => {
|
getClassBalance: async (classId: bigint) => {
|
||||||
const cls = await DrizzleClient.query.classes.findFirst({
|
const cls = await DrizzleClient.query.classes.findFirst({
|
||||||
@@ -31,15 +32,15 @@ export const classService = {
|
|||||||
});
|
});
|
||||||
return cls?.balance || 0n;
|
return cls?.balance || 0n;
|
||||||
},
|
},
|
||||||
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: any) => {
|
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const cls = await txFn.query.classes.findFirst({
|
const cls = await txFn.query.classes.findFirst({
|
||||||
where: eq(classes.id, classId),
|
where: eq(classes.id, classId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cls) throw new UserError("Class not found");
|
if (!cls) throw new UserError("Class not found");
|
||||||
|
|
||||||
if ((cls.balance ?? 0n) < amount) {
|
if ((cls.balance ?? 0n) + amount < 0n) {
|
||||||
throw new UserError("Insufficient class funds");
|
throw new UserError("Insufficient class funds");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,35 +52,31 @@ export const classService = {
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return updatedClass;
|
return updatedClass;
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: any) => {
|
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const [updatedClass] = await txFn.update(classes)
|
const [updatedClass] = await txFn.update(classes)
|
||||||
.set(data)
|
.set(data)
|
||||||
.where(eq(classes.id, id))
|
.where(eq(classes.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
return updatedClass;
|
return updatedClass;
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
createClass: async (data: typeof classes.$inferInsert, tx?: any) => {
|
createClass: async (data: typeof classes.$inferInsert, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const [newClass] = await txFn.insert(classes)
|
const [newClass] = await txFn.insert(classes)
|
||||||
.values(data)
|
.values(data)
|
||||||
.returning();
|
.returning();
|
||||||
return newClass;
|
return newClass;
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteClass: async (id: bigint, tx?: any) => {
|
deleteClass: async (id: bigint, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
await txFn.delete(classes).where(eq(classes.id, id));
|
await txFn.delete(classes).where(eq(classes.id, id));
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
40
src/modules/inventory/inventory.view.ts
Normal file
40
src/modules/inventory/inventory.view.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import type { ItemUsageData } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory entry with item details
|
||||||
|
*/
|
||||||
|
interface InventoryEntry {
|
||||||
|
quantity: bigint | null;
|
||||||
|
item: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed displaying a user's inventory
|
||||||
|
*/
|
||||||
|
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
|
||||||
|
const description = items.map(entry => {
|
||||||
|
return `**${entry.item.name}** x${entry.quantity}`;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(`📦 ${username}'s Inventory`)
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(0x3498db); // Blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed showing the results of using an item
|
||||||
|
*/
|
||||||
|
export function getItemUseResultEmbed(results: string[], itemName?: string): EmbedBuilder {
|
||||||
|
const description = results.map(r => `• ${r}`).join("\n");
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("✅ Item Used!")
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(0x2ecc71); // Green/Success
|
||||||
|
}
|
||||||
48
src/modules/leveling/leveling.view.ts
Normal file
48
src/modules/leveling/leveling.view.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User data for leaderboard display
|
||||||
|
*/
|
||||||
|
interface LeaderboardUser {
|
||||||
|
username: string;
|
||||||
|
level: number | null;
|
||||||
|
xp: bigint | null;
|
||||||
|
balance: bigint | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the appropriate medal emoji for a ranking position
|
||||||
|
*/
|
||||||
|
function getMedalEmoji(index: number): string {
|
||||||
|
if (index === 0) return "🥇";
|
||||||
|
if (index === 1) return "🥈";
|
||||||
|
if (index === 2) return "🥉";
|
||||||
|
return `${index + 1}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a single leaderboard entry based on type
|
||||||
|
*/
|
||||||
|
function formatLeaderEntry(user: LeaderboardUser, index: number, type: 'xp' | 'balance'): string {
|
||||||
|
const medal = getMedalEmoji(index);
|
||||||
|
const value = type === 'xp'
|
||||||
|
? `Lvl ${user.level ?? 1} (${user.xp ?? 0n} XP)`
|
||||||
|
: `${user.balance ?? 0n} 🪙`;
|
||||||
|
return `${medal} **${user.username}** — ${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a leaderboard embed for either XP or Balance rankings
|
||||||
|
*/
|
||||||
|
export function getLeaderboardEmbed(leaders: LeaderboardUser[], type: 'xp' | 'balance'): EmbedBuilder {
|
||||||
|
const description = leaders.map((user, index) =>
|
||||||
|
formatLeaderEntry(user, index, type)
|
||||||
|
).join("\n");
|
||||||
|
|
||||||
|
const title = type === 'xp' ? "🏆 XP Leaderboard" : "💰 Richest Players";
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(0xFFD700); // Gold
|
||||||
|
}
|
||||||
54
src/modules/quest/quest.view.ts
Normal file
54
src/modules/quest/quest.view.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quest entry with quest details and progress
|
||||||
|
*/
|
||||||
|
interface QuestEntry {
|
||||||
|
progress: number | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
quest: {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rewards: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats quest rewards object into a human-readable string
|
||||||
|
*/
|
||||||
|
function formatQuestRewards(rewards: { xp?: number, balance?: number }): string {
|
||||||
|
const rewardStr: string[] = [];
|
||||||
|
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||||
|
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||||
|
return rewardStr.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the quest status display string
|
||||||
|
*/
|
||||||
|
function getQuestStatus(completedAt: Date | null): string {
|
||||||
|
return completedAt ? "✅ Completed" : "📝 In Progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed displaying a user's quest log
|
||||||
|
*/
|
||||||
|
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("📜 Quest Log")
|
||||||
|
.setColor(0x3498db); // Blue
|
||||||
|
|
||||||
|
userQuests.forEach(entry => {
|
||||||
|
const status = getQuestStatus(entry.completedAt);
|
||||||
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: `${entry.quest.name} (${status})`,
|
||||||
|
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
TextChannel,
|
TextChannel,
|
||||||
EmbedBuilder
|
EmbedBuilder
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { TradeService } from "./trade.service";
|
import { tradeService } from "./trade.service";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||||
@@ -55,10 +55,10 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
const user = interaction.user;
|
const user = interaction.user;
|
||||||
|
|
||||||
TradeService.endSession(threadId);
|
tradeService.endSession(threadId);
|
||||||
|
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
@@ -70,11 +70,11 @@ async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInt
|
|||||||
|
|
||||||
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
const isLocked = TradeService.toggleLock(threadId, interaction.user.id);
|
const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
|
|
||||||
// Check if trade executed (both locked)
|
// Check if trade executed (both locked)
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (session && session.state === 'COMPLETED') {
|
if (session && session.state === 'COMPLETED') {
|
||||||
// Trade executed during updateTradeDashboard
|
// Trade executed during updateTradeDashboard
|
||||||
return;
|
return;
|
||||||
@@ -95,7 +95,7 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
|
|||||||
|
|
||||||
if (amount < 0n) throw new Error("Amount must be positive");
|
if (amount < 0n) throw new Error("Amount must be positive");
|
||||||
|
|
||||||
TradeService.updateMoney(threadId, interaction.user.id, amount);
|
tradeService.updateMoney(threadId, interaction.user.id, amount);
|
||||||
await interaction.deferUpdate(); // Acknowledge modal
|
await interaction.deferUpdate(); // Acknowledge modal
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
}
|
}
|
||||||
@@ -128,14 +128,14 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
|
|||||||
const item = await inventoryService.getItem(itemId);
|
const item = await inventoryService.getItem(itemId);
|
||||||
if (!item) throw new Error("Item not found");
|
if (!item) throw new Error("Item not found");
|
||||||
|
|
||||||
TradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
||||||
|
|
||||||
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
|
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
||||||
@@ -158,7 +158,7 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
|
|||||||
const value = interaction.values[0];
|
const value = interaction.values[0];
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
const itemId = parseInt(value);
|
const itemId = parseInt(value);
|
||||||
TradeService.removeItem(threadId, interaction.user.id, itemId);
|
tradeService.removeItem(threadId, interaction.user.id, itemId);
|
||||||
|
|
||||||
await interaction.update({ content: `Removed item.`, components: [] });
|
await interaction.update({ content: `Removed item.`, components: [] });
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
@@ -168,14 +168,14 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
|
|||||||
// --- DASHBOARD UPDATER ---
|
// --- DASHBOARD UPDATER ---
|
||||||
|
|
||||||
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
// Check Auto-Execute (If both locked)
|
// Check Auto-Execute (If both locked)
|
||||||
if (session.userA.locked && session.userB.locked) {
|
if (session.userA.locked && session.userB.locked) {
|
||||||
// Execute Trade
|
// Execute Trade
|
||||||
try {
|
try {
|
||||||
await TradeService.executeTrade(threadId);
|
await tradeService.executeTrade(threadId);
|
||||||
const embed = getTradeCompletedEmbed(session);
|
const embed = getTradeCompletedEmbed(session);
|
||||||
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,80 @@
|
|||||||
import type { TradeSession, TradeParticipant } from "./trade.types";
|
import type { TradeSession, TradeParticipant } from "./trade.types";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
import { itemTransactions } from "@/db/schema";
|
import { itemTransactions } from "@/db/schema";
|
||||||
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
|
||||||
export class TradeService {
|
// Module-level session storage
|
||||||
private static sessions = new Map<string, TradeSession>();
|
const sessions = new Map<string, TradeSession>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocks both participants in a trade session
|
||||||
|
*/
|
||||||
|
const unlockAll = (session: TradeSession) => {
|
||||||
|
session.userA.locked = false;
|
||||||
|
session.userB.locked = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a one-way transfer from one participant to another
|
||||||
|
*/
|
||||||
|
const processTransfer = async (tx: Transaction, 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})`,
|
||||||
|
to.id,
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
await economyService.modifyUserBalance(
|
||||||
|
to.id,
|
||||||
|
from.offer.money,
|
||||||
|
'TRADE_IN',
|
||||||
|
`Trade with ${from.username} (Thread: ${threadId})`,
|
||||||
|
from.id,
|
||||||
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tradeService = {
|
||||||
/**
|
/**
|
||||||
* Creates a new trade session
|
* Creates a new trade session
|
||||||
*/
|
*/
|
||||||
static createSession(threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession {
|
createSession: (threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession => {
|
||||||
const session: TradeSession = {
|
const session: TradeSession = {
|
||||||
threadId,
|
threadId,
|
||||||
userA: {
|
userA: {
|
||||||
@@ -30,24 +93,24 @@ export class TradeService {
|
|||||||
lastInteraction: Date.now()
|
lastInteraction: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(threadId, session);
|
sessions.set(threadId, session);
|
||||||
return session;
|
return session;
|
||||||
}
|
},
|
||||||
|
|
||||||
static getSession(threadId: string): TradeSession | undefined {
|
getSession: (threadId: string): TradeSession | undefined => {
|
||||||
return this.sessions.get(threadId);
|
return sessions.get(threadId);
|
||||||
}
|
},
|
||||||
|
|
||||||
static endSession(threadId: string) {
|
endSession: (threadId: string) => {
|
||||||
this.sessions.delete(threadId);
|
sessions.delete(threadId);
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an offer. If allowed, validation checks should be done BEFORE calling this.
|
* Updates an offer. If allowed, validation checks should be done BEFORE calling this.
|
||||||
* unlocking logic is handled here (if offer changes, unlock both).
|
* unlocking logic is handled here (if offer changes, unlock both).
|
||||||
*/
|
*/
|
||||||
static updateMoney(threadId: string, userId: string, amount: bigint) {
|
updateMoney: (threadId: string, userId: string, amount: bigint) => {
|
||||||
const session = this.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) throw new Error("Session not found");
|
if (!session) throw new Error("Session not found");
|
||||||
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
|
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
|
||||||
|
|
||||||
@@ -55,12 +118,12 @@ export class TradeService {
|
|||||||
if (!participant) throw new Error("User not in trade");
|
if (!participant) throw new Error("User not in trade");
|
||||||
|
|
||||||
participant.offer.money = amount;
|
participant.offer.money = amount;
|
||||||
this.unlockAll(session);
|
unlockAll(session);
|
||||||
session.lastInteraction = Date.now();
|
session.lastInteraction = Date.now();
|
||||||
}
|
},
|
||||||
|
|
||||||
static addItem(threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) {
|
addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
|
||||||
const session = this.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) throw new Error("Session not found");
|
if (!session) throw new Error("Session not found");
|
||||||
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
|
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
|
||||||
|
|
||||||
@@ -74,12 +137,12 @@ export class TradeService {
|
|||||||
participant.offer.items.push({ id: item.id, name: item.name, quantity });
|
participant.offer.items.push({ id: item.id, name: item.name, quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.unlockAll(session);
|
unlockAll(session);
|
||||||
session.lastInteraction = Date.now();
|
session.lastInteraction = Date.now();
|
||||||
}
|
},
|
||||||
|
|
||||||
static removeItem(threadId: string, userId: string, itemId: number) {
|
removeItem: (threadId: string, userId: string, itemId: number) => {
|
||||||
const session = this.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) throw new Error("Session not found");
|
if (!session) throw new Error("Session not found");
|
||||||
|
|
||||||
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
||||||
@@ -87,12 +150,12 @@ export class TradeService {
|
|||||||
|
|
||||||
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
|
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
|
||||||
|
|
||||||
this.unlockAll(session);
|
unlockAll(session);
|
||||||
session.lastInteraction = Date.now();
|
session.lastInteraction = Date.now();
|
||||||
}
|
},
|
||||||
|
|
||||||
static toggleLock(threadId: string, userId: string): boolean {
|
toggleLock: (threadId: string, userId: string): boolean => {
|
||||||
const session = this.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) throw new Error("Session not found");
|
if (!session) throw new Error("Session not found");
|
||||||
|
|
||||||
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
||||||
@@ -102,12 +165,7 @@ export class TradeService {
|
|||||||
session.lastInteraction = Date.now();
|
session.lastInteraction = Date.now();
|
||||||
|
|
||||||
return participant.locked;
|
return participant.locked;
|
||||||
}
|
},
|
||||||
|
|
||||||
private static unlockAll(session: TradeSession) {
|
|
||||||
session.userA.locked = false;
|
|
||||||
session.userB.locked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the trade atomically.
|
* Executes the trade atomically.
|
||||||
@@ -116,8 +174,8 @@ export class TradeService {
|
|||||||
* 3. Swaps items.
|
* 3. Swaps items.
|
||||||
* 4. Logs transactions.
|
* 4. Logs transactions.
|
||||||
*/
|
*/
|
||||||
static async executeTrade(threadId: string): Promise<void> {
|
executeTrade: async (threadId: string): Promise<void> => {
|
||||||
const session = this.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) throw new Error("Session not found");
|
if (!session) throw new Error("Session not found");
|
||||||
|
|
||||||
if (!session.userA.locked || !session.userB.locked) {
|
if (!session.userA.locked || !session.userB.locked) {
|
||||||
@@ -126,65 +184,14 @@ export class TradeService {
|
|||||||
|
|
||||||
session.state = 'COMPLETED'; // Prevent double execution
|
session.state = 'COMPLETED'; // Prevent double execution
|
||||||
|
|
||||||
await DrizzleClient.transaction(async (tx) => {
|
await withTransaction(async (tx) => {
|
||||||
// -- Validate & Execute User A -> User B --
|
// -- Validate & Execute User A -> User B --
|
||||||
await this.processTransfer(tx, session.userA, session.userB, session.threadId);
|
await processTransfer(tx, session.userA, session.userB, session.threadId);
|
||||||
|
|
||||||
// -- Validate & Execute User B -> User A --
|
// -- Validate & Execute User B -> User A --
|
||||||
await this.processTransfer(tx, session.userB, session.userA, session.threadId);
|
await processTransfer(tx, session.userB, session.userA, session.threadId);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.endSession(threadId);
|
tradeService.endSession(threadId);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
private static async processTransfer(tx: Transaction, 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})`,
|
|
||||||
to.id,
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
await economyService.modifyUserBalance(
|
|
||||||
to.id,
|
|
||||||
from.offer.money,
|
|
||||||
'TRADE_IN',
|
|
||||||
`Trade with ${from.username} (Thread: ${threadId})`,
|
|
||||||
from.id,
|
|
||||||
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}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user