From c2a34f398ea55313a29e3e73fe526bee303229ec Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Tue, 9 Dec 2025 23:26:33 +0000 Subject: [PATCH] Add server mode configuration and dynamic status updates Introduces a new server mode configuration system (Federation/Standalone) with associated command changes, dynamic status rotation for the bot, and adds new commands and features. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: b08e6ba5-7498-4b9f-b1c9-7dc11b362ddd Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/R9PkDi8 Replit-Helium-Checkpoint-Created: true --- .replit | 4 - aethex-bot/bot.js | 37 +- aethex-bot/commands/8ball.js | 61 +++ aethex-bot/commands/afk.js | 96 ++++ aethex-bot/commands/birthday.js | 187 +++++++ aethex-bot/commands/coinflip.js | 44 ++ aethex-bot/commands/color.js | 122 +++++ aethex-bot/commands/config.js | 92 +++- aethex-bot/commands/daily.js | 286 ++++++----- aethex-bot/commands/define.js | 76 +++ aethex-bot/commands/duel.js | 176 +++++++ aethex-bot/commands/federation.js | 11 + aethex-bot/commands/gift.js | 98 ++++ aethex-bot/commands/heist.js | 189 ++++++++ aethex-bot/commands/help.js | 136 ++++-- aethex-bot/commands/hug.js | 47 ++ aethex-bot/commands/inventory.js | 59 +++ aethex-bot/commands/leaderboard.js | 697 ++++++++++++++++----------- aethex-bot/commands/math.js | 67 +++ aethex-bot/commands/prestige.js | 287 ++++++++--- aethex-bot/commands/profile.js | 355 ++++++++------ aethex-bot/commands/qr.js | 40 ++ aethex-bot/commands/rank.js | 189 +++++--- aethex-bot/commands/refresh-roles.js | 12 + aethex-bot/commands/remind.js | 193 ++++++++ aethex-bot/commands/rep.js | 83 ++++ aethex-bot/commands/roll.js | 98 ++++ aethex-bot/commands/set-realm.js | 12 + aethex-bot/commands/slots.js | 127 +++++ aethex-bot/commands/starboard.js | 113 +++++ aethex-bot/commands/status.js | 57 ++- aethex-bot/commands/trade.js | 218 +++++++++ aethex-bot/commands/translate.js | 93 ++++ aethex-bot/commands/trivia.js | 283 +++++++++++ aethex-bot/commands/work.js | 76 +++ aethex-bot/events/guildSetup.js | 183 +++++++ aethex-bot/events/messageCreate.js | 11 +- aethex-bot/listeners/starboard.js | 127 +++++ aethex-bot/listeners/xpTracker.js | 207 ++++++++ aethex-bot/public/index.html | 122 ++++- aethex-bot/utils/modeHelper.js | 105 ++++ aethex-bot/utils/standaloneXp.js | 230 +++++++++ 42 files changed, 4960 insertions(+), 746 deletions(-) create mode 100644 aethex-bot/commands/8ball.js create mode 100644 aethex-bot/commands/afk.js create mode 100644 aethex-bot/commands/birthday.js create mode 100644 aethex-bot/commands/coinflip.js create mode 100644 aethex-bot/commands/color.js create mode 100644 aethex-bot/commands/define.js create mode 100644 aethex-bot/commands/duel.js create mode 100644 aethex-bot/commands/gift.js create mode 100644 aethex-bot/commands/heist.js create mode 100644 aethex-bot/commands/hug.js create mode 100644 aethex-bot/commands/inventory.js create mode 100644 aethex-bot/commands/math.js create mode 100644 aethex-bot/commands/qr.js create mode 100644 aethex-bot/commands/remind.js create mode 100644 aethex-bot/commands/rep.js create mode 100644 aethex-bot/commands/roll.js create mode 100644 aethex-bot/commands/slots.js create mode 100644 aethex-bot/commands/starboard.js create mode 100644 aethex-bot/commands/trade.js create mode 100644 aethex-bot/commands/translate.js create mode 100644 aethex-bot/commands/trivia.js create mode 100644 aethex-bot/commands/work.js create mode 100644 aethex-bot/events/guildSetup.js create mode 100644 aethex-bot/listeners/starboard.js create mode 100644 aethex-bot/utils/modeHelper.js create mode 100644 aethex-bot/utils/standaloneXp.js diff --git a/.replit b/.replit index 2adea26..9585c0f 100644 --- a/.replit +++ b/.replit @@ -21,10 +21,6 @@ externalPort = 80 localPort = 8080 externalPort = 8080 -[[ports]] -localPort = 34949 -externalPort = 3001 - [workflows] runButton = "Project" diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index 01347c2..7ecb390 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -718,7 +718,7 @@ if (fs.existsSync(sentinelPath)) { // ============================================================================= const listenersPath = path.join(__dirname, "listeners"); -const generalListenerFiles = ['welcome.js', 'goodbye.js', 'xpTracker.js', 'reactionXp.js', 'voiceXp.js']; +const generalListenerFiles = ['welcome.js', 'goodbye.js', 'xpTracker.js', 'reactionXp.js', 'voiceXp.js', 'starboard.js']; for (const file of generalListenerFiles) { const filePath = path.join(listenersPath, file); if (fs.existsSync(filePath)) { @@ -2499,6 +2499,36 @@ client.login(token).catch((error) => { process.exit(1); }); +// ============================================================================= +// DYNAMIC STATUS ROTATION +// ============================================================================= + +function startDynamicStatus(client) { + const statuses = [ + () => ({ name: `${client.guilds.cache.size} servers`, type: 3 }), // Watching + () => ({ name: `${client.guilds.cache.reduce((sum, g) => sum + g.memberCount, 0).toLocaleString()} members`, type: 3 }), // Watching + () => ({ name: '/help | aethex.studio', type: 0 }), // Playing + () => ({ name: 'šŸ›”ļø Guarding the Federation', type: 4 }), // Custom + () => ({ name: 'for threats', type: 3 }), // Watching + () => ({ name: 'āš”ļø Warden • Free Forever', type: 4 }), // Custom + ]; + + let currentIndex = 0; + + const updateStatus = () => { + try { + const status = statuses[currentIndex](); + client.user.setActivity(status.name, { type: status.type }); + currentIndex = (currentIndex + 1) % statuses.length; + } catch (e) { + console.error('[Status] Error updating status:', e.message); + } + }; + + updateStatus(); + setInterval(updateStatus, 30000); // Rotate every 30 seconds +} + client.once("clientReady", async () => { console.log(`Bot logged in as ${client.user.tag}`); console.log(`Bot ID: ${client.user.id}`); @@ -2520,13 +2550,14 @@ client.once("clientReady", async () => { console.error("Failed to register commands:", regResult.error); } - client.user.setActivity("Protecting the Federation", { type: 3 }); + // Dynamic rotating status + startDynamicStatus(client); if (setupFeedListener && supabase) { setupFeedListener(client); } - sendAlert(`AeThex Bot is now online! Watching ${client.guilds.cache.size} servers.`); + sendAlert(`Warden is now online! Watching ${client.guilds.cache.size} servers.`); }); // ============================================================================= diff --git a/aethex-bot/commands/8ball.js b/aethex-bot/commands/8ball.js new file mode 100644 index 0000000..23194ba --- /dev/null +++ b/aethex-bot/commands/8ball.js @@ -0,0 +1,61 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor } = require('../utils/modeHelper'); + +const RESPONSES = [ + { text: 'It is certain.', positive: true }, + { text: 'It is decidedly so.', positive: true }, + { text: 'Without a doubt.', positive: true }, + { text: 'Yes, definitely.', positive: true }, + { text: 'You may rely on it.', positive: true }, + { text: 'As I see it, yes.', positive: true }, + { text: 'Most likely.', positive: true }, + { text: 'Outlook good.', positive: true }, + { text: 'Yes.', positive: true }, + { text: 'Signs point to yes.', positive: true }, + { text: 'Reply hazy, try again.', positive: null }, + { text: 'Ask again later.', positive: null }, + { text: 'Better not tell you now.', positive: null }, + { text: 'Cannot predict now.', positive: null }, + { text: 'Concentrate and ask again.', positive: null }, + { text: "Don't count on it.", positive: false }, + { text: 'My reply is no.', positive: false }, + { text: 'My sources say no.', positive: false }, + { text: 'Outlook not so good.', positive: false }, + { text: 'Very doubtful.', positive: false }, +]; + +module.exports = { + data: new SlashCommandBuilder() + .setName('8ball') + .setDescription('Ask the magic 8-ball a question') + .addStringOption(option => + option.setName('question') + .setDescription('Your question for the 8-ball') + .setRequired(true) + .setMaxLength(256) + ), + + async execute(interaction, supabase, client) { + const question = interaction.options.getString('question'); + const response = RESPONSES[Math.floor(Math.random() * RESPONSES.length)]; + + const mode = await getServerMode(supabase, interaction.guildId); + + let color; + if (response.positive === true) color = 0x22C55E; + else if (response.positive === false) color = 0xEF4444; + else color = 0xF59E0B; + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle('šŸŽ± Magic 8-Ball') + .addFields( + { name: 'ā“ Question', value: question }, + { name: 'šŸ”® Answer', value: response.text } + ) + .setFooter({ text: `Asked by ${interaction.user.username}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/afk.js b/aethex-bot/commands/afk.js new file mode 100644 index 0000000..744f10c --- /dev/null +++ b/aethex-bot/commands/afk.js @@ -0,0 +1,96 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor } = require('../utils/modeHelper'); + +const afkUsers = new Map(); + +module.exports = { + data: new SlashCommandBuilder() + .setName('afk') + .setDescription('Set your AFK status') + .addStringOption(option => + option.setName('reason') + .setDescription('Reason for being AFK') + .setRequired(false) + .setMaxLength(200) + ), + + afkUsers, + + async execute(interaction, supabase, client) { + const reason = interaction.options.getString('reason') || 'AFK'; + const userId = interaction.user.id; + const guildId = interaction.guildId; + + const mode = await getServerMode(supabase, interaction.guildId); + + if (afkUsers.has(`${guildId}-${userId}`)) { + afkUsers.delete(`${guildId}-${userId}`); + + const embed = new EmbedBuilder() + .setColor(0x22C55E) + .setTitle('šŸ‘‹ Welcome Back!') + .setDescription('Your AFK status has been removed.') + .setTimestamp(); + + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + + afkUsers.set(`${guildId}-${userId}`, { + reason, + timestamp: Date.now(), + username: interaction.user.username + }); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('šŸ’¤ AFK Status Set') + .setDescription(`You are now AFK: **${reason}**`) + .addFields({ + name: 'šŸ’” Tip', + value: 'Use `/afk` again or send a message to remove your AFK status.' + }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, + + checkAfk(message) { + const guildId = message.guildId; + const userId = message.author.id; + const key = `${guildId}-${userId}`; + + if (afkUsers.has(key)) { + const afkData = afkUsers.get(key); + afkUsers.delete(key); + + const duration = Math.floor((Date.now() - afkData.timestamp) / 1000); + let timeStr; + if (duration < 60) timeStr = `${duration} seconds`; + else if (duration < 3600) timeStr = `${Math.floor(duration / 60)} minutes`; + else timeStr = `${Math.floor(duration / 3600)} hours`; + + message.reply({ + content: `šŸ‘‹ Welcome back! You were AFK for ${timeStr}.`, + allowedMentions: { repliedUser: false } + }).catch(() => {}); + } + + const mentions = message.mentions.users; + mentions.forEach(user => { + const mentionKey = `${guildId}-${user.id}`; + if (afkUsers.has(mentionKey)) { + const afkData = afkUsers.get(mentionKey); + const ago = Math.floor((Date.now() - afkData.timestamp) / 1000); + let timeStr; + if (ago < 60) timeStr = `${ago} seconds ago`; + else if (ago < 3600) timeStr = `${Math.floor(ago / 60)} minutes ago`; + else timeStr = `${Math.floor(ago / 3600)} hours ago`; + + message.reply({ + content: `šŸ’¤ **${afkData.username}** is AFK: ${afkData.reason} (set ${timeStr})`, + allowedMentions: { repliedUser: false } + }).catch(() => {}); + } + }); + } +}; diff --git a/aethex-bot/commands/birthday.js b/aethex-bot/commands/birthday.js new file mode 100644 index 0000000..be02e6e --- /dev/null +++ b/aethex-bot/commands/birthday.js @@ -0,0 +1,187 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('birthday') + .setDescription('Set or view birthdays') + .addSubcommand(subcommand => + subcommand + .setName('set') + .setDescription('Set your birthday') + .addIntegerOption(option => + option.setName('month') + .setDescription('Birth month (1-12)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(12) + ) + .addIntegerOption(option => + option.setName('day') + .setDescription('Birth day (1-31)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(31) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('view') + .setDescription('View someone\'s birthday') + .addUserOption(option => + option.setName('user') + .setDescription('The user to check') + .setRequired(false) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('upcoming') + .setDescription('View upcoming birthdays in this server') + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove your birthday') + ), + + async execute(interaction, supabase, client) { + const subcommand = interaction.options.getSubcommand(); + const mode = await getServerMode(supabase, interaction.guildId); + const guildId = interaction.guildId; + const userId = interaction.user.id; + + if (subcommand === 'set') { + const month = interaction.options.getInteger('month'); + const day = interaction.options.getInteger('day'); + + const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (day > daysInMonth[month - 1]) { + return interaction.reply({ + content: `Invalid day for month ${month}. Maximum is ${daysInMonth[month - 1]}.`, + ephemeral: true + }); + } + + if (supabase) { + try { + await supabase.from('birthdays').upsert({ + guild_id: guildId, + user_id: userId, + username: interaction.user.username, + month: month, + day: day, + updated_at: new Date().toISOString() + }, { onConflict: 'guild_id,user_id' }); + } catch (e) {} + } + + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('šŸŽ‚ Birthday Set!') + .setDescription(`Your birthday is now set to **${monthNames[month - 1]} ${day}**!`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + + else if (subcommand === 'view') { + const targetUser = interaction.options.getUser('user') || interaction.user; + + if (!supabase) { + return interaction.reply({ content: 'Birthday system unavailable.', ephemeral: true }); + } + + const { data } = await supabase + .from('birthdays') + .select('month, day') + .eq('guild_id', guildId) + .eq('user_id', targetUser.id) + .maybeSingle(); + + if (!data) { + return interaction.reply({ + content: targetUser.id === userId + ? 'You haven\'t set your birthday yet! Use `/birthday set`.' + : `${targetUser.username} hasn't set their birthday.`, + ephemeral: true + }); + } + + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('šŸŽ‚ Birthday') + .setDescription(`**${targetUser.username}**'s birthday is on **${monthNames[data.month - 1]} ${data.day}**!`) + .setThumbnail(targetUser.displayAvatarURL({ size: 128 })) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + + else if (subcommand === 'upcoming') { + if (!supabase) { + return interaction.reply({ content: 'Birthday system unavailable.', ephemeral: true }); + } + + const { data } = await supabase + .from('birthdays') + .select('user_id, username, month, day') + .eq('guild_id', guildId); + + if (!data || data.length === 0) { + return interaction.reply({ content: 'No birthdays set in this server yet!', ephemeral: true }); + } + + const now = new Date(); + const currentMonth = now.getMonth() + 1; + const currentDay = now.getDate(); + + const sorted = data.map(b => { + let daysUntil = (b.month - currentMonth) * 30 + (b.day - currentDay); + if (daysUntil < 0) daysUntil += 365; + return { ...b, daysUntil }; + }).sort((a, b) => a.daysUntil - b.daysUntil).slice(0, 10); + + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + const list = sorted.map((b, i) => { + const emoji = b.daysUntil === 0 ? 'šŸŽ‰' : 'šŸŽ‚'; + const status = b.daysUntil === 0 ? '**TODAY!**' : `in ${b.daysUntil} days`; + return `${i + 1}. ${emoji} **${b.username}** - ${monthNames[b.month - 1]} ${b.day} (${status})`; + }).join('\n'); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('šŸŽ‚ Upcoming Birthdays') + .setDescription(list || 'No upcoming birthdays.') + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + + else if (subcommand === 'remove') { + if (supabase) { + await supabase + .from('birthdays') + .delete() + .eq('guild_id', guildId) + .eq('user_id', userId); + } + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('šŸ—‘ļø Birthday Removed') + .setDescription('Your birthday has been removed from this server.') + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + }, +}; diff --git a/aethex-bot/commands/coinflip.js b/aethex-bot/commands/coinflip.js new file mode 100644 index 0000000..bb4107a --- /dev/null +++ b/aethex-bot/commands/coinflip.js @@ -0,0 +1,44 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor } = require('../utils/modeHelper'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('coinflip') + .setDescription('Flip a coin') + .addStringOption(option => + option.setName('call') + .setDescription('Call heads or tails before the flip') + .setRequired(false) + .addChoices( + { name: 'Heads', value: 'heads' }, + { name: 'Tails', value: 'tails' } + ) + ), + + async execute(interaction, supabase, client) { + const call = interaction.options.getString('call'); + const result = Math.random() < 0.5 ? 'heads' : 'tails'; + const emoji = result === 'heads' ? 'šŸŖ™' : 'šŸ’æ'; + + const mode = await getServerMode(supabase, interaction.guildId); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle(`${emoji} Coin Flip`) + .setDescription(`The coin landed on **${result.toUpperCase()}**!`) + .setTimestamp(); + + if (call) { + const won = call === result; + embed.addFields({ + name: won ? 'šŸŽ‰ Result' : 'šŸ˜” Result', + value: won ? `You called it! You win!` : `You called ${call}, better luck next time!` + }); + embed.setColor(won ? 0x22C55E : 0xEF4444); + } + + embed.setFooter({ text: `Flipped by ${interaction.user.username}` }); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/color.js b/aethex-bot/commands/color.js new file mode 100644 index 0000000..3479cdf --- /dev/null +++ b/aethex-bot/commands/color.js @@ -0,0 +1,122 @@ +const { SlashCommandBuilder, EmbedBuilder, AttachmentBuilder } = require('discord.js'); +const { getServerMode } = require('../utils/modeHelper'); + +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +} + +function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + } + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100) + }; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName('color') + .setDescription('View color information') + .addStringOption(option => + option.setName('hex') + .setDescription('Hex color code (e.g., #FF5733 or FF5733)') + .setRequired(false) + ) + .addIntegerOption(option => + option.setName('red') + .setDescription('Red value (0-255)') + .setRequired(false) + .setMinValue(0) + .setMaxValue(255) + ) + .addIntegerOption(option => + option.setName('green') + .setDescription('Green value (0-255)') + .setRequired(false) + .setMinValue(0) + .setMaxValue(255) + ) + .addIntegerOption(option => + option.setName('blue') + .setDescription('Blue value (0-255)') + .setRequired(false) + .setMinValue(0) + .setMaxValue(255) + ), + + async execute(interaction, supabase, client) { + const hexInput = interaction.options.getString('hex'); + const red = interaction.options.getInteger('red'); + const green = interaction.options.getInteger('green'); + const blue = interaction.options.getInteger('blue'); + + let r, g, b, hexColor; + + if (hexInput) { + const rgb = hexToRgb(hexInput); + if (!rgb) { + return interaction.reply({ + content: 'Invalid hex color. Use format like #FF5733 or FF5733.', + ephemeral: true + }); + } + r = rgb.r; + g = rgb.g; + b = rgb.b; + hexColor = hexInput.replace('#', '').toUpperCase(); + } else if (red !== null && green !== null && blue !== null) { + r = red; + g = green; + b = blue; + hexColor = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase(); + } else if (red !== null || green !== null || blue !== null) { + return interaction.reply({ + content: 'Please provide all RGB values (red, green, blue) or use a hex code.', + ephemeral: true + }); + } else { + r = Math.floor(Math.random() * 256); + g = Math.floor(Math.random() * 256); + b = Math.floor(Math.random() * 256); + hexColor = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase(); + } + + const hsl = rgbToHsl(r, g, b); + const colorInt = parseInt(hexColor, 16); + + const embed = new EmbedBuilder() + .setColor(colorInt) + .setTitle(`šŸŽØ Color: #${hexColor}`) + .addFields( + { name: 'Hex', value: `\`#${hexColor}\``, inline: true }, + { name: 'RGB', value: `\`rgb(${r}, ${g}, ${b})\``, inline: true }, + { name: 'HSL', value: `\`hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)\``, inline: true }, + { name: 'Integer', value: `\`${colorInt}\``, inline: true } + ) + .setThumbnail(`https://singlecolorimage.com/get/${hexColor}/100x100`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/config.js b/aethex-bot/commands/config.js index 6c851c3..44ce4ea 100644 --- a/aethex-bot/commands/config.js +++ b/aethex-bot/commands/config.js @@ -1,4 +1,5 @@ -const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ChannelType } = require('discord.js'); +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { getServerMode, setServerMode, getEmbedColor, getModeDisplayName, getModeEmoji, EMBED_COLORS } = require('../utils/modeHelper'); module.exports = { data: new SlashCommandBuilder() @@ -73,6 +74,10 @@ module.exports = { .setMinValue(1) .setMaxValue(100) ) + ) + .addSubcommand(sub => + sub.setName('mode') + .setDescription('Switch between Federation and Standalone mode') ), async execute(interaction, supabase, client) { @@ -84,6 +89,86 @@ module.exports = { await interaction.deferReply({ ephemeral: true }); try { + if (subcommand === 'mode') { + const currentMode = await getServerMode(supabase, interaction.guildId); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(currentMode)) + .setTitle('Server Mode Configuration') + .setDescription( + `${getModeEmoji(currentMode)} Currently running in **${getModeDisplayName(currentMode)}** mode\n\n` + + 'Choose your server mode:' + ) + .addFields( + { + name: '🌐 Federation Mode', + value: '• Unified XP across all AeThex servers\n• Cross-server profiles and leaderboards\n• Realm selection and role sync', + inline: true + }, + { + name: 'šŸ  Standalone Mode', + value: '• Isolated XP system for this server\n• Local leaderboards only\n• Full moderation features', + inline: true + } + ) + .setFooter({ text: 'This affects how XP and profiles work in your server' }); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('config_mode_federated') + .setLabel('Federation') + .setStyle(currentMode === 'federated' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setEmoji('🌐') + .setDisabled(currentMode === 'federated'), + new ButtonBuilder() + .setCustomId('config_mode_standalone') + .setLabel('Standalone') + .setStyle(currentMode === 'standalone' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setEmoji('šŸ ') + .setDisabled(currentMode === 'standalone') + ); + + const response = await interaction.editReply({ embeds: [embed], components: [row] }); + + const collector = response.createMessageComponentCollector({ time: 60000 }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + return i.reply({ content: 'Only the command user can change this.', ephemeral: true }); + } + + const newMode = i.customId === 'config_mode_federated' ? 'federated' : 'standalone'; + const success = await setServerMode(supabase, interaction.guildId, newMode); + + if (success) { + const confirmEmbed = new EmbedBuilder() + .setColor(getEmbedColor(newMode)) + .setTitle(`${getModeEmoji(newMode)} Mode Changed!`) + .setDescription( + `Server is now running in **${getModeDisplayName(newMode)}** mode.\n\n` + + (newMode === 'federated' + ? 'XP will now count globally across the AeThex ecosystem.' + : 'XP is now tracked locally for this server only.') + ) + .setTimestamp(); + + await i.update({ embeds: [confirmEmbed], components: [] }); + collector.stop(); + } else { + await i.reply({ content: 'Failed to change mode. Please try again.', ephemeral: true }); + } + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + await interaction.editReply({ components: [] }).catch(() => {}); + } + }); + + return; + } + if (subcommand === 'view') { const { data: config } = await supabase .from('server_config') @@ -91,10 +176,13 @@ module.exports = { .eq('guild_id', interaction.guildId) .single(); + const currentMode = config?.mode || 'federated'; + const embed = new EmbedBuilder() - .setColor(0x7c3aed) + .setColor(getEmbedColor(currentMode)) .setTitle('Server Configuration') .addFields( + { name: 'Server Mode', value: `${getModeEmoji(currentMode)} ${getModeDisplayName(currentMode)}`, inline: true }, { name: 'Welcome Channel', value: config?.welcome_channel ? `<#${config.welcome_channel}>` : 'Not set', inline: true }, { name: 'Goodbye Channel', value: config?.goodbye_channel ? `<#${config.goodbye_channel}>` : 'Not set', inline: true }, { name: 'Mod Log Channel', value: config?.modlog_channel ? `<#${config.modlog_channel}>` : 'Not set', inline: true }, diff --git a/aethex-bot/commands/daily.js b/aethex-bot/commands/daily.js index 21be54d..e31928f 100644 --- a/aethex-bot/commands/daily.js +++ b/aethex-bot/commands/daily.js @@ -1,6 +1,8 @@ const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); const { checkAchievements } = require('./achievements'); const { getUserStats, calculateLevel, updateQuestProgress } = require('../listeners/xpTracker'); +const { getServerMode, EMBED_COLORS } = require('../utils/modeHelper'); +const { claimStandaloneDaily, getStandaloneXp, calculateLevel: calcStandaloneLevel } = require('../utils/standaloneXp'); const DAILY_XP = 50; const STREAK_BONUS = 10; @@ -19,129 +21,13 @@ module.exports = { await interaction.deferReply(); try { - const { data: link } = await supabase - .from('discord_links') - .select('user_id') - .eq('discord_id', interaction.user.id) - .single(); + const mode = await getServerMode(supabase, interaction.guildId); - if (!link) { - return interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setColor(0xff6b6b) - .setDescription('You need to link your account first! Use `/verify` to get started.') - ] - }); + if (mode === 'standalone') { + return handleStandaloneDaily(interaction, supabase); + } else { + return handleFederatedDaily(interaction, supabase, client); } - - const { data: profile } = await supabase - .from('user_profiles') - .select('xp, daily_streak, last_daily, prestige_level, total_xp_earned') - .eq('id', link.user_id) - .single(); - - const now = new Date(); - const lastDaily = profile?.last_daily ? new Date(profile.last_daily) : null; - const currentXp = profile?.xp || 0; - const prestige = profile?.prestige_level || 0; - let streak = profile?.daily_streak || 0; - - if (lastDaily) { - const hoursSince = (now - lastDaily) / (1000 * 60 * 60); - - if (hoursSince < 20) { - const nextClaim = new Date(lastDaily.getTime() + 20 * 60 * 60 * 1000); - return interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setColor(0xfbbf24) - .setTitle('Already Claimed!') - .setDescription(`You've already claimed your daily XP.\nNext claim: `) - .addFields({ name: 'Current Streak', value: `šŸ”„ ${streak} days` }) - ] - }); - } - - if (hoursSince > 48) { - streak = 0; - } - } - - streak += 1; - const streakBonus = Math.min(streak * STREAK_BONUS, MAX_STREAK_BONUS); - - // Prestige level 4+ gets bonus daily XP (+25) - const prestigeDailyBonus = prestige >= 4 ? 25 : 0; - - // Base total before prestige multiplier - let totalXp = DAILY_XP + streakBonus + prestigeDailyBonus; - - // Apply prestige XP bonus (+5% per prestige level) - if (prestige > 0) { - const prestigeMultiplier = 1 + (prestige * 0.05); - totalXp = Math.floor(totalXp * prestigeMultiplier); - } - - const newXp = currentXp + totalXp; - const totalEarned = (profile?.total_xp_earned || currentXp) + totalXp; - - await supabase - .from('user_profiles') - .update({ - xp: newXp, - daily_streak: streak, - last_daily: now.toISOString(), - total_xp_earned: totalEarned - }) - .eq('id', link.user_id); - - const newLevel = Math.floor(Math.sqrt(newXp / 100)); - const oldLevel = Math.floor(Math.sqrt(currentXp / 100)); - - const embed = new EmbedBuilder() - .setColor(prestige > 0 ? getPrestigeColor(prestige) : 0x00ff00) - .setTitle('Daily Reward Claimed!') - .setDescription(`You received **+${totalXp} XP**!${prestige > 0 ? ` *(includes P${prestige} bonus)*` : ''}`) - .addFields( - { name: 'Base XP', value: `+${DAILY_XP}`, inline: true }, - { name: 'Streak Bonus', value: `+${streakBonus}`, inline: true }, - { name: 'Current Streak', value: `šŸ”„ ${streak} days`, inline: true }, - { name: 'Total XP', value: newXp.toLocaleString(), inline: true }, - { name: 'Level', value: `${newLevel}`, inline: true } - ); - - if (prestige > 0) { - embed.addFields({ name: 'Prestige Bonus', value: `+${prestige * 5}% XP${prestigeDailyBonus > 0 ? ` + ${prestigeDailyBonus} daily bonus` : ''}`, inline: true }); - } - - embed.setFooter({ text: 'Come back tomorrow to keep your streak!' }) - .setTimestamp(); - - if (newLevel > oldLevel) { - embed.addFields({ name: 'šŸŽ‰ Level Up!', value: `You reached level ${newLevel}!` }); - } - - await interaction.editReply({ embeds: [embed] }); - - // Check achievements with updated stats - const guildId = interaction.guildId; - const stats = await getUserStats(supabase, link.user_id, guildId); - stats.level = newLevel; - stats.prestige = prestige; - stats.totalXp = totalEarned; - stats.dailyStreak = streak; - - await checkAchievements(link.user_id, interaction.member, stats, supabase, guildId, client); - - // Track quest progress for daily claims and XP earned - await updateQuestProgress(supabase, link.user_id, guildId, 'daily_claims', 1); - await updateQuestProgress(supabase, link.user_id, guildId, 'xp_earned', totalXp); - - if (newLevel > oldLevel) { - await updateQuestProgress(supabase, link.user_id, guildId, 'level_ups', 1); - } - } catch (error) { console.error('Daily error:', error); await interaction.editReply({ content: 'Failed to claim daily reward.' }); @@ -149,6 +35,164 @@ module.exports = { }, }; +async function handleStandaloneDaily(interaction, supabase) { + const result = await claimStandaloneDaily( + supabase, + interaction.user.id, + interaction.guildId, + interaction.user.username + ); + + if (!result.success) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.warning) + .setTitle('Already Claimed!') + .setDescription(result.message) + ] + }); + } + + const level = calcStandaloneLevel(result.totalXp, 'normal'); + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('Daily Reward Claimed!') + .setDescription(`You received **+${result.xpGained} XP**!`) + .addFields( + { name: 'Base XP', value: `+50`, inline: true }, + { name: 'Streak Bonus', value: `+${Math.min((result.streak - 1) * 5, 100)}`, inline: true }, + { name: 'Current Streak', value: `${result.streak} days`, inline: true }, + { name: 'Total XP', value: result.totalXp.toLocaleString(), inline: true }, + { name: 'Level', value: `${level}`, inline: true } + ) + .setFooter({ text: `šŸ  Standalone Mode • Come back tomorrow!` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleFederatedDaily(interaction, supabase, client) { + const { data: link } = await supabase + .from('discord_links') + .select('user_id') + .eq('discord_id', interaction.user.id) + .single(); + + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('You need to link your account first! Use `/verify` to get started.') + ] + }); + } + + const { data: profile } = await supabase + .from('user_profiles') + .select('xp, daily_streak, last_daily, prestige_level, total_xp_earned') + .eq('id', link.user_id) + .single(); + + const now = new Date(); + const lastDaily = profile?.last_daily ? new Date(profile.last_daily) : null; + const currentXp = profile?.xp || 0; + const prestige = profile?.prestige_level || 0; + let streak = profile?.daily_streak || 0; + + if (lastDaily) { + const hoursSince = (now - lastDaily) / (1000 * 60 * 60); + + if (hoursSince < 20) { + const nextClaim = new Date(lastDaily.getTime() + 20 * 60 * 60 * 1000); + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('Already Claimed!') + .setDescription(`You've already claimed your daily XP.\nNext claim: `) + .addFields({ name: 'Current Streak', value: `${streak} days` }) + ] + }); + } + + if (hoursSince > 48) { + streak = 0; + } + } + + streak += 1; + const streakBonus = Math.min(streak * STREAK_BONUS, MAX_STREAK_BONUS); + + const prestigeDailyBonus = prestige >= 4 ? 25 : 0; + + let totalXp = DAILY_XP + streakBonus + prestigeDailyBonus; + + if (prestige > 0) { + const prestigeMultiplier = 1 + (prestige * 0.05); + totalXp = Math.floor(totalXp * prestigeMultiplier); + } + + const newXp = currentXp + totalXp; + const totalEarned = (profile?.total_xp_earned || currentXp) + totalXp; + + await supabase + .from('user_profiles') + .update({ + xp: newXp, + daily_streak: streak, + last_daily: now.toISOString(), + total_xp_earned: totalEarned + }) + .eq('id', link.user_id); + + const newLevel = Math.floor(Math.sqrt(newXp / 100)); + const oldLevel = Math.floor(Math.sqrt(currentXp / 100)); + + const embed = new EmbedBuilder() + .setColor(prestige > 0 ? getPrestigeColor(prestige) : 0x00ff00) + .setTitle('Daily Reward Claimed!') + .setDescription(`You received **+${totalXp} XP**!${prestige > 0 ? ` *(includes P${prestige} bonus)*` : ''}`) + .addFields( + { name: 'Base XP', value: `+${DAILY_XP}`, inline: true }, + { name: 'Streak Bonus', value: `+${streakBonus}`, inline: true }, + { name: 'Current Streak', value: `${streak} days`, inline: true }, + { name: 'Total XP', value: newXp.toLocaleString(), inline: true }, + { name: 'Level', value: `${newLevel}`, inline: true } + ); + + if (prestige > 0) { + embed.addFields({ name: 'Prestige Bonus', value: `+${prestige * 5}% XP${prestigeDailyBonus > 0 ? ` + ${prestigeDailyBonus} daily bonus` : ''}`, inline: true }); + } + + embed.setFooter({ text: '🌐 Federation • Come back tomorrow to keep your streak!' }) + .setTimestamp(); + + if (newLevel > oldLevel) { + embed.addFields({ name: 'Level Up!', value: `You reached level ${newLevel}!` }); + } + + await interaction.editReply({ embeds: [embed] }); + + const guildId = interaction.guildId; + const stats = await getUserStats(supabase, link.user_id, guildId); + stats.level = newLevel; + stats.prestige = prestige; + stats.totalXp = totalEarned; + stats.dailyStreak = streak; + + await checkAchievements(link.user_id, interaction.member, stats, supabase, guildId, client); + + await updateQuestProgress(supabase, link.user_id, guildId, 'daily_claims', 1); + await updateQuestProgress(supabase, link.user_id, guildId, 'xp_earned', totalXp); + + if (newLevel > oldLevel) { + await updateQuestProgress(supabase, link.user_id, guildId, 'level_ups', 1); + } +} + function getPrestigeColor(level) { const colors = [0x6b7280, 0xcd7f32, 0xc0c0c0, 0xffd700, 0xe5e4e2, 0xb9f2ff, 0xff4500, 0x9400d3, 0xffd700, 0xff69b4, 0x7c3aed]; return colors[Math.min(level, 10)] || 0x00ff00; diff --git a/aethex-bot/commands/define.js b/aethex-bot/commands/define.js new file mode 100644 index 0000000..0b5378c --- /dev/null +++ b/aethex-bot/commands/define.js @@ -0,0 +1,76 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('define') + .setDescription('Look up the definition of a word') + .addStringOption(option => + option.setName('word') + .setDescription('The word to define') + .setRequired(true) + .setMaxLength(100) + ), + + async execute(interaction, supabase, client) { + const word = interaction.options.getString('word').toLowerCase().trim(); + const mode = await getServerMode(supabase, interaction.guildId); + + await interaction.deferReply(); + + try { + const response = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`); + + if (!response.ok) { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setTitle('šŸ“– Word Not Found') + .setDescription(`Could not find a definition for "**${word}**".`) + .setTimestamp(); + return interaction.editReply({ embeds: [embed] }); + } + + const data = await response.json(); + const entry = data[0]; + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle(`šŸ“– ${entry.word}`) + .setTimestamp(); + + if (entry.phonetic) { + embed.setDescription(`*${entry.phonetic}*`); + } + + const meanings = entry.meanings.slice(0, 3); + for (const meaning of meanings) { + const definitions = meaning.definitions.slice(0, 2); + const defText = definitions.map((d, i) => { + let text = `${i + 1}. ${d.definition}`; + if (d.example) { + text += `\n *"${d.example}"*`; + } + return text; + }).join('\n'); + + embed.addFields({ + name: `${meaning.partOfSpeech}`, + value: defText.substring(0, 1024) + }); + } + + if (entry.sourceUrls && entry.sourceUrls[0]) { + embed.setFooter({ text: 'Source: Wiktionary' }); + } + + await interaction.editReply({ embeds: [embed] }); + } catch (e) { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setTitle('šŸ“– Error') + .setDescription('Failed to fetch definition. Please try again.') + .setTimestamp(); + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/aethex-bot/commands/duel.js b/aethex-bot/commands/duel.js new file mode 100644 index 0000000..b25af71 --- /dev/null +++ b/aethex-bot/commands/duel.js @@ -0,0 +1,176 @@ +const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); +const { updateStandaloneXp } = require('../utils/standaloneXp'); + +const activeDuels = new Map(); + +module.exports = { + data: new SlashCommandBuilder() + .setName('duel') + .setDescription('Challenge someone to a duel!') + .addUserOption(option => + option.setName('opponent') + .setDescription('The user to challenge') + .setRequired(true) + ) + .addIntegerOption(option => + option.setName('bet') + .setDescription('XP to bet (0-100)') + .setRequired(false) + .setMinValue(0) + .setMaxValue(100) + ), + + async execute(interaction, supabase, client) { + const opponent = interaction.options.getUser('opponent'); + const bet = interaction.options.getInteger('bet') || 0; + const challenger = interaction.user; + const mode = await getServerMode(supabase, interaction.guildId); + const guildId = interaction.guildId; + + if (opponent.id === challenger.id) { + return interaction.reply({ content: "You can't duel yourself!", ephemeral: true }); + } + + if (opponent.bot) { + return interaction.reply({ content: "You can't duel bots!", ephemeral: true }); + } + + const duelKey = `${guildId}-${challenger.id}`; + if (activeDuels.has(duelKey)) { + return interaction.reply({ content: 'You already have an active duel!', ephemeral: true }); + } + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('āš”ļø Duel Challenge!') + .setDescription(`${challenger} challenges ${opponent} to a duel!`) + .addFields( + { name: 'šŸ’° Bet', value: bet > 0 ? `${bet} XP` : 'No bet', inline: true }, + { name: 'ā±ļø Expires', value: 'In 60 seconds', inline: true } + ) + .setTimestamp(); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`duel_accept_${challenger.id}_${opponent.id}`) + .setLabel('Accept') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`duel_decline_${challenger.id}_${opponent.id}`) + .setLabel('Decline') + .setStyle(ButtonStyle.Danger) + ); + + const message = await interaction.reply({ + content: `${opponent}`, + embeds: [embed], + components: [row], + fetchReply: true + }); + + activeDuels.set(duelKey, { opponent: opponent.id, bet }); + + const collector = message.createMessageComponentCollector({ + filter: i => i.user.id === opponent.id && i.customId.includes(challenger.id), + time: 60000, + max: 1 + }); + + collector.on('collect', async (i) => { + activeDuels.delete(duelKey); + + if (i.customId.startsWith('duel_decline')) { + const declineEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setTitle('āš”ļø Duel Declined') + .setDescription(`${opponent} declined the duel challenge.`) + .setTimestamp(); + + await i.update({ embeds: [declineEmbed], components: [] }); + return; + } + + const challengerRoll = Math.floor(Math.random() * 100) + 1; + const opponentRoll = Math.floor(Math.random() * 100) + 1; + + let winner, loser, winnerRoll, loserRoll; + if (challengerRoll > opponentRoll) { + winner = challenger; + loser = opponent; + winnerRoll = challengerRoll; + loserRoll = opponentRoll; + } else if (opponentRoll > challengerRoll) { + winner = opponent; + loser = challenger; + winnerRoll = opponentRoll; + loserRoll = challengerRoll; + } else { + const tieEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.warning) + .setTitle('āš”ļø It\'s a Tie!') + .setDescription(`Both rolled **${challengerRoll}**! Nobody wins.`) + .addFields( + { name: `${challenger.username}`, value: `šŸŽ² ${challengerRoll}`, inline: true }, + { name: `${opponent.username}`, value: `šŸŽ² ${opponentRoll}`, inline: true } + ) + .setTimestamp(); + + await i.update({ embeds: [tieEmbed], components: [] }); + return; + } + + if (bet > 0) { + if (mode === 'standalone') { + await updateStandaloneXp(supabase, winner.id, guildId, bet, winner.username); + } else if (supabase) { + try { + const { data: winnerProfile } = await supabase + .from('user_profiles') + .select('xp') + .eq('discord_id', winner.id) + .maybeSingle(); + + if (winnerProfile) { + await supabase + .from('user_profiles') + .update({ xp: (winnerProfile.xp || 0) + bet }) + .eq('discord_id', winner.id); + } + } catch (e) {} + } + } + + const resultEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('āš”ļø Duel Results!') + .setDescription(`šŸ† **${winner.username}** wins the duel!`) + .addFields( + { name: `${challenger.username}`, value: `šŸŽ² Rolled: **${challengerRoll}**`, inline: true }, + { name: `${opponent.username}`, value: `šŸŽ² Rolled: **${opponentRoll}**`, inline: true } + ) + .setTimestamp(); + + if (bet > 0) { + resultEmbed.addFields({ name: 'šŸ’° Reward', value: `${winner.username} wins **${bet} XP**!` }); + } + + await i.update({ embeds: [resultEmbed], components: [] }); + }); + + collector.on('end', async (collected) => { + if (collected.size === 0) { + activeDuels.delete(duelKey); + + const timeoutEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.warning) + .setTitle('āš”ļø Duel Expired') + .setDescription(`${opponent} didn't respond in time.`) + .setTimestamp(); + + await interaction.editReply({ embeds: [timeoutEmbed], components: [] }); + } + }); + }, +}; diff --git a/aethex-bot/commands/federation.js b/aethex-bot/commands/federation.js index 0f2d544..678aa32 100644 --- a/aethex-bot/commands/federation.js +++ b/aethex-bot/commands/federation.js @@ -1,4 +1,5 @@ const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); +const { getServerMode, EMBED_COLORS } = require('../utils/modeHelper'); module.exports = { data: new SlashCommandBuilder() @@ -32,6 +33,16 @@ module.exports = { ), async execute(interaction, supabase, client) { + const mode = await getServerMode(supabase, interaction.guildId); + + if (mode === 'standalone') { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setTitle('šŸ  Standalone Mode') + .setDescription('Federation features are disabled in standalone mode.\n\nThis server operates independently and does not sync roles across the AeThex network.\n\nUse `/config mode` to switch to federated mode.'); + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + const subcommand = interaction.options.getSubcommand(); if (subcommand === 'link') { diff --git a/aethex-bot/commands/gift.js b/aethex-bot/commands/gift.js new file mode 100644 index 0000000..30a0a09 --- /dev/null +++ b/aethex-bot/commands/gift.js @@ -0,0 +1,98 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); +const { updateStandaloneXp, getStandaloneXp } = require('../utils/standaloneXp'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('gift') + .setDescription('Gift XP to another user') + .addUserOption(option => + option.setName('user') + .setDescription('User to gift XP to') + .setRequired(true) + ) + .addIntegerOption(option => + option.setName('amount') + .setDescription('Amount of XP to gift') + .setRequired(true) + .setMinValue(1) + .setMaxValue(1000) + ), + + async execute(interaction, supabase, client) { + const recipient = interaction.options.getUser('user'); + const amount = interaction.options.getInteger('amount'); + const sender = interaction.user; + const mode = await getServerMode(supabase, interaction.guildId); + const guildId = interaction.guildId; + + if (recipient.id === sender.id) { + return interaction.reply({ content: "You can't gift XP to yourself!", ephemeral: true }); + } + + if (recipient.bot) { + return interaction.reply({ content: "You can't gift XP to bots!", ephemeral: true }); + } + + if (!supabase) { + return interaction.reply({ content: 'Gift system unavailable.', ephemeral: true }); + } + + try { + let senderXp = 0; + + if (mode === 'standalone') { + const senderData = await getStandaloneXp(supabase, sender.id, guildId); + senderXp = senderData?.xp || 0; + } else { + const { data } = await supabase + .from('user_profiles') + .select('xp') + .eq('discord_id', sender.id) + .maybeSingle(); + senderXp = data?.xp || 0; + } + + if (senderXp < amount) { + return interaction.reply({ + content: `You don't have enough XP! You have ${senderXp} XP.`, + ephemeral: true + }); + } + + if (mode === 'standalone') { + await updateStandaloneXp(supabase, sender.id, guildId, -amount, sender.username); + await updateStandaloneXp(supabase, recipient.id, guildId, amount, recipient.username); + } else { + await supabase + .from('user_profiles') + .update({ xp: senderXp - amount }) + .eq('discord_id', sender.id); + + const { data: recipientData } = await supabase + .from('user_profiles') + .select('xp') + .eq('discord_id', recipient.id) + .maybeSingle(); + + if (recipientData) { + await supabase + .from('user_profiles') + .update({ xp: (recipientData.xp || 0) + amount }) + .eq('discord_id', recipient.id); + } + } + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('šŸŽ Gift Sent!') + .setDescription(`${sender} gifted **${amount} XP** to ${recipient}!`) + .setThumbnail(recipient.displayAvatarURL({ size: 128 })) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } catch (e) { + await interaction.reply({ content: 'Failed to send gift.', ephemeral: true }); + } + }, +}; diff --git a/aethex-bot/commands/heist.js b/aethex-bot/commands/heist.js new file mode 100644 index 0000000..6e0cbef --- /dev/null +++ b/aethex-bot/commands/heist.js @@ -0,0 +1,189 @@ +const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); +const { updateStandaloneXp } = require('../utils/standaloneXp'); + +const activeHeists = new Map(); + +const TARGETS = [ + { name: 'Corner Store', emoji: 'šŸŖ', difficulty: 0.7, minReward: 20, maxReward: 50 }, + { name: 'Gas Station', emoji: '⛽', difficulty: 0.6, minReward: 30, maxReward: 70 }, + { name: 'Jewelry Store', emoji: 'šŸ’Ž', difficulty: 0.5, minReward: 50, maxReward: 120 }, + { name: 'Bank', emoji: 'šŸ¦', difficulty: 0.4, minReward: 80, maxReward: 200 }, + { name: 'Casino', emoji: 'šŸŽ°', difficulty: 0.3, minReward: 100, maxReward: 300 }, + { name: 'Federal Reserve', emoji: 'šŸ›ļø', difficulty: 0.2, minReward: 150, maxReward: 500 }, +]; + +module.exports = { + data: new SlashCommandBuilder() + .setName('heist') + .setDescription('Start a group heist!') + .addStringOption(option => + option.setName('target') + .setDescription('Choose your heist target') + .setRequired(true) + .addChoices( + { name: 'šŸŖ Corner Store (Easy)', value: '0' }, + { name: '⛽ Gas Station', value: '1' }, + { name: 'šŸ’Ž Jewelry Store', value: '2' }, + { name: 'šŸ¦ Bank (Medium)', value: '3' }, + { name: 'šŸŽ° Casino', value: '4' }, + { name: 'šŸ›ļø Federal Reserve (Hard)', value: '5' } + ) + ), + + async execute(interaction, supabase, client) { + const targetIndex = parseInt(interaction.options.getString('target')); + const target = TARGETS[targetIndex]; + const leader = interaction.user; + const mode = await getServerMode(supabase, interaction.guildId); + const guildId = interaction.guildId; + + const heistKey = `${guildId}-heist`; + if (activeHeists.has(heistKey)) { + return interaction.reply({ + content: 'There\'s already an active heist in this server! Wait for it to finish.', + ephemeral: true + }); + } + + const participants = new Set([leader.id]); + activeHeists.set(heistKey, { + target, + leader: leader.id, + participants, + usernames: { [leader.id]: leader.username } + }); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle(`${target.emoji} Heist: ${target.name}`) + .setDescription(`${leader} is planning a heist!\n\nClick **Join Heist** to participate.\nMore participants = higher success chance!`) + .addFields( + { name: 'šŸŽÆ Target', value: target.name, inline: true }, + { name: 'šŸ’° Reward', value: `${target.minReward}-${target.maxReward} XP each`, inline: true }, + { name: 'šŸ“Š Base Success', value: `${Math.round(target.difficulty * 100)}%`, inline: true }, + { name: 'šŸ‘„ Participants', value: `1. ${leader.username}` } + ) + .setFooter({ text: 'Heist starts in 60 seconds!' }) + .setTimestamp(); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`heist_join_${guildId}`) + .setLabel('Join Heist') + .setStyle(ButtonStyle.Primary) + .setEmoji('šŸ”«'), + new ButtonBuilder() + .setCustomId(`heist_start_${guildId}`) + .setLabel('Start Now') + .setStyle(ButtonStyle.Success) + .setEmoji('šŸš€') + ); + + const message = await interaction.reply({ embeds: [embed], components: [row], fetchReply: true }); + + const collector = message.createMessageComponentCollector({ + filter: i => i.customId.startsWith('heist_'), + time: 60000 + }); + + collector.on('collect', async (i) => { + const heistData = activeHeists.get(heistKey); + if (!heistData) return; + + if (i.customId.startsWith('heist_join')) { + if (heistData.participants.has(i.user.id)) { + return i.reply({ content: 'You\'re already in this heist!', ephemeral: true }); + } + + heistData.participants.add(i.user.id); + heistData.usernames[i.user.id] = i.user.username; + + const participantList = Array.from(heistData.participants) + .map((id, idx) => `${idx + 1}. ${heistData.usernames[id]}`) + .join('\n'); + + const bonusChance = (heistData.participants.size - 1) * 5; + const totalChance = Math.min(95, Math.round(target.difficulty * 100) + bonusChance); + + embed.spliceFields(3, 1, { name: 'šŸ‘„ Participants', value: participantList }); + embed.spliceFields(2, 1, { name: 'šŸ“Š Success Chance', value: `${totalChance}%`, inline: true }); + + await i.update({ embeds: [embed] }); + } + + if (i.customId.startsWith('heist_start') && i.user.id === heistData.leader) { + collector.stop('started'); + } + }); + + collector.on('end', async (collected, reason) => { + const heistData = activeHeists.get(heistKey); + if (!heistData) return; + + activeHeists.delete(heistKey); + + const participantCount = heistData.participants.size; + const bonusChance = (participantCount - 1) * 5; + const successChance = Math.min(95, target.difficulty * 100 + bonusChance) / 100; + + const success = Math.random() < successChance; + + if (success) { + const baseReward = Math.floor(Math.random() * (target.maxReward - target.minReward + 1)) + target.minReward; + const teamBonus = Math.floor(baseReward * (participantCount - 1) * 0.1); + const totalReward = baseReward + teamBonus; + + for (const participantId of heistData.participants) { + if (mode === 'standalone') { + await updateStandaloneXp(supabase, participantId, guildId, totalReward, heistData.usernames[participantId]); + } else if (supabase) { + try { + const { data: profile } = await supabase + .from('user_profiles') + .select('xp') + .eq('discord_id', participantId) + .maybeSingle(); + + if (profile) { + await supabase + .from('user_profiles') + .update({ xp: (profile.xp || 0) + totalReward }) + .eq('discord_id', participantId); + } + } catch (e) {} + } + } + + const participantMentions = Array.from(heistData.participants) + .map(id => `<@${id}>`) + .join(', '); + + const successEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle(`${target.emoji} Heist Successful!`) + .setDescription(`The crew pulled off the ${target.name} heist!`) + .addFields( + { name: 'šŸ‘„ Crew', value: participantMentions }, + { name: 'šŸ’° Each Earned', value: `${totalReward} XP` } + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [successEmbed], components: [] }); + } else { + const failEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setTitle(`${target.emoji} Heist Failed!`) + .setDescription(`The ${target.name} heist went wrong! The crew escaped empty-handed.`) + .addFields({ + name: 'šŸ‘„ Crew', + value: Array.from(heistData.participants).map(id => heistData.usernames[id]).join(', ') + }) + .setTimestamp(); + + await interaction.editReply({ embeds: [failEmbed], components: [] }); + } + }); + }, +}; diff --git a/aethex-bot/commands/help.js b/aethex-bot/commands/help.js index f5d4d0f..5904b8f 100644 --- a/aethex-bot/commands/help.js +++ b/aethex-bot/commands/help.js @@ -1,4 +1,5 @@ const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, StringSelectMenuBuilder } = require("discord.js"); +const { getServerMode, getEmbedColor, getModeEmoji, getModeDisplayName } = require('../utils/modeHelper'); module.exports = { data: new SlashCommandBuilder() @@ -13,27 +14,33 @@ module.exports = { { name: 'āš”ļø Realms', value: 'realms' }, { name: 'šŸ“Š Community', value: 'community' }, { name: '⭐ Leveling', value: 'leveling' }, + { name: 'šŸŽ® Fun & Games', value: 'fun' }, + { name: 'šŸ’° Economy', value: 'economy' }, + { name: 'šŸ‘„ Social', value: 'social' }, { name: 'šŸ›”ļø Moderation', value: 'moderation' }, { name: 'šŸ”§ Utility', value: 'utility' }, { name: 'āš™ļø Admin', value: 'admin' } ) ), - async execute(interaction) { + async execute(interaction, supabase) { const category = interaction.options.getString('category'); + const mode = await getServerMode(supabase, interaction.guildId); if (category) { - const embed = getCategoryEmbed(category); + const embed = getCategoryEmbed(category, mode); return interaction.reply({ embeds: [embed], ephemeral: true }); } + const modeIndicator = `${getModeEmoji(mode)} ${getModeDisplayName(mode)} Mode`; + const mainEmbed = new EmbedBuilder() - .setColor(0x7c3aed) + .setColor(getEmbedColor(mode)) .setAuthor({ name: 'AeThex Bot Help', iconURL: interaction.client.user.displayAvatarURL() }) - .setDescription('Welcome to AeThex Bot! Select a category below to view commands.') + .setDescription(`Welcome to AeThex Bot! Select a category below to view commands.\n\n**Current Mode:** ${modeIndicator}`) .addFields( { name: "šŸ”— Account", @@ -42,52 +49,52 @@ module.exports = { }, { name: "āš”ļø Realms", - value: "`/set-realm` `/verify-role` `/refresh-roles`", + value: "`/set-realm` `/federation` `/refresh-roles`", inline: true, }, { name: "šŸ“Š Community", - value: "`/stats` `/leaderboard` `/post` `/poll`", + value: "`/stats` `/leaderboard` `/poll` `/post`", inline: true, }, { name: "⭐ Leveling", - value: "`/rank` `/daily` `/badges`", + value: "`/rank` `/daily` `/prestige` `/badges`", + inline: true, + }, + { + name: "šŸŽ® Fun & Games", + value: "`/8ball` `/roll` `/trivia` `/duel` `/slots`", + inline: true, + }, + { + name: "šŸ’° Economy", + value: "`/work` `/heist` `/gift` `/shop`", + inline: true, + }, + { + name: "šŸ‘„ Social", + value: "`/rep` `/hug` `/birthday` `/remind`", inline: true, }, { name: "šŸ›”ļø Moderation", - value: "`/warn` `/kick` `/ban` `/timeout` `/modlog`", + value: "`/warn` `/kick` `/ban` `/timeout`", inline: true, }, { name: "šŸ”§ Utility", - value: "`/userinfo` `/serverinfo` `/avatar`", + value: "`/translate` `/define` `/math` `/qr`", inline: true, }, { name: "āš™ļø Admin", - value: "`/config` `/announce` `/embed` `/rolepanel`", - inline: true, - }, - { - name: "šŸŽ‰ Fun", - value: "`/giveaway` `/schedule`", - inline: true, - }, - { - name: "šŸ›”ļø Auto-Mod", - value: "`/automod`", + value: "`/config` `/starboard` `/automod`", inline: true, } ) - .addFields({ - name: "šŸ”— Quick Links", - value: "[AeThex Platform](https://aethex.dev) • [Creator Directory](https://aethex.dev/creators) • [Community Feed](https://aethex.dev/community/feed)", - inline: false, - }) .setFooter({ - text: "Use /help [category] for detailed commands • AeThex Bot", + text: "Use /help [category] for detailed commands", iconURL: interaction.client.user.displayAvatarURL() }) .setTimestamp(); @@ -100,6 +107,9 @@ module.exports = { { label: 'Realms', description: 'Realm selection and roles', emoji: 'āš”ļø', value: 'realms' }, { label: 'Community', description: 'Community features', emoji: 'šŸ“Š', value: 'community' }, { label: 'Leveling', description: 'XP and leveling system', emoji: '⭐', value: 'leveling' }, + { label: 'Fun & Games', description: 'Fun commands and minigames', emoji: 'šŸŽ®', value: 'fun' }, + { label: 'Economy', description: 'Earn and spend XP', emoji: 'šŸ’°', value: 'economy' }, + { label: 'Social', description: 'Social interactions', emoji: 'šŸ‘„', value: 'social' }, { label: 'Moderation', description: 'Moderation tools', emoji: 'šŸ›”ļø', value: 'moderation' }, { label: 'Utility', description: 'Utility commands', emoji: 'šŸ”§', value: 'utility' }, { label: 'Admin', description: 'Admin and config', emoji: 'āš™ļø', value: 'admin' }, @@ -111,7 +121,7 @@ module.exports = { }, }; -function getCategoryEmbed(category) { +function getCategoryEmbed(category, mode = 'federated') { const categories = { account: { title: 'šŸ”— Account Commands', @@ -119,16 +129,17 @@ function getCategoryEmbed(category) { commands: [ { name: '/verify', desc: 'Link your Discord account to AeThex' }, { name: '/unlink', desc: 'Disconnect your Discord from AeThex' }, - { name: '/profile [@user]', desc: 'View your or another user\'s AeThex profile' }, + { name: '/profile [@user]', desc: 'View your or another user\'s profile' }, ] }, realms: { title: 'āš”ļø Realm Commands', color: 0xf97316, commands: [ - { name: '/set-realm', desc: 'Choose your primary realm (Labs, GameForge, Corp, Foundation, Dev-Link)' }, - { name: '/verify-role', desc: 'Check your assigned Discord roles' }, + { name: '/set-realm', desc: 'Choose your primary realm (Federation mode only)' }, + { name: '/federation', desc: 'Manage cross-server role sync' }, { name: '/refresh-roles', desc: 'Sync your roles based on AeThex profile' }, + { name: '/verify-role', desc: 'Check your assigned Discord roles' }, ] }, community: { @@ -147,9 +158,47 @@ function getCategoryEmbed(category) { title: '⭐ Leveling Commands', color: 0xfbbf24, commands: [ - { name: '/rank [@user]', desc: 'View your level and unified XP' }, + { name: '/rank [@user]', desc: 'View your level and XP' }, { name: '/daily', desc: 'Claim your daily XP bonus (+50 base + streak)' }, - { name: '/badges', desc: 'View earned badges across platforms' }, + { name: '/prestige', desc: 'Prestige at Level 50 for permanent bonuses' }, + { name: '/badges', desc: 'View earned badges' }, + { name: '/achievements', desc: 'View available achievements' }, + { name: '/quests', desc: 'View and track your quests' }, + ] + }, + fun: { + title: 'šŸŽ® Fun & Games Commands', + color: 0xec4899, + commands: [ + { name: '/8ball [question]', desc: 'Ask the magic 8-ball a question' }, + { name: '/coinflip [call]', desc: 'Flip a coin' }, + { name: '/roll [dice]', desc: 'Roll dice (e.g., 2d6, d20, 3d8+5)' }, + { name: '/trivia [category]', desc: 'Answer trivia questions for XP' }, + { name: '/duel @user [bet]', desc: 'Challenge someone to a duel' }, + { name: '/slots [bet]', desc: 'Try your luck at the slot machine' }, + { name: '/afk [reason]', desc: 'Set your AFK status' }, + ] + }, + economy: { + title: 'šŸ’° Economy Commands', + color: 0x10b981, + commands: [ + { name: '/work', desc: 'Work to earn XP (hourly)' }, + { name: '/heist [target]', desc: 'Start a group heist' }, + { name: '/gift @user [amount]', desc: 'Gift XP to another user' }, + { name: '/shop', desc: 'Browse and purchase items' }, + { name: '/inventory [@user]', desc: 'View your inventory' }, + { name: '/trade @user [offer] [request]', desc: 'Trade items with another user' }, + ] + }, + social: { + title: 'šŸ‘„ Social Commands', + color: 0x8b5cf6, + commands: [ + { name: '/rep @user [reason]', desc: 'Give reputation to someone' }, + { name: '/hug @user', desc: 'Give someone a virtual hug' }, + { name: '/birthday set/view/upcoming', desc: 'Manage birthdays' }, + { name: '/remind set/list/cancel', desc: 'Set personal reminders' }, ] }, moderation: { @@ -166,28 +215,35 @@ function getCategoryEmbed(category) { }, utility: { title: 'šŸ”§ Utility Commands', - color: 0x8b5cf6, + color: 0x6366f1, commands: [ + { name: '/translate [text] [to]', desc: 'Translate text to another language' }, + { name: '/define [word]', desc: 'Look up word definitions' }, + { name: '/math [expression]', desc: 'Calculate math expressions' }, + { name: '/color [hex/rgb]', desc: 'View color information' }, + { name: '/qr [text]', desc: 'Generate a QR code' }, { name: '/userinfo [@user]', desc: 'View detailed user information' }, - { name: '/serverinfo', desc: 'View server statistics and info' }, + { name: '/serverinfo', desc: 'View server statistics' }, { name: '/avatar [@user]', desc: 'Get a user\'s avatar' }, - { name: '/status', desc: 'View network status' }, + { name: '/status', desc: 'View bot status' }, ] }, admin: { title: 'āš™ļø Admin Commands', color: 0x6b7280, commands: [ - { name: '/config', desc: 'View and edit server configuration' }, - { name: '/announce', desc: 'Send cross-server announcements' }, + { name: '/config', desc: 'View and edit server configuration (including mode)' }, + { name: '/starboard setup/disable/status', desc: 'Configure the starboard' }, + { name: '/announce', desc: 'Send announcements' }, { name: '/embed', desc: 'Create custom embed messages' }, { name: '/rolepanel', desc: 'Create role button panels' }, { name: '/giveaway', desc: 'Create and manage giveaways' }, { name: '/schedule', desc: 'Schedule messages for later' }, { name: '/automod', desc: 'Configure auto-moderation' }, - { name: '/admin', desc: 'Bot administration commands' }, - { name: '/federation', desc: 'Manage cross-server role sync' }, - { name: '/ticket', desc: 'Manage support tickets' }, + { name: '/xp-settings', desc: 'Configure XP system' }, + { name: '/level-roles', desc: 'Set up level-up role rewards' }, + { name: '/quests-manage', desc: 'Manage quests' }, + { name: '/shop-manage', desc: 'Manage shop items' }, ] } }; diff --git a/aethex-bot/commands/hug.js b/aethex-bot/commands/hug.js new file mode 100644 index 0000000..96b02ce --- /dev/null +++ b/aethex-bot/commands/hug.js @@ -0,0 +1,47 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor } = require('../utils/modeHelper'); + +const HUG_GIFS = [ + 'https://media.giphy.com/media/l2QDM9Jnim1YVILXa/giphy.gif', + 'https://media.giphy.com/media/od5H3PmEG5EVq/giphy.gif', + 'https://media.giphy.com/media/ZQN9jsRWp1M76/giphy.gif', + 'https://media.giphy.com/media/lrr9rHuoJOE0w/giphy.gif', + 'https://media.giphy.com/media/3M4NpbLCTxBqU/giphy.gif', +]; + +const HUG_MESSAGES = [ + '{user} gives {target} a warm hug!', + '{user} wraps {target} in a cozy embrace!', + '{user} hugs {target} tightly!', + '{user} sends virtual hugs to {target}!', + '{user} gives {target} the biggest hug ever!', +]; + +module.exports = { + data: new SlashCommandBuilder() + .setName('hug') + .setDescription('Give someone a virtual hug') + .addUserOption(option => + option.setName('user') + .setDescription('The user to hug') + .setRequired(true) + ), + + async execute(interaction, supabase, client) { + const targetUser = interaction.options.getUser('user'); + const mode = await getServerMode(supabase, interaction.guildId); + + const gif = HUG_GIFS[Math.floor(Math.random() * HUG_GIFS.length)]; + const message = HUG_MESSAGES[Math.floor(Math.random() * HUG_MESSAGES.length)] + .replace('{user}', interaction.user.toString()) + .replace('{target}', targetUser.toString()); + + const embed = new EmbedBuilder() + .setColor(0xFFB6C1) + .setDescription(`šŸ¤— ${message}`) + .setImage(gif) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/inventory.js b/aethex-bot/commands/inventory.js new file mode 100644 index 0000000..fd39680 --- /dev/null +++ b/aethex-bot/commands/inventory.js @@ -0,0 +1,59 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('inventory') + .setDescription('View your inventory') + .addUserOption(option => + option.setName('user') + .setDescription('User to view inventory of') + .setRequired(false) + ), + + async execute(interaction, supabase, client) { + const targetUser = interaction.options.getUser('user') || interaction.user; + const mode = await getServerMode(supabase, interaction.guildId); + const guildId = interaction.guildId; + + if (!supabase) { + return interaction.reply({ content: 'Inventory system unavailable.', ephemeral: true }); + } + + try { + const { data: items } = await supabase + .from('user_inventory') + .select('*, shop_items(*)') + .eq('guild_id', guildId) + .eq('user_id', targetUser.id); + + if (!items || items.length === 0) { + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle(`šŸŽ’ ${targetUser.username}'s Inventory`) + .setDescription('Inventory is empty. Buy items from the `/shop`!') + .setThumbnail(targetUser.displayAvatarURL({ size: 128 })) + .setTimestamp(); + return interaction.reply({ embeds: [embed] }); + } + + const itemList = items.map(inv => { + const item = inv.shop_items; + if (!item) return null; + return `${item.emoji || 'šŸ“¦'} **${item.name}** x${inv.quantity}`; + }).filter(Boolean).join('\n'); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle(`šŸŽ’ ${targetUser.username}'s Inventory`) + .setDescription(itemList || 'No items') + .setThumbnail(targetUser.displayAvatarURL({ size: 128 })) + .setFooter({ text: `${items.length} unique item(s)` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } catch (e) { + await interaction.reply({ content: 'Failed to fetch inventory.', ephemeral: true }); + } + }, +}; diff --git a/aethex-bot/commands/leaderboard.js b/aethex-bot/commands/leaderboard.js index 694ac31..d0e20a9 100644 --- a/aethex-bot/commands/leaderboard.js +++ b/aethex-bot/commands/leaderboard.js @@ -1,21 +1,23 @@ const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const { getServerMode, EMBED_COLORS } = require("../utils/modeHelper"); +const { getStandaloneLeaderboard, calculateLevel } = require("../utils/standaloneXp"); module.exports = { data: new SlashCommandBuilder() .setName("leaderboard") - .setDescription("View the top AeThex contributors") + .setDescription("View the top contributors") .addStringOption((option) => option .setName("category") .setDescription("Leaderboard category") .setRequired(false) .addChoices( - { name: "⭐ XP Leaders (All-Time)", value: "xp" }, - { name: "šŸ“… This Week", value: "weekly" }, - { name: "šŸ“† This Month", value: "monthly" }, - { name: "šŸ”„ Most Active (Posts)", value: "posts" }, - { name: "ā¤ļø Most Liked", value: "likes" }, - { name: "šŸŽØ Top Creators", value: "creators" } + { name: "XP Leaders (All-Time)", value: "xp" }, + { name: "This Week", value: "weekly" }, + { name: "This Month", value: "monthly" }, + { name: "Most Active (Posts)", value: "posts" }, + { name: "Most Liked", value: "likes" }, + { name: "Top Creators", value: "creators" } ) ), @@ -26,293 +28,420 @@ module.exports = { await interaction.deferReply(); try { + const mode = await getServerMode(supabase, interaction.guildId); const category = interaction.options.getString("category") || "xp"; - const guildId = interaction.guildId; - let leaderboardData = []; - let title = ""; - let emoji = ""; - let color = 0x7c3aed; - let periodInfo = ""; - - if (category === "weekly") { - title = "Weekly XP Leaderboard"; - emoji = "šŸ“…"; - color = 0x22c55e; - - const now = new Date(); - const dayOfWeek = now.getDay(); - const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; - const weekStart = new Date(now); - weekStart.setDate(now.getDate() - diffToMonday); - weekStart.setHours(0, 0, 0, 0); - const weekStartStr = weekStart.toISOString().split('T')[0]; - - const weekEnd = new Date(weekStart); - weekEnd.setDate(weekStart.getDate() + 6); - periodInfo = `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`; - - // Fetch weekly records using period_type - const { data: periodicData } = await supabase - .from("periodic_xp") - .select("discord_id, weekly_xp, weekly_messages") - .eq("guild_id", guildId) - .eq("period_type", "week") - .eq("period_start", weekStartStr); - - // Aggregate per user (handles multiple records if they exist) - const aggregated = {}; - for (const entry of periodicData || []) { - if (!aggregated[entry.discord_id]) { - aggregated[entry.discord_id] = { xp: 0, messages: 0 }; - } - aggregated[entry.discord_id].xp += entry.weekly_xp || 0; - aggregated[entry.discord_id].messages += entry.weekly_messages || 0; - } - - // Sort and limit after aggregation - const sortedUsers = Object.entries(aggregated) - .sort(([, a], [, b]) => b.xp - a.xp) - .slice(0, 10); - - for (const [discordId, data] of sortedUsers) { - try { - const member = await interaction.guild.members.fetch(discordId).catch(() => null); - const displayName = member?.displayName || member?.user?.username || "Unknown User"; - leaderboardData.push({ - name: displayName, - value: `${data.xp.toLocaleString()} XP • ${data.messages} msgs`, - xp: data.xp - }); - } catch (e) { - continue; - } - } - } else if (category === "monthly") { - title = "Monthly XP Leaderboard"; - emoji = "šŸ“†"; - color = 0x3b82f6; - - const now = new Date(); - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); - const monthStartStr = monthStart.toISOString().split('T')[0]; - - periodInfo = monthStart.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - - // Fetch monthly records using period_type - const { data: periodicData } = await supabase - .from("periodic_xp") - .select("discord_id, monthly_xp, monthly_messages") - .eq("guild_id", guildId) - .eq("period_type", "month") - .eq("period_start", monthStartStr); - - // Aggregate all entries per user first - const aggregated = {}; - for (const entry of periodicData || []) { - if (!aggregated[entry.discord_id]) { - aggregated[entry.discord_id] = { xp: 0, messages: 0 }; - } - aggregated[entry.discord_id].xp += entry.monthly_xp || 0; - aggregated[entry.discord_id].messages += entry.monthly_messages || 0; - } - - // Sort and limit AFTER aggregation - const sortedUsers = Object.entries(aggregated) - .sort(([, a], [, b]) => b.xp - a.xp) - .slice(0, 10); - - for (const [discordId, data] of sortedUsers) { - try { - const member = await interaction.guild.members.fetch(discordId).catch(() => null); - const displayName = member?.displayName || member?.user?.username || "Unknown User"; - leaderboardData.push({ - name: displayName, - value: `${data.xp.toLocaleString()} XP • ${data.messages} msgs`, - xp: data.xp - }); - } catch (e) { - continue; - } - } - } else if (category === "xp") { - title = "XP Leaderboard (All-Time)"; - emoji = "⭐"; - color = 0xfbbf24; - - const { data: profiles } = await supabase - .from("user_profiles") - .select("id, username, full_name, avatar_url, xp") - .not("xp", "is", null) - .order("xp", { ascending: false }) - .limit(10); - - for (const profile of profiles || []) { - const level = Math.floor(Math.sqrt((profile.xp || 0) / 100)); - leaderboardData.push({ - name: profile.full_name || profile.username || "Anonymous", - value: `Level ${level} • ${(profile.xp || 0).toLocaleString()} XP`, - username: profile.username, - xp: profile.xp || 0 - }); - } - } else if (category === "posts") { - title = "Most Active Posters"; - emoji = "šŸ”„"; - color = 0xef4444; - - const { data: posts } = await supabase - .from("community_posts") - .select("user_id") - .not("user_id", "is", null); - - const postCounts = {}; - posts?.forEach((post) => { - postCounts[post.user_id] = (postCounts[post.user_id] || 0) + 1; - }); - - const sortedUsers = Object.entries(postCounts) - .sort(([, a], [, b]) => b - a) - .slice(0, 10); - - for (const [userId, count] of sortedUsers) { - const { data: profile } = await supabase - .from("user_profiles") - .select("username, full_name, avatar_url") - .eq("id", userId) - .single(); - - if (profile) { - leaderboardData.push({ - name: profile.full_name || profile.username || "Anonymous", - value: `${count} posts`, - username: profile.username, - }); - } - } - } else if (category === "likes") { - title = "Most Liked Users"; - emoji = "ā¤ļø"; - color = 0xec4899; - - const { data: posts } = await supabase - .from("community_posts") - .select("user_id, likes_count") - .not("user_id", "is", null) - .order("likes_count", { ascending: false }); - - const likeCounts = {}; - posts?.forEach((post) => { - likeCounts[post.user_id] = - (likeCounts[post.user_id] || 0) + (post.likes_count || 0); - }); - - const sortedUsers = Object.entries(likeCounts) - .sort(([, a], [, b]) => b - a) - .slice(0, 10); - - for (const [userId, count] of sortedUsers) { - const { data: profile } = await supabase - .from("user_profiles") - .select("username, full_name, avatar_url") - .eq("id", userId) - .single(); - - if (profile) { - leaderboardData.push({ - name: profile.full_name || profile.username || "Anonymous", - value: `${count.toLocaleString()} likes`, - username: profile.username, - }); - } - } - } else if (category === "creators") { - title = "Top Creators"; - emoji = "šŸŽØ"; - color = 0x8b5cf6; - - const { data: creators } = await supabase - .from("aethex_creators") - .select("user_id, total_projects, verified, featured") - .order("total_projects", { ascending: false }) - .limit(10); - - for (const creator of creators || []) { - const { data: profile } = await supabase - .from("user_profiles") - .select("username, full_name, avatar_url") - .eq("id", creator.user_id) - .single(); - - if (profile) { - const badges = []; - if (creator.verified) badges.push("āœ…"); - if (creator.featured) badges.push("⭐"); - - leaderboardData.push({ - name: profile.full_name || profile.username || "Anonymous", - value: `${creator.total_projects || 0} projects ${badges.join(" ")}`, - username: profile.username, - }); - } - } + if (mode === 'standalone') { + return handleStandaloneLeaderboard(interaction, supabase, category); + } else { + return handleFederatedLeaderboard(interaction, supabase, category); } - - const medals = ['šŸ„‡', '🄈', 'šŸ„‰']; - - const description = leaderboardData.length > 0 - ? leaderboardData - .map((user, index) => { - const medal = index < 3 ? medals[index] : `\`${index + 1}.\``; - return `${medal} **${user.name}**\n ā”” ${user.value}`; - }) - .join("\n\n") - : "No data available yet. Be the first to contribute!"; - - const embed = new EmbedBuilder() - .setColor(color) - .setTitle(`${emoji} ${title}`) - .setDescription(description) - .setThumbnail(interaction.guild.iconURL({ size: 128 })) - .setFooter({ - text: `${interaction.guild.name} • Updated in real-time`, - iconURL: interaction.guild.iconURL({ size: 32 }) - }) - .setTimestamp(); - - if (periodInfo) { - embed.addFields({ - name: 'šŸ“Š Period', - value: periodInfo, - inline: true - }); - } - - if (leaderboardData.length > 0) { - embed.addFields({ - name: 'šŸ‘„ Showing', - value: `Top ${leaderboardData.length} contributors`, - inline: true - }); - } - - if (category === "weekly" || category === "monthly") { - embed.addFields({ - name: 'šŸ’” Tip', - value: 'Leaderboards reset automatically at the start of each period!', - inline: false - }); - } - - await interaction.editReply({ embeds: [embed] }); } catch (error) { console.error("Leaderboard command error:", error); const embed = new EmbedBuilder() .setColor(0xff0000) - .setTitle("āŒ Error") + .setTitle("Error") .setDescription("Failed to fetch leaderboard. Please try again."); await interaction.editReply({ embeds: [embed] }); } }, }; + +async function handleStandaloneLeaderboard(interaction, supabase, category) { + const guildId = interaction.guildId; + + if (category !== 'xp' && category !== 'weekly' && category !== 'monthly') { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setTitle("šŸ  Standalone Mode") + .setDescription("This leaderboard category is only available in Federation mode.\n\nIn Standalone mode, only XP, Weekly, and Monthly leaderboards are available.") + ] + }); + } + + let leaderboardData = []; + let title = ""; + let color = EMBED_COLORS.standalone; + + if (category === 'xp') { + title = "Server XP Leaderboard"; + const data = await getStandaloneLeaderboard(supabase, guildId, 10); + + for (const entry of data) { + const level = calculateLevel(entry.xp || 0, 'normal'); + let displayName = entry.username || 'Unknown'; + + try { + const member = await interaction.guild.members.fetch(entry.discord_id).catch(() => null); + if (member) displayName = member.displayName; + } catch (e) {} + + leaderboardData.push({ + name: displayName, + value: `Level ${level} • ${(entry.xp || 0).toLocaleString()} XP`, + xp: entry.xp || 0 + }); + } + } else if (category === 'weekly' || category === 'monthly') { + const periodType = category === 'weekly' ? 'week' : 'month'; + title = category === 'weekly' ? 'Weekly XP Leaderboard' : 'Monthly XP Leaderboard'; + + const now = new Date(); + let periodStart; + + if (category === 'weekly') { + const dayOfWeek = now.getDay(); + const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + periodStart = new Date(now); + periodStart.setDate(now.getDate() - diffToMonday); + periodStart.setHours(0, 0, 0, 0); + } else { + periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + } + + const periodStartStr = periodStart.toISOString().split('T')[0]; + + const { data: periodicData } = await supabase + .from("periodic_xp") + .select("discord_id, weekly_xp, monthly_xp") + .eq("guild_id", guildId) + .eq("period_type", periodType) + .eq("period_start", periodStartStr); + + const aggregated = {}; + for (const entry of periodicData || []) { + if (!aggregated[entry.discord_id]) { + aggregated[entry.discord_id] = 0; + } + aggregated[entry.discord_id] += category === 'weekly' ? (entry.weekly_xp || 0) : (entry.monthly_xp || 0); + } + + const sortedUsers = Object.entries(aggregated) + .sort(([, a], [, b]) => b - a) + .slice(0, 10); + + for (const [discordId, xp] of sortedUsers) { + let displayName = 'Unknown User'; + try { + const member = await interaction.guild.members.fetch(discordId).catch(() => null); + if (member) displayName = member.displayName; + } catch (e) {} + + leaderboardData.push({ + name: displayName, + value: `${xp.toLocaleString()} XP`, + xp: xp + }); + } + } + + const medals = ['1.', '2.', '3.']; + + const description = leaderboardData.length > 0 + ? leaderboardData + .map((user, index) => { + const medal = index < 3 ? medals[index] : `${index + 1}.`; + return `**${medal}** ${user.name}\n ${user.value}`; + }) + .join("\n\n") + : "No data available yet. Be the first to contribute!"; + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(`šŸ  ${title}`) + .setDescription(description) + .setThumbnail(interaction.guild.iconURL({ size: 128 })) + .setFooter({ + text: `${interaction.guild.name} • Standalone Mode`, + iconURL: interaction.guild.iconURL({ size: 32 }) + }) + .setTimestamp(); + + if (leaderboardData.length > 0) { + embed.addFields({ + name: 'Showing', + value: `Top ${leaderboardData.length} contributors`, + inline: true + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleFederatedLeaderboard(interaction, supabase, category) { + const guildId = interaction.guildId; + + let leaderboardData = []; + let title = ""; + let emoji = ""; + let color = 0x7c3aed; + let periodInfo = ""; + + if (category === "weekly") { + title = "Weekly XP Leaderboard"; + emoji = ""; + color = 0x22c55e; + + const now = new Date(); + const dayOfWeek = now.getDay(); + const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - diffToMonday); + weekStart.setHours(0, 0, 0, 0); + const weekStartStr = weekStart.toISOString().split('T')[0]; + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 6); + periodInfo = `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`; + + const { data: periodicData } = await supabase + .from("periodic_xp") + .select("discord_id, weekly_xp, weekly_messages") + .eq("guild_id", guildId) + .eq("period_type", "week") + .eq("period_start", weekStartStr); + + const aggregated = {}; + for (const entry of periodicData || []) { + if (!aggregated[entry.discord_id]) { + aggregated[entry.discord_id] = { xp: 0, messages: 0 }; + } + aggregated[entry.discord_id].xp += entry.weekly_xp || 0; + aggregated[entry.discord_id].messages += entry.weekly_messages || 0; + } + + const sortedUsers = Object.entries(aggregated) + .sort(([, a], [, b]) => b.xp - a.xp) + .slice(0, 10); + + for (const [discordId, data] of sortedUsers) { + try { + const member = await interaction.guild.members.fetch(discordId).catch(() => null); + const displayName = member?.displayName || member?.user?.username || "Unknown User"; + leaderboardData.push({ + name: displayName, + value: `${data.xp.toLocaleString()} XP • ${data.messages} msgs`, + xp: data.xp + }); + } catch (e) { + continue; + } + } + } else if (category === "monthly") { + title = "Monthly XP Leaderboard"; + emoji = ""; + color = 0x3b82f6; + + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthStartStr = monthStart.toISOString().split('T')[0]; + + periodInfo = monthStart.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + + const { data: periodicData } = await supabase + .from("periodic_xp") + .select("discord_id, monthly_xp, monthly_messages") + .eq("guild_id", guildId) + .eq("period_type", "month") + .eq("period_start", monthStartStr); + + const aggregated = {}; + for (const entry of periodicData || []) { + if (!aggregated[entry.discord_id]) { + aggregated[entry.discord_id] = { xp: 0, messages: 0 }; + } + aggregated[entry.discord_id].xp += entry.monthly_xp || 0; + aggregated[entry.discord_id].messages += entry.monthly_messages || 0; + } + + const sortedUsers = Object.entries(aggregated) + .sort(([, a], [, b]) => b.xp - a.xp) + .slice(0, 10); + + for (const [discordId, data] of sortedUsers) { + try { + const member = await interaction.guild.members.fetch(discordId).catch(() => null); + const displayName = member?.displayName || member?.user?.username || "Unknown User"; + leaderboardData.push({ + name: displayName, + value: `${data.xp.toLocaleString()} XP • ${data.messages} msgs`, + xp: data.xp + }); + } catch (e) { + continue; + } + } + } else if (category === "xp") { + title = "XP Leaderboard (All-Time)"; + emoji = ""; + color = 0xfbbf24; + + const { data: profiles } = await supabase + .from("user_profiles") + .select("id, username, full_name, avatar_url, xp") + .not("xp", "is", null) + .order("xp", { ascending: false }) + .limit(10); + + for (const profile of profiles || []) { + const level = Math.floor(Math.sqrt((profile.xp || 0) / 100)); + leaderboardData.push({ + name: profile.full_name || profile.username || "Anonymous", + value: `Level ${level} • ${(profile.xp || 0).toLocaleString()} XP`, + username: profile.username, + xp: profile.xp || 0 + }); + } + } else if (category === "posts") { + title = "Most Active Posters"; + emoji = ""; + color = 0xef4444; + + const { data: posts } = await supabase + .from("community_posts") + .select("user_id") + .not("user_id", "is", null); + + const postCounts = {}; + posts?.forEach((post) => { + postCounts[post.user_id] = (postCounts[post.user_id] || 0) + 1; + }); + + const sortedUsers = Object.entries(postCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 10); + + for (const [userId, count] of sortedUsers) { + const { data: profile } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", userId) + .single(); + + if (profile) { + leaderboardData.push({ + name: profile.full_name || profile.username || "Anonymous", + value: `${count} posts`, + username: profile.username, + }); + } + } + } else if (category === "likes") { + title = "Most Liked Users"; + emoji = ""; + color = 0xec4899; + + const { data: posts } = await supabase + .from("community_posts") + .select("user_id, likes_count") + .not("user_id", "is", null) + .order("likes_count", { ascending: false }); + + const likeCounts = {}; + posts?.forEach((post) => { + likeCounts[post.user_id] = + (likeCounts[post.user_id] || 0) + (post.likes_count || 0); + }); + + const sortedUsers = Object.entries(likeCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 10); + + for (const [userId, count] of sortedUsers) { + const { data: profile } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", userId) + .single(); + + if (profile) { + leaderboardData.push({ + name: profile.full_name || profile.username || "Anonymous", + value: `${count.toLocaleString()} likes`, + username: profile.username, + }); + } + } + } else if (category === "creators") { + title = "Top Creators"; + emoji = ""; + color = 0x8b5cf6; + + const { data: creators } = await supabase + .from("aethex_creators") + .select("user_id, total_projects, verified, featured") + .order("total_projects", { ascending: false }) + .limit(10); + + for (const creator of creators || []) { + const { data: profile } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", creator.user_id) + .single(); + + if (profile) { + const badges = []; + if (creator.verified) badges.push("Verified"); + if (creator.featured) badges.push("Featured"); + + leaderboardData.push({ + name: profile.full_name || profile.username || "Anonymous", + value: `${creator.total_projects || 0} projects ${badges.join(" ")}`, + username: profile.username, + }); + } + } + } + + const medals = ['1.', '2.', '3.']; + + const description = leaderboardData.length > 0 + ? leaderboardData + .map((user, index) => { + const medal = index < 3 ? medals[index] : `${index + 1}.`; + return `**${medal}** ${user.name}\n ${user.value}`; + }) + .join("\n\n") + : "No data available yet. Be the first to contribute!"; + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(`🌐 ${title}`) + .setDescription(description) + .setThumbnail(interaction.guild.iconURL({ size: 128 })) + .setFooter({ + text: `${interaction.guild.name} • Federation Mode`, + iconURL: interaction.guild.iconURL({ size: 32 }) + }) + .setTimestamp(); + + if (periodInfo) { + embed.addFields({ + name: 'Period', + value: periodInfo, + inline: true + }); + } + + if (leaderboardData.length > 0) { + embed.addFields({ + name: 'Showing', + value: `Top ${leaderboardData.length} contributors`, + inline: true + }); + } + + if (category === "weekly" || category === "monthly") { + embed.addFields({ + name: 'Tip', + value: 'Leaderboards reset automatically at the start of each period!', + inline: false + }); + } + + await interaction.editReply({ embeds: [embed] }); +} diff --git a/aethex-bot/commands/math.js b/aethex-bot/commands/math.js new file mode 100644 index 0000000..0e2e2db --- /dev/null +++ b/aethex-bot/commands/math.js @@ -0,0 +1,67 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); + +function safeEval(expression) { + const sanitized = expression.replace(/[^0-9+\-*/().%^sqrt\s]/gi, ''); + + if (!sanitized || sanitized.trim() === '') { + return { error: 'Invalid expression' }; + } + + try { + let processed = sanitized + .replace(/\^/g, '**') + .replace(/sqrt\(([^)]+)\)/gi, 'Math.sqrt($1)'); + + const result = Function('"use strict"; return (' + processed + ')')(); + + if (typeof result !== 'number' || isNaN(result) || !isFinite(result)) { + return { error: 'Result is not a valid number' }; + } + + return { result }; + } catch (e) { + return { error: 'Invalid expression' }; + } +} + +module.exports = { + data: new SlashCommandBuilder() + .setName('math') + .setDescription('Calculate a math expression') + .addStringOption(option => + option.setName('expression') + .setDescription('The math expression (e.g., 2+2, sqrt(16), 5^2)') + .setRequired(true) + .setMaxLength(200) + ), + + async execute(interaction, supabase, client) { + const expression = interaction.options.getString('expression'); + const mode = await getServerMode(supabase, interaction.guildId); + + const { result, error } = safeEval(expression); + + if (error) { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setTitle('🧮 Math Error') + .setDescription(error) + .setTimestamp(); + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + + const formattedResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/, ''); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('🧮 Calculator') + .addFields( + { name: 'šŸ“„ Expression', value: `\`${expression}\`` }, + { name: 'šŸ“¤ Result', value: `\`${formattedResult}\`` } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/prestige.js b/aethex-bot/commands/prestige.js index c685fa6..05e2166 100644 --- a/aethex-bot/commands/prestige.js +++ b/aethex-bot/commands/prestige.js @@ -1,4 +1,6 @@ const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { getServerMode, EMBED_COLORS } = require('../utils/modeHelper'); +const { getStandaloneXp, prestigeStandalone, calculateLevel } = require('../utils/standaloneXp'); module.exports = { data: new SlashCommandBuilder() @@ -28,22 +30,70 @@ module.exports = { } const sub = interaction.options.getSubcommand(); + const mode = await getServerMode(supabase, interaction.guildId); if (sub === 'view') { - return viewPrestige(interaction, supabase); + return viewPrestige(interaction, supabase, mode); } else if (sub === 'up') { - return prestigeUp(interaction, supabase, client); + return prestigeUp(interaction, supabase, client, mode); } else if (sub === 'rewards') { - return viewRewards(interaction); + return viewRewards(interaction, mode); } }, }; -async function viewPrestige(interaction, supabase) { +async function viewPrestige(interaction, supabase, mode) { const target = interaction.options.getUser('user') || interaction.user; await interaction.deferReply(); try { + if (mode === 'standalone') { + const data = await getStandaloneXp(supabase, target.id, interaction.guildId); + + if (!data) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setDescription(`${target.id === interaction.user.id ? 'You have' : `${target.tag} has`} no XP data yet.`) + ] + }); + } + + const prestige = data.prestige_level || 0; + const currentXp = data.xp || 0; + const totalXpEarned = data.total_xp_earned || currentXp; + const level = calculateLevel(currentXp, 'normal'); + + const prestigeInfo = getPrestigeInfo(prestige); + const canPrestige = level >= 50; + + const embed = new EmbedBuilder() + .setColor(prestigeInfo.color) + .setTitle(`${prestigeInfo.icon} ${target.tag}'s Prestige`) + .setThumbnail(target.displayAvatarURL({ size: 256 })) + .addFields( + { name: 'Prestige Level', value: `**${prestigeInfo.name}** (${prestige})`, inline: true }, + { name: 'XP Bonus', value: `+${prestige * 5}%`, inline: true }, + { name: 'Current Level', value: `${level}`, inline: true }, + { name: 'Total XP Earned', value: totalXpEarned.toLocaleString(), inline: true }, + { name: 'Current XP', value: currentXp.toLocaleString(), inline: true }, + { name: 'Can Prestige?', value: canPrestige ? 'Yes (Level 50+)' : `Need Level 50 (${level}/50)`, inline: true } + ) + .setFooter({ text: `šŸ  Standalone Mode • ${interaction.guild.name}` }) + .setTimestamp(); + + if (prestige > 0) { + embed.addFields({ + name: 'Prestige Rewards Unlocked', + value: getUnlockedRewards(prestige).join('\n') || 'None yet', + inline: false + }); + } + + return interaction.editReply({ embeds: [embed] }); + } + const { data: link } = await supabase .from('discord_links') .select('user_id') @@ -72,7 +122,6 @@ async function viewPrestige(interaction, supabase) { const level = Math.floor(Math.sqrt(currentXp / 100)); const prestigeInfo = getPrestigeInfo(prestige); - const nextPrestigeReq = getPrestigeRequirement(prestige + 1); const canPrestige = level >= 50; const embed = new EmbedBuilder() @@ -85,14 +134,14 @@ async function viewPrestige(interaction, supabase) { { name: 'Current Level', value: `${level}`, inline: true }, { name: 'Total XP Earned', value: totalXpEarned.toLocaleString(), inline: true }, { name: 'Current XP', value: currentXp.toLocaleString(), inline: true }, - { name: 'Can Prestige?', value: canPrestige ? 'āœ… Yes (Level 50+)' : `āŒ Need Level 50 (${level}/50)`, inline: true } + { name: 'Can Prestige?', value: canPrestige ? 'Yes (Level 50+)' : `Need Level 50 (${level}/50)`, inline: true } ) - .setFooter({ text: `Next prestige requirement: Level 50` }) + .setFooter({ text: `🌐 Federation • Next prestige requirement: Level 50` }) .setTimestamp(); if (prestige > 0) { embed.addFields({ - name: 'šŸ† Prestige Rewards Unlocked', + name: 'Prestige Rewards Unlocked', value: getUnlockedRewards(prestige).join('\n') || 'None yet', inline: false }); @@ -105,10 +154,127 @@ async function viewPrestige(interaction, supabase) { } } -async function prestigeUp(interaction, supabase, client) { +async function prestigeUp(interaction, supabase, client, mode) { await interaction.deferReply(); try { + if (mode === 'standalone') { + const data = await getStandaloneXp(supabase, interaction.user.id, interaction.guildId); + + if (!data) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setDescription('You have no XP data yet. Start chatting to earn XP!') + ] + }); + } + + const currentXp = data.xp || 0; + const level = calculateLevel(currentXp, 'normal'); + const prestige = data.prestige_level || 0; + + if (level < 50) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setTitle('Cannot Prestige') + .setDescription(`You need to reach **Level 50** to prestige.\nCurrent level: **${level}**/50`) + ] + }); + } + + if (prestige >= 10) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xffd700) + .setTitle('Maximum Prestige!') + .setDescription('You have reached the maximum prestige level!') + ] + }); + } + + const newPrestige = prestige + 1; + const newPrestigeInfo = getPrestigeInfo(newPrestige); + + const confirmEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.warning) + .setTitle('Confirm Prestige') + .setDescription(`Are you sure you want to prestige?\n\n**What will happen:**\n• Your XP will reset to **0**\n• Your level will reset to **0**\n• You gain **Prestige ${newPrestige}** (${newPrestigeInfo.name})\n• You get a permanent **+${newPrestige * 5}%** XP bonus\n\n**Current Stats:**\n• Level: ${level}\n• XP: ${currentXp.toLocaleString()}`) + .setFooter({ text: 'This action cannot be undone!' }); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('prestige_confirm_standalone') + .setLabel('Prestige Up!') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId('prestige_cancel_standalone') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + + const response = await interaction.editReply({ embeds: [confirmEmbed], components: [row] }); + + try { + const confirmation = await response.awaitMessageComponent({ + filter: i => i.user.id === interaction.user.id, + time: 60000 + }); + + if (confirmation.customId === 'prestige_confirm_standalone') { + const result = await prestigeStandalone(supabase, interaction.user.id, interaction.guildId); + + if (!result.success) { + return confirmation.update({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setDescription(result.message) + ], + components: [] + }); + } + + const successEmbed = new EmbedBuilder() + .setColor(newPrestigeInfo.color) + .setTitle(`${newPrestigeInfo.icon} Prestige ${result.newPrestige} Achieved!`) + .setDescription(`Congratulations! You are now **${newPrestigeInfo.name}**!`) + .addFields( + { name: 'XP Bonus', value: `+${result.bonus}%`, inline: true } + ) + .setThumbnail(interaction.user.displayAvatarURL({ size: 256 })) + .setFooter({ text: 'šŸ  Standalone Mode' }) + .setTimestamp(); + + await confirmation.update({ embeds: [successEmbed], components: [] }); + } else { + await confirmation.update({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setDescription('Prestige cancelled.') + ], + components: [] + }); + } + } catch (e) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setDescription('Prestige request timed out.') + ], + components: [] + }); + } + return; + } + const { data: link } = await supabase .from('discord_links') .select('user_id') @@ -141,7 +307,7 @@ async function prestigeUp(interaction, supabase, client) { embeds: [ new EmbedBuilder() .setColor(0xff6b6b) - .setTitle('āŒ Cannot Prestige') + .setTitle('Cannot Prestige') .setDescription(`You need to reach **Level 50** to prestige.\nCurrent level: **${level}**/50`) ] }); @@ -152,7 +318,7 @@ async function prestigeUp(interaction, supabase, client) { embeds: [ new EmbedBuilder() .setColor(0xffd700) - .setTitle('šŸ‘‘ Maximum Prestige!') + .setTitle('Maximum Prestige!') .setDescription('You have reached the maximum prestige level! You are a true legend.') ] }); @@ -164,7 +330,7 @@ async function prestigeUp(interaction, supabase, client) { const confirmEmbed = new EmbedBuilder() .setColor(0xf59e0b) - .setTitle('āš ļø Confirm Prestige') + .setTitle('Confirm Prestige') .setDescription(`Are you sure you want to prestige?\n\n**What will happen:**\n• Your XP will reset to **0**\n• Your level will reset to **0**\n• You gain **Prestige ${newPrestige}** (${newPrestigeInfo.name})\n• You get a permanent **+${xpBonus}%** XP bonus\n• You unlock new **prestige rewards**\n\n**Current Stats:**\n• Level: ${level}\n• XP: ${currentXp.toLocaleString()}`) .setFooter({ text: 'This action cannot be undone!' }); @@ -172,7 +338,7 @@ async function prestigeUp(interaction, supabase, client) { .addComponents( new ButtonBuilder() .setCustomId('prestige_confirm') - .setLabel('✨ Prestige Up!') + .setLabel('Prestige Up!') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId('prestige_cancel') @@ -218,6 +384,7 @@ async function prestigeUp(interaction, supabase, client) { { name: 'Total XP Earned (All Time)', value: totalEarned.toLocaleString(), inline: true } ) .setThumbnail(interaction.user.displayAvatarURL({ size: 256 })) + .setFooter({ text: '🌐 Federation' }) .setTimestamp(); await confirmation.update({ embeds: [successEmbed], components: [] }); @@ -247,24 +414,24 @@ async function prestigeUp(interaction, supabase, client) { } } -async function viewRewards(interaction) { +async function viewRewards(interaction, mode) { const embed = new EmbedBuilder() - .setColor(0x7c3aed) - .setTitle('✨ Prestige Rewards') + .setColor(mode === 'standalone' ? EMBED_COLORS.standalone : 0x7c3aed) + .setTitle('Prestige Rewards') .setDescription('Each prestige level grants permanent rewards!\n\n**Requirements:** Level 50 to prestige') .addFields( - { name: '⭐ Prestige 1 - Bronze', value: '+5% XP bonus\nšŸ·ļø Bronze Prestige badge', inline: true }, - { name: '⭐ Prestige 2 - Silver', value: '+10% XP bonus\nšŸ·ļø Silver Prestige badge', inline: true }, - { name: '⭐ Prestige 3 - Gold', value: '+15% XP bonus\nšŸ·ļø Gold Prestige badge', inline: true }, - { name: 'šŸ’Ž Prestige 4 - Platinum', value: '+20% XP bonus\nšŸ·ļø Platinum badge\nšŸŽ Bonus daily XP', inline: true }, - { name: 'šŸ’Ž Prestige 5 - Diamond', value: '+25% XP bonus\nšŸ·ļø Diamond badge\nā±ļø Reduced cooldowns', inline: true }, - { name: 'šŸ”„ Prestige 6 - Master', value: '+30% XP bonus\nšŸ·ļø Master badge\nšŸŽÆ XP milestone rewards', inline: true }, - { name: 'šŸ”„ Prestige 7 - Grandmaster', value: '+35% XP bonus\nšŸ·ļø Grandmaster badge\nšŸ’« Special profile effects', inline: true }, - { name: 'šŸ‘‘ Prestige 8 - Champion', value: '+40% XP bonus\nšŸ·ļø Champion badge\nšŸ† Leaderboard priority', inline: true }, - { name: 'šŸ‘‘ Prestige 9 - Legend', value: '+45% XP bonus\nšŸ·ļø Legend badge\n✨ Legendary profile aura', inline: true }, - { name: '🌟 Prestige 10 - Mythic', value: '+50% XP bonus\nšŸ·ļø Mythic badge\n🌈 All prestige perks!', inline: true } + { name: 'Prestige 1 - Bronze', value: '+5% XP bonus\nBronze Prestige badge', inline: true }, + { name: 'Prestige 2 - Silver', value: '+10% XP bonus\nSilver Prestige badge', inline: true }, + { name: 'Prestige 3 - Gold', value: '+15% XP bonus\nGold Prestige badge', inline: true }, + { name: 'Prestige 4 - Platinum', value: '+20% XP bonus\nPlatinum badge\nBonus daily XP', inline: true }, + { name: 'Prestige 5 - Diamond', value: '+25% XP bonus\nDiamond badge\nReduced cooldowns', inline: true }, + { name: 'Prestige 6 - Master', value: '+30% XP bonus\nMaster badge\nXP milestone rewards', inline: true }, + { name: 'Prestige 7 - Grandmaster', value: '+35% XP bonus\nGrandmaster badge\nSpecial profile effects', inline: true }, + { name: 'Prestige 8 - Champion', value: '+40% XP bonus\nChampion badge\nLeaderboard priority', inline: true }, + { name: 'Prestige 9 - Legend', value: '+45% XP bonus\nLegend badge\nLegendary profile aura', inline: true }, + { name: 'Prestige 10 - Mythic', value: '+50% XP bonus\nMythic badge\nAll prestige perks!', inline: true } ) - .setFooter({ text: 'Use /prestige up when you reach Level 50!' }) + .setFooter({ text: `${mode === 'standalone' ? 'šŸ  Standalone' : '🌐 Federation'} • Use /prestige up when you reach Level 50!` }) .setTimestamp(); await interaction.reply({ embeds: [embed] }); @@ -272,52 +439,48 @@ async function viewRewards(interaction) { function getPrestigeInfo(level) { const prestiges = [ - { name: 'Unprestiged', icon: '⚪', color: 0x6b7280 }, - { name: 'Bronze', icon: 'šŸ„‰', color: 0xcd7f32 }, - { name: 'Silver', icon: '🄈', color: 0xc0c0c0 }, - { name: 'Gold', icon: 'šŸ„‡', color: 0xffd700 }, - { name: 'Platinum', icon: 'šŸ’Ž', color: 0xe5e4e2 }, - { name: 'Diamond', icon: 'šŸ’ ', color: 0xb9f2ff }, - { name: 'Master', icon: 'šŸ”„', color: 0xff4500 }, - { name: 'Grandmaster', icon: 'āš”ļø', color: 0x9400d3 }, - { name: 'Champion', icon: 'šŸ‘‘', color: 0xffd700 }, - { name: 'Legend', icon: '🌟', color: 0xff69b4 }, - { name: 'Mythic', icon: '🌈', color: 0x7c3aed } + { name: 'Unprestiged', icon: '', color: 0x6b7280 }, + { name: 'Bronze', icon: '', color: 0xcd7f32 }, + { name: 'Silver', icon: '', color: 0xc0c0c0 }, + { name: 'Gold', icon: '', color: 0xffd700 }, + { name: 'Platinum', icon: '', color: 0xe5e4e2 }, + { name: 'Diamond', icon: '', color: 0xb9f2ff }, + { name: 'Master', icon: '', color: 0xff4500 }, + { name: 'Grandmaster', icon: '', color: 0x9400d3 }, + { name: 'Champion', icon: '', color: 0xffd700 }, + { name: 'Legend', icon: '', color: 0xff69b4 }, + { name: 'Mythic', icon: '', color: 0x7c3aed } ]; return prestiges[Math.min(level, 10)] || prestiges[0]; } -function getPrestigeRequirement(level) { - return 50; -} - function getUnlockedRewards(prestige) { const rewards = []; - if (prestige >= 1) rewards.push('šŸ„‰ Bronze Prestige badge'); - if (prestige >= 2) rewards.push('🄈 Silver Prestige badge'); - if (prestige >= 3) rewards.push('šŸ„‡ Gold Prestige badge'); - if (prestige >= 4) rewards.push('šŸ’Ž Platinum badge + Bonus daily XP'); - if (prestige >= 5) rewards.push('šŸ’  Diamond badge + Reduced cooldowns'); - if (prestige >= 6) rewards.push('šŸ”„ Master badge + XP milestones'); - if (prestige >= 7) rewards.push('āš”ļø Grandmaster badge + Profile effects'); - if (prestige >= 8) rewards.push('šŸ‘‘ Champion badge + Leaderboard priority'); - if (prestige >= 9) rewards.push('🌟 Legend badge + Legendary aura'); - if (prestige >= 10) rewards.push('🌈 Mythic badge + All perks unlocked'); + if (prestige >= 1) rewards.push('Bronze Prestige badge'); + if (prestige >= 2) rewards.push('Silver Prestige badge'); + if (prestige >= 3) rewards.push('Gold Prestige badge'); + if (prestige >= 4) rewards.push('Platinum badge + Bonus daily XP'); + if (prestige >= 5) rewards.push('Diamond badge + Reduced cooldowns'); + if (prestige >= 6) rewards.push('Master badge + XP milestones'); + if (prestige >= 7) rewards.push('Grandmaster badge + Profile effects'); + if (prestige >= 8) rewards.push('Champion badge + Leaderboard priority'); + if (prestige >= 9) rewards.push('Legend badge + Legendary aura'); + if (prestige >= 10) rewards.push('Mythic badge + All perks unlocked'); return rewards; } function getNewRewards(prestige) { const rewardMap = { - 1: ['šŸ„‰ Bronze Prestige badge', '+5% XP bonus on all XP gains'], - 2: ['🄈 Silver Prestige badge', '+10% XP bonus on all XP gains'], - 3: ['šŸ„‡ Gold Prestige badge', '+15% XP bonus on all XP gains'], - 4: ['šŸ’Ž Platinum badge', '+20% XP bonus', 'šŸŽ +25 bonus daily XP'], - 5: ['šŸ’  Diamond badge', '+25% XP bonus', 'ā±ļø 10% reduced XP cooldowns'], - 6: ['šŸ”„ Master badge', '+30% XP bonus', 'šŸŽÆ XP milestone rewards'], - 7: ['āš”ļø Grandmaster badge', '+35% XP bonus', 'šŸ’« Special profile effects'], - 8: ['šŸ‘‘ Champion badge', '+40% XP bonus', 'šŸ† Leaderboard priority display'], - 9: ['🌟 Legend badge', '+45% XP bonus', '✨ Legendary profile aura'], - 10: ['🌈 Mythic badge', '+50% XP bonus', '🌟 All prestige perks unlocked!'] + 1: ['Bronze Prestige badge', '+5% XP bonus on all XP gains'], + 2: ['Silver Prestige badge', '+10% XP bonus on all XP gains'], + 3: ['Gold Prestige badge', '+15% XP bonus on all XP gains'], + 4: ['Platinum badge', '+20% XP bonus', '+25 bonus daily XP'], + 5: ['Diamond badge', '+25% XP bonus', '10% reduced XP cooldowns'], + 6: ['Master badge', '+30% XP bonus', 'XP milestone rewards'], + 7: ['Grandmaster badge', '+35% XP bonus', 'Special profile effects'], + 8: ['Champion badge', '+40% XP bonus', 'Leaderboard priority display'], + 9: ['Legend badge', '+45% XP bonus', 'Legendary profile aura'], + 10: ['Mythic badge', '+50% XP bonus', 'All prestige perks unlocked!'] }; return rewardMap[prestige] || []; } diff --git a/aethex-bot/commands/profile.js b/aethex-bot/commands/profile.js index 08925c2..4111527 100644 --- a/aethex-bot/commands/profile.js +++ b/aethex-bot/commands/profile.js @@ -1,9 +1,11 @@ const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const { getServerMode, EMBED_COLORS } = require("../utils/modeHelper"); +const { getStandaloneXp, calculateLevel } = require("../utils/standaloneXp"); module.exports = { data: new SlashCommandBuilder() .setName("profile") - .setDescription("View your AeThex profile in Discord") + .setDescription("View your profile") .addUserOption(option => option.setName('user') .setDescription('User to view profile of') @@ -19,140 +21,18 @@ module.exports = { const targetUser = interaction.options.getUser('user') || interaction.user; try { - const { data: link } = await supabase - .from("discord_links") - .select("user_id, primary_arm") - .eq("discord_id", targetUser.id) - .single(); + const mode = await getServerMode(supabase, interaction.guildId); - if (!link) { - const embed = new EmbedBuilder() - .setColor(0xff6b6b) - .setTitle("āŒ Not Linked") - .setThumbnail(targetUser.displayAvatarURL({ size: 256 })) - .setDescription( - targetUser.id === interaction.user.id - ? "You must link your Discord account to AeThex first.\nUse `/verify` to get started." - : `${targetUser.tag} hasn't linked their Discord account to AeThex yet.` - ); - - return await interaction.editReply({ embeds: [embed] }); + if (mode === 'standalone') { + return handleStandaloneProfile(interaction, supabase, targetUser); + } else { + return handleFederatedProfile(interaction, supabase, targetUser); } - - const { data: profile } = await supabase - .from("user_profiles") - .select("*") - .eq("id", link.user_id) - .single(); - - if (!profile) { - const embed = new EmbedBuilder() - .setColor(0xff6b6b) - .setTitle("āŒ Profile Not Found") - .setDescription("The AeThex profile could not be found."); - - return await interaction.editReply({ embeds: [embed] }); - } - - const armEmojis = { - labs: "🧪", - gameforge: "šŸŽ®", - corp: "šŸ’¼", - foundation: "šŸ¤", - devlink: "šŸ’»", - }; - - const armColors = { - labs: 0x22c55e, - gameforge: 0xf97316, - corp: 0x3b82f6, - foundation: 0xec4899, - devlink: 0x8b5cf6, - }; - - const xp = profile.xp || 0; - const prestige = profile.prestige_level || 0; - const level = Math.floor(Math.sqrt(xp / 100)); - const currentLevelXp = level * level * 100; - const nextLevelXp = (level + 1) * (level + 1) * 100; - const progressXp = xp - currentLevelXp; - const neededXp = nextLevelXp - currentLevelXp; - const progressPercent = Math.min(100, Math.floor((progressXp / neededXp) * 100)); - - const progressBar = createProgressBar(progressPercent); - const prestigeInfo = getPrestigeInfo(prestige); - - const badges = profile.badges || []; - const badgeDisplay = badges.length > 0 - ? badges.map(b => getBadgeEmoji(b)).join(' ') - : 'No badges yet'; - - // Validate avatar URL - must be http/https, not base64 - let avatarUrl = targetUser.displayAvatarURL({ size: 256 }); - if (profile.avatar_url && profile.avatar_url.startsWith('http')) { - avatarUrl = profile.avatar_url; - } - - const embed = new EmbedBuilder() - .setColor(armColors[link.primary_arm] || 0x7c3aed) - .setAuthor({ - name: `${profile.full_name || profile.username || 'AeThex User'}`, - iconURL: targetUser.displayAvatarURL({ size: 64 }) - }) - .setThumbnail(avatarUrl) - .setDescription(profile.bio || '*No bio set*') - .addFields( - { - name: "šŸ‘¤ Username", - value: `\`${profile.username || 'N/A'}\``, - inline: true, - }, - { - name: `${armEmojis[link.primary_arm] || "āš”ļø"} Realm`, - value: capitalizeFirst(link.primary_arm) || "Not set", - inline: true, - }, - { - name: "šŸ“Š Role", - value: formatRole(profile.user_type), - inline: true, - }, - { - name: `${prestigeInfo.icon} Prestige`, - value: prestige > 0 ? `**${prestigeInfo.name}** (P${prestige}) +${prestige * 5}% XP` : 'Not prestiged', - inline: true, - }, - { - name: `šŸ“ˆ Level ${level}`, - value: `${progressBar}\n\`${xp.toLocaleString()}\` / \`${nextLevelXp.toLocaleString()}\` XP`, - inline: false, - }, - { - name: "šŸ† Badges", - value: badgeDisplay, - inline: false, - } - ) - .addFields({ - name: "šŸ”— Links", - value: `[View Full Profile](https://aethex.dev/creators/${profile.username}) • [AeThex Platform](https://aethex.dev)`, - }) - .setFooter({ - text: `AeThex | ${targetUser.tag}`, - iconURL: 'https://aethex.dev/favicon.ico' - }) - .setTimestamp(); - - if (profile.banner_url) { - embed.setImage(profile.banner_url); - } - - await interaction.editReply({ embeds: [embed] }); } catch (error) { console.error("Profile command error:", error); const embed = new EmbedBuilder() .setColor(0xff0000) - .setTitle("āŒ Error") + .setTitle("Error") .setDescription("Failed to fetch profile. Please try again."); await interaction.editReply({ embeds: [embed] }); @@ -160,6 +40,175 @@ module.exports = { }, }; +async function handleStandaloneProfile(interaction, supabase, targetUser) { + const data = await getStandaloneXp(supabase, targetUser.id, interaction.guildId); + + if (!data) { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setTitle("No Profile Found") + .setThumbnail(targetUser.displayAvatarURL({ size: 256 })) + .setDescription( + targetUser.id === interaction.user.id + ? "You don't have any XP yet. Start chatting to build your profile!" + : `${targetUser.tag} hasn't earned any XP yet in this server.` + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + const xp = data.xp || 0; + const prestige = data.prestige_level || 0; + const totalXpEarned = data.total_xp_earned || xp; + const level = calculateLevel(xp, 'normal'); + const dailyStreak = data.daily_streak || 0; + + const currentLevelXp = level * level * 100; + const nextLevelXp = (level + 1) * (level + 1) * 100; + const progressXp = xp - currentLevelXp; + const neededXp = nextLevelXp - currentLevelXp; + const progressPercent = Math.min(100, Math.floor((progressXp / neededXp) * 100)); + + const progressBar = createProgressBar(progressPercent); + const prestigeInfo = getPrestigeInfo(prestige); + + const { count: rankPosition } = await supabase + .from('guild_user_xp') + .select('*', { count: 'exact', head: true }) + .eq('guild_id', interaction.guildId) + .gt('xp', xp); + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setAuthor({ + name: targetUser.tag, + iconURL: targetUser.displayAvatarURL({ size: 64 }) + }) + .setThumbnail(targetUser.displayAvatarURL({ size: 256 })) + .addFields( + { name: "Username", value: `\`${data.username || targetUser.username}\``, inline: true }, + { name: "Server Rank", value: `#${(rankPosition || 0) + 1}`, inline: true }, + { name: "Daily Streak", value: `${dailyStreak} days`, inline: true }, + { name: `${prestigeInfo.icon || ''} Prestige`, value: prestige > 0 ? `**${prestigeInfo.name}** (P${prestige}) +${prestige * 5}% XP` : 'Not prestiged', inline: true }, + { name: `Level ${level}`, value: `${progressBar}\n\`${xp.toLocaleString()}\` / \`${nextLevelXp.toLocaleString()}\` XP`, inline: false }, + { name: "Total XP Earned", value: totalXpEarned.toLocaleString(), inline: true } + ) + .setFooter({ + text: `šŸ  Standalone Mode • ${interaction.guild.name}`, + iconURL: interaction.guild.iconURL({ size: 32 }) + }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleFederatedProfile(interaction, supabase, targetUser) { + const { data: link } = await supabase + .from("discord_links") + .select("user_id, primary_arm") + .eq("discord_id", targetUser.id) + .single(); + + if (!link) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("Not Linked") + .setThumbnail(targetUser.displayAvatarURL({ size: 256 })) + .setDescription( + targetUser.id === interaction.user.id + ? "You must link your Discord account to AeThex first.\nUse `/verify` to get started." + : `${targetUser.tag} hasn't linked their Discord account to AeThex yet.` + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + const { data: profile } = await supabase + .from("user_profiles") + .select("*") + .eq("id", link.user_id) + .single(); + + if (!profile) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("Profile Not Found") + .setDescription("The AeThex profile could not be found."); + + return await interaction.editReply({ embeds: [embed] }); + } + + const armEmojis = { + labs: "", + gameforge: "", + corp: "", + foundation: "", + devlink: "", + }; + + const armColors = { + labs: 0x22c55e, + gameforge: 0xf97316, + corp: 0x3b82f6, + foundation: 0xec4899, + devlink: 0x8b5cf6, + }; + + const xp = profile.xp || 0; + const prestige = profile.prestige_level || 0; + const level = Math.floor(Math.sqrt(xp / 100)); + const currentLevelXp = level * level * 100; + const nextLevelXp = (level + 1) * (level + 1) * 100; + const progressXp = xp - currentLevelXp; + const neededXp = nextLevelXp - currentLevelXp; + const progressPercent = Math.min(100, Math.floor((progressXp / neededXp) * 100)); + + const progressBar = createProgressBar(progressPercent); + const prestigeInfo = getPrestigeInfo(prestige); + + const badges = profile.badges || []; + const badgeDisplay = badges.length > 0 + ? badges.map(b => getBadgeEmoji(b)).join(' ') + : 'No badges yet'; + + let avatarUrl = targetUser.displayAvatarURL({ size: 256 }); + if (profile.avatar_url && profile.avatar_url.startsWith('http')) { + avatarUrl = profile.avatar_url; + } + + const embed = new EmbedBuilder() + .setColor(armColors[link.primary_arm] || 0x7c3aed) + .setAuthor({ + name: `${profile.full_name || profile.username || 'AeThex User'}`, + iconURL: targetUser.displayAvatarURL({ size: 64 }) + }) + .setThumbnail(avatarUrl) + .setDescription(profile.bio || '*No bio set*') + .addFields( + { name: "Username", value: `\`${profile.username || 'N/A'}\``, inline: true }, + { name: `${armEmojis[link.primary_arm] || ""} Realm`, value: capitalizeFirst(link.primary_arm) || "Not set", inline: true }, + { name: "Role", value: formatRole(profile.user_type), inline: true }, + { name: `${prestigeInfo.icon || ''} Prestige`, value: prestige > 0 ? `**${prestigeInfo.name}** (P${prestige}) +${prestige * 5}% XP` : 'Not prestiged', inline: true }, + { name: `Level ${level}`, value: `${progressBar}\n\`${xp.toLocaleString()}\` / \`${nextLevelXp.toLocaleString()}\` XP`, inline: false }, + { name: "Badges", value: badgeDisplay, inline: false } + ) + .addFields({ + name: "Links", + value: `[View Full Profile](https://aethex.dev/creators/${profile.username}) • [AeThex Platform](https://aethex.dev)`, + }) + .setFooter({ + text: `🌐 Federation • ${targetUser.tag}`, + iconURL: 'https://aethex.dev/favicon.ico' + }) + .setTimestamp(); + + if (profile.banner_url) { + embed.setImage(profile.banner_url); + } + + await interaction.editReply({ embeds: [embed] }); +} + function createProgressBar(percent) { const filled = Math.floor(percent / 10); const empty = 10 - filled; @@ -178,36 +227,36 @@ function formatRole(role) { function getBadgeEmoji(badge) { const badgeMap = { - 'verified': 'āœ…', - 'founder': 'šŸ‘‘', - 'early_adopter': '🌟', - 'contributor': 'šŸ’Ž', - 'creator': 'šŸŽØ', - 'developer': 'šŸ’»', - 'moderator': 'šŸ›”ļø', - 'partner': 'šŸ¤', - 'premium': 'šŸ’«', - 'top_poster': 'šŸ“', - 'helpful': 'ā¤ļø', - 'bug_hunter': 'šŸ›', - 'event_winner': 'šŸ†', + 'verified': 'Verified', + 'founder': 'Founder', + 'early_adopter': 'Early Adopter', + 'contributor': 'Contributor', + 'creator': 'Creator', + 'developer': 'Developer', + 'moderator': 'Moderator', + 'partner': 'Partner', + 'premium': 'Premium', + 'top_poster': 'Top Poster', + 'helpful': 'Helpful', + 'bug_hunter': 'Bug Hunter', + 'event_winner': 'Event Winner', }; return badgeMap[badge] || `[${badge}]`; } function getPrestigeInfo(level) { const prestiges = [ - { name: 'Unprestiged', icon: '⚪', color: 0x6b7280 }, - { name: 'Bronze', icon: 'šŸ„‰', color: 0xcd7f32 }, - { name: 'Silver', icon: '🄈', color: 0xc0c0c0 }, - { name: 'Gold', icon: 'šŸ„‡', color: 0xffd700 }, - { name: 'Platinum', icon: 'šŸ’Ž', color: 0xe5e4e2 }, - { name: 'Diamond', icon: 'šŸ’ ', color: 0xb9f2ff }, - { name: 'Master', icon: 'šŸ”„', color: 0xff4500 }, - { name: 'Grandmaster', icon: 'āš”ļø', color: 0x9400d3 }, - { name: 'Champion', icon: 'šŸ‘‘', color: 0xffd700 }, - { name: 'Legend', icon: '🌟', color: 0xff69b4 }, - { name: 'Mythic', icon: '🌈', color: 0x7c3aed } + { name: 'Unprestiged', icon: '', color: 0x6b7280 }, + { name: 'Bronze', icon: '', color: 0xcd7f32 }, + { name: 'Silver', icon: '', color: 0xc0c0c0 }, + { name: 'Gold', icon: '', color: 0xffd700 }, + { name: 'Platinum', icon: '', color: 0xe5e4e2 }, + { name: 'Diamond', icon: '', color: 0xb9f2ff }, + { name: 'Master', icon: '', color: 0xff4500 }, + { name: 'Grandmaster', icon: '', color: 0x9400d3 }, + { name: 'Champion', icon: '', color: 0xffd700 }, + { name: 'Legend', icon: '', color: 0xff69b4 }, + { name: 'Mythic', icon: '', color: 0x7c3aed } ]; return prestiges[Math.min(level, 10)] || prestiges[0]; } diff --git a/aethex-bot/commands/qr.js b/aethex-bot/commands/qr.js new file mode 100644 index 0000000..00764fa --- /dev/null +++ b/aethex-bot/commands/qr.js @@ -0,0 +1,40 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor } = require('../utils/modeHelper'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('qr') + .setDescription('Generate a QR code') + .addStringOption(option => + option.setName('text') + .setDescription('The text or URL to encode') + .setRequired(true) + .setMaxLength(500) + ) + .addIntegerOption(option => + option.setName('size') + .setDescription('QR code size (default: 200)') + .setRequired(false) + .setMinValue(100) + .setMaxValue(500) + ), + + async execute(interaction, supabase, client) { + const text = interaction.options.getString('text'); + const size = interaction.options.getInteger('size') || 200; + const mode = await getServerMode(supabase, interaction.guildId); + + const encodedText = encodeURIComponent(text); + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodedText}`; + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('šŸ“± QR Code Generated') + .setDescription(`\`${text.length > 100 ? text.substring(0, 100) + '...' : text}\``) + .setImage(qrUrl) + .setFooter({ text: `Size: ${size}x${size}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/rank.js b/aethex-bot/commands/rank.js index 3b546dc..1f62e7a 100644 --- a/aethex-bot/commands/rank.js +++ b/aethex-bot/commands/rank.js @@ -1,9 +1,11 @@ const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); +const { getStandaloneXp, calculateLevel } = require('../utils/standaloneXp'); module.exports = { data: new SlashCommandBuilder() .setName('rank') - .setDescription('View your unified level and XP across all platforms') + .setDescription('View your level and XP') .addUserOption(option => option.setName('user') .setDescription('User to check (defaults to yourself)') @@ -19,71 +21,13 @@ module.exports = { await interaction.deferReply(); try { - const { data: link } = await supabase - .from('discord_links') - .select('user_id, primary_arm') - .eq('discord_id', target.id) - .single(); + const mode = await getServerMode(supabase, interaction.guildId); - if (!link) { - return interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setColor(0xff6b6b) - .setDescription(`${target.id === interaction.user.id ? 'You are' : `${target.tag} is`} not linked to AeThex. Use \`/verify\` to link your account.`) - ] - }); + if (mode === 'standalone') { + return handleStandaloneRank(interaction, supabase, target); + } else { + return handleFederatedRank(interaction, supabase, target); } - - const { data: profile } = await supabase - .from('user_profiles') - .select('username, avatar_url, xp, bio, prestige_level, total_xp_earned') - .eq('id', link.user_id) - .single(); - - const xp = profile?.xp || 0; - const prestige = profile?.prestige_level || 0; - const totalXpEarned = profile?.total_xp_earned || xp; - const level = Math.floor(Math.sqrt(xp / 100)); - const currentLevelXp = level * level * 100; - const nextLevelXp = (level + 1) * (level + 1) * 100; - const progress = xp - currentLevelXp; - const needed = nextLevelXp - currentLevelXp; - const progressPercent = Math.floor((progress / needed) * 100); - - const progressBar = createProgressBar(progressPercent); - const prestigeInfo = getPrestigeInfo(prestige); - - const { count: rankPosition } = await supabase - .from('user_profiles') - .select('*', { count: 'exact', head: true }) - .gt('xp', xp); - - // Validate avatar URL - must be http/https, not base64 - let avatarUrl = target.displayAvatarURL(); - if (profile?.avatar_url && profile.avatar_url.startsWith('http')) { - avatarUrl = profile.avatar_url; - } - - const embed = new EmbedBuilder() - .setColor(prestigeInfo.color) - .setTitle(`${prestigeInfo.icon} ${profile?.username || target.tag}'s Rank`) - .setThumbnail(avatarUrl) - .addFields( - { name: 'Prestige', value: prestige > 0 ? `**${prestigeInfo.name}** (P${prestige})` : 'Not prestiged', inline: true }, - { name: 'Level', value: `**${level}**`, inline: true }, - { name: 'Rank', value: `#${(rankPosition || 0) + 1}`, inline: true }, - { name: 'Current XP', value: `**${xp.toLocaleString()}**`, inline: true }, - { name: 'XP Bonus', value: prestige > 0 ? `+${prestige * 5}%` : 'None', inline: true }, - { name: 'Total XP Earned', value: totalXpEarned.toLocaleString(), inline: true }, - { name: 'Progress to Next Level', value: `${progressBar}\n${progress.toLocaleString()} / ${needed.toLocaleString()} XP (${progressPercent}%)` }, - { name: 'Primary Realm', value: link.primary_arm || 'None set', inline: true } - ) - .setFooter({ text: prestige >= 1 ? `Prestige ${prestige} | XP earned across Discord & AeThex platforms` : 'XP earned across Discord & AeThex platforms' }) - .setTimestamp(); - - await interaction.editReply({ embeds: [embed] }); - } catch (error) { console.error('Rank error:', error); await interaction.editReply({ content: 'Failed to fetch rank data.' }); @@ -91,6 +35,123 @@ module.exports = { }, }; +async function handleStandaloneRank(interaction, supabase, target) { + const data = await getStandaloneXp(supabase, target.id, interaction.guildId); + + if (!data) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setDescription(`${target.id === interaction.user.id ? 'You have' : `${target.tag} has`} no XP yet. Start chatting to earn XP!`) + ] + }); + } + + const xp = data.xp || 0; + const prestige = data.prestige_level || 0; + const totalXpEarned = data.total_xp_earned || xp; + const level = calculateLevel(xp, 'normal'); + const currentLevelXp = level * level * 100; + const nextLevelXp = (level + 1) * (level + 1) * 100; + const progress = xp - currentLevelXp; + const needed = nextLevelXp - currentLevelXp; + const progressPercent = Math.floor((progress / needed) * 100); + + const progressBar = createProgressBar(progressPercent); + const prestigeInfo = getPrestigeInfo(prestige); + + const { count: rankPosition } = await supabase + .from('guild_user_xp') + .select('*', { count: 'exact', head: true }) + .eq('guild_id', interaction.guildId) + .gt('xp', xp); + + const embed = new EmbedBuilder() + .setColor(prestigeInfo.color) + .setTitle(`${prestigeInfo.icon} ${target.tag}'s Rank`) + .setThumbnail(target.displayAvatarURL()) + .addFields( + { name: 'Prestige', value: prestige > 0 ? `**${prestigeInfo.name}** (P${prestige})` : 'Not prestiged', inline: true }, + { name: 'Level', value: `**${level}**`, inline: true }, + { name: 'Server Rank', value: `#${(rankPosition || 0) + 1}`, inline: true }, + { name: 'Current XP', value: `**${xp.toLocaleString()}**`, inline: true }, + { name: 'XP Bonus', value: prestige > 0 ? `+${prestige * 5}%` : 'None', inline: true }, + { name: 'Total XP Earned', value: totalXpEarned.toLocaleString(), inline: true }, + { name: 'Progress to Next Level', value: `${progressBar}\n${progress.toLocaleString()} / ${needed.toLocaleString()} XP (${progressPercent}%)` } + ) + .setFooter({ text: `šŸ  Standalone Mode • ${interaction.guild.name}` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleFederatedRank(interaction, supabase, target) { + const { data: link } = await supabase + .from('discord_links') + .select('user_id, primary_arm') + .eq('discord_id', target.id) + .single(); + + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription(`${target.id === interaction.user.id ? 'You are' : `${target.tag} is`} not linked to AeThex. Use \`/verify\` to link your account.`) + ] + }); + } + + const { data: profile } = await supabase + .from('user_profiles') + .select('username, avatar_url, xp, bio, prestige_level, total_xp_earned') + .eq('id', link.user_id) + .single(); + + const xp = profile?.xp || 0; + const prestige = profile?.prestige_level || 0; + const totalXpEarned = profile?.total_xp_earned || xp; + const level = Math.floor(Math.sqrt(xp / 100)); + const currentLevelXp = level * level * 100; + const nextLevelXp = (level + 1) * (level + 1) * 100; + const progress = xp - currentLevelXp; + const needed = nextLevelXp - currentLevelXp; + const progressPercent = Math.floor((progress / needed) * 100); + + const progressBar = createProgressBar(progressPercent); + const prestigeInfo = getPrestigeInfo(prestige); + + const { count: rankPosition } = await supabase + .from('user_profiles') + .select('*', { count: 'exact', head: true }) + .gt('xp', xp); + + let avatarUrl = target.displayAvatarURL(); + if (profile?.avatar_url && profile.avatar_url.startsWith('http')) { + avatarUrl = profile.avatar_url; + } + + const embed = new EmbedBuilder() + .setColor(prestigeInfo.color) + .setTitle(`${prestigeInfo.icon} ${profile?.username || target.tag}'s Rank`) + .setThumbnail(avatarUrl) + .addFields( + { name: 'Prestige', value: prestige > 0 ? `**${prestigeInfo.name}** (P${prestige})` : 'Not prestiged', inline: true }, + { name: 'Level', value: `**${level}**`, inline: true }, + { name: 'Global Rank', value: `#${(rankPosition || 0) + 1}`, inline: true }, + { name: 'Current XP', value: `**${xp.toLocaleString()}**`, inline: true }, + { name: 'XP Bonus', value: prestige > 0 ? `+${prestige * 5}%` : 'None', inline: true }, + { name: 'Total XP Earned', value: totalXpEarned.toLocaleString(), inline: true }, + { name: 'Progress to Next Level', value: `${progressBar}\n${progress.toLocaleString()} / ${needed.toLocaleString()} XP (${progressPercent}%)` }, + { name: 'Primary Realm', value: link.primary_arm || 'None set', inline: true } + ) + .setFooter({ text: prestige >= 1 ? `🌐 Federation • Prestige ${prestige} | XP earned across Discord & AeThex platforms` : '🌐 Federation • XP earned across Discord & AeThex platforms' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + function createProgressBar(percent) { const filled = Math.floor(percent / 10); const empty = 10 - filled; diff --git a/aethex-bot/commands/refresh-roles.js b/aethex-bot/commands/refresh-roles.js index 9f138da..5eff6c1 100644 --- a/aethex-bot/commands/refresh-roles.js +++ b/aethex-bot/commands/refresh-roles.js @@ -1,5 +1,6 @@ const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); const { assignRoleByArm, getUserArm } = require("../utils/roleManager"); +const { getServerMode, EMBED_COLORS } = require("../utils/modeHelper"); module.exports = { data: new SlashCommandBuilder() @@ -12,6 +13,17 @@ module.exports = { if (!supabase) { return interaction.reply({ content: "This feature requires Supabase to be configured.", ephemeral: true }); } + + const mode = await getServerMode(supabase, interaction.guildId); + + if (mode === 'standalone') { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setTitle('šŸ  Standalone Mode') + .setDescription('Role syncing is disabled in standalone mode.\n\nThis server operates independently without AeThex role federation.\n\nUse `/config mode` to switch to federated mode.'); + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + await interaction.deferReply({ ephemeral: true }); try { diff --git a/aethex-bot/commands/remind.js b/aethex-bot/commands/remind.js new file mode 100644 index 0000000..23778bb --- /dev/null +++ b/aethex-bot/commands/remind.js @@ -0,0 +1,193 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); + +const activeReminders = new Map(); + +function parseTime(timeStr) { + const regex = /^(\d+)(s|m|h|d)$/i; + const match = timeStr.match(regex); + if (!match) return null; + + const value = parseInt(match[1]); + const unit = match[2].toLowerCase(); + + const multipliers = { + 's': 1000, + 'm': 60 * 1000, + 'h': 60 * 60 * 1000, + 'd': 24 * 60 * 60 * 1000 + }; + + return value * multipliers[unit]; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName('remind') + .setDescription('Set a personal reminder') + .addSubcommand(subcommand => + subcommand + .setName('set') + .setDescription('Set a new reminder') + .addStringOption(option => + option.setName('time') + .setDescription('When to remind (e.g., 30m, 2h, 1d)') + .setRequired(true) + ) + .addStringOption(option => + option.setName('message') + .setDescription('What to remind you about') + .setRequired(true) + .setMaxLength(500) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('List your active reminders') + ) + .addSubcommand(subcommand => + subcommand + .setName('cancel') + .setDescription('Cancel a reminder') + .addStringOption(option => + option.setName('id') + .setDescription('The reminder ID to cancel') + .setRequired(true) + ) + ), + + async execute(interaction, supabase, client) { + const subcommand = interaction.options.getSubcommand(); + const mode = await getServerMode(supabase, interaction.guildId); + const userId = interaction.user.id; + + if (subcommand === 'set') { + const timeStr = interaction.options.getString('time'); + const message = interaction.options.getString('message'); + + const duration = parseTime(timeStr); + if (!duration) { + return interaction.reply({ + content: 'Invalid time format. Use formats like: 30s, 5m, 2h, 1d', + ephemeral: true + }); + } + + if (duration < 10000) { + return interaction.reply({ + content: 'Reminder must be at least 10 seconds in the future.', + ephemeral: true + }); + } + + if (duration > 30 * 24 * 60 * 60 * 1000) { + return interaction.reply({ + content: 'Reminder cannot be more than 30 days in the future.', + ephemeral: true + }); + } + + const reminderId = `${Date.now()}-${Math.random().toString(36).substr(2, 6)}`; + const reminderTime = Date.now() + duration; + + const timeout = setTimeout(async () => { + try { + const user = await client.users.fetch(userId); + const reminderEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.warning) + .setTitle('ā° Reminder!') + .setDescription(message) + .addFields({ + name: 'šŸ“ Set', + value: `` + }) + .setTimestamp(); + + await user.send({ embeds: [reminderEmbed] }); + } catch (e) { + try { + const channel = await client.channels.fetch(interaction.channelId); + await channel.send({ + content: `<@${userId}> ā° **Reminder:** ${message}` + }); + } catch (e2) {} + } + + const userReminders = activeReminders.get(userId) || []; + activeReminders.set(userId, userReminders.filter(r => r.id !== reminderId)); + }, duration); + + const userReminders = activeReminders.get(userId) || []; + userReminders.push({ + id: reminderId, + message, + time: reminderTime, + timeout, + channelId: interaction.channelId + }); + activeReminders.set(userId, userReminders); + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('ā° Reminder Set!') + .setDescription(`I'll remind you: **${message}**`) + .addFields( + { name: 'ā±ļø In', value: timeStr, inline: true }, + { name: 'šŸ“… At', value: ``, inline: true }, + { name: 'šŸ†” ID', value: `\`${reminderId}\``, inline: true } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + + else if (subcommand === 'list') { + const userReminders = activeReminders.get(userId) || []; + + if (userReminders.length === 0) { + return interaction.reply({ + content: 'You have no active reminders.', + ephemeral: true + }); + } + + const list = userReminders.map((r, i) => { + return `${i + 1}. **${r.message.substring(0, 50)}${r.message.length > 50 ? '...' : ''}**\n ā° | ID: \`${r.id}\``; + }).join('\n\n'); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('ā° Your Reminders') + .setDescription(list) + .setFooter({ text: `${userReminders.length} active reminder(s)` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + + else if (subcommand === 'cancel') { + const reminderId = interaction.options.getString('id'); + const userReminders = activeReminders.get(userId) || []; + + const reminder = userReminders.find(r => r.id === reminderId); + if (!reminder) { + return interaction.reply({ + content: 'Reminder not found. Use `/remind list` to see your reminders.', + ephemeral: true + }); + } + + clearTimeout(reminder.timeout); + activeReminders.set(userId, userReminders.filter(r => r.id !== reminderId)); + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('šŸ—‘ļø Reminder Cancelled') + .setDescription(`Cancelled reminder: **${reminder.message.substring(0, 100)}**`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + }, +}; diff --git a/aethex-bot/commands/rep.js b/aethex-bot/commands/rep.js new file mode 100644 index 0000000..aebe5a6 --- /dev/null +++ b/aethex-bot/commands/rep.js @@ -0,0 +1,83 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); + +const repCooldowns = new Map(); +const REP_COOLDOWN = 12 * 60 * 60 * 1000; + +module.exports = { + data: new SlashCommandBuilder() + .setName('rep') + .setDescription('Give a reputation point to someone') + .addUserOption(option => + option.setName('user') + .setDescription('The user to give rep to') + .setRequired(true) + ) + .addStringOption(option => + option.setName('reason') + .setDescription('Why are you giving them rep?') + .setRequired(false) + .setMaxLength(200) + ), + + async execute(interaction, supabase, client) { + const targetUser = interaction.options.getUser('user'); + const reason = interaction.options.getString('reason'); + const userId = interaction.user.id; + const guildId = interaction.guildId; + const mode = await getServerMode(supabase, interaction.guildId); + + if (targetUser.id === userId) { + return interaction.reply({ + content: "You can't give rep to yourself!", + ephemeral: true + }); + } + + if (targetUser.bot) { + return interaction.reply({ + content: "You can't give rep to bots!", + ephemeral: true + }); + } + + const cooldownKey = `${guildId}-${userId}`; + const lastRep = repCooldowns.get(cooldownKey); + if (lastRep && Date.now() - lastRep < REP_COOLDOWN) { + const remaining = REP_COOLDOWN - (Date.now() - lastRep); + const hours = Math.floor(remaining / (60 * 60 * 1000)); + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); + return interaction.reply({ + content: `You can give rep again in ${hours}h ${minutes}m.`, + ephemeral: true + }); + } + + repCooldowns.set(cooldownKey, Date.now()); + + if (supabase) { + try { + await supabase.from('reputation').insert({ + guild_id: guildId, + giver_id: userId, + receiver_id: targetUser.id, + reason: reason, + created_at: new Date().toISOString() + }); + } catch (e) {} + } + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('⭐ Reputation Given!') + .setDescription(`${interaction.user} gave a rep point to ${targetUser}!`) + .setThumbnail(targetUser.displayAvatarURL({ size: 128 })) + .setTimestamp(); + + if (reason) { + embed.addFields({ name: 'šŸ’¬ Reason', value: reason }); + } + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/roll.js b/aethex-bot/commands/roll.js new file mode 100644 index 0000000..3ee8e1e --- /dev/null +++ b/aethex-bot/commands/roll.js @@ -0,0 +1,98 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor } = require('../utils/modeHelper'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('roll') + .setDescription('Roll dice') + .addStringOption(option => + option.setName('dice') + .setDescription('Dice notation (e.g., 2d6, d20, 3d8+5)') + .setRequired(false) + ) + .addIntegerOption(option => + option.setName('sides') + .setDescription('Number of sides (default: 6)') + .setRequired(false) + .setMinValue(2) + .setMaxValue(1000) + ) + .addIntegerOption(option => + option.setName('count') + .setDescription('Number of dice to roll (default: 1)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(100) + ), + + async execute(interaction, supabase, client) { + const diceNotation = interaction.options.getString('dice'); + let sides = interaction.options.getInteger('sides') || 6; + let count = interaction.options.getInteger('count') || 1; + let modifier = 0; + + if (diceNotation) { + const match = diceNotation.match(/^(\d*)d(\d+)([+-]\d+)?$/i); + if (match) { + count = match[1] ? parseInt(match[1]) : 1; + sides = parseInt(match[2]); + modifier = match[3] ? parseInt(match[3]) : 0; + + if (count > 100) count = 100; + if (sides > 1000) sides = 1000; + } else { + return interaction.reply({ + content: 'Invalid dice notation. Use format like `2d6`, `d20`, or `3d8+5`', + ephemeral: true + }); + } + } + + const rolls = []; + for (let i = 0; i < count; i++) { + rolls.push(Math.floor(Math.random() * sides) + 1); + } + + const sum = rolls.reduce((a, b) => a + b, 0); + const total = sum + modifier; + + const mode = await getServerMode(supabase, interaction.guildId); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('šŸŽ² Dice Roll') + .setTimestamp() + .setFooter({ text: `Rolled by ${interaction.user.username}` }); + + if (count === 1 && modifier === 0) { + embed.setDescription(`You rolled a **${total}**!`); + } else { + const rollsDisplay = rolls.length <= 20 + ? rolls.map(r => `\`${r}\``).join(' + ') + : `${rolls.slice(0, 20).map(r => `\`${r}\``).join(' + ')} ... (+${rolls.length - 20} more)`; + + let formula = `${count}d${sides}`; + if (modifier > 0) formula += `+${modifier}`; + else if (modifier < 0) formula += modifier; + + embed.addFields( + { name: 'šŸŽÆ Formula', value: formula, inline: true }, + { name: 'šŸ“Š Total', value: `**${total}**`, inline: true } + ); + + if (rolls.length <= 20) { + embed.addFields({ name: 'šŸŽ² Rolls', value: rollsDisplay }); + } + + if (modifier !== 0) { + embed.addFields({ + name: 'āž• Calculation', + value: `${sum} ${modifier >= 0 ? '+' : ''}${modifier} = **${total}**`, + inline: true + }); + } + } + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/set-realm.js b/aethex-bot/commands/set-realm.js index 2205d24..79f83c9 100644 --- a/aethex-bot/commands/set-realm.js +++ b/aethex-bot/commands/set-realm.js @@ -5,6 +5,7 @@ const { ActionRowBuilder, } = require("discord.js"); const { assignRoleByArm } = require("../utils/roleManager"); +const { getServerMode, EMBED_COLORS } = require("../utils/modeHelper"); const REALMS = [ { value: "labs", label: "🧪 Labs", description: "Research & Development" }, @@ -35,6 +36,17 @@ module.exports = { if (!supabase) { return interaction.reply({ content: "This feature requires Supabase to be configured.", ephemeral: true }); } + + const mode = await getServerMode(supabase, interaction.guildId); + + if (mode === 'standalone') { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.standalone) + .setTitle('šŸ  Standalone Mode') + .setDescription('Realm selection is disabled in standalone mode.\n\nThis server operates independently without AeThex realm affiliation.\n\nUse `/config mode` to switch to federated mode.'); + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + await interaction.deferReply({ ephemeral: true }); try { diff --git a/aethex-bot/commands/slots.js b/aethex-bot/commands/slots.js new file mode 100644 index 0000000..b95ab33 --- /dev/null +++ b/aethex-bot/commands/slots.js @@ -0,0 +1,127 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); +const { updateStandaloneXp } = require('../utils/standaloneXp'); + +const SYMBOLS = ['šŸ’', 'šŸ‹', 'šŸŠ', 'šŸ‡', '⭐', 'šŸ’Ž', '7ļøāƒ£']; +const WEIGHTS = [30, 25, 20, 15, 7, 2, 1]; + +const PAYOUTS = { + 'šŸ’šŸ’šŸ’': 5, + 'šŸ‹šŸ‹šŸ‹': 10, + 'šŸŠšŸŠšŸŠ': 15, + 'šŸ‡šŸ‡šŸ‡': 20, + '⭐⭐⭐': 50, + 'šŸ’ŽšŸ’ŽšŸ’Ž': 100, + '7ļøāƒ£7ļøāƒ£7ļøāƒ£': 250, +}; + +const slotsCooldowns = new Map(); +const COOLDOWN = 30000; + +function spin() { + const totalWeight = WEIGHTS.reduce((a, b) => a + b, 0); + const random = Math.random() * totalWeight; + let cumulativeWeight = 0; + + for (let i = 0; i < SYMBOLS.length; i++) { + cumulativeWeight += WEIGHTS[i]; + if (random < cumulativeWeight) { + return SYMBOLS[i]; + } + } + return SYMBOLS[0]; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName('slots') + .setDescription('Try your luck at the slot machine!') + .addIntegerOption(option => + option.setName('bet') + .setDescription('XP to bet (10-50)') + .setRequired(false) + .setMinValue(10) + .setMaxValue(50) + ), + + async execute(interaction, supabase, client) { + const bet = interaction.options.getInteger('bet') || 10; + const userId = interaction.user.id; + const guildId = interaction.guildId; + const mode = await getServerMode(supabase, interaction.guildId); + + const cooldownKey = `${guildId}-${userId}`; + const lastSpin = slotsCooldowns.get(cooldownKey); + if (lastSpin && Date.now() - lastSpin < COOLDOWN) { + const remaining = Math.ceil((COOLDOWN - (Date.now() - lastSpin)) / 1000); + return interaction.reply({ + content: `You can spin again in ${remaining} seconds.`, + ephemeral: true + }); + } + + slotsCooldowns.set(cooldownKey, Date.now()); + + const slot1 = spin(); + const slot2 = spin(); + const slot3 = spin(); + const result = `${slot1}${slot2}${slot3}`; + + const payout = PAYOUTS[result]; + let netGain = 0; + let resultMessage = ''; + let color = getEmbedColor(mode); + + if (payout) { + netGain = bet * payout; + resultMessage = `šŸŽ‰ JACKPOT! You won **${netGain} XP**!`; + color = EMBED_COLORS.success; + } else if (slot1 === slot2 || slot2 === slot3 || slot1 === slot3) { + netGain = Math.floor(bet * 0.5); + resultMessage = `Nice! Two matching! You won **${netGain} XP**!`; + color = 0xF59E0B; + } else { + netGain = -bet; + resultMessage = `No luck this time. You lost **${bet} XP**.`; + color = EMBED_COLORS.error; + } + + if (netGain !== 0) { + if (mode === 'standalone') { + await updateStandaloneXp(supabase, userId, guildId, netGain, interaction.user.username); + } else if (supabase) { + try { + const { data: profile } = await supabase + .from('user_profiles') + .select('xp') + .eq('discord_id', userId) + .maybeSingle(); + + if (profile) { + const newXp = Math.max(0, (profile.xp || 0) + netGain); + await supabase + .from('user_profiles') + .update({ xp: newXp }) + .eq('discord_id', userId); + } + } catch (e) {} + } + } + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle('šŸŽ° Slot Machine') + .setDescription(` +╔═══════════╗ +ā•‘ ${slot1} ${slot2} ${slot3} ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +${resultMessage} + `) + .addFields({ name: 'šŸ’° Bet', value: `${bet} XP`, inline: true }) + .setFooter({ text: `${interaction.user.username}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/commands/starboard.js b/aethex-bot/commands/starboard.js new file mode 100644 index 0000000..437161c --- /dev/null +++ b/aethex-bot/commands/starboard.js @@ -0,0 +1,113 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ChannelType } = require('discord.js'); +const { EMBED_COLORS } = require('../utils/modeHelper'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('starboard') + .setDescription('Configure the starboard system') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('setup') + .setDescription('Set up or update the starboard channel') + .addChannelOption(option => + option.setName('channel') + .setDescription('The starboard channel') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + .addIntegerOption(option => + option.setName('threshold') + .setDescription('Minimum stars needed (default: 3)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(50) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('disable') + .setDescription('Disable the starboard') + ) + .addSubcommand(subcommand => + subcommand + .setName('status') + .setDescription('View current starboard settings') + ), + + async execute(interaction, supabase, client) { + const subcommand = interaction.options.getSubcommand(); + const guildId = interaction.guildId; + + if (!supabase) { + return interaction.reply({ content: 'Starboard system unavailable.', ephemeral: true }); + } + + if (subcommand === 'setup') { + const channel = interaction.options.getChannel('channel'); + const threshold = interaction.options.getInteger('threshold') || 3; + + await supabase.from('starboard_config').upsert({ + guild_id: guildId, + channel_id: channel.id, + threshold: threshold, + enabled: true, + updated_at: new Date().toISOString() + }, { onConflict: 'guild_id' }); + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('⭐ Starboard Configured!') + .addFields( + { name: 'šŸ“ Channel', value: `${channel}`, inline: true }, + { name: 'šŸŽÆ Threshold', value: `${threshold} stars`, inline: true } + ) + .setDescription('Messages that receive enough ⭐ reactions will be posted to the starboard!') + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + + else if (subcommand === 'disable') { + await supabase + .from('starboard_config') + .update({ enabled: false }) + .eq('guild_id', guildId); + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.warning) + .setTitle('⭐ Starboard Disabled') + .setDescription('The starboard has been disabled. Use `/starboard setup` to re-enable.') + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + + else if (subcommand === 'status') { + const { data } = await supabase + .from('starboard_config') + .select('*') + .eq('guild_id', guildId) + .maybeSingle(); + + if (!data) { + return interaction.reply({ + content: 'Starboard is not set up. Use `/starboard setup` to configure it.', + ephemeral: true + }); + } + + const embed = new EmbedBuilder() + .setColor(data.enabled ? EMBED_COLORS.success : EMBED_COLORS.warning) + .setTitle('⭐ Starboard Status') + .addFields( + { name: 'šŸ“Š Status', value: data.enabled ? 'āœ… Enabled' : 'āŒ Disabled', inline: true }, + { name: 'šŸ“ Channel', value: `<#${data.channel_id}>`, inline: true }, + { name: 'šŸŽÆ Threshold', value: `${data.threshold || 3} stars`, inline: true } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + }, +}; diff --git a/aethex-bot/commands/status.js b/aethex-bot/commands/status.js index b1cd9ca..34452a5 100644 --- a/aethex-bot/commands/status.js +++ b/aethex-bot/commands/status.js @@ -1,4 +1,5 @@ const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, getModeDisplayName, getModeEmoji } = require('../utils/modeHelper'); module.exports = { data: new SlashCommandBuilder() @@ -12,9 +13,30 @@ module.exports = { const hours = Math.floor(uptime / 3600); const minutes = Math.floor((uptime % 3600) / 60); const seconds = uptime % 60; + + const serverMode = supabase ? await getServerMode(supabase, interaction.guildId) : 'standalone'; + const embedColor = getEmbedColor(serverMode); + + let federatedCount = 0; + let standaloneCount = 0; + + if (supabase) { + try { + const { data: configs } = await supabase + .from('server_config') + .select('mode'); + + if (configs) { + federatedCount = configs.filter(c => c.mode === 'federated' || !c.mode).length; + standaloneCount = configs.filter(c => c.mode === 'standalone').length; + } + } catch (e) { + federatedCount = guildCount; + } + } const realmStatus = []; - const REALM_GUILDS = client.REALM_GUILDS; + const REALM_GUILDS = client.REALM_GUILDS || {}; for (const [realm, guildId] of Object.entries(REALM_GUILDS)) { if (!guildId) { @@ -37,19 +59,34 @@ module.exports = { })); const embed = new EmbedBuilder() - .setColor(0x7c3aed) - .setTitle('AeThex Network Status') - .setDescription('Current status of the AeThex Federation') + .setColor(embedColor) + .setTitle('āš”ļø Warden Status') + .setDescription(`${getModeEmoji(serverMode)} This server is running in **${getModeDisplayName(serverMode)}** mode`) .addFields( { name: 'Total Servers', value: `${guildCount}`, inline: true }, { name: 'Total Members', value: `${memberCount.toLocaleString()}`, inline: true }, { name: 'Uptime', value: `${hours}h ${minutes}m ${seconds}s`, inline: true }, - ...realmFields, - { name: 'Sentinel Status', value: client.heatMap.size > 0 ? `āš ļø Monitoring ${client.heatMap.size} user(s)` : 'šŸ›”ļø All Clear', inline: false }, - { name: 'Active Tickets', value: `${client.activeTickets.size}`, inline: true }, - { name: 'Federation Mappings', value: `${client.federationMappings.size}`, inline: true } - ) - .setFooter({ text: 'AeThex Unified Bot' }) + { name: 'Federated Servers', value: `🌐 ${federatedCount}`, inline: true }, + { name: 'Standalone Servers', value: `šŸ  ${standaloneCount}`, inline: true }, + { name: '\u200b', value: '\u200b', inline: true } + ); + + if (serverMode === 'federated' && realmFields.length > 0) { + embed.addFields( + { name: '── Federation Realms ──', value: '\u200b', inline: false }, + ...realmFields + ); + } + + embed.addFields( + { name: '── Security ──', value: '\u200b', inline: false }, + { name: 'Sentinel Status', value: client.heatMap?.size > 0 ? `āš ļø Monitoring ${client.heatMap.size} user(s)` : 'šŸ›”ļø All Clear', inline: true }, + { name: 'Active Tickets', value: `${client.activeTickets?.size || 0}`, inline: true }, + { name: 'Federation Mappings', value: `${client.federationMappings?.size || 0}`, inline: true } + ); + + embed + .setFooter({ text: `Warden • ${getModeDisplayName(serverMode)} Mode` }) .setTimestamp(); await interaction.reply({ embeds: [embed] }); diff --git a/aethex-bot/commands/trade.js b/aethex-bot/commands/trade.js new file mode 100644 index 0000000..8f85d85 --- /dev/null +++ b/aethex-bot/commands/trade.js @@ -0,0 +1,218 @@ +const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); + +const activeTrades = new Map(); + +module.exports = { + data: new SlashCommandBuilder() + .setName('trade') + .setDescription('Trade items with another user') + .addUserOption(option => + option.setName('user') + .setDescription('User to trade with') + .setRequired(true) + ) + .addStringOption(option => + option.setName('offer') + .setDescription('What you\'re offering (item name)') + .setRequired(true) + ) + .addStringOption(option => + option.setName('request') + .setDescription('What you want in return (item name)') + .setRequired(true) + ), + + async execute(interaction, supabase, client) { + const partner = interaction.options.getUser('user'); + const offer = interaction.options.getString('offer'); + const request = interaction.options.getString('request'); + const initiator = interaction.user; + const mode = await getServerMode(supabase, interaction.guildId); + const guildId = interaction.guildId; + + if (partner.id === initiator.id) { + return interaction.reply({ content: "You can't trade with yourself!", ephemeral: true }); + } + + if (partner.bot) { + return interaction.reply({ content: "You can't trade with bots!", ephemeral: true }); + } + + if (!supabase) { + return interaction.reply({ content: 'Trade system unavailable.', ephemeral: true }); + } + + const tradeKey = `${guildId}-${initiator.id}`; + if (activeTrades.has(tradeKey)) { + return interaction.reply({ content: 'You already have an active trade!', ephemeral: true }); + } + + const { data: initiatorItem } = await supabase + .from('user_inventory') + .select('*, shop_items(*)') + .eq('guild_id', guildId) + .eq('user_id', initiator.id) + .ilike('shop_items.name', `%${offer}%`) + .gt('quantity', 0) + .maybeSingle(); + + if (!initiatorItem) { + return interaction.reply({ + content: `You don't have "${offer}" in your inventory!`, + ephemeral: true + }); + } + + const { data: partnerItem } = await supabase + .from('user_inventory') + .select('*, shop_items(*)') + .eq('guild_id', guildId) + .eq('user_id', partner.id) + .ilike('shop_items.name', `%${request}%`) + .gt('quantity', 0) + .maybeSingle(); + + if (!partnerItem) { + return interaction.reply({ + content: `${partner.username} doesn't have "${request}" in their inventory!`, + ephemeral: true + }); + } + + activeTrades.set(tradeKey, { + partner: partner.id, + offer: initiatorItem, + request: partnerItem + }); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('šŸ”„ Trade Request') + .setDescription(`${initiator} wants to trade with ${partner}!`) + .addFields( + { + name: `${initiator.username} Offers`, + value: `${initiatorItem.shop_items?.emoji || 'šŸ“¦'} ${initiatorItem.shop_items?.name}`, + inline: true + }, + { + name: `${partner.username} Offers`, + value: `${partnerItem.shop_items?.emoji || 'šŸ“¦'} ${partnerItem.shop_items?.name}`, + inline: true + } + ) + .setFooter({ text: 'Trade expires in 60 seconds' }) + .setTimestamp(); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`trade_accept_${initiator.id}`) + .setLabel('Accept') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`trade_decline_${initiator.id}`) + .setLabel('Decline') + .setStyle(ButtonStyle.Danger) + ); + + const message = await interaction.reply({ + content: `${partner}`, + embeds: [embed], + components: [row], + fetchReply: true + }); + + const collector = message.createMessageComponentCollector({ + filter: i => i.user.id === partner.id && i.customId.includes(initiator.id), + time: 60000, + max: 1 + }); + + collector.on('collect', async (i) => { + const tradeData = activeTrades.get(tradeKey); + activeTrades.delete(tradeKey); + + if (!tradeData) return; + + if (i.customId.startsWith('trade_decline')) { + const declineEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setTitle('šŸ”„ Trade Declined') + .setDescription(`${partner} declined the trade.`) + .setTimestamp(); + await i.update({ embeds: [declineEmbed], components: [] }); + return; + } + + try { + await supabase.rpc('execute_trade', { + p_guild_id: guildId, + p_user1_id: initiator.id, + p_user2_id: partner.id, + p_item1_id: tradeData.offer.item_id, + p_item2_id: tradeData.request.item_id + }); + + const successEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('šŸ”„ Trade Complete!') + .setDescription(`${initiator} and ${partner} successfully traded items!`) + .addFields( + { name: `${initiator.username} received`, value: `${partnerItem.shop_items?.emoji || 'šŸ“¦'} ${partnerItem.shop_items?.name}`, inline: true }, + { name: `${partner.username} received`, value: `${initiatorItem.shop_items?.emoji || 'šŸ“¦'} ${initiatorItem.shop_items?.name}`, inline: true } + ) + .setTimestamp(); + + await i.update({ embeds: [successEmbed], components: [] }); + } catch (e) { + await supabase + .from('user_inventory') + .update({ quantity: tradeData.offer.quantity - 1 }) + .eq('id', tradeData.offer.id); + + await supabase + .from('user_inventory') + .update({ quantity: tradeData.request.quantity - 1 }) + .eq('id', tradeData.request.id); + + await supabase.from('user_inventory').upsert({ + guild_id: guildId, + user_id: partner.id, + item_id: tradeData.offer.item_id, + quantity: 1 + }, { onConflict: 'guild_id,user_id,item_id' }); + + await supabase.from('user_inventory').upsert({ + guild_id: guildId, + user_id: initiator.id, + item_id: tradeData.request.item_id, + quantity: 1 + }, { onConflict: 'guild_id,user_id,item_id' }); + + const successEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle('šŸ”„ Trade Complete!') + .setDescription(`${initiator} and ${partner} successfully traded items!`) + .setTimestamp(); + + await i.update({ embeds: [successEmbed], components: [] }); + } + }); + + collector.on('end', async (collected) => { + if (collected.size === 0) { + activeTrades.delete(tradeKey); + + const timeoutEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.warning) + .setTitle('šŸ”„ Trade Expired') + .setDescription(`${partner} didn't respond in time.`) + .setTimestamp(); + + await interaction.editReply({ embeds: [timeoutEmbed], components: [] }); + } + }); + }, +}; diff --git a/aethex-bot/commands/translate.js b/aethex-bot/commands/translate.js new file mode 100644 index 0000000..61d4918 --- /dev/null +++ b/aethex-bot/commands/translate.js @@ -0,0 +1,93 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); + +const LANGUAGES = { + 'en': 'English', + 'es': 'Spanish', + 'fr': 'French', + 'de': 'German', + 'it': 'Italian', + 'pt': 'Portuguese', + 'ru': 'Russian', + 'ja': 'Japanese', + 'ko': 'Korean', + 'zh': 'Chinese', + 'ar': 'Arabic', + 'hi': 'Hindi', + 'nl': 'Dutch', + 'pl': 'Polish', + 'tr': 'Turkish', + 'vi': 'Vietnamese', + 'th': 'Thai', + 'sv': 'Swedish', + 'da': 'Danish', + 'fi': 'Finnish' +}; + +module.exports = { + data: new SlashCommandBuilder() + .setName('translate') + .setDescription('Translate text to another language') + .addStringOption(option => + option.setName('text') + .setDescription('The text to translate') + .setRequired(true) + .setMaxLength(500) + ) + .addStringOption(option => + option.setName('to') + .setDescription('Target language') + .setRequired(true) + .addChoices( + { name: 'English', value: 'en' }, + { name: 'Spanish', value: 'es' }, + { name: 'French', value: 'fr' }, + { name: 'German', value: 'de' }, + { name: 'Italian', value: 'it' }, + { name: 'Portuguese', value: 'pt' }, + { name: 'Russian', value: 'ru' }, + { name: 'Japanese', value: 'ja' }, + { name: 'Korean', value: 'ko' }, + { name: 'Chinese', value: 'zh' } + ) + ), + + async execute(interaction, supabase, client) { + const text = interaction.options.getString('text'); + const targetLang = interaction.options.getString('to'); + const mode = await getServerMode(supabase, interaction.guildId); + + await interaction.deferReply(); + + try { + const response = await fetch(`https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=auto|${targetLang}`); + const data = await response.json(); + + if (data.responseStatus !== 200 || !data.responseData?.translatedText) { + throw new Error('Translation failed'); + } + + const translated = data.responseData.translatedText; + const detectedLang = data.responseData.detectedLanguage || 'auto'; + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('🌐 Translation') + .addFields( + { name: 'šŸ“„ Original', value: text.substring(0, 1000) }, + { name: `šŸ“¤ ${LANGUAGES[targetLang] || targetLang}`, value: translated.substring(0, 1000) } + ) + .setFooter({ text: `Translated to ${LANGUAGES[targetLang]}` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (e) { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.error) + .setTitle('🌐 Translation Error') + .setDescription('Failed to translate text. Please try again.') + .setTimestamp(); + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/aethex-bot/commands/trivia.js b/aethex-bot/commands/trivia.js new file mode 100644 index 0000000..34bcb62 --- /dev/null +++ b/aethex-bot/commands/trivia.js @@ -0,0 +1,283 @@ +const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); +const { updateStandaloneXp } = require('../utils/standaloneXp'); + +const TRIVIA_QUESTIONS = [ + { + question: "What is the capital of Japan?", + answers: ["Tokyo", "Osaka", "Kyoto", "Hiroshima"], + correct: 0, + category: "Geography" + }, + { + question: "Which planet is known as the Red Planet?", + answers: ["Venus", "Mars", "Jupiter", "Saturn"], + correct: 1, + category: "Science" + }, + { + question: "What year did World War II end?", + answers: ["1943", "1944", "1945", "1946"], + correct: 2, + category: "History" + }, + { + question: "What is the largest mammal on Earth?", + answers: ["African Elephant", "Blue Whale", "Giraffe", "Hippopotamus"], + correct: 1, + category: "Science" + }, + { + question: "Who painted the Mona Lisa?", + answers: ["Michelangelo", "Leonardo da Vinci", "Raphael", "Donatello"], + correct: 1, + category: "Art" + }, + { + question: "What is the chemical symbol for gold?", + answers: ["Go", "Gd", "Au", "Ag"], + correct: 2, + category: "Science" + }, + { + question: "Which programming language was created by Brendan Eich?", + answers: ["Python", "Java", "JavaScript", "Ruby"], + correct: 2, + category: "Technology" + }, + { + question: "What is the smallest country in the world?", + answers: ["Monaco", "Vatican City", "San Marino", "Liechtenstein"], + correct: 1, + category: "Geography" + }, + { + question: "How many bones are in the adult human body?", + answers: ["186", "206", "226", "246"], + correct: 1, + category: "Science" + }, + { + question: "What year was the first iPhone released?", + answers: ["2005", "2006", "2007", "2008"], + correct: 2, + category: "Technology" + }, + { + question: "Which element has the atomic number 1?", + answers: ["Helium", "Hydrogen", "Oxygen", "Carbon"], + correct: 1, + category: "Science" + }, + { + question: "What is the capital of Australia?", + answers: ["Sydney", "Melbourne", "Canberra", "Perth"], + correct: 2, + category: "Geography" + }, + { + question: "Who wrote 'Romeo and Juliet'?", + answers: ["Charles Dickens", "William Shakespeare", "Jane Austen", "Mark Twain"], + correct: 1, + category: "Literature" + }, + { + question: "What is the speed of light in km/s (approximately)?", + answers: ["150,000", "200,000", "300,000", "400,000"], + correct: 2, + category: "Science" + }, + { + question: "Which company created Discord?", + answers: ["Hammer & Chisel", "Meta", "Microsoft", "Google"], + correct: 0, + category: "Technology" + } +]; + +const activeTrivia = new Map(); + +module.exports = { + data: new SlashCommandBuilder() + .setName('trivia') + .setDescription('Answer trivia questions to earn XP!') + .addStringOption(option => + option.setName('category') + .setDescription('Choose a category (optional)') + .setRequired(false) + .addChoices( + { name: 'All', value: 'all' }, + { name: 'Science', value: 'Science' }, + { name: 'Geography', value: 'Geography' }, + { name: 'History', value: 'History' }, + { name: 'Technology', value: 'Technology' }, + { name: 'Art', value: 'Art' }, + { name: 'Literature', value: 'Literature' } + ) + ), + + async execute(interaction, supabase, client) { + const category = interaction.options.getString('category') || 'all'; + const mode = await getServerMode(supabase, interaction.guildId); + const userId = interaction.user.id; + const guildId = interaction.guildId; + + const activeKey = `${guildId}-${userId}`; + if (activeTrivia.has(activeKey)) { + return interaction.reply({ + content: 'You already have an active trivia question! Answer it first.', + ephemeral: true + }); + } + + let questions = TRIVIA_QUESTIONS; + if (category !== 'all') { + questions = TRIVIA_QUESTIONS.filter(q => q.category === category); + } + + if (questions.length === 0) { + return interaction.reply({ + content: 'No questions available for that category.', + ephemeral: true + }); + } + + const question = questions[Math.floor(Math.random() * questions.length)]; + const shuffledIndexes = [0, 1, 2, 3].sort(() => Math.random() - 0.5); + const shuffledAnswers = shuffledIndexes.map(i => question.answers[i]); + const correctIndex = shuffledIndexes.indexOf(question.correct); + + activeTrivia.set(activeKey, { + correctIndex, + question: question.question, + startTime: Date.now() + }); + + const embed = new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle('🧠 Trivia Time!') + .setDescription(`**${question.question}**`) + .addFields( + { name: 'šŸ“š Category', value: question.category, inline: true }, + { name: 'ā±ļø Time Limit', value: '30 seconds', inline: true }, + { name: 'šŸŽ Reward', value: '25-50 XP', inline: true } + ) + .setFooter({ text: 'Click a button to answer!' }) + .setTimestamp(); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`trivia_0_${interaction.user.id}`) + .setLabel(shuffledAnswers[0]) + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`trivia_1_${interaction.user.id}`) + .setLabel(shuffledAnswers[1]) + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`trivia_2_${interaction.user.id}`) + .setLabel(shuffledAnswers[2]) + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`trivia_3_${interaction.user.id}`) + .setLabel(shuffledAnswers[3]) + .setStyle(ButtonStyle.Primary) + ); + + const message = await interaction.reply({ embeds: [embed], components: [row], fetchReply: true }); + + const collector = message.createMessageComponentCollector({ + filter: i => i.customId.startsWith('trivia_') && i.customId.endsWith(`_${interaction.user.id}`), + time: 30000, + max: 1 + }); + + collector.on('collect', async (i) => { + const triviaData = activeTrivia.get(activeKey); + if (!triviaData) return; + + const selectedIndex = parseInt(i.customId.split('_')[1]); + const isCorrect = selectedIndex === triviaData.correctIndex; + const timeTaken = (Date.now() - triviaData.startTime) / 1000; + + activeTrivia.delete(activeKey); + + let xpReward = 0; + if (isCorrect) { + xpReward = timeTaken < 5 ? 50 : timeTaken < 15 ? 35 : 25; + + if (mode === 'standalone') { + await updateStandaloneXp(supabase, userId, guildId, xpReward, interaction.user.username); + } else if (supabase) { + try { + const { data: profile } = await supabase + .from('user_profiles') + .select('xp') + .eq('discord_id', userId) + .maybeSingle(); + + if (profile) { + await supabase + .from('user_profiles') + .update({ xp: (profile.xp || 0) + xpReward }) + .eq('discord_id', userId); + } + } catch (e) {} + } + } + + const resultEmbed = new EmbedBuilder() + .setColor(isCorrect ? EMBED_COLORS.success : EMBED_COLORS.error) + .setTitle(isCorrect ? 'āœ… Correct!' : 'āŒ Incorrect!') + .setDescription(isCorrect + ? `Great job! You answered in ${timeTaken.toFixed(1)} seconds.` + : `The correct answer was: **${question.answers[question.correct]}**` + ) + .addFields( + { name: 'ā“ Question', value: question.question } + ) + .setTimestamp(); + + if (isCorrect) { + resultEmbed.addFields({ name: 'šŸŽ XP Earned', value: `+${xpReward} XP`, inline: true }); + } + + const disabledRow = new ActionRowBuilder() + .addComponents( + ...row.components.map((btn, idx) => + ButtonBuilder.from(btn) + .setStyle(idx === triviaData.correctIndex ? ButtonStyle.Success : + (idx === selectedIndex && !isCorrect) ? ButtonStyle.Danger : + ButtonStyle.Secondary) + .setDisabled(true) + ) + ); + + await i.update({ embeds: [resultEmbed], components: [disabledRow] }); + }); + + collector.on('end', async (collected) => { + if (collected.size === 0) { + activeTrivia.delete(activeKey); + + const timeoutEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.warning) + .setTitle('ā° Time\'s Up!') + .setDescription(`You didn't answer in time. The correct answer was: **${question.answers[question.correct]}**`) + .setTimestamp(); + + const disabledRow = new ActionRowBuilder() + .addComponents( + ...row.components.map(btn => + ButtonBuilder.from(btn) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true) + ) + ); + + await interaction.editReply({ embeds: [timeoutEmbed], components: [disabledRow] }); + } + }); + }, +}; diff --git a/aethex-bot/commands/work.js b/aethex-bot/commands/work.js new file mode 100644 index 0000000..0b01cea --- /dev/null +++ b/aethex-bot/commands/work.js @@ -0,0 +1,76 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { getServerMode, getEmbedColor, EMBED_COLORS } = require('../utils/modeHelper'); +const { updateStandaloneXp } = require('../utils/standaloneXp'); + +const JOBS = [ + { name: 'Developer', emoji: 'šŸ’»', minXp: 15, maxXp: 35, message: 'You wrote some clean code' }, + { name: 'Designer', emoji: 'šŸŽØ', minXp: 12, maxXp: 30, message: 'You created a beautiful design' }, + { name: 'Writer', emoji: 'āœļø', minXp: 10, maxXp: 25, message: 'You wrote an engaging article' }, + { name: 'Streamer', emoji: 'šŸ“ŗ', minXp: 18, maxXp: 40, message: 'Your stream was a hit' }, + { name: 'Chef', emoji: 'šŸ‘Øā€šŸ³', minXp: 8, maxXp: 22, message: 'You cooked a delicious meal' }, + { name: 'Teacher', emoji: 'šŸ‘Øā€šŸ«', minXp: 12, maxXp: 28, message: 'You helped students learn' }, + { name: 'Artist', emoji: 'šŸ–¼ļø', minXp: 14, maxXp: 32, message: 'You created a masterpiece' }, + { name: 'Musician', emoji: 'šŸŽµ', minXp: 10, maxXp: 26, message: 'You performed an amazing song' }, + { name: 'Photographer', emoji: 'šŸ“·', minXp: 11, maxXp: 24, message: 'You captured a perfect shot' }, + { name: 'YouTuber', emoji: 'šŸŽ¬', minXp: 16, maxXp: 38, message: 'Your video went viral' }, +]; + +const workCooldowns = new Map(); +const COOLDOWN = 60 * 60 * 1000; + +module.exports = { + data: new SlashCommandBuilder() + .setName('work') + .setDescription('Work to earn some XP!'), + + async execute(interaction, supabase, client) { + const userId = interaction.user.id; + const guildId = interaction.guildId; + const mode = await getServerMode(supabase, interaction.guildId); + + const cooldownKey = `${guildId}-${userId}`; + const lastWork = workCooldowns.get(cooldownKey); + if (lastWork && Date.now() - lastWork < COOLDOWN) { + const remaining = COOLDOWN - (Date.now() - lastWork); + const minutes = Math.floor(remaining / 60000); + const seconds = Math.floor((remaining % 60000) / 1000); + return interaction.reply({ + content: `You're tired! You can work again in ${minutes}m ${seconds}s.`, + ephemeral: true + }); + } + + workCooldowns.set(cooldownKey, Date.now()); + + const job = JOBS[Math.floor(Math.random() * JOBS.length)]; + const xpEarned = Math.floor(Math.random() * (job.maxXp - job.minXp + 1)) + job.minXp; + + if (mode === 'standalone') { + await updateStandaloneXp(supabase, userId, guildId, xpEarned, interaction.user.username); + } else if (supabase) { + try { + const { data: profile } = await supabase + .from('user_profiles') + .select('xp') + .eq('discord_id', userId) + .maybeSingle(); + + if (profile) { + await supabase + .from('user_profiles') + .update({ xp: (profile.xp || 0) + xpEarned }) + .eq('discord_id', userId); + } + } catch (e) {} + } + + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.success) + .setTitle(`${job.emoji} ${job.name}`) + .setDescription(`${job.message} and earned **${xpEarned} XP**!`) + .setFooter({ text: `${interaction.user.username} | Work again in 1 hour` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/aethex-bot/events/guildSetup.js b/aethex-bot/events/guildSetup.js new file mode 100644 index 0000000..df1fac7 --- /dev/null +++ b/aethex-bot/events/guildSetup.js @@ -0,0 +1,183 @@ +const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } = require('discord.js'); +const { setServerMode, EMBED_COLORS } = require('../utils/modeHelper'); + +module.exports = { + name: 'guildCreate', + + async execute(guild, client, supabase) { + if (!supabase) return; + + const { data: existingConfig } = await supabase + .from('server_config') + .select('mode') + .eq('guild_id', guild.id) + .maybeSingle(); + + if (existingConfig?.mode) { + return; + } + + let targetChannel = null; + + if (guild.systemChannel && guild.systemChannel.permissionsFor(guild.members.me)?.has(PermissionFlagsBits.SendMessages)) { + targetChannel = guild.systemChannel; + } + + if (!targetChannel) { + const channels = guild.channels.cache + .filter(c => + c.type === 0 && + c.permissionsFor(guild.members.me)?.has(PermissionFlagsBits.SendMessages) + ) + .sort((a, b) => a.position - b.position); + + targetChannel = channels.first(); + } + + if (!targetChannel) { + try { + const owner = await guild.fetchOwner(); + await sendSetupDM(owner, guild, client, supabase); + } catch (e) { + console.log(`[Setup] Could not send setup prompt to ${guild.name}`); + } + return; + } + + await sendSetupEmbed(targetChannel, guild, client, supabase); + } +}; + +async function sendSetupEmbed(channel, guild, client, supabase) { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.federated) + .setTitle('āš”ļø Welcome to Warden!') + .setDescription( + `Thanks for adding **Warden** to **${guild.name}**!\n\n` + + `Choose how you want to run Warden:` + ) + .addFields( + { + name: '🌐 Join the Federation', + value: + '• Unified XP across all AeThex servers\n' + + '• Cross-server profiles and leaderboards\n' + + '• Realm selection and role sync\n' + + '• Part of the AeThex ecosystem', + inline: true + }, + { + name: 'šŸ  Run Standalone', + value: + '• Isolated XP system for this server only\n' + + '• Local leaderboards and profiles\n' + + '• Full moderation and anti-nuke\n' + + '• Independent from other servers', + inline: true + } + ) + .setFooter({ text: 'You can change this later with /config mode' }) + .setTimestamp(); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('setup_federated') + .setLabel('Join Federation') + .setStyle(ButtonStyle.Primary) + .setEmoji('🌐'), + new ButtonBuilder() + .setCustomId('setup_standalone') + .setLabel('Run Standalone') + .setStyle(ButtonStyle.Secondary) + .setEmoji('šŸ ') + ); + + try { + const message = await channel.send({ embeds: [embed], components: [row] }); + + const collector = message.createMessageComponentCollector({ + filter: (i) => { + return i.member.permissions.has(PermissionFlagsBits.Administrator) || + i.member.id === guild.ownerId; + }, + time: 86400000 + }); + + collector.on('collect', async (interaction) => { + const mode = interaction.customId === 'setup_federated' ? 'federated' : 'standalone'; + + const success = await setServerMode(supabase, guild.id, mode); + + if (success) { + const confirmEmbed = new EmbedBuilder() + .setColor(mode === 'federated' ? EMBED_COLORS.federated : EMBED_COLORS.standalone) + .setTitle(mode === 'federated' ? '🌐 Federation Mode Activated!' : 'šŸ  Standalone Mode Activated!') + .setDescription( + mode === 'federated' + ? `**${guild.name}** is now part of the AeThex Federation!\n\n` + + '• XP earned here counts globally\n' + + '• Use `/verify` to link your AeThex account\n' + + '• Use `/set-realm` to choose your realm\n' + + '• Use `/help` to see all commands' + : `**${guild.name}** is now running in Standalone mode!\n\n` + + '• XP is tracked locally for this server\n' + + '• Use `/rank` to check your server level\n' + + '• Use `/leaderboard` to see top members\n' + + '• Use `/help` to see all commands' + ) + .setFooter({ text: 'Change mode anytime with /config mode' }) + .setTimestamp(); + + await interaction.update({ embeds: [confirmEmbed], components: [] }); + collector.stop(); + } else { + await interaction.reply({ + content: 'Failed to set mode. Please try `/config mode` later.', + ephemeral: true + }); + } + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + await setServerMode(supabase, guild.id, 'federated'); + + const timeoutEmbed = new EmbedBuilder() + .setColor(EMBED_COLORS.federated) + .setTitle('🌐 Federation Mode (Default)') + .setDescription( + `No selection was made, so **${guild.name}** has been set to Federation mode by default.\n\n` + + 'Use `/config mode` to change this anytime.' + ) + .setTimestamp(); + + await message.edit({ embeds: [timeoutEmbed], components: [] }).catch(() => {}); + } + }); + + } catch (e) { + console.error('[Setup] Error sending setup embed:', e.message); + } +} + +async function sendSetupDM(owner, guild, client, supabase) { + const embed = new EmbedBuilder() + .setColor(EMBED_COLORS.federated) + .setTitle('āš”ļø Warden Setup Required') + .setDescription( + `Thanks for adding **Warden** to **${guild.name}**!\n\n` + + `I couldn't find a channel to send the setup prompt. Please use \`/config mode\` in your server to choose:\n\n` + + '**🌐 Federation** - Join the AeThex ecosystem with unified XP\n' + + '**šŸ  Standalone** - Independent XP for your server only\n\n' + + 'Until you choose, Federation mode is enabled by default.' + ) + .setTimestamp(); + + try { + await owner.send({ embeds: [embed] }); + await setServerMode(supabase, guild.id, 'federated'); + } catch (e) { + await setServerMode(supabase, guild.id, 'federated'); + } +} diff --git a/aethex-bot/events/messageCreate.js b/aethex-bot/events/messageCreate.js index afc0b30..636a053 100644 --- a/aethex-bot/events/messageCreate.js +++ b/aethex-bot/events/messageCreate.js @@ -162,10 +162,17 @@ async function syncMessageToFeed(message) { module.exports = { name: "messageCreate", async execute(message, client) { - if (!supabase) return; - if (message.author.bot) return; + try { + const afkCommand = require('../commands/afk'); + if (afkCommand && afkCommand.checkAfk) { + afkCommand.checkAfk(message); + } + } catch (e) {} + + if (!supabase) return; + if (!message.content && message.attachments.size === 0) return; if (!FEED_CHANNEL_ID) { diff --git a/aethex-bot/listeners/starboard.js b/aethex-bot/listeners/starboard.js new file mode 100644 index 0000000..4ce80a2 --- /dev/null +++ b/aethex-bot/listeners/starboard.js @@ -0,0 +1,127 @@ +const { EmbedBuilder } = require('discord.js'); +const { createClient } = require('@supabase/supabase-js'); + +let supabase = null; +if (process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE) { + supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE); +} + +const starboardCache = new Map(); +const STAR_EMOJI = '⭐'; +const DEFAULT_THRESHOLD = 3; + +async function getStarboardConfig(guildId) { + if (!supabase) return null; + + const cached = starboardCache.get(guildId); + if (cached && Date.now() - cached.timestamp < 60000) { + return cached.config; + } + + try { + const { data } = await supabase + .from('starboard_config') + .select('*') + .eq('guild_id', guildId) + .maybeSingle(); + + starboardCache.set(guildId, { config: data, timestamp: Date.now() }); + return data; + } catch (e) { + return null; + } +} + +async function handleStarboard(reaction, user) { + if (reaction.emoji.name !== STAR_EMOJI) return; + if (reaction.message.author?.bot) return; + + const guildId = reaction.message.guildId; + const config = await getStarboardConfig(guildId); + + if (!config || !config.enabled || !config.channel_id) return; + + const threshold = config.threshold || DEFAULT_THRESHOLD; + const starCount = reaction.count; + + if (starCount < threshold) return; + + const message = reaction.message; + const starboardChannel = message.guild.channels.cache.get(config.channel_id); + + if (!starboardChannel) return; + + try { + const { data: existing } = await supabase + .from('starboard_messages') + .select('starboard_message_id') + .eq('original_message_id', message.id) + .maybeSingle(); + + const embed = new EmbedBuilder() + .setColor(0xFFD700) + .setAuthor({ + name: message.author.tag, + iconURL: message.author.displayAvatarURL({ size: 128 }) + }) + .setDescription(message.content || '*No text content*') + .addFields( + { name: 'šŸ“ Source', value: `[Jump to message](${message.url})`, inline: true }, + { name: '⭐ Stars', value: `${starCount}`, inline: true } + ) + .setTimestamp(message.createdAt) + .setFooter({ text: `#${message.channel.name}` }); + + if (message.attachments.size > 0) { + const attachment = message.attachments.first(); + if (attachment.contentType?.startsWith('image/')) { + embed.setImage(attachment.url); + } + } + + const starEmoji = starCount >= 10 ? '🌟' : starCount >= 5 ? '✨' : '⭐'; + const content = `${starEmoji} **${starCount}** | <#${message.channelId}>`; + + if (existing?.starboard_message_id) { + try { + const starboardMessage = await starboardChannel.messages.fetch(existing.starboard_message_id); + await starboardMessage.edit({ content, embeds: [embed] }); + } catch (e) {} + } else { + const starboardMessage = await starboardChannel.send({ content, embeds: [embed] }); + + await supabase.from('starboard_messages').insert({ + guild_id: guildId, + original_message_id: message.id, + starboard_message_id: starboardMessage.id, + channel_id: message.channelId, + author_id: message.author.id, + star_count: starCount + }); + } + + if (existing) { + await supabase + .from('starboard_messages') + .update({ star_count: starCount }) + .eq('original_message_id', message.id); + } + } catch (e) { + console.error('[Starboard] Error:', e.message); + } +} + +module.exports = { + name: 'messageReactionAdd', + async execute(reaction, user, client) { + if (reaction.partial) { + try { + await reaction.fetch(); + } catch (e) { + return; + } + } + + await handleStarboard(reaction, user); + } +}; diff --git a/aethex-bot/listeners/xpTracker.js b/aethex-bot/listeners/xpTracker.js index 1fa2c3f..01cd3af 100644 --- a/aethex-bot/listeners/xpTracker.js +++ b/aethex-bot/listeners/xpTracker.js @@ -1,5 +1,7 @@ const { EmbedBuilder } = require('discord.js'); const { checkAchievements } = require('../commands/achievements'); +const { getServerMode, getEmbedColor } = require('../utils/modeHelper'); +const { updateStandaloneXp, calculateLevel: standaloneCalcLevel } = require('../utils/standaloneXp'); const xpCooldowns = new Map(); const xpConfigCache = new Map(); @@ -103,6 +105,16 @@ module.exports = { if (now - lastXp < cooldownMs) return; + // Check server mode + const serverMode = await getServerMode(supabase, guildId); + + // Handle standalone mode separately + if (serverMode === 'standalone') { + await handleStandaloneXp(message, client, supabase, config, discordUserId, guildId, channelId, now); + return; + } + + // FEDERATED MODE - requires linked account try { const { data: link, error: linkError } = await supabase .from('discord_links') @@ -537,6 +549,201 @@ async function upsertPeriodXp(supabase, userId, guildId, discordId, periodType, } } +// STANDALONE MODE XP HANDLER +async function handleStandaloneXp(message, client, supabase, config, discordUserId, guildId, channelId, now) { + try { + // Get existing standalone XP data + const { data: existingXp } = await supabase + .from('guild_user_xp') + .select('*') + .eq('discord_id', discordUserId) + .eq('guild_id', guildId) + .maybeSingle(); + + // Calculate base XP + let xpGain = config.message_xp || 5; + const prestige = existingXp?.prestige_level || 0; + + // Apply channel bonus + const bonusChannels = config.bonus_channels || []; + const channelBonus = bonusChannels.find(c => c.channel_id === channelId); + if (channelBonus) { + xpGain = Math.floor(xpGain * channelBonus.multiplier); + } + + // Apply role multipliers (use highest) + const multiplierRoles = config.multiplier_roles || []; + let highestMultiplier = 1; + for (const mr of multiplierRoles) { + if (message.member?.roles.cache.has(mr.role_id)) { + highestMultiplier = Math.max(highestMultiplier, mr.multiplier); + } + } + + // Server boosters get 1.5x XP bonus automatically + if (message.member?.premiumSince) { + highestMultiplier = Math.max(highestMultiplier, 1.5); + } + + xpGain = Math.floor(xpGain * highestMultiplier); + + // Apply prestige bonus (+5% per prestige level) + if (prestige > 0) { + const prestigeBonus = 1 + (prestige * 0.05); + xpGain = Math.floor(xpGain * prestigeBonus); + } + + const currentXp = existingXp?.xp || 0; + const newXp = currentXp + xpGain; + + // Calculate levels + const oldLevel = standaloneCalcLevel(currentXp, config.level_curve); + const newLevel = standaloneCalcLevel(newXp, config.level_curve); + + // Update or create standalone XP record + const result = await updateStandaloneXp(supabase, discordUserId, guildId, xpGain, message.author.username); + + if (!result) return; + + // Update cooldown + const cooldownKey = `${guildId}:${discordUserId}`; + xpCooldowns.set(cooldownKey, now); + + // Track XP for analytics + if (client.trackXP) { + client.trackXP(xpGain); + } + + // Level up announcement for standalone + if (newLevel > oldLevel) { + await sendStandaloneLevelUp(message, newLevel, newXp, config, client); + await checkMilestoneRolesStandalone(message.member, { + level: newLevel, + prestige: prestige, + totalXp: result.total_xp_earned || newXp + }, supabase, guildId); + } + + } catch (error) { + console.error('Standalone XP tracking error:', error.message); + } +} + +async function sendStandaloneLevelUp(message, newLevel, newXp, config, client) { + try { + const messageTemplate = config.levelup_message || 'šŸŽ‰ Congratulations {user}! You reached **Level {level}**!'; + const channelId = config.levelup_channel_id; + const sendDm = config.levelup_dm === true; + const useEmbed = config.levelup_embed === true; + const embedColor = '#6B7280'; // Standalone gray color + + const formattedMessage = messageTemplate + .replace(/{user}/g, message.author.toString()) + .replace(/{username}/g, message.author.username) + .replace(/{level}/g, newLevel.toString()) + .replace(/{xp}/g, newXp.toLocaleString()) + .replace(/{server}/g, message.guild.name); + + let messageContent; + + if (useEmbed) { + const embed = new EmbedBuilder() + .setDescription(formattedMessage) + .setColor(parseInt(embedColor.replace('#', ''), 16)) + .setThumbnail(message.author.displayAvatarURL({ dynamic: true })) + .setFooter({ text: 'šŸ  Standalone Mode' }) + .setTimestamp(); + + messageContent = { embeds: [embed] }; + } else { + messageContent = { content: formattedMessage }; + } + + if (sendDm) { + const dmSent = await message.author.send(messageContent).catch(() => null); + if (!dmSent) { + await message.channel.send(messageContent).catch(() => {}); + } + } else if (channelId) { + const channel = await client.channels.fetch(channelId).catch(() => null); + if (channel) { + await channel.send(messageContent).catch(() => {}); + } else { + await message.channel.send(messageContent).catch(() => {}); + } + } else { + await message.channel.send(messageContent).catch(() => {}); + } + } catch (error) { + console.error('Standalone level-up announcement error:', error.message); + } +} + +async function checkMilestoneRolesStandalone(member, milestones, supabase, guildId) { + if (!member || !supabase) return; + + try { + const { data: allRoles, error } = await supabase + .from('level_roles') + .select('*') + .eq('guild_id', guildId); + + if (error || !allRoles || allRoles.length === 0) return; + + const rolesToAdd = []; + const rolesToRemove = []; + + for (const roleConfig of allRoles) { + const { role_id, milestone_type, milestone_value, stack_roles } = roleConfig; + let qualifies = false; + + switch (milestone_type) { + case 'level': + qualifies = milestones.level >= milestone_value; + break; + case 'prestige': + qualifies = milestones.prestige >= milestone_value; + break; + case 'total_xp': + qualifies = milestones.totalXp >= milestone_value; + break; + } + + if (qualifies && !member.roles.cache.has(role_id)) { + rolesToAdd.push({ role_id, milestone_type, milestone_value, stack_roles }); + } + } + + for (const roleToAdd of rolesToAdd) { + try { + await member.roles.add(roleToAdd.role_id); + + if (!roleToAdd.stack_roles) { + const sameTypeRoles = allRoles.filter(r => + r.milestone_type === roleToAdd.milestone_type && + r.milestone_value < roleToAdd.milestone_value && + member.roles.cache.has(r.role_id) + ); + + for (const oldRole of sameTypeRoles) { + if (!rolesToRemove.includes(oldRole.role_id)) { + rolesToRemove.push(oldRole.role_id); + } + } + } + } catch (e) { + // Role addition failed + } + } + + for (const roleId of rolesToRemove) { + await member.roles.remove(roleId).catch(() => {}); + } + } catch (e) { + // Silently ignore + } +} + // Export functions for use in other commands module.exports.calculateLevel = calculateLevel; module.exports.checkMilestoneRoles = checkMilestoneRoles; diff --git a/aethex-bot/public/index.html b/aethex-bot/public/index.html index d57e46c..159750f 100644 --- a/aethex-bot/public/index.html +++ b/aethex-bot/public/index.html @@ -1324,7 +1324,7 @@
Unified Profiles
-
44+
+
60+
Commands
@@ -1597,6 +1597,126 @@
+ + +
+
+
šŸŽ®
+

Fun & Games

+
+
+
+
šŸŽ±
+

8-Ball & Fortune

+

Ask the magic 8-ball questions, flip coins, and roll dice with custom notation.

+
+
+
🧠
+

Trivia

+

Multiple categories, earn XP for correct answers. Test your knowledge daily.

+
+
+
āš”ļø
+

Duels

+

Challenge others to 1v1 battles. Bet XP on the outcome for extra rewards.

+
+
+
šŸŽ°
+

Slot Machine

+

Try your luck at slots. Match symbols for XP jackpots and winning streaks.

+
+
+
+ + +
+
+
ā¤ļø
+

Social & Interaction

+
+
+
+
⭐
+

Reputation

+

Give and receive rep points. Build your community standing over time.

+
+
+
šŸ¤—
+

Social Actions

+

Hugs, high-fives, and more with animated GIFs. Express yourself!

+
+
+
šŸŽ‚
+

Birthdays

+

Set your birthday, view upcoming celebrations, get special recognition.

+
+
+
ā°
+

Reminders

+

Set personal reminders. Never forget important events or tasks.

+
+
+
+ + +
+
+
šŸ’°
+

Economy & Trading

+
+
+
+
šŸ’¼
+

Work System

+

Work hourly for XP rewards. Different jobs with varying payouts.

+
+
+
šŸ¦
+

Heists

+

Team up for group heists. Higher risk, higher rewards. Strategy matters.

+
+
+
šŸŽ
+

Gifting

+

Gift XP to friends and community members. Spread the wealth.

+
+
+
šŸ”„
+

Trading

+

Trade items between users. Full inventory system with secure trades.

+
+
+
+ + +
+
+
šŸ”§
+

Utility Tools

+
+
+
+
🌐
+

Translation

+

Translate text between 100+ languages instantly. Break language barriers.

+
+
+
šŸ“–
+

Definitions

+

Look up word definitions, synonyms, and usage examples.

+
+
+
šŸ”¢
+

Calculator

+

Safe math expression evaluator. Complex calculations made easy.

+
+
+
šŸ“±
+

QR Codes

+

Generate QR codes for any text or URL. Share links instantly.

+
+
+
diff --git a/aethex-bot/utils/modeHelper.js b/aethex-bot/utils/modeHelper.js new file mode 100644 index 0000000..aa640d3 --- /dev/null +++ b/aethex-bot/utils/modeHelper.js @@ -0,0 +1,105 @@ +const { EmbedBuilder } = require('discord.js'); + +const EMBED_COLORS = { + federated: 0x4A90E2, + standalone: 0x6B7280, + success: 0x22C55E, + error: 0xEF4444, + warning: 0xF59E0B, +}; + +const modeConfigCache = new Map(); +const MODE_CACHE_TTL = 60000; + +async function getServerMode(supabase, guildId) { + if (!supabase) return 'standalone'; + + const now = Date.now(); + const cached = modeConfigCache.get(guildId); + + if (cached && (now - cached.timestamp < MODE_CACHE_TTL)) { + return cached.mode; + } + + try { + const { data } = await supabase + .from('server_config') + .select('mode') + .eq('guild_id', guildId) + .maybeSingle(); + + const mode = data?.mode || 'federated'; + modeConfigCache.set(guildId, { mode, timestamp: now }); + return mode; + } catch (e) { + return 'federated'; + } +} + +async function setServerMode(supabase, guildId, mode) { + if (!supabase) return false; + + try { + await supabase.from('server_config').upsert({ + guild_id: guildId, + mode: mode, + mode_changed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, { onConflict: 'guild_id' }); + + modeConfigCache.set(guildId, { mode, timestamp: Date.now() }); + return true; + } catch (e) { + console.error('Failed to set server mode:', e.message); + return false; + } +} + +function clearModeCache(guildId) { + if (guildId) { + modeConfigCache.delete(guildId); + } else { + modeConfigCache.clear(); + } +} + +function getEmbedColor(mode) { + return mode === 'standalone' ? EMBED_COLORS.standalone : EMBED_COLORS.federated; +} + +function createModeEmbed(mode, title, description) { + return new EmbedBuilder() + .setColor(getEmbedColor(mode)) + .setTitle(title) + .setDescription(description) + .setTimestamp(); +} + +function isFederated(mode) { + return mode === 'federated'; +} + +function isStandalone(mode) { + return mode === 'standalone'; +} + +function getModeDisplayName(mode) { + return mode === 'federated' ? 'Federation' : 'Standalone'; +} + +function getModeEmoji(mode) { + return mode === 'federated' ? '🌐' : 'šŸ '; +} + +module.exports = { + EMBED_COLORS, + getServerMode, + setServerMode, + clearModeCache, + getEmbedColor, + createModeEmbed, + isFederated, + isStandalone, + getModeDisplayName, + getModeEmoji, +}; diff --git a/aethex-bot/utils/standaloneXp.js b/aethex-bot/utils/standaloneXp.js new file mode 100644 index 0000000..3ef9e0a --- /dev/null +++ b/aethex-bot/utils/standaloneXp.js @@ -0,0 +1,230 @@ +const { EmbedBuilder } = require('discord.js'); +const { getEmbedColor } = require('./modeHelper'); + +async function getStandaloneXp(supabase, discordId, guildId) { + if (!supabase) return null; + + try { + const { data } = await supabase + .from('guild_user_xp') + .select('*') + .eq('discord_id', discordId) + .eq('guild_id', guildId) + .maybeSingle(); + + return data || null; + } catch (e) { + return null; + } +} + +async function updateStandaloneXp(supabase, discordId, guildId, xpGain, username) { + if (!supabase) return null; + + try { + const { data: existing } = await supabase + .from('guild_user_xp') + .select('*') + .eq('discord_id', discordId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (existing) { + const newXp = (existing.xp || 0) + xpGain; + const totalEarned = (existing.total_xp_earned || 0) + xpGain; + + const { data } = await supabase + .from('guild_user_xp') + .update({ + xp: newXp, + total_xp_earned: totalEarned, + updated_at: new Date().toISOString(), + }) + .eq('id', existing.id) + .select() + .single(); + + return data; + } else { + const { data } = await supabase + .from('guild_user_xp') + .insert({ + discord_id: discordId, + guild_id: guildId, + username: username, + xp: xpGain, + total_xp_earned: xpGain, + prestige_level: 0, + daily_streak: 0, + }) + .select() + .single(); + + return data; + } + } catch (e) { + console.error('Standalone XP update error:', e.message); + return null; + } +} + +async function getStandaloneLeaderboard(supabase, guildId, limit = 10) { + if (!supabase) return []; + + try { + const { data } = await supabase + .from('guild_user_xp') + .select('discord_id, username, xp, prestige_level') + .eq('guild_id', guildId) + .order('xp', { ascending: false }) + .limit(limit); + + return data || []; + } catch (e) { + return []; + } +} + +async function claimStandaloneDaily(supabase, discordId, guildId, username) { + if (!supabase) return { success: false, message: 'Database not available' }; + + try { + const { data: existing } = await supabase + .from('guild_user_xp') + .select('*') + .eq('discord_id', discordId) + .eq('guild_id', guildId) + .maybeSingle(); + + const now = new Date(); + const today = now.toISOString().split('T')[0]; + + if (existing) { + const lastClaim = existing.last_daily_claim ? existing.last_daily_claim.split('T')[0] : null; + + if (lastClaim === today) { + return { success: false, message: 'You already claimed your daily reward today!' }; + } + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = yesterday.toISOString().split('T')[0]; + + let newStreak = 1; + if (lastClaim === yesterdayStr) { + newStreak = (existing.daily_streak || 0) + 1; + } + + const baseXp = 50; + const streakBonus = Math.min(newStreak * 5, 100); + const totalXp = baseXp + streakBonus; + + const newXp = (existing.xp || 0) + totalXp; + const totalEarned = (existing.total_xp_earned || 0) + totalXp; + + await supabase + .from('guild_user_xp') + .update({ + xp: newXp, + total_xp_earned: totalEarned, + daily_streak: newStreak, + last_daily_claim: now.toISOString(), + updated_at: now.toISOString(), + }) + .eq('id', existing.id); + + return { + success: true, + xpGained: totalXp, + streak: newStreak, + totalXp: newXp, + }; + } else { + const baseXp = 50; + + await supabase + .from('guild_user_xp') + .insert({ + discord_id: discordId, + guild_id: guildId, + username: username, + xp: baseXp, + total_xp_earned: baseXp, + prestige_level: 0, + daily_streak: 1, + last_daily_claim: now.toISOString(), + }); + + return { + success: true, + xpGained: baseXp, + streak: 1, + totalXp: baseXp, + }; + } + } catch (e) { + console.error('Standalone daily claim error:', e.message); + return { success: false, message: 'An error occurred' }; + } +} + +async function prestigeStandalone(supabase, discordId, guildId) { + if (!supabase) return { success: false, message: 'Database not available' }; + + try { + const { data: existing } = await supabase + .from('guild_user_xp') + .select('*') + .eq('discord_id', discordId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (!existing) { + return { success: false, message: 'No XP data found' }; + } + + const level = calculateLevel(existing.xp || 0, 'normal'); + if (level < 50) { + return { success: false, message: `You need Level 50 to prestige! (Current: Level ${level})` }; + } + + const newPrestige = (existing.prestige_level || 0) + 1; + + await supabase + .from('guild_user_xp') + .update({ + xp: 0, + prestige_level: newPrestige, + updated_at: new Date().toISOString(), + }) + .eq('id', existing.id); + + return { + success: true, + newPrestige: newPrestige, + bonus: newPrestige * 5, + }; + } catch (e) { + console.error('Standalone prestige error:', e.message); + return { success: false, message: 'An error occurred' }; + } +} + +function calculateLevel(xp, curve = 'normal') { + const bases = { + easy: 50, + normal: 100, + hard: 200, + }; + const base = bases[curve] || 100; + return Math.floor(Math.sqrt(xp / base)); +} + +module.exports = { + getStandaloneXp, + updateStandaloneXp, + getStandaloneLeaderboard, + claimStandaloneDaily, + prestigeStandalone, + calculateLevel, +};