ClientX/_opt/scorekeeper.js

595 lines
18 KiB
JavaScript
Raw Normal View History

2025-04-25 21:27:00 -04:00
// 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
*/
function calculateScore(data, baseOutput, commendationValue, citationValue) {
// Score = ((Commendations * CommendationValue) - (Citations * CitationValue)) + (Input / (Output + BaseOutput))
const permanentModifier = (data.commendations * commendationValue) - (data.citations * citationValue);
const activityScore = data.input / (data.output + baseOutput);
return permanentModifier + 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 score or another user\'s score')
.addUserOption(option =>
option.setName('user')
.setDescription('User to check 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;
try {
const scoreData = await client.scorekeeper.getScore(interaction.guildId, targetUser.id);
const embed = new EmbedBuilder()
.setTitle(`Score for ${targetUser.username}`)
.setColor(0x00AE86)
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: 'Total Score', value: scoreData.totalScore.toFixed(2), inline: false },
{ name: 'Commendations', value: scoreData.commendations.toString(), inline: false },
{ name: 'Citations', value: scoreData.citations.toString(), inline: false },
{ name: 'Input Score', value: scoreData.input.toString(), inline: true },
{ name: 'Output Score', value: scoreData.output.toString(), 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}`);
await interaction.reply({
content: 'Failed to retrieve score data.',
ephemeral
});
}
}
},
// Command to view top scores
{
data: new SlashCommandBuilder()
.setName('leaderboard')
.setDescription('View the server\'s score leaderboard')
.addIntegerOption(option =>
option.setName('limit')
.setDescription('Number of users to show (default: 10, max: 25)')
.setRequired(false)
.setMinValue(1)
.setMaxValue(25)
)
.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 = interaction.options.getInteger('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 username = user ? user.user.username : 'Unknown User';
leaderboardText += `${i + 1}. **${username}**: ${score.totalScore.toFixed(2)}\n`;
}
const embed = new EmbedBuilder()
.setTitle(`${guild.name} 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.');
}
}
}
];