From e3ee259ac7f55c04522a5164900937844fa4c8f6 Mon Sep 17 00:00:00 2001 From: jrmyr Date: Wed, 30 Apr 2025 16:06:35 +0000 Subject: [PATCH] Scorekeeper category support. --- _opt/scorekeeper.js | 370 +++++++++++++++++++++++++++++++++++++++----- config.js | 1 + 2 files changed, 332 insertions(+), 39 deletions(-) diff --git a/_opt/scorekeeper.js b/_opt/scorekeeper.js index c4032c2..e58cafe 100644 --- a/_opt/scorekeeper.js +++ b/_opt/scorekeeper.js @@ -28,7 +28,10 @@ export const init = async (client, config) => { // Check if scorekeeper collection exists await checkCollection(client); - // Create scorekeeper interface on client + // Ensure auxiliary collections exist (categories & events) + await checkCategoriesCollection(client); + await checkEventsCollection(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), @@ -39,8 +42,10 @@ export const init = async (client, config) => { runDecay: (guildId) => runDecay(client, guildId) }; - // Set up cron job for decay - setupDecayCron(client, config.scorekeeper.schedule); + // Set up cron job for decay + setupDecayCron(client, config.scorekeeper.schedule); + // Enable autocomplete for category options in commend/cite + registerCategoryAutocomplete(client); client.logger.info('Scorekeeper module initialized'); }; @@ -66,6 +71,46 @@ async function checkCollection(client) { client.logger.warn('- lastDecay (date, required)'); } } +/** + * Ensure the "scorekeeper_categories" collection exists in PocketBase. + * Logs guidance if missing. This collection holds per-guild award categories. + * @param {import('discord.js').Client} client - The Discord client with PocketBase attached. + */ +async function checkCategoriesCollection(client) { + try { + await client.pb.collection('scorekeeper_categories').getList(1, 1); + client.logger.info('scorekeeper_categories collection exists'); + } catch (error) { + client.logger.warn('scorekeeper_categories collection does not exist in PocketBase'); + client.logger.warn('Please create a "scorekeeper_categories" collection with fields:'); + client.logger.warn('- guildId (text, required)'); + client.logger.warn('- name (text, required, unique per guild)'); + client.logger.warn('- createdBy (text, required)'); + client.logger.warn('- createdAt (date, required)'); + } +} + +/** + * Ensure the "scorekeeper_events" collection exists in PocketBase. + * Logs guidance if missing. This collection stores each commendation/citation event with category. + * @param {import('discord.js').Client} client - The Discord client with PocketBase attached. + */ +async function checkEventsCollection(client) { + try { + await client.pb.collection('scorekeeper_events').getList(1, 1); + client.logger.info('scorekeeper_events collection exists'); + } catch (error) { + client.logger.warn('scorekeeper_events collection does not exist in PocketBase'); + client.logger.warn('Please create a "scorekeeper_events" collection with fields:'); + client.logger.warn('- guildId (text, required)'); + client.logger.warn('- userId (text, required)'); + client.logger.warn('- type (text, required) // "commendation" or "citation"'); + client.logger.warn('- categoryId (text, required)'); + client.logger.warn('- amount (number, required)'); + client.logger.warn('- awardedBy (text, required)'); + client.logger.warn('- timestamp (date, required)'); + } +} /** * Set up cron job for decay @@ -424,6 +469,35 @@ export const commands = [ 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); + // Load categories for breakdown + const categories = await client.pb.collection('scorekeeper_categories').getFullList({ + filter: `guildId = "${interaction.guildId}"` + }); + const catMap = new Map(categories.map(c => [c.id, c.name])); + // Commendations per category + const commendEvents = await client.pb.collection('scorekeeper_events').getFullList({ + filter: `guildId = "${interaction.guildId}" && userId = "${targetUser.id}" && type = "commendation"` + }); + const commendByCat = new Map(); + commendEvents.forEach(e => { + const cnt = commendByCat.get(e.categoryId) || 0; + commendByCat.set(e.categoryId, cnt + e.amount); + }); + const commendBreakdown = commendByCat.size > 0 + ? Array.from(commendByCat.entries()).map(([cid, cnt]) => `${catMap.get(cid) || 'Unknown'}: ${cnt}`).join('\n') + : 'None'; + // Citations per category + const citeEvents = await client.pb.collection('scorekeeper_events').getFullList({ + filter: `guildId = "${interaction.guildId}" && userId = "${targetUser.id}" && type = "citation"` + }); + const citeByCat = new Map(); + citeEvents.forEach(e => { + const cnt = citeByCat.get(e.categoryId) || 0; + citeByCat.set(e.categoryId, cnt + e.amount); + }); + const citeBreakdown = citeByCat.size > 0 + ? Array.from(citeByCat.entries()).map(([cid, cnt]) => `${catMap.get(cid) || 'Unknown'}: ${cnt}`).join('\n') + : 'None'; 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}`) @@ -431,8 +505,8 @@ export const commands = [ .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: 'Commendations', value: commendBreakdown, inline: false }, + { name: 'Citations', value: citeBreakdown, 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 }, @@ -511,27 +585,75 @@ export const commands = [ { 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), + .setDescription('Give a commendation to a user (Admin only)') + .addUserOption(option => + option.setName('user') + .setDescription('User to commend') + .setRequired(true)) + .addStringOption(option => + option.setName('category') + .setDescription('Category to award') + .setRequired(true) + .setAutocomplete(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - execute: async (interaction, client) => { - const targetUser = interaction.options.getUser('user'); - const amount = interaction.options.getInteger('amount') || 1; + execute: async (interaction, client) => { + const guildId = interaction.guildId; + // Ensure categories exist before proceeding + let catList = []; + try { + catList = await client.pb.collection('scorekeeper_categories').getFullList({ + filter: `guildId = "${guildId}"` + }); + } catch {} + if (catList.length === 0) { + return interaction.reply({ + content: 'No categories defined for this server. Ask an admin to create one with /addcategory.', + ephemeral: true + }); + } + const targetUser = interaction.options.getUser('user'); + const categoryId = interaction.options.getString('category'); + const amount = 1; + // Enforce per-category cooldown + const cooldown = client.config.scorekeeper.cooldown || 0; + if (cooldown > 0) { + const recent = await client.pb.collection('scorekeeper_events').getList(1, 1, { + filter: `guildId = \"${guildId}\" && userId = \"${targetUser.id}\" && type = \"commendation\" && categoryId = \"${categoryId}\"`, + sort: '-timestamp' + }); + const lastItem = recent.items?.[0]; + if (lastItem) { + const lastTs = new Date(lastItem.timestamp).getTime(); + const elapsed = Date.now() - lastTs; + if (elapsed < cooldown) { + const expireTs = lastTs + cooldown; + const expireSec = Math.ceil(expireTs / 1000); + const categoryRecord = catList.find(c => c.id === categoryId); + const categoryName = categoryRecord ? categoryRecord.name : categoryId; + return interaction.reply({ + content: `${targetUser} cannot receive another commendation in the ${categoryName} category for .`, + ephemeral: true + }); + } + } + } try { - await client.scorekeeper.addCommendation(interaction.guildId, targetUser.id, amount); + await client.scorekeeper.addCommendation(interaction.guildId, targetUser.id, amount); + // Log event + await client.pb.collection('scorekeeper_events').create({ + guildId: interaction.guildId, + userId: targetUser.id, + type: 'commendation', + categoryId, + amount, + awardedBy: interaction.user.id, + timestamp: new Date().toISOString() + }); - await interaction.reply(`Added ${amount} commendation${amount !== 1 ? 's' : ''} to ${targetUser}.`); + await interaction.reply(`Added commendation to ${targetUser}.`); } catch (error) { client.logger.error(`Error in commend command: ${error.message}`); await interaction.reply({ @@ -545,28 +667,76 @@ export const commands = [ // 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)) + .setName('cite') + .setDescription('Give a citation to a user (Admin only)') + .addUserOption(option => + option.setName('user') + .setDescription('User to cite') + .setRequired(true)) + .addStringOption(option => + option.setName('category') + .setDescription('Category to award') + .setRequired(true) + .setAutocomplete(true) + ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - execute: async (interaction, client) => { - const targetUser = interaction.options.getUser('user'); - const amount = interaction.options.getInteger('amount') || 1; + execute: async (interaction, client) => { + const guildId = interaction.guildId; + // Ensure categories exist before proceeding + let catList = []; + try { + catList = await client.pb.collection('scorekeeper_categories').getFullList({ + filter: `guildId = "${guildId}"` + }); + } catch {} + if (catList.length === 0) { + return interaction.reply({ + content: 'No categories defined for this server. Ask an admin to create one with /addcategory.', + ephemeral: true + }); + } + const targetUser = interaction.options.getUser('user'); + const categoryId = interaction.options.getString('category'); + const amount = 1; + // Enforce per-category cooldown + const cooldown = client.config.scorekeeper.cooldown || 0; + if (cooldown > 0) { + const recent = await client.pb.collection('scorekeeper_events').getList(1, 1, { + filter: `guildId = \"${guildId}\" && userId = \"${targetUser.id}\" && type = \"citation\" && categoryId = \"${categoryId}\"`, + sort: '-timestamp' + }); + const lastItem = recent.items?.[0]; + if (lastItem) { + const lastTs = new Date(lastItem.timestamp).getTime(); + const elapsed = Date.now() - lastTs; + if (elapsed < cooldown) { + const expireTs = lastTs + cooldown; + const expireSec = Math.ceil(expireTs / 1000); + const categoryRecord = catList.find(c => c.id === categoryId); + const categoryName = categoryRecord ? categoryRecord.name : categoryId; + return interaction.reply({ + content: `${targetUser} cannot receive another citation in the ${categoryName} category for .`, + ephemeral: true + }); + } + } + } try { - await client.scorekeeper.addCitation(interaction.guildId, targetUser.id, amount); + await client.scorekeeper.addCitation(interaction.guildId, targetUser.id, amount); + // Log event + await client.pb.collection('scorekeeper_events').create({ + guildId: interaction.guildId, + userId: targetUser.id, + type: 'citation', + categoryId, + amount, + awardedBy: interaction.user.id, + timestamp: new Date().toISOString() + }); - await interaction.reply(`Added ${amount} citation${amount !== 1 ? 's' : ''} to ${targetUser}.`); + await interaction.reply(`Added citation to ${targetUser}.`); } catch (error) { client.logger.error(`Error in cite command: ${error.message}`); await interaction.reply({ @@ -597,4 +767,126 @@ export const commands = [ } } } + // Admin command: add a new category + ,{ + data: new SlashCommandBuilder() + .setName('addcategory') + .setDescription('Create a new commendation/citation category (Admin only)') + .addStringOption(opt => + opt.setName('name') + .setDescription('Name of the new category') + .setRequired(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction, client) => { + const name = interaction.options.getString('name').trim(); + const guildId = interaction.guildId; + try { + // Check for existing + const existing = await client.pb.collection('scorekeeper_categories').getFirstListItem( + `guildId = "${guildId}" && name = "${name}"` + ).catch(() => null); + if (existing) { + return interaction.reply({ content: `Category '${name}' already exists.`, ephemeral: true }); + } + // Create new category + await client.pb.collection('scorekeeper_categories').create({ + guildId, + name, + createdBy: interaction.user.id, + createdAt: new Date().toISOString() + }); + await interaction.reply({ content: `Category '${name}' created.`, ephemeral: true }); + } catch (err) { + client.logger.error(`Error in addcategory: ${err.message}`); + await interaction.reply({ content: 'Failed to create category.', ephemeral: true }); + } + } + } + // Admin command: remove a category + ,{ + data: new SlashCommandBuilder() + .setName('removecategory') + .setDescription('Delete a commendation/citation category (Admin only)') + .addStringOption(opt => + opt.setName('name') + .setDescription('Name of the category to remove') + .setRequired(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction, client) => { + const name = interaction.options.getString('name').trim(); + const guildId = interaction.guildId; + try { + const record = await client.pb.collection('scorekeeper_categories').getFirstListItem( + `guildId = "${guildId}" && name = "${name}"` + ).catch(() => null); + if (!record) { + return interaction.reply({ content: `Category '${name}' not found.`, ephemeral: true }); + } + await client.pb.collection('scorekeeper_categories').delete(record.id); + await interaction.reply({ content: `Category '${name}' removed.`, ephemeral: true }); + } catch (err) { + client.logger.error(`Error in removecategory: ${err.message}`); + await interaction.reply({ content: 'Failed to remove category.', ephemeral: true }); + } + } + } + // Public command: list categories + ,{ + data: new SlashCommandBuilder() + .setName('listcategories') + .setDescription('List all commendation/citation categories') + .addBooleanOption(opt => + opt.setName('ephemeral') + .setDescription('Whether the result should be ephemeral') + .setRequired(false) + ), + execute: async (interaction, client) => { + const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; + const guildId = interaction.guildId; + try { + const records = await client.pb.collection('scorekeeper_categories').getFullList({ filter: `guildId = "${guildId}"` }); + if (records.length === 0) { + return interaction.reply({ content: 'No categories defined for this guild.', ephemeral }); + } + const list = records.map(r => r.name).join(', '); + await interaction.reply({ content: `Categories: ${list}`, ephemeral }); + } catch (err) { + client.logger.error(`Error in listcategories: ${err.message}`); + await interaction.reply({ content: 'Failed to list categories.', ephemeral }); + } + } + } ]; +/** + * Attach autocomplete handlers for category options in commend and cite commands. + */ +/** + * Attach a handler for slash-command autocomplete of "category" options. + * Dynamically fetches and filters categories from PocketBase per guild. + * @param {import('discord.js').Client} client - The Discord client. + */ +export function registerCategoryAutocomplete(client) { + client.on('interactionCreate', async interaction => { + if (!interaction.isAutocomplete()) return; + const cmd = interaction.commandName; + if (cmd !== 'commend' && cmd !== 'cite') return; + const focused = interaction.options.getFocused(true); + if (focused.name !== 'category') return; + const guildId = interaction.guildId; + try { + const records = await client.pb.collection('scorekeeper_categories').getFullList({ + filter: `guildId = "${guildId}"` + }); + const choices = records + .filter(r => r.name.toLowerCase().startsWith(focused.value.toLowerCase())) + .slice(0, 25) + .map(r => ({ name: r.name, value: r.id })); + await interaction.respond(choices); + } catch (error) { + client.logger.error(`Category autocomplete error: ${error.message}`); + await interaction.respond([]); + } + }); +} diff --git a/config.js b/config.js index ba6eadf..0d3fc96 100644 --- a/config.js +++ b/config.js @@ -187,6 +187,7 @@ export default { baseOutput: 1000, commendationValue: 0.25, citationValue: 0.35, + cooldown: 43200000, decay: 80, schedule: '0 0 * * 0' },