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 ( )
2025-04-30 13:54:14 +00:00
. setAuthor ( { name : ` ${ client . user . username } : Scorekeeper Module ` , iconURL : client . user . displayAvatarURL ( ) } )
2025-04-30 00:30:34 +00:00
. setTitle ( ` I/O Score for ${ ( await interaction . guild . members . fetch ( targetUser . id ) . catch ( ( ) => null ) ) ? . displayName || targetUser . username } ` )
2025-04-30 13:54:14 +00:00
. 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.' )
2025-04-30 02:13:32 +00:00
. addFields (
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 13:54:14 +00:00
{ 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 } ,
2025-04-30 02:13:32 +00:00
)
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
}
2025-04-30 13:54:14 +00:00
const embed = new EmbedBuilder ( )
. setAuthor ( { name : ` ${ client . user . username } : Scorekeeper Module ` , iconURL : client . user . displayAvatarURL ( ) } )
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.' ) ;
}
}
}
] ;