Add xp settings command and update xp tracking logic

Introduces the `/xp-settings` command for server administrators to configure XP gain, cooldowns, and multipliers. Updates the `xpTracker.js` listener to fetch server-specific XP configurations from a new `xp_config` table, incorporating enhanced XP calculation logic including channel bonuses and role multipliers. Adds caching for XP configurations and updates command documentation in `replit.md`.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 665c9c2a-b623-41ea-b686-e23a04285c6d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/ZjyNKqu
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sirpiglr 2025-12-08 21:00:29 +00:00
parent fbc5c0533d
commit 78647044bb
4 changed files with 487 additions and 17 deletions

View file

@ -22,10 +22,6 @@ externalPort = 80
localPort = 8080
externalPort = 8080
[[ports]]
localPort = 39769
externalPort = 3000
[workflows]
runButton = "Project"

View file

@ -0,0 +1,378 @@
const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('xp-settings')
.setDescription('Configure XP settings for your server')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand(sub =>
sub.setName('view')
.setDescription('View current XP settings'))
.addSubcommand(sub =>
sub.setName('message-xp')
.setDescription('Set XP earned per message')
.addIntegerOption(opt =>
opt.setName('amount')
.setDescription('XP per message (1-50)')
.setRequired(true)
.setMinValue(1)
.setMaxValue(50)))
.addSubcommand(sub =>
sub.setName('cooldown')
.setDescription('Set cooldown between XP gains (seconds)')
.addIntegerOption(opt =>
opt.setName('seconds')
.setDescription('Cooldown in seconds (10-300)')
.setRequired(true)
.setMinValue(10)
.setMaxValue(300)))
.addSubcommand(sub =>
sub.setName('multiplier-role')
.setDescription('Add/remove a role with XP multiplier')
.addRoleOption(opt =>
opt.setName('role')
.setDescription('The role to add/remove multiplier')
.setRequired(true))
.addNumberOption(opt =>
opt.setName('multiplier')
.setDescription('XP multiplier (1.1-5.0, or 0 to remove)')
.setRequired(true)
.setMinValue(0)
.setMaxValue(5)))
.addSubcommand(sub =>
sub.setName('bonus-channel')
.setDescription('Add/remove a channel with bonus XP')
.addChannelOption(opt =>
opt.setName('channel')
.setDescription('The channel to add/remove bonus')
.setRequired(true))
.addNumberOption(opt =>
opt.setName('multiplier')
.setDescription('XP multiplier (1.1-5.0, or 0 to remove)')
.setRequired(true)
.setMinValue(0)
.setMaxValue(5)))
.addSubcommand(sub =>
sub.setName('toggle')
.setDescription('Enable or disable XP system')
.addBooleanOption(opt =>
opt.setName('enabled')
.setDescription('Enable or disable XP')
.setRequired(true)))
.addSubcommand(sub =>
sub.setName('level-curve')
.setDescription('Set leveling difficulty curve')
.addStringOption(opt =>
opt.setName('curve')
.setDescription('Leveling curve type')
.setRequired(true)
.addChoices(
{ name: 'Easy - Faster leveling', value: 'easy' },
{ name: 'Normal - Standard leveling', value: 'normal' },
{ name: 'Hard - Slower leveling', value: 'hard' }
))),
async execute(interaction, client, supabase) {
if (!supabase) {
return interaction.reply({
content: '❌ Database not configured. XP settings require Supabase.',
ephemeral: true
});
}
const guildId = interaction.guildId;
const subcommand = interaction.options.getSubcommand();
// Get current config or create default
let config = await getXpConfig(supabase, guildId);
switch (subcommand) {
case 'view':
return handleView(interaction, config);
case 'message-xp':
return handleMessageXp(interaction, supabase, guildId, config);
case 'cooldown':
return handleCooldown(interaction, supabase, guildId, config);
case 'multiplier-role':
return handleMultiplierRole(interaction, supabase, guildId, config);
case 'bonus-channel':
return handleBonusChannel(interaction, supabase, guildId, config);
case 'toggle':
return handleToggle(interaction, supabase, guildId, config);
case 'level-curve':
return handleLevelCurve(interaction, supabase, guildId, config);
}
}
};
async function getXpConfig(supabase, guildId) {
try {
const { data, error } = await supabase
.from('xp_config')
.select('*')
.eq('guild_id', guildId)
.maybeSingle();
if (error) throw error;
if (!data) {
// Return default config
return {
guild_id: guildId,
message_xp: 5,
message_cooldown: 60,
multiplier_roles: [],
bonus_channels: [],
level_curve: 'normal',
xp_enabled: true
};
}
return data;
} catch (e) {
console.error('Failed to get XP config:', e.message);
return {
guild_id: guildId,
message_xp: 5,
message_cooldown: 60,
multiplier_roles: [],
bonus_channels: [],
level_curve: 'normal',
xp_enabled: true
};
}
}
async function saveXpConfig(supabase, guildId, config) {
try {
const { error } = await supabase
.from('xp_config')
.upsert({
guild_id: guildId,
message_xp: config.message_xp,
message_cooldown: config.message_cooldown,
multiplier_roles: config.multiplier_roles,
bonus_channels: config.bonus_channels,
level_curve: config.level_curve,
xp_enabled: config.xp_enabled,
updated_at: new Date().toISOString()
});
if (error) throw error;
return true;
} catch (e) {
console.error('Failed to save XP config:', e.message);
return false;
}
}
async function handleView(interaction, config) {
const multiplierRoles = config.multiplier_roles || [];
const bonusChannels = config.bonus_channels || [];
const rolesText = multiplierRoles.length > 0
? multiplierRoles.map(r => `<@&${r.role_id}> → ${r.multiplier}x`).join('\n')
: 'None configured';
const channelsText = bonusChannels.length > 0
? bonusChannels.map(c => `<#${c.channel_id}> → ${c.multiplier}x`).join('\n')
: 'None configured';
const curveInfo = {
easy: 'Easy (50 XP per level²)',
normal: 'Normal (100 XP per level²)',
hard: 'Hard (200 XP per level²)'
};
const embed = new EmbedBuilder()
.setTitle('⚙️ XP Settings')
.setColor(config.xp_enabled ? 0x00ff88 : 0xff4444)
.addFields(
{ name: '📊 Status', value: config.xp_enabled ? '✅ Enabled' : '❌ Disabled', inline: true },
{ name: '💬 Message XP', value: `${config.message_xp} XP`, inline: true },
{ name: '⏱️ Cooldown', value: `${config.message_cooldown}s`, inline: true },
{ name: '📈 Level Curve', value: curveInfo[config.level_curve] || 'Normal', inline: false },
{ name: '🎭 Multiplier Roles', value: rolesText, inline: false },
{ name: '📢 Bonus Channels', value: channelsText, inline: false }
)
.setFooter({ text: 'Use /xp-settings subcommands to modify' })
.setTimestamp();
return interaction.reply({ embeds: [embed] });
}
async function handleMessageXp(interaction, supabase, guildId, config) {
const amount = interaction.options.getInteger('amount');
config.message_xp = amount;
const saved = await saveXpConfig(supabase, guildId, config);
if (!saved) {
return interaction.reply({
content: '❌ Failed to save settings. Please try again.',
ephemeral: true
});
}
return interaction.reply({
content: `✅ Message XP set to **${amount} XP** per message.`,
ephemeral: true
});
}
async function handleCooldown(interaction, supabase, guildId, config) {
const seconds = interaction.options.getInteger('seconds');
config.message_cooldown = seconds;
const saved = await saveXpConfig(supabase, guildId, config);
if (!saved) {
return interaction.reply({
content: '❌ Failed to save settings. Please try again.',
ephemeral: true
});
}
return interaction.reply({
content: `✅ XP cooldown set to **${seconds} seconds**.`,
ephemeral: true
});
}
async function handleMultiplierRole(interaction, supabase, guildId, config) {
const role = interaction.options.getRole('role');
const multiplier = interaction.options.getNumber('multiplier');
let roles = config.multiplier_roles || [];
if (multiplier === 0) {
// Remove role
roles = roles.filter(r => r.role_id !== role.id);
config.multiplier_roles = roles;
const saved = await saveXpConfig(supabase, guildId, config);
if (!saved) {
return interaction.reply({
content: '❌ Failed to save settings. Please try again.',
ephemeral: true
});
}
return interaction.reply({
content: `✅ Removed XP multiplier from ${role}.`,
ephemeral: true
});
}
// Add or update role
const existing = roles.findIndex(r => r.role_id === role.id);
if (existing >= 0) {
roles[existing].multiplier = multiplier;
} else {
roles.push({ role_id: role.id, multiplier });
}
config.multiplier_roles = roles;
const saved = await saveXpConfig(supabase, guildId, config);
if (!saved) {
return interaction.reply({
content: '❌ Failed to save settings. Please try again.',
ephemeral: true
});
}
return interaction.reply({
content: `${role} now has a **${multiplier}x** XP multiplier.`,
ephemeral: true
});
}
async function handleBonusChannel(interaction, supabase, guildId, config) {
const channel = interaction.options.getChannel('channel');
const multiplier = interaction.options.getNumber('multiplier');
let channels = config.bonus_channels || [];
if (multiplier === 0) {
// Remove channel
channels = channels.filter(c => c.channel_id !== channel.id);
config.bonus_channels = channels;
const saved = await saveXpConfig(supabase, guildId, config);
if (!saved) {
return interaction.reply({
content: '❌ Failed to save settings. Please try again.',
ephemeral: true
});
}
return interaction.reply({
content: `✅ Removed bonus XP from ${channel}.`,
ephemeral: true
});
}
// Add or update channel
const existing = channels.findIndex(c => c.channel_id === channel.id);
if (existing >= 0) {
channels[existing].multiplier = multiplier;
} else {
channels.push({ channel_id: channel.id, multiplier });
}
config.bonus_channels = channels;
const saved = await saveXpConfig(supabase, guildId, config);
if (!saved) {
return interaction.reply({
content: '❌ Failed to save settings. Please try again.',
ephemeral: true
});
}
return interaction.reply({
content: `${channel} now has a **${multiplier}x** XP bonus.`,
ephemeral: true
});
}
async function handleToggle(interaction, supabase, guildId, config) {
const enabled = interaction.options.getBoolean('enabled');
config.xp_enabled = enabled;
const saved = await saveXpConfig(supabase, guildId, config);
if (!saved) {
return interaction.reply({
content: '❌ Failed to save settings. Please try again.',
ephemeral: true
});
}
return interaction.reply({
content: enabled
? '✅ XP system is now **enabled**.'
: '❌ XP system is now **disabled**.',
ephemeral: true
});
}
async function handleLevelCurve(interaction, supabase, guildId, config) {
const curve = interaction.options.getString('curve');
config.level_curve = curve;
const saved = await saveXpConfig(supabase, guildId, config);
if (!saved) {
return interaction.reply({
content: '❌ Failed to save settings. Please try again.',
ephemeral: true
});
}
const curveNames = {
easy: 'Easy (faster leveling)',
normal: 'Normal (standard leveling)',
hard: 'Hard (slower leveling)'
};
return interaction.reply({
content: `✅ Level curve set to **${curveNames[curve]}**.`,
ephemeral: true
});
}

View file

@ -1,6 +1,6 @@
const XP_PER_MESSAGE = 5;
const XP_COOLDOWN_MS = 60000;
const xpCooldowns = new Map();
const xpConfigCache = new Map();
const CACHE_TTL = 60000; // 1 minute cache
module.exports = {
name: 'messageCreate',
@ -11,10 +11,22 @@ module.exports = {
if (!message.guild) return;
const discordUserId = message.author.id;
const guildId = message.guildId;
const channelId = message.channelId;
const now = Date.now();
const lastXp = xpCooldowns.get(discordUserId) || 0;
if (now - lastXp < XP_COOLDOWN_MS) return;
// Get XP config for this server
const config = await getXpConfig(supabase, guildId);
// Check if XP is enabled
if (!config.xp_enabled) return;
// Check cooldown
const cooldownKey = `${guildId}:${discordUserId}`;
const lastXp = xpCooldowns.get(cooldownKey) || 0;
const cooldownMs = (config.message_cooldown || 60) * 1000;
if (now - lastXp < cooldownMs) return;
try {
const { data: link, error: linkError } = await supabase
@ -33,10 +45,32 @@ module.exports = {
if (profileError || !profile) return;
// Calculate base XP
let xpGain = config.message_xp || 5;
// 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);
}
}
xpGain = Math.floor(xpGain * highestMultiplier);
const currentXp = profile.xp || 0;
const newXp = currentXp + XP_PER_MESSAGE;
const oldLevel = Math.floor(Math.sqrt(currentXp / 100));
const newLevel = Math.floor(Math.sqrt(newXp / 100));
const newXp = currentXp + xpGain;
// Calculate levels based on curve
const oldLevel = calculateLevel(currentXp, config.level_curve);
const newLevel = calculateLevel(newXp, config.level_curve);
const { error: updateError } = await supabase
.from('user_profiles')
@ -45,11 +79,16 @@ module.exports = {
if (updateError) return;
xpCooldowns.set(discordUserId, now);
xpCooldowns.set(cooldownKey, now);
// Track XP for analytics
if (client.trackXP) {
client.trackXP(xpGain);
}
if (newLevel > oldLevel) {
const config = client.serverConfigs?.get(message.guildId);
const levelUpChannelId = config?.level_up_channel;
const serverConfig = client.serverConfigs?.get(guildId);
const levelUpChannelId = serverConfig?.level_up_channel;
const levelUpMessage = `🎉 Congratulations ${message.author}! You reached **Level ${newLevel}**!`;
@ -62,7 +101,7 @@ module.exports = {
await message.channel.send(levelUpMessage).catch(() => {});
}
await checkLevelRoles(message.member, newLevel, supabase, message.guildId);
await checkLevelRoles(message.member, newLevel, supabase, guildId);
}
} catch (error) {
@ -71,6 +110,59 @@ module.exports = {
}
};
async function getXpConfig(supabase, guildId) {
const now = Date.now();
const cached = xpConfigCache.get(guildId);
if (cached && (now - cached.timestamp < CACHE_TTL)) {
return cached.config;
}
try {
const { data, error } = await supabase
.from('xp_config')
.select('*')
.eq('guild_id', guildId)
.maybeSingle();
if (error) {
// Table might not exist - return defaults
const defaults = getDefaultConfig();
xpConfigCache.set(guildId, { config: defaults, timestamp: now });
return defaults;
}
const config = data || getDefaultConfig();
xpConfigCache.set(guildId, { config, timestamp: now });
return config;
} catch (e) {
return getDefaultConfig();
}
}
function getDefaultConfig() {
return {
message_xp: 5,
message_cooldown: 60,
multiplier_roles: [],
bonus_channels: [],
level_curve: 'normal',
xp_enabled: true
};
}
function calculateLevel(xp, curve = 'normal') {
// XP required per level: level² * base
// So level = sqrt(xp / base)
const bases = {
easy: 50, // Faster leveling
normal: 100, // Standard
hard: 200 // Slower leveling
};
const base = bases[curve] || 100;
return Math.floor(Math.sqrt(xp / base));
}
async function checkLevelRoles(member, level, supabase, guildId) {
if (!member || !supabase) return;
@ -93,3 +185,6 @@ async function checkLevelRoles(member, level, supabase, guildId) {
// Table may not exist yet - silently ignore
}
}
// Export calculateLevel for use in other commands
module.exports.calculateLevel = calculateLevel;

View file

@ -69,7 +69,8 @@ aethex-bot/
│ ├── userinfo.js # /userinfo - user details
│ ├── verify-role.js # /verify-role - check roles
│ ├── verify.js # /verify - link account
│ └── warn.js # /warn - warn users
│ ├── warn.js # /warn - warn users
│ └── xp-settings.js # /xp-settings - configure XP per server
├── events/
│ └── messageCreate.js # Message event handler
├── listeners/
@ -87,7 +88,7 @@ aethex-bot/
└── register-commands.js # Slash command registration
```
## Commands (36 Total)
## Commands (37 Total)
### Community Commands
| Command | Description |