linked counters

This commit is contained in:
Jill 2023-11-15 13:03:01 +03:00
parent 4351e6dfad
commit a0c9b12da0
Signed by: oat
GPG Key ID: 33489AA58A955108
8 changed files with 353 additions and 128 deletions

View File

@ -0,0 +1,21 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.alterTable('counters', table => {
table.integer('linkedItem');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.alterTable('counters', table => {
table.dropColumn('linkedItem');
});
};

View File

@ -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<Counter>('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<Counter>('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!);

View File

@ -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<ItemInventory>('itemInventories')
.where('user', user.id)
.where('item', itemID)
.first();
const inv = await giveItem(user.id, item, quantity);
let inv;
if (storedItem) {
inv = await db<ItemInventory>('itemInventories')
.update({
'quantity': db.raw('MIN(quantity + ?, ?)', [quantity, getMaxStack(item)])
})
.limit(1)
.where('user', user.id)
.where('item', itemID)
.returning('*');
} else {
inv = await db<ItemInventory>('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<CustomItem>('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);
}
}
};

36
src/commands/put.ts Normal file
View File

@ -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
};

36
src/commands/take.ts Normal file
View File

@ -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
};

View File

@ -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<Counter>('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<Counter>('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<CounterConfiguration>('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<boolean> {
if (amount > 0 && !(await getCounterConfig(counter.id, isLinkedAction ? 'canPut' : 'canIncrement') as boolean)) return false;
if (amount > 0 && counter.allowlistProducer) {
const userLink = await db<CounterUserLink>('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>('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>('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>('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<Counter>('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 }))
);
}
// 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<Counter>('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);

View File

@ -33,7 +33,8 @@ export interface Counter {
guild: string,
message?: string,
allowlistConsumer: boolean,
allowlistProducer: boolean
allowlistProducer: boolean,
linkedItem?: number
}
export interface CounterUserLink {
id: number,

View File

@ -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<CustomItem, 'guild'>; // uses negative IDs
type Item = DefaultItem | CustomItem;
@ -53,13 +53,74 @@ export async function getItem(id: number): Promise<Item | undefined> {
}
}
export async function getItemQuantity(user: string, itemID: number) {
return (await db<ItemInventory>('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<ItemInventory>('itemInventories')
.where('user', user)
.where('item', item.id)
.first();
let inv;
if (storedItem) {
inv = await db<ItemInventory>('itemInventories')
.update({
'quantity': db.raw('MIN(quantity + ?, ?)', [quantity, getMaxStack(item)])
})
.limit(1)
.where('user', user)
.where('item', item.id)
.returning('*');
} else {
inv = await db<ItemInventory>('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<CustomItem>('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() }))
);
}