603 lines
20 KiB
JavaScript
603 lines
20 KiB
JavaScript
// 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 and compute multiplier
|
||
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 multiplierValue = 1 + (scoreData.commendations * commendationValue) - (scoreData.citations * citationValue);
|
||
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}**`, inline: false },
|
||
{ name: 'Citations', value: `**${scoreData.citations}**`, inline: false },
|
||
{ name: 'Input Score', value: `**${scoreData.input}**`, inline: true },
|
||
{ name: 'Output Score', value: `**${scoreData.output}**`, inline: true }
|
||
)
|
||
.addFields({
|
||
name: 'Formula',
|
||
value:
|
||
`CV = ${commendationValue}\n` +
|
||
`CiV = ${citationValue}\n` +
|
||
`BO = ${baseOutput}\n` +
|
||
`M = 1 + **${scoreData.commendations}** × CV - **${scoreData.citations}** × CiV\n` +
|
||
`M × **${scoreData.input}** / (**${scoreData.output}** + BO) = **${scoreData.totalScore.toFixed(2)}**`,
|
||
inline: false
|
||
})
|
||
.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.');
|
||
}
|
||
}
|
||
}
|
||
];
|