Scorekeeper category support.

This commit is contained in:
jrmyr 2025-04-30 16:06:35 +00:00
parent 87c650174c
commit e3ee259ac7
2 changed files with 332 additions and 39 deletions

View File

@ -28,6 +28,9 @@ export const init = async (client, config) => {
// Check if scorekeeper collection exists // Check if scorekeeper collection exists
await checkCollection(client); await checkCollection(client);
// Ensure auxiliary collections exist (categories & events)
await checkCategoriesCollection(client);
await checkEventsCollection(client);
// Create scorekeeper interface on client // Create scorekeeper interface on client
client.scorekeeper = { client.scorekeeper = {
addInput: (guildId, userId, amount) => addInput(client, guildId, userId, amount), addInput: (guildId, userId, amount) => addInput(client, guildId, userId, amount),
@ -41,6 +44,8 @@ export const init = async (client, config) => {
// Set up cron job for decay // Set up cron job for decay
setupDecayCron(client, config.scorekeeper.schedule); setupDecayCron(client, config.scorekeeper.schedule);
// Enable autocomplete for category options in commend/cite
registerCategoryAutocomplete(client);
client.logger.info('Scorekeeper module initialized'); client.logger.info('Scorekeeper module initialized');
}; };
@ -66,6 +71,46 @@ async function checkCollection(client) {
client.logger.warn('- lastDecay (date, required)'); 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 * Set up cron job for decay
@ -424,6 +469,35 @@ export const commands = [
const citationValue = client.config.scorekeeper.citationValue; const citationValue = client.config.scorekeeper.citationValue;
const scoreData = await client.scorekeeper.getScore(interaction.guildId, targetUser.id); const scoreData = await client.scorekeeper.getScore(interaction.guildId, targetUser.id);
const multiplierValue = 1 + (scoreData.commendations * commendationValue) - (scoreData.citations * citationValue); 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() const embed = new EmbedBuilder()
.setAuthor({ name: `${client.user.username}: Scorekeeper Module`, iconURL: client.user.displayAvatarURL() }) .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}`) .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()) .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.') .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( .addFields(
{ name: 'Commendations', value: `**${scoreData.commendations}**`, inline: false }, { name: 'Commendations', value: commendBreakdown, inline: false },
{ name: 'Citations', value: `**${scoreData.citations}**`, inline: false }, { name: 'Citations', value: citeBreakdown, inline: false },
{ name: 'Input', value: `${scoreData.input}`, inline: true }, { name: 'Input', value: `${scoreData.input}`, inline: true },
{ name: 'Output', value: `${scoreData.output}`, inline: true }, { name: 'Output', value: `${scoreData.output}`, inline: true },
{ name: 'Priority Score', value: `${scoreData.totalScore.toFixed(2)}`, inline: true }, { name: 'Priority Score', value: `${scoreData.totalScore.toFixed(2)}`, inline: true },
@ -516,22 +590,70 @@ export const commands = [
option.setName('user') option.setName('user')
.setDescription('User to commend') .setDescription('User to commend')
.setRequired(true)) .setRequired(true))
.addIntegerOption(option => .addStringOption(option =>
option.setName('amount') option.setName('category')
.setDescription('Amount of commendations (default: 1)') .setDescription('Category to award')
.setRequired(false) .setRequired(true)
.setMinValue(1) .setAutocomplete(true)
.setMaxValue(10)) )
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction, client) => { 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 targetUser = interaction.options.getUser('user');
const amount = interaction.options.getInteger('amount') || 1; 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 <t:${expireSec}:R>.`,
ephemeral: true
});
}
}
}
try { 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) { } catch (error) {
client.logger.error(`Error in commend command: ${error.message}`); client.logger.error(`Error in commend command: ${error.message}`);
await interaction.reply({ await interaction.reply({
@ -551,22 +673,70 @@ export const commands = [
option.setName('user') option.setName('user')
.setDescription('User to cite') .setDescription('User to cite')
.setRequired(true)) .setRequired(true))
.addIntegerOption(option => .addStringOption(option =>
option.setName('amount') option.setName('category')
.setDescription('Amount of citations (default: 1)') .setDescription('Category to award')
.setRequired(false) .setRequired(true)
.setMinValue(1) .setAutocomplete(true)
.setMaxValue(10)) )
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction, client) => { 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 targetUser = interaction.options.getUser('user');
const amount = interaction.options.getInteger('amount') || 1; 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 <t:${expireSec}:R>.`,
ephemeral: true
});
}
}
}
try { 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) { } catch (error) {
client.logger.error(`Error in cite command: ${error.message}`); client.logger.error(`Error in cite command: ${error.message}`);
await interaction.reply({ 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([]);
}
});
}

View File

@ -187,6 +187,7 @@ export default {
baseOutput: 1000, baseOutput: 1000,
commendationValue: 0.25, commendationValue: 0.25,
citationValue: 0.35, citationValue: 0.35,
cooldown: 43200000,
decay: 80, decay: 80,
schedule: '0 0 * * 0' schedule: '0 0 * * 0'
}, },