diff --git a/migrations/20231115005045_counterLinks.js b/migrations/20231115005045_counterLinks.js new file mode 100644 index 0000000..17887f3 --- /dev/null +++ b/migrations/20231115005045_counterLinks.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema + .alterTable('counters', table => { + table.integer('linkedItem'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema + .alterTable('counters', table => { + table.dropColumn('linkedItem'); + }); +}; diff --git a/src/commands/counter.ts b/src/commands/counter.ts index a4ed008..4ac2a98 100644 --- a/src/commands/counter.ts +++ b/src/commands/counter.ts @@ -2,6 +2,7 @@ import { AutocompleteInteraction, Interaction, SlashCommandBuilder } from 'disco import { Counter, CounterUserLink, db } from '../lib/db'; import { counterAutocomplete, counterConfigs, findCounter, getCounterConfigRaw, getOptions, parseConfig, setCounterConfig, toStringConfig, updateCounter } from '../lib/counter'; import { outdent } from 'outdent'; +import { formatItem, formatItems, getItem, itemAutocomplete } from '../lib/items'; function extendOption(t: string) { return {name: t, value: t}; @@ -9,7 +10,7 @@ function extendOption(t: string) { const help = new Map([ ['message templates', outdent` - When using \`messageTemplate\`, \`messageTemplateIncrease\` or \`messageTemplateDecrease\`, you are providing a **template string**. + When using \`messageTemplate\`, \`messageTemplateIncrease\`, \`messageTemplateDecrease\`, \`messageTemplatePut\` or \`messageTemplateTake\`, you are providing a **template string**. A template string is a **specially-formatted** string with placeholder values. For instance, a template string like so: > **%user** has %action the counter by **%amt**. @@ -215,6 +216,25 @@ module.exports = { .setChoices(...[...help.keys()].map(extendOption)) ) ) + .addSubcommand(sub => + sub + .setName('link') + .setDescription('[ADMIN] THIS IS IRREVERSIBLE! Attach an item to this counter, letting you take or put items in.') + .addStringOption(opt => + opt + .setName('type') + .setDescription('The counter to operate on') + .setRequired(true) + .setAutocomplete(true) + ) + .addStringOption(opt => + opt + .setName('item') + .setDescription('The item') + .setAutocomplete(true) + .setRequired(true) + ) + ) .setDefaultMemberPermissions('0') .setDMPermission(false), @@ -233,10 +253,7 @@ module.exports = { try { counter = await findCounter(type, interaction.guildId!); } catch(err) { - await interaction.followUp({ - content: 'No such counter!' - }); - return; + return interaction.followUp('No such counter!'); } if (subcommand === 'add') { @@ -276,12 +293,7 @@ module.exports = { .where('producer', userType === 'producer') .first(); - if (!link) { - await interaction.followUp({ - content: `<@${user.id}> is not in the ${counter.emoji} **${userType}** allowlist!` - }); - return; - } + if (!link) return interaction.followUp(`<@${user.id}> is not in the ${counter.emoji} **${userType}** allowlist!`); await interaction.followUp({ content: `<@${user.id}> has been removed from the ${counter.emoji} **${userType}** allowlist.` @@ -350,15 +362,14 @@ module.exports = { try { counter = await findCounter(type, interaction.guildId!); } catch(err) { - await interaction.followUp({ - content: 'No such counter!' - }); - return; + return interaction.followUp('No such counter!'); } const config = await getCounterConfigRaw(counter); const key = interaction.options.getString('key', true); const value = interaction.options.getString('value', true); + + if (key === 'emoji' && counter.linkedItem) return interaction.followUp(`Cannot modify emoji - this counter is linked to ${formatItem(await getItem(counter.linkedItem))}`); const defaultConfig = counterConfigs.get(key); if (!defaultConfig) return interaction.followUp(`No config named \`${key}\` exists!`); @@ -366,7 +377,7 @@ module.exports = { const parsedValue = parseConfig(value, defaultConfig.type); const restringedValue = toStringConfig(parsedValue, defaultConfig.type); - await setCounterConfig(counter.id, key, restringedValue); + await setCounterConfig(counter, key, restringedValue); await interaction.followUp(`${counter.emoji} \`${key}\` is now \`${restringedValue}\`. (was \`${config.get(key) || toStringConfig(defaultConfig.default, defaultConfig.type)}\`)`); } else if (subcommand === 'delete') { @@ -376,10 +387,7 @@ module.exports = { try { counter = await findCounter(type, interaction.guildId!); } catch(err) { - await interaction.followUp({ - content: 'No such counter!' - }); - return; + return interaction.followUp('No such counter!'); } await db('counters') @@ -400,6 +408,32 @@ module.exports = { await interaction.followUp(counters.map(c => `${c.emoji} **${c.value}** <#${c.channel}>`).join('\n')); } else if (subcommand === 'help') { await interaction.followUp(help.get(interaction.options.getString('topic', true))!); + } else if (subcommand === 'link') { + const type = interaction.options.getString('type', true); + const itemID = parseInt(interaction.options.getString('item', true)); + + let counter; + try { + counter = await findCounter(type, interaction.guildId!); + } catch(err) { + return interaction.followUp('No such counter!'); + } + + const item = await getItem(itemID); + if (!item) return interaction.followUp('No such item exists!'); + + await db('counters') + .where('id', counter.id) + .update({ + 'linkedItem': item.id, + 'emoji': item.emoji, + 'value': 0 + }); + + await setCounterConfig(counter, 'canIncrement', 'false'); + await setCounterConfig(counter, 'canDecrement', 'false'); + + await interaction.followUp(`Done. **The counter has been reset** to ${formatItems(item, 0)}. Users will not be able to take out or put in items until you enable this with \`canTake\` or \`canPut\`.\n\`canIncrement\` and \`canDecrement\` have also been **automatically disabled**, and you are recommended to keep them as such if you want to maintain balance in the universe.`); } } }, @@ -409,6 +443,8 @@ module.exports = { if (focused.name === 'type') { return counterAutocomplete(interaction); + } else if (focused.name === 'item') { + return itemAutocomplete(interaction); } else if (focused.name === 'value') { const type = interaction.options.getString('type', true); const counter = await findCounter(type, interaction.guildId!); diff --git a/src/commands/item.ts b/src/commands/item.ts index 83a850b..a6f77ed 100644 --- a/src/commands/item.ts +++ b/src/commands/item.ts @@ -1,6 +1,6 @@ import { AutocompleteInteraction, Interaction, SlashCommandBuilder } from 'discord.js'; import { CustomItem, ItemInventory, db } from '../lib/db'; -import { behaviors, defaultItems, formatItems, getItem, getMaxStack } from '../lib/items'; +import { behaviors, formatItems, getItem, getMaxStack, giveItem, itemAutocomplete } from '../lib/items'; //function extendOption(t: string) { // return {name: t, value: t}; @@ -204,32 +204,9 @@ module.exports = { const item = await getItem(itemID); if (!item) return interaction.followUp('No such item exists!'); - const storedItem = await db('itemInventories') - .where('user', user.id) - .where('item', itemID) - .first(); + const inv = await giveItem(user.id, item, quantity); - let inv; - if (storedItem) { - inv = await db('itemInventories') - .update({ - 'quantity': db.raw('MIN(quantity + ?, ?)', [quantity, getMaxStack(item)]) - }) - .limit(1) - .where('user', user.id) - .where('item', itemID) - .returning('*'); - } else { - inv = await db('itemInventories') - .insert({ - 'user': user.id, - 'item': Math.min(itemID, getMaxStack(item)), - 'quantity': quantity - }) - .returning('*'); - } - - await interaction.followUp(`${user.toString()} now has ${formatItems(item, inv[0].quantity)}.`); + await interaction.followUp(`${user.toString()} now has ${formatItems(item, inv.quantity)}.`); } } }, @@ -238,20 +215,7 @@ module.exports = { const focused = interaction.options.getFocused(true); if (focused.name === 'item') { - const customItems = await db('customItems') - .select('emoji', 'name', 'id') - // @ts-expect-error this LITERALLY works - .whereLike(db.raw('UPPER(name)'), `%${focused.value.toUpperCase()}%`) - .where('guild', interaction.guildId!) - .limit(25); - - const foundDefaultItems = defaultItems.filter(item => item.name.toUpperCase().includes(focused.value.toUpperCase())); - - const items = [...foundDefaultItems, ...customItems]; - - await interaction.respond( - items.map(choice => ({ name: `${choice.emoji} ${choice.name}`, value: choice.id.toString() })) - ); + return itemAutocomplete(interaction); } } }; \ No newline at end of file diff --git a/src/commands/put.ts b/src/commands/put.ts new file mode 100644 index 0000000..5be26de --- /dev/null +++ b/src/commands/put.ts @@ -0,0 +1,36 @@ +import { GuildMember, Interaction, SlashCommandBuilder } from 'discord.js'; +import { changeLinkedCounterInteraction, linkedCounterAutocomplete } from '../lib/counter'; + +module.exports = { + data: new SlashCommandBuilder() + .setName('put') + .setDescription('Put an item from your inventory into the counter') + .addStringOption(option => + option + .setName('type') + .setAutocomplete(true) + .setDescription('The name of the counter') + .setRequired(true) + ) + .addIntegerOption((option) => + option + .setName('amount') + .setRequired(false) + .setDescription('Amount of items to put in') + .setMinValue(1) + ) + .setDMPermission(false), + + execute: async (interaction: Interaction, member: GuildMember) => { + if (!interaction.isChatInputCommand()) return; + + const amount = Math.trunc(interaction.options.getInteger('amount') || 1); + const type = interaction.options.getString('type')!; + + await interaction.deferReply({ephemeral: true}); + + changeLinkedCounterInteraction(interaction, member, amount, type); + }, + + autocomplete: linkedCounterAutocomplete +}; \ No newline at end of file diff --git a/src/commands/take.ts b/src/commands/take.ts new file mode 100644 index 0000000..d66a416 --- /dev/null +++ b/src/commands/take.ts @@ -0,0 +1,36 @@ +import { GuildMember, Interaction, SlashCommandBuilder } from 'discord.js'; +import { changeLinkedCounterInteraction, linkedCounterAutocomplete } from '../lib/counter'; + +module.exports = { + data: new SlashCommandBuilder() + .setName('take') + .setDescription('Take an item from a counter') + .addStringOption(option => + option + .setName('type') + .setAutocomplete(true) + .setDescription('The name of the counter') + .setRequired(true) + ) + .addIntegerOption((option) => + option + .setName('amount') + .setRequired(false) + .setDescription('Amount of items to take') + .setMinValue(1) + ) + .setDMPermission(false), + + execute: async (interaction: Interaction, member: GuildMember) => { + if (!interaction.isChatInputCommand()) return; + + const amount = Math.trunc(interaction.options.getInteger('amount') || 1); + const type = interaction.options.getString('type')!; + + await interaction.deferReply({ephemeral: true}); + + changeLinkedCounterInteraction(interaction, member, -amount, type); + }, + + autocomplete: linkedCounterAutocomplete +}; \ No newline at end of file diff --git a/src/lib/counter.ts b/src/lib/counter.ts index 5c04b83..fe6d2f4 100644 --- a/src/lib/counter.ts +++ b/src/lib/counter.ts @@ -1,6 +1,7 @@ -import { Client, CommandInteraction, GuildMember, EmbedBuilder, TextChannel, AutocompleteInteraction } from 'discord.js'; +import { Client, CommandInteraction, GuildMember, EmbedBuilder, TextChannel, AutocompleteInteraction, User } from 'discord.js'; import { getSign } from './util'; import { Counter, CounterConfiguration, CounterUserLink, db } from './db'; +import { formatItems, getItem, getItemQuantity, getMaxStack, giveItem } from './items'; export async function getCounter(id: number) { const counter = await db('counters') @@ -83,11 +84,11 @@ export async function getCounterConfig(id: number, key: string) { return value; } -export async function setCounterConfig(id: number, option: string, value: string) { +export async function setCounterConfig(counter: Counter, option: string, value: string) { // just the ugly way of life - if (option === 'emoji') { + if (option === 'emoji' && !counter.linkedItem) { await db('counters') - .where('id', id) + .where('id', counter.id) .update({ 'emoji': value }); @@ -98,13 +99,13 @@ export async function setCounterConfig(id: number, option: string, value: string .update({ value: value }) - .where('id', id) + .where('id', counter.id) .where('configName', option); if (updated === 0) { await db('counterConfigurations') .insert({ - 'id': id, + 'id': counter.id, 'configName': option, 'value': value }); @@ -174,6 +175,30 @@ export const counterConfigs = new Map([ type: ConfigType.String, default: 'null' }], + ['messageTemplateTake', { + type: ConfigType.String, + default: '**%user** has taken **%amt** from the counter.' + }], + ['messageTemplatePut', { + type: ConfigType.String, + default: '**%user** has put **%amt** into the counter.' + }], + ['canIncrement', { + type: ConfigType.Bool, + default: true + }], + ['canDecrement', { + type: ConfigType.Bool, + default: true + }], + ['canPut', { + type: ConfigType.Bool, + default: false + }], + ['canTake', { + type: ConfigType.Bool, + default: false + }], // these ones are fake and are just stand-ins for values defined inside the actual counters table ['emoji', { @@ -210,7 +235,7 @@ export async function updateCounter(bot: Client, counter: Counter, value: number } } -export async function announceCounterUpdate(bot: Client, member: GuildMember, delta: number, counter: Counter, value: number) { +export async function announceCounterUpdate(bot: Client, member: GuildMember, delta: number, counter: Counter, value: number, linked: boolean = false) { const channel = await bot.channels.fetch(counter.channel) as TextChannel; let template = await getCounterConfig(counter.id, 'messageTemplate') as string; @@ -218,6 +243,10 @@ export async function announceCounterUpdate(bot: Client, member: GuildMember, de if (templateIncrease !== 'null' && delta > 0) template = templateIncrease; const templateDecrease = await getCounterConfig(counter.id, 'messageTemplateDecrease') as string; if (templateDecrease !== 'null' && delta < 0) template = templateDecrease; + const templatePut = await getCounterConfig(counter.id, 'messageTemplatePut') as string; + if (templatePut !== 'null' && delta > 0 && linked) template = templatePut; + const templateTake = await getCounterConfig(counter.id, 'messageTemplateTake') as string; + if (templateTake !== 'null' && delta < 0 && linked) template = templateTake; const anonymous = await getCounterConfig(counter.id, 'anonymous') as boolean; @@ -226,7 +255,7 @@ export async function announceCounterUpdate(bot: Client, member: GuildMember, de .setDescription( template .replaceAll('%user', anonymous ? 'someone' : member.toString()) - .replaceAll('%action', delta > 0 ? 'increased' : 'decreased') + .replaceAll('%action', delta > 0 ? (linked ? 'put into' : 'increased') : (linked ? 'taken from' : 'decreased')) .replaceAll('%amt', Math.abs(delta).toString()) .replaceAll('%total', value.toString()) ) @@ -249,66 +278,107 @@ export async function announceCounterUpdate(bot: Client, member: GuildMember, de }); } -export async function changeCounterInteraction(interaction: CommandInteraction, member: GuildMember, amount: number, type: string) { - try { - const counter = await findCounter(type, member.guild.id); +async function canUseCounter(user: User, counter: Counter, amount: number, isLinkedAction = false): Promise { + if (amount > 0 && !(await getCounterConfig(counter.id, isLinkedAction ? 'canPut' : 'canIncrement') as boolean)) return false; + if (amount > 0 && counter.allowlistProducer) { + const userLink = await db('counterUserLink') + .where('id', counter.id) + .where('user', user.id) + .where('producer', true) + .first(); - let canUse = true; - if (amount > 0 && counter.allowlistProducer) { - const userLink = await db('counterUserLink') - .where('id', counter.id) - .where('user', member.id) - .where('producer', true) - .first(); - - if (!userLink) canUse = false; - } - if (amount < 0 && counter.allowlistConsumer) { - const userLink = await db('counterUserLink') - .where('id', counter.id) - .where('user', member.id) - .where('producer', false) - .first(); - - if (!userLink) canUse = false; - } - - if (!canUse) { - await interaction.followUp({ - content: `You cannot **${amount > 0 ? 'produce' : 'consume'}** ${counter.emoji}.` - }); - return; - } - - const newCount = await changeCounter(counter.id, amount); - await updateCounter(interaction.client, counter, newCount); - await announceCounterUpdate(interaction.client, member, amount, counter, newCount); - await interaction.followUp({ - content: `${counter.emoji} **You have ${amount > 0 ? 'increased' : 'decreased'} the counter.**\n\`\`\`diff\n ${newCount - amount}\n${getSign(amount)}${Math.abs(amount)}\n ${newCount}\`\`\`` - }); - } catch(err) { - await interaction.followUp({ - content: (err as Error).toString() - }); + if (!userLink) return false; } + if (amount < 0 && !(await getCounterConfig(counter.id, isLinkedAction ? 'canTake' : 'canDecrement') as boolean)) return false; + if (amount < 0 && counter.allowlistConsumer) { + const userLink = await db('counterUserLink') + .where('id', counter.id) + .where('user', user.id) + .where('producer', false) + .first(); + + if (!userLink) return false; + } + + return true; } -export async function counterAutocomplete(interaction: AutocompleteInteraction) { - const focusedValue = interaction.options.getFocused(); - const guild = interaction.guildId; +function changeCounterInteractionBuilder(linked: boolean) { + return async (interaction: CommandInteraction, member: GuildMember, amount: number, type: string) => { + try { + const counter = await findCounter(type, member.guild.id); + if (linked && !counter.linkedItem) return interaction.followUp('There is no such linked counter!'); - const query = db('counters') - .select('emoji', 'key') - .whereLike('key', `%${focusedValue.toLowerCase()}%`) - .limit(25); + const canUse = await canUseCounter(member.user, counter, amount, linked); - if (guild) { - query.where('guild', guild); - } + if (!canUse) { + return interaction.followUp(`You cannot **${amount > 0 ? (linked ? 'put' : 'produce') : (linked ? 'take' : 'consume')}** ${counter.emoji}.`); + } - const foundCounters = await query; + let item; + let newInv; + if (linked) { + const inv = await getItemQuantity(member.id, counter.linkedItem!); + item = (await getItem(counter.linkedItem!))!; - await interaction.respond( - foundCounters.map(choice => ({ name: choice.emoji, value: choice.key })) - ); -} \ No newline at end of file + // change counter by -10 = increment own counter by 10 + const amtInv = -amount; + const amtAbs = Math.abs(amtInv); + + if (amtInv > getMaxStack(item)) { + return interaction.followUp(`You cannot take ${formatItems(item, amtAbs)}, because the max stack size is ${getMaxStack(item)}x!`); + } + if ((inv.quantity + amtInv) > getMaxStack(item)) { + return interaction.followUp(`You cannot take ${formatItems(item, amtAbs)}, because the max stack size is ${getMaxStack(item)}x and you already have ${inv.quantity}x!`); + } + if ((inv.quantity + amtInv) < 0) { + return interaction.followUp(`You cannot put in ${formatItems(item, amtAbs)}, as you only have ${formatItems(item, inv.quantity)}!`); + } + + newInv = await giveItem(member.id, item, amtInv); + } + + const newCount = await changeCounter(counter.id, amount); + await updateCounter(interaction.client, counter, newCount); + await announceCounterUpdate(interaction.client, member, amount, counter, newCount, linked); + await interaction.followUp({ + content: `${counter.emoji} **You have ${amount > 0 ? (linked ? 'put into' : 'increased') : (linked ? 'taken from' : 'decreased')} the counter.**\n\`\`\`diff\n ${newCount - amount}\n${getSign(amount)}${Math.abs(amount)}\n ${newCount}\`\`\`${newInv ? `\nYou now have ${formatItems(item, newInv.quantity)}.` : ''}` + }); + } catch(err) { + await interaction.followUp({ + content: (err as Error).toString() + }); + } + }; +} + +export const changeCounterInteraction = changeCounterInteractionBuilder(false); +export const changeLinkedCounterInteraction = changeCounterInteractionBuilder(true); + +function counterAutocompleteBuilder(linked: boolean) { + return async (interaction: AutocompleteInteraction) => { + const focusedValue = interaction.options.getFocused(); + const guild = interaction.guildId; + + const query = db('counters') + .select('emoji', 'key') + .whereLike('key', `%${focusedValue.toLowerCase()}%`) + .limit(25); + + if (guild) { + query.where('guild', guild); + } + if (linked) { + query.whereNotNull('linkedItem'); + } + + const foundCounters = await query; + + await interaction.respond( + foundCounters.map(choice => ({ name: choice.emoji, value: choice.key })) + ); + }; +} + +export const counterAutocomplete = counterAutocompleteBuilder(false); +export const linkedCounterAutocomplete = counterAutocompleteBuilder(true); \ No newline at end of file diff --git a/src/lib/db.ts b/src/lib/db.ts index 73da31e..6ce77c3 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -33,7 +33,8 @@ export interface Counter { guild: string, message?: string, allowlistConsumer: boolean, - allowlistProducer: boolean + allowlistProducer: boolean, + linkedItem?: number } export interface CounterUserLink { id: number, diff --git a/src/lib/items.ts b/src/lib/items.ts index ad4a160..688cd46 100644 --- a/src/lib/items.ts +++ b/src/lib/items.ts @@ -1,5 +1,5 @@ -import { User } from 'discord.js'; -import { CustomItem, db } from './db'; +import { AutocompleteInteraction, User } from 'discord.js'; +import { CustomItem, ItemInventory, db } from './db'; type DefaultItem = Omit; // uses negative IDs type Item = DefaultItem | CustomItem; @@ -53,13 +53,74 @@ export async function getItem(id: number): Promise { } } +export async function getItemQuantity(user: string, itemID: number) { + return (await db('itemInventories') + .where('item', itemID) + .where('user', user) + .first()) + || { + 'user': user, + 'item': itemID, + 'quantity': 0 + }; +} + +export async function giveItem(user: string, item: Item, quantity = 1) { + const storedItem = await db('itemInventories') + .where('user', user) + .where('item', item.id) + .first(); + + let inv; + if (storedItem) { + inv = await db('itemInventories') + .update({ + 'quantity': db.raw('MIN(quantity + ?, ?)', [quantity, getMaxStack(item)]) + }) + .limit(1) + .where('user', user) + .where('item', item.id) + .returning('*'); + } else { + inv = await db('itemInventories') + .insert({ + 'user': user, + 'item': Math.min(item.id, getMaxStack(item)), + 'quantity': quantity + }) + .returning('*'); + } + + return inv[0]; +} + export function getMaxStack(item: Item) { return item.type === 'weapon' ? 1 : item.maxStack; } -export function formatItem(item: Item) { +export function formatItem(item: Item | undefined) { + if (!item) return '? **MISSINGNO**'; return `${item.emoji} **${item.name}**`; } -export function formatItems(item: Item, quantity: number) { +export function formatItems(item: Item | undefined, quantity: number) { return `${quantity}x ${formatItem(item)}`; +} + +export async function itemAutocomplete(interaction: AutocompleteInteraction) { + const focused = interaction.options.getFocused(); + + const customItems = await db('customItems') + .select('emoji', 'name', 'id') + // @ts-expect-error this LITERALLY works + .whereLike(db.raw('UPPER(name)'), `%${focused.toUpperCase()}%`) + .where('guild', interaction.guildId!) + .limit(25); + + const foundDefaultItems = defaultItems.filter(item => item.name.toUpperCase().includes(focused.toUpperCase())); + + const items = [...foundDefaultItems, ...customItems]; + + await interaction.respond( + items.map(choice => ({ name: `${choice.emoji} ${choice.name}`, value: choice.id.toString() })) + ); } \ No newline at end of file