ClientX/_opt/scorekeeper.js
2025-04-30 13:54:14 +00:00

601 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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()
.setAuthor({ name: `${client.user.username}: Scorekeeper Module`, iconURL: client.user.displayAvatarURL() })
.setTitle(`I/O Score for ${(await interaction.guild.members.fetch(targetUser.id).catch(() => null))?.displayName || targetUser.username}`)
.setColor(0x00AE86)
.setThumbnail(targetUser.displayAvatarURL())
.setDescription('I/O is a mechanism to rank the users within a system based on the resources they put into the system and the resources they take from it. The resulting priority score can be used for a variety of dystopian purposes.')
.addFields(
{ name: 'Commendations', value: `**${scoreData.commendations}**`, inline: false },
{ name: 'Citations', value: `**${scoreData.citations}**`, inline: false },
{ name: 'Input', value: `${scoreData.input}`, inline: true },
{ name: 'Output', value: `${scoreData.output}`, inline: true },
{ name: 'Priority Score', value: `${scoreData.totalScore.toFixed(2)}`, inline: true },
{ name: 'Base Output', value: `-# ${baseOutput}`, inline: true },
{ name: 'Commendation Value', value: `-# ${commendationValue}`, inline: true },
{ name: 'Citation Value', value: `-# ${citationValue}`, inline: true },
{ name: 'Multiplier Formula', value: `-# 1 + (${scoreData.commendations} * ${commendationValue}) - (${scoreData.citations} * ${citationValue}) = ${multiplierValue.toFixed(2)}`, inline: false },
{ name: 'Priority Score Formula', value: `-# ${multiplierValue.toFixed(2)} × ${scoreData.input} / (${scoreData.output} + ${baseOutput}) = ${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()
.setAuthor({ name: `${client.user.username}: Scorekeeper Module`, iconURL: client.user.displayAvatarURL() })
.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.');
}
}
}
];