// opt/scorekeeper.js import cron from 'node-cron'; import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; // Module state container const moduleState = { cronJobs: new Map(), // Store cron jobs by client ID }; /** * Initialize the scorekeeper module */ export const init = async (client, config) => { client.logger.info('Initializing Scorekeeper module'); // Check if configuration exists if (!config.scorekeeper) { client.logger.warn('Scorekeeper configuration missing, using defaults'); config.scorekeeper = { baseOutput: 1000, commendationValue: 1.0, citationValue: 1.2, decay: 90, schedule: '0 0 * * 0' // Default: weekly at midnight on Sunday }; } // Check if scorekeeper collection exists await checkCollection(client); // Create scorekeeper interface on client client.scorekeeper = { addInput: (guildId, userId, amount) => addInput(client, guildId, userId, amount), addOutput: (guildId, userId, amount) => addOutput(client, guildId, userId, amount), addCommendation: (guildId, userId, amount = 1) => addCommendation(client, guildId, userId, amount), addCitation: (guildId, userId, amount = 1) => addCitation(client, guildId, userId, amount), getScore: (guildId, userId) => getScore(client, guildId, userId), getScores: (guildId, limit = 10) => getScores(client, guildId, limit), runDecay: (guildId) => runDecay(client, guildId) }; // Set up cron job for decay setupDecayCron(client, config.scorekeeper.schedule); client.logger.info('Scorekeeper module initialized'); }; /** * Check if the scorekeeper collection exists in PocketBase */ async function checkCollection(client) { try { // Check if collection exists by trying to list records await client.pb.collection('scorekeeper').getList(1, 1); client.logger.info('Scorekeeper collection exists in PocketBase'); } catch (error) { // If collection doesn't exist, log warning client.logger.warn('Scorekeeper collection does not exist in PocketBase'); client.logger.warn('Please create a "scorekeeper" collection with fields:'); client.logger.warn('- guildId (text, required)'); client.logger.warn('- userId (text, required)'); client.logger.warn('- input (number, default: 0)'); client.logger.warn('- output (number, default: 0)'); client.logger.warn('- commendations (number, default: 0)'); client.logger.warn('- citations (number, default: 0)'); client.logger.warn('- lastDecay (date, required)'); } } /** * Set up cron job for decay */ function setupDecayCron(client, schedule) { try { // Validate cron expression if (!cron.validate(schedule)) { client.logger.error(`Invalid cron schedule: ${schedule}, using default`); schedule = '0 0 * * 0'; // Default: weekly at midnight on Sunday } // Create and start the cron job const job = cron.schedule(schedule, async () => { client.logger.info('Running scheduled score decay'); try { // Get all guilds the bot is in const guilds = client.guilds.cache.map(guild => guild.id); // Run decay for each guild for (const guildId of guilds) { await runDecay(client, guildId); } client.logger.info('Score decay completed'); } catch (error) { client.logger.error(`Error during scheduled score decay: ${error.message}`); } }); // Store the job in module state moduleState.cronJobs.set(client.config.id, job); client.logger.info(`Score decay scheduled with cron: ${schedule}`); } catch (error) { client.logger.error(`Failed to set up decay cron job: ${error.message}`); } } /** * Add input points for a user */ async function addInput(client, guildId, userId, amount) { if (!guildId || !userId || !amount || amount <= 0) { throw new Error(`Invalid parameters for addInput - guildId: ${guildId}, userId: ${userId}, amount: ${amount}`); } try { // Get or create user record const scoreData = await getOrCreateScoreData(client, guildId, userId); // Calculate new input score const newInput = scoreData.input + amount; client.logger.debug(`Updating record ${scoreData.id} - input from ${scoreData.input} to ${newInput}`); // Use direct update with ID to avoid duplicate records return await client.pb.collection('scorekeeper').update(scoreData.id, { input: newInput }); } catch (error) { client.logger.error(`Error adding input points: ${error.message}`); throw error; } } /** * Add output points for a user */ async function addOutput(client, guildId, userId, amount) { if (!guildId || !userId || !amount || amount <= 0) { throw new Error('Invalid parameters for addOutput'); } try { // Get or create user record const scoreData = await getOrCreateScoreData(client, guildId, userId); // Calculate new output score const newOutput = scoreData.output + amount; client.logger.debug(`Updating record ${scoreData.id} - output from ${scoreData.output} to ${newOutput}`); // Use direct update with ID to avoid duplicate records return await client.pb.collection('scorekeeper').update(scoreData.id, { output: newOutput }); } catch (error) { client.logger.error(`Error adding output points: ${error.message}`); throw error; } } /** * Add commendations for a user */ async function addCommendation(client, guildId, userId, amount = 1) { if (!guildId || !userId || amount <= 0) { throw new Error('Invalid parameters for addCommendation'); } try { // Log the search to debug client.logger.debug(`Looking for record with guildId=${guildId} and userId=${userId}`); // Get or create user record const scoreData = await getOrCreateScoreData(client, guildId, userId); // Log the found/created record client.logger.debug(`Found/created record with ID: ${scoreData.id}`); // Calculate new commendations value const newCommendations = scoreData.commendations + amount; // Log the update attempt client.logger.debug(`Updating record ${scoreData.id} - commendations from ${scoreData.commendations} to ${newCommendations}`); // Use direct update with ID to avoid duplicate records return await client.pb.collection('scorekeeper').update(scoreData.id, { commendations: newCommendations }); } catch (error) { client.logger.error(`Error adding commendations: ${error.message}`); throw error; } } /** * Add citations for a user */ async function addCitation(client, guildId, userId, amount = 1) { if (!guildId || !userId || amount <= 0) { throw new Error('Invalid parameters for addCitation'); } try { // Get or create user record const scoreData = await getOrCreateScoreData(client, guildId, userId); // Calculate new citations value const newCitations = scoreData.citations + amount; client.logger.debug(`Updating record ${scoreData.id} - citations from ${scoreData.citations} to ${newCitations}`); // Use direct update with ID to avoid duplicate records return await client.pb.collection('scorekeeper').update(scoreData.id, { citations: newCitations }); } catch (error) { client.logger.error(`Error adding citations: ${error.message}`); throw error; } } /** * Get a user's score */ async function getScore(client, guildId, userId) { if (!guildId || !userId) { throw new Error('Guild ID and User ID are required'); } try { // Get score data, create if not exists const scoreData = await getOrCreateScoreData(client, guildId, userId); // Calculate total score const totalScore = calculateScore( scoreData, client.config.scorekeeper.baseOutput, client.config.scorekeeper.commendationValue, client.config.scorekeeper.citationValue ); return { ...scoreData, totalScore }; } catch (error) { client.logger.error(`Error getting score: ${error.message}`); throw error; } } /** * Get scores for a guild */ async function getScores(client, guildId, limit = 10) { if (!guildId) { throw new Error('Guild ID is required'); } try { // Fetch all score records for this guild const records = await client.pb.collection('scorekeeper').getFullList({ filter: `guildId = "${guildId}"` }); // Compute totalScore for each, then sort and limit const scored = records.map(record => { const totalScore = calculateScore( record, client.config.scorekeeper.baseOutput, client.config.scorekeeper.commendationValue, client.config.scorekeeper.citationValue ); return { ...record, totalScore }; }); // Sort descending by score scored.sort((a, b) => b.totalScore - a.totalScore); // Return top 'limit' entries return scored.slice(0, limit); } catch (error) { client.logger.error(`Error getting scores: ${error.message}`); throw error; } } /** * Run decay process for a guild */ async function runDecay(client, guildId) { if (!guildId) { throw new Error('Guild ID is required'); } try { const decayFactor = client.config.scorekeeper.decay / 100; const baseOutput = client.config.scorekeeper.baseOutput; // Get all records for this guild const records = await client.pb.collection('scorekeeper').getFullList({ filter: `guildId = "${guildId}"` }); // Update each record with decay let updatedCount = 0; for (const record of records) { try { const newInput = Math.floor(record.input * decayFactor); // Calculate new output, but ensure it never falls below baseOutput let newOutput = Math.floor(record.output * decayFactor); if (newOutput < baseOutput) { newOutput = baseOutput; client.logger.debug(`Output for record ${record.id} would fall below BaseOutput - setting to ${baseOutput}`); } // Update record directly await client.pb.collection('scorekeeper').update(record.id, { input: newInput, output: newOutput, lastDecay: new Date().toISOString() }); updatedCount++; } catch (updateError) { client.logger.error(`Error updating record ${record.id} during decay: ${updateError.message}`); } } client.logger.info(`Decay completed for guild ${guildId}: ${updatedCount} records updated`); return updatedCount; } catch (error) { client.logger.error(`Error running decay: ${error.message}`); throw error; } } /** * Calculate score based on formula (no scaling factor) * Formula: (1 + C*commendationValue - D*citationValue) * (input / (output + baseOutput)) */ function calculateScore(data, baseOutput, commendationValue, citationValue) { const multiplier = 1 + (data.commendations * commendationValue) - (data.citations * citationValue); const activityScore = data.input / (data.output + baseOutput); // Removed aesthetic scaling (×100) for raw score return multiplier * activityScore; } /** * Get or create score data for a user in a guild */ async function getOrCreateScoreData(client, guildId, userId) { try { // Always try to get existing record first let existingRecord = null; const baseOutput = client.config.scorekeeper.baseOutput; // Try to find the record using filter try { existingRecord = await client.pb.collection('scorekeeper').getFirstListItem( `guildId = "${guildId}" && userId = "${userId}"` ); client.logger.debug(`Found existing score record for ${userId} in guild ${guildId}`); return existingRecord; } catch (error) { // Only create new record if specifically not found (404) if (error.status === 404) { client.logger.debug(`No existing score record found, creating new one for ${userId} in guild ${guildId}`); // Create new record with default values // Note: output is now initialized to baseOutput instead of 0 const newData = { guildId, userId, input: 0, output: baseOutput, // Initialize to baseOutput, not 0 commendations: 0, citations: 0, lastDecay: new Date().toISOString() }; return await client.pb.collection('scorekeeper').create(newData); } // For any other error, rethrow client.logger.error(`Error searching for score record: ${error.message}`); throw error; } } catch (error) { client.logger.error(`Error in getOrCreateScoreData: ${error.message}`); throw error; } } // Define slash commands for the module export const commands = [ // Command to view a user's score { data: new SlashCommandBuilder() .setName('score') .setDescription('View your I/O score or another user\'s I/O score') .addUserOption(option => option.setName('user') .setDescription('User to check I/O score for (defaults to you)') .setRequired(false) ) .addBooleanOption(option => option.setName('ephemeral') .setDescription('Whether the response should be ephemeral') .setRequired(false) ), execute: async (interaction, client) => { const targetUser = interaction.options.getUser('user') || interaction.user; const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; // Wrap score retrieval and embed generation in try/catch to handle errors gracefully try { // Fetch score data const baseOutput = client.config.scorekeeper.baseOutput; const commendationValue = client.config.scorekeeper.commendationValue; const citationValue = client.config.scorekeeper.citationValue; const scoreData = await client.scorekeeper.getScore(interaction.guildId, targetUser.id); const embed = new EmbedBuilder() .setTitle(`I/O Score for ${(await interaction.guild.members.fetch(targetUser.id).catch(() => null))?.displayName || targetUser.username}`) .setColor(0x00AE86) .setThumbnail(targetUser.displayAvatarURL()) .addFields( { name: 'Total Score', value: `**${scoreData.totalScore.toFixed(2)}**`, inline: false }, { name: 'Commendations', value: `**${scoreData.commendations}** x ${commendationValue}`, inline: false }, { name: 'Citations', value: `**${scoreData.citations}** x ${citationValue}`, inline: false }, { name: 'Input Score', value: `**${scoreData.input}**`, inline: true }, { name: 'Output Score', value: `**${scoreData.output}** + ${baseOutput}`, inline: true } ) .setFooter({ text: 'Last decay: ' + new Date(scoreData.lastDecay).toLocaleDateString() }) .setTimestamp(); await interaction.reply({ embeds: [embed], ephemeral }); } catch (error) { client.logger.error(`Error in score command: ${error.message}`); try { await interaction.reply({ content: 'Failed to retrieve I/O score.', ephemeral }); } catch {} } } }, // Command to view top scores { data: new SlashCommandBuilder() .setName('leaderboard') .setDescription('View the server\'s I/O score leaderboard') .addBooleanOption(option => option.setName('ephemeral') .setDescription('Whether the response should be ephemeral') .setRequired(false) ), execute: async (interaction, client) => { const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; await interaction.deferReply({ ephemeral }); const limit = 10; try { const scores = await client.scorekeeper.getScores(interaction.guildId, limit); if (scores.length === 0) { return interaction.editReply('No scores found for this server.'); } // Format leaderboard const guild = interaction.guild; let leaderboardText = ''; for (let i = 0; i < scores.length; i++) { const score = scores[i]; const user = await guild.members.fetch(score.userId).catch(() => null); const displayName = user ? user.displayName : 'Unknown User'; leaderboardText += `${i + 1}. **${displayName}**: ${score.totalScore.toFixed(2)}\n`; } const embed = new EmbedBuilder() .setTitle('I/O Score Leaderboard') .setColor(0x00AE86) .setDescription(leaderboardText) .setFooter({ text: `Showing top ${scores.length} users` }) .setTimestamp(); await interaction.editReply({ embeds: [embed] }); } catch (error) { client.logger.error(`Error in leaderboard command: ${error.message}`); await interaction.editReply('Failed to retrieve leaderboard data.'); } } }, // Command to give a commendation (admin only) { data: new SlashCommandBuilder() .setName('commend') .setDescription('Give a commendation to a user (Admin only)') .addUserOption(option => option.setName('user') .setDescription('User to commend') .setRequired(true)) .addIntegerOption(option => option.setName('amount') .setDescription('Amount of commendations (default: 1)') .setRequired(false) .setMinValue(1) .setMaxValue(10)) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { const targetUser = interaction.options.getUser('user'); const amount = interaction.options.getInteger('amount') || 1; try { await client.scorekeeper.addCommendation(interaction.guildId, targetUser.id, amount); await interaction.reply(`Added ${amount} commendation${amount !== 1 ? 's' : ''} to ${targetUser}.`); } catch (error) { client.logger.error(`Error in commend command: ${error.message}`); await interaction.reply({ content: 'Failed to add commendation.', ephemeral: true }); } } }, // Command to give a citation (admin only) { data: new SlashCommandBuilder() .setName('cite') .setDescription('Give a citation to a user (Admin only)') .addUserOption(option => option.setName('user') .setDescription('User to cite') .setRequired(true)) .addIntegerOption(option => option.setName('amount') .setDescription('Amount of citations (default: 1)') .setRequired(false) .setMinValue(1) .setMaxValue(10)) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { const targetUser = interaction.options.getUser('user'); const amount = interaction.options.getInteger('amount') || 1; try { await client.scorekeeper.addCitation(interaction.guildId, targetUser.id, amount); await interaction.reply(`Added ${amount} citation${amount !== 1 ? 's' : ''} to ${targetUser}.`); } catch (error) { client.logger.error(`Error in cite command: ${error.message}`); await interaction.reply({ content: 'Failed to add citation.', ephemeral: true }); } } }, // Command to manually run decay (admin only) { data: new SlashCommandBuilder() .setName('run-decay') .setDescription('Manually run score decay (Admin only)') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { await interaction.deferReply(); try { const updatedCount = await client.scorekeeper.runDecay(interaction.guildId); await interaction.editReply(`Score decay completed. Updated ${updatedCount} user records.`); } catch (error) { client.logger.error(`Error in run-decay command: ${error.message}`); await interaction.editReply('Failed to run score decay.'); } } } ];