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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-04-30 00:30:34 +00:00
|
|
|
|
* Calculate score based on formula (no scaling factor)
|
|
|
|
|
|
* Formula: (1 + C*commendationValue - D*citationValue) * (input / (output + baseOutput))
|
2025-04-25 21:27:00 -04:00
|
|
|
|
*/
|
|
|
|
|
|
function calculateScore(data, baseOutput, commendationValue, citationValue) {
|
2025-04-30 00:30:34 +00:00
|
|
|
|
const multiplier = 1 + (data.commendations * commendationValue) - (data.citations * citationValue);
|
|
|
|
|
|
const activityScore = data.input / (data.output + baseOutput);
|
2025-04-25 21:27:00 -04:00
|
|
|
|
|
2025-04-30 00:30:34 +00:00
|
|
|
|
// Removed aesthetic scaling (×100) for raw score
|
|
|
|
|
|
return multiplier * activityScore;
|
2025-04-25 21:27:00 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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')
|
2025-04-30 00:30:34 +00:00
|
|
|
|
.setDescription('View your I/O score or another user\'s I/O score')
|
2025-04-25 21:27:00 -04:00
|
|
|
|
.addUserOption(option =>
|
|
|
|
|
|
option.setName('user')
|
2025-04-30 00:30:34 +00:00
|
|
|
|
.setDescription('User to check I/O score for (defaults to you)')
|
2025-04-25 21:27:00 -04:00
|
|
|
|
.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;
|
2025-04-30 00:30:34 +00:00
|
|
|
|
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
|
|
|
|
|
|
// Wrap score retrieval and embed generation in try/catch to handle errors gracefully
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
2025-04-30 02:13:32 +00:00
|
|
|
|
// Fetch score data and compute multiplier
|
2025-04-30 00:30:34 +00:00
|
|
|
|
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);
|
2025-04-30 02:13:32 +00:00
|
|
|
|
const multiplierValue = 1 + (scoreData.commendations * commendationValue) - (scoreData.citations * citationValue);
|
2025-04-30 00:30:34 +00:00
|
|
|
|
const embed = new EmbedBuilder()
|
|
|
|
|
|
.setTitle(`I/O Score for ${(await interaction.guild.members.fetch(targetUser.id).catch(() => null))?.displayName || targetUser.username}`)
|
2025-04-25 21:27:00 -04:00
|
|
|
|
.setColor(0x00AE86)
|
|
|
|
|
|
.setThumbnail(targetUser.displayAvatarURL())
|
2025-04-30 02:13:32 +00:00
|
|
|
|
.addFields(
|
|
|
|
|
|
{ name: 'Total Score', value: `**${scoreData.totalScore.toFixed(2)}**`, inline: false },
|
2025-04-30 02:30:16 +00:00
|
|
|
|
{ name: 'Commendations', value: `**${scoreData.commendations}**`, inline: false },
|
|
|
|
|
|
{ name: 'Citations', value: `**${scoreData.citations}**`, inline: false },
|
2025-04-30 02:13:32 +00:00
|
|
|
|
{ name: 'Input Score', value: `**${scoreData.input}**`, inline: true },
|
2025-04-30 02:30:16 +00:00
|
|
|
|
{ name: 'Output Score', value: `**${scoreData.output}**`, inline: true }
|
2025-04-30 02:13:32 +00:00
|
|
|
|
)
|
|
|
|
|
|
.addFields({
|
|
|
|
|
|
name: 'Formula',
|
|
|
|
|
|
value:
|
2025-04-30 02:30:16 +00:00
|
|
|
|
`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)}**`,
|
2025-04-30 02:13:32 +00:00
|
|
|
|
inline: false
|
|
|
|
|
|
})
|
2025-04-25 21:27:00 -04:00
|
|
|
|
.setFooter({ text: 'Last decay: ' + new Date(scoreData.lastDecay).toLocaleDateString() })
|
|
|
|
|
|
.setTimestamp();
|
|
|
|
|
|
|
2025-04-30 00:30:34 +00:00
|
|
|
|
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 {}
|
|
|
|
|
|
}
|
2025-04-25 21:27:00 -04:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Command to view top scores
|
|
|
|
|
|
{
|
|
|
|
|
|
data: new SlashCommandBuilder()
|
|
|
|
|
|
.setName('leaderboard')
|
2025-04-30 00:30:34 +00:00
|
|
|
|
.setDescription('View the server\'s I/O score leaderboard')
|
2025-04-25 21:27:00 -04:00
|
|
|
|
.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 });
|
|
|
|
|
|
|
2025-04-30 00:30:34 +00:00
|
|
|
|
const limit = 10;
|
2025-04-25 21:27:00 -04:00
|
|
|
|
|
|
|
|
|
|
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);
|
2025-04-30 00:30:34 +00:00
|
|
|
|
const displayName = user ? user.displayName : 'Unknown User';
|
2025-04-25 21:27:00 -04:00
|
|
|
|
|
2025-04-30 00:30:34 +00:00
|
|
|
|
leaderboardText += `${i + 1}. **${displayName}**: ${score.totalScore.toFixed(2)}\n`;
|
2025-04-25 21:27:00 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const embed = new EmbedBuilder()
|
2025-04-30 00:30:34 +00:00
|
|
|
|
.setTitle('I/O Score Leaderboard')
|
2025-04-25 21:27:00 -04:00
|
|
|
|
.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.');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|