Compare commits

...

5 Commits

Author SHA1 Message Date
Jill 2bb3512316
min and max limits for counters 2023-11-15 13:18:25 +03:00
Jill 233a663d0c
light counter consistency stuff 2023-11-15 13:08:01 +03:00
Jill a0c9b12da0
linked counters 2023-11-15 13:03:01 +03:00
Jill 4351e6dfad
/inventory 2023-11-15 03:50:29 +03:00
Jill 2a2cdb8dff
item groundwork 2023-11-15 03:40:57 +03:00
10 changed files with 737 additions and 87 deletions

View File

@ -0,0 +1,34 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.createTable('customItems', table => {
table.increments('id');
table.string('guild').notNullable();
table.string('name').notNullable();
table.text('description');
table.string('emoji').notNullable();
table.enum('type', ['plain', 'weapon', 'consumable']).notNullable();
table.integer('maxStack').notNullable(); // or damage for weapons
table.string('behavior');
table.boolean('untradable').defaultTo(false);
table.float('behaviorValue');
})
.createTable('itemInventories', table => {
table.string('user').notNullable();
table.integer('item').notNullable();
table.integer('quantity').defaultTo(1);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTable('customItems')
.dropTable('itemInventories');
};

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**.
@ -143,7 +144,7 @@ module.exports = {
.addStringOption(option =>
option
.setName('key')
.setDescription('The codename. Best to leave descriptive for later; used in searching for counters')
.setDescription('Give your counter a simple name')
.setRequired(true)
)
.addStringOption(option =>
@ -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')
@ -391,15 +399,44 @@ module.exports = {
.delete();
await interaction.followUp({
content: `The ${counter.emoji} counter has been removed. 😭`
content: `The ${counter.emoji} ${counter.key} counter has been removed. 😭`
});
} else if (subcommand === 'list') {
const counters = await db<Counter>('counters')
.where('guild', interaction.guildId!);
await interaction.followUp(counters.map(c => `${c.emoji} **${c.value}** <#${c.channel}>`).join('\n'));
await interaction.followUp(counters.map(c => `${c.emoji} ${c.key}: **${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!');
if (item.untradable) return interaction.followUp('This item is untradable!');
await db<Counter>('counters')
.where('id', counter.id)
.update({
'linkedItem': item.id,
'emoji': item.emoji,
'key': item.name,
'value': 0
});
await setCounterConfig(counter, 'canIncrement', 'false');
await setCounterConfig(counter, 'canDecrement', 'false');
await setCounterConfig(counter, 'min', '0');
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 \`min\` has been set to **0**, and you are recommended to keep these values as such if you want to maintain balance in the universe.`);
}
}
},
@ -409,6 +446,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!);

27
src/commands/inventory.ts Normal file
View File

@ -0,0 +1,27 @@
import { GuildMember, Interaction, SlashCommandBuilder } from 'discord.js';
import { ItemInventory, db } from '../lib/db';
import { formatItems, getItem } from '../lib/items';
module.exports = {
data: new SlashCommandBuilder()
.setName('inventory')
.setDescription('Check your inventory')
.setDMPermission(false),
execute: async (interaction: Interaction, member: GuildMember) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({ephemeral: true});
const itemsList = await db<ItemInventory>('itemInventories')
.select('item', 'quantity')
.where('user', member.user.id);
// kind of stupid kind of awful
const items = (await Promise.all(itemsList.map(async i => ({item: await getItem(i.item), quantity: i.quantity})))).filter(i => i.item);
await interaction.followUp(
`Your inventory:\n${items.length === 0 ? '_Your inventory is empty!_' : items.map(i => `- ${formatItems(i.item!, i.quantity)}`).join('\n')}`
);
}
};

221
src/commands/item.ts Normal file
View File

@ -0,0 +1,221 @@
import { AutocompleteInteraction, Interaction, SlashCommandBuilder } from 'discord.js';
import { CustomItem, ItemInventory, db } from '../lib/db';
import { behaviors, formatItems, getItem, getMaxStack, giveItem, itemAutocomplete } from '../lib/items';
//function extendOption(t: string) {
// return {name: t, value: t};
//}
module.exports = {
data: new SlashCommandBuilder()
.setName('item')
.setDescription('[ADMIN] Create, edit and otherwise deal with custom items')
.addSubcommandGroup(grp =>
grp
.setName('add')
.setDescription('[ADMIN] Create an item')
.addSubcommand(cmd =>
cmd
.setName('plain')
.setDescription('A normal, functionless item')
.addStringOption(opt =>
opt
.setName('name')
.setDescription('The item name')
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('emoji')
.setDescription('An emoji or symbol that could represent this item')
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('description')
.setDescription('A short description')
)
.addIntegerOption(opt =>
opt
.setName('maxstack')
.setDescription('Maximum amount of this item you\'re able to hold at once')
)
.addStringOption(opt =>
opt
.setName('behavior')
.setDescription('Special behavior type')
.setChoices(...behaviors.filter(b => b.itemType === 'plain').map(b => ({name: `${b.name} - ${b.description}`, value: b.name})))
)
.addBooleanOption(opt =>
opt
.setName('untradable')
.setDescription('Can you give this item to other people?')
)
.addNumberOption(opt =>
opt
.setName('behaviorvalue')
.setDescription('A value to use for the behavior type; not always applicable')
)
)
.addSubcommand(cmd =>
cmd
.setName('weapon')
.setDescription('A weapon that you can attack things with')
.addStringOption(opt =>
opt
.setName('name')
.setDescription('The item name')
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('emoji')
.setDescription('An emoji or symbol that could represent this item')
.setRequired(true)
)
.addIntegerOption(opt =>
opt
.setName('damage')
.setDescription('How much base damage this weapon is intended to deal')
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('description')
.setDescription('A short description')
)
.addStringOption(opt =>
opt
.setName('behavior')
.setDescription('Special behavior type')
.setChoices(...behaviors.filter(b => b.itemType === 'weapon').map(b => ({name: `${b.name} - ${b.description}`, value: b.name})))
)
.addBooleanOption(opt =>
opt
.setName('untradable')
.setDescription('Can you give this item to other people?')
)
.addNumberOption(opt =>
opt
.setName('behaviorvalue')
.setDescription('A value to use for the behavior type; not always applicable')
)
)
.addSubcommand(cmd =>
cmd
.setName('consumable')
.setDescription('Consumable item, usable once and never again')
.addStringOption(opt =>
opt
.setName('name')
.setDescription('The item name')
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('emoji')
.setDescription('An emoji or symbol that could represent this item')
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('description')
.setDescription('A short description')
)
.addIntegerOption(opt =>
opt
.setName('maxstack')
.setDescription('Maximum amount of this item you\'re able to hold at once')
)
.addStringOption(opt =>
opt
.setName('behavior')
.setDescription('Special behavior type')
.setChoices(...behaviors.filter(b => b.itemType === 'consumable').map(b => ({name: `${b.name} - ${b.description}`, value: b.name})))
)
.addBooleanOption(opt =>
opt
.setName('untradable')
.setDescription('Can you give this item to other people?')
)
.addNumberOption(opt =>
opt
.setName('behaviorvalue')
.setDescription('A value to use for the behavior type; not always applicable')
)
)
)
.addSubcommand(cmd =>
cmd
.setName('give')
.setDescription('[ADMIN] Give a user an item')
.addUserOption(opt =>
opt
.setName('who')
.setDescription('The user')
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('item')
.setDescription('The item')
.setAutocomplete(true)
.setRequired(true)
)
.addIntegerOption(opt =>
opt
.setName('quantity')
.setDescription('Amount of items to give')
)
)
.setDefaultMemberPermissions('0')
.setDMPermission(false),
execute: async (interaction: Interaction) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({ephemeral: true});
const subcommand = interaction.options.getSubcommand(true);
const group = interaction.options.getSubcommandGroup();
if (group === 'add') {
const item = await db<CustomItem>('customItems')
.insert({
'guild': interaction.guildId!,
'name': interaction.options.getString('name', true).trim(),
'description': interaction.options.getString('description') || undefined,
'emoji': interaction.options.getString('emoji', true).trim(),
'type': subcommand as 'plain' | 'weapon' | 'consumable', // kind of wild that ts makes you do this
'maxStack': (interaction.options.getInteger('maxstack') || interaction.options.getInteger('damage')) || (subcommand === 'weapon' ? 1 : 64),
'behavior': interaction.options.getString('behavior') || undefined,
'untradable': interaction.options.getBoolean('untradable') || false,
'behaviorValue': interaction.options.getNumber('behaviorValue') || undefined,
})
.returning('*');
await interaction.followUp(`${JSON.stringify(item[0])}`);
} else {
if (subcommand === 'give') {
const user = interaction.options.getUser('who', true);
const itemID = parseInt(interaction.options.getString('item', true));
const quantity = interaction.options.getInteger('quantity') || 1;
const item = await getItem(itemID);
if (!item) return interaction.followUp('No such item exists!');
const inv = await giveItem(user.id, item, quantity);
await interaction.followUp(`${user.toString()} now has ${formatItems(item, inv.quantity)}.`);
}
}
},
autocomplete: async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused(true);
if (focused.name === 'item') {
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,38 @@ 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
}],
['min', {
type: ConfigType.Number,
default: -Number.MIN_SAFE_INTEGER
}],
['max', {
type: ConfigType.Number,
default: Number.MAX_SAFE_INTEGER
}],
// these ones are fake and are just stand-ins for values defined inside the actual counters table
['emoji', {
@ -210,7 +243,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 +251,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 +263,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 +286,120 @@ 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 min = await getCounterConfig(counter.id, 'min') as number;
const max = await getCounterConfig(counter.id, 'max') as number;
if (counter.value + amount < min) {
if (min === 0) {
return interaction.followUp(`You cannot remove more than how much is in the counter (${counter.value}x ${counter.emoji})!`);
} else {
return interaction.followUp(`You cannot decrement past the minimum value (${min})!`);
}
}
if (counter.value + amount > max) {
return interaction.followUp(`You are adding more than the counter can hold (${max}x)!`);
}
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} ${choice.key}`, 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,
@ -44,4 +45,22 @@ export interface CounterConfiguration {
id: number,
configName: string,
value: string
}
export interface CustomItem {
id: number,
guild: string,
name: string,
description?: string,
emoji: string,
type: 'plain' | 'weapon' | 'consumable',
// also damage for weapons; weapons are always unstackable (cus i said so)
maxStack: number,
behavior?: string,
untradable: boolean,
behaviorValue?: number
}
export interface ItemInventory {
user: string,
item: number,
quantity: number
}

126
src/lib/items.ts Normal file
View File

@ -0,0 +1,126 @@
import { AutocompleteInteraction, User } from 'discord.js';
import { CustomItem, ItemInventory, db } from './db';
type DefaultItem = Omit<CustomItem, 'guild'>; // uses negative IDs
type Item = DefaultItem | CustomItem;
interface Behavior {
name: string,
description: string,
itemType: 'plain' | 'weapon' | 'consumable',
// triggers upon use
// for 'weapons', this is on hit
// for 'consumable', this is on use
// for 'plain', ...??
// returns `true` upon success, `false` otherwise
action?: (item: Item, user: User) => Promise<boolean>
}
export const defaultItems: DefaultItem[] = [
{
'id': -1,
'name': 'Coin',
'emoji': '🪙',
'type': 'plain',
'maxStack': 9999,
'untradable': false
}
];
export const behaviors: Behavior[] = [
{
'name': 'heal',
'description': 'Heals the user by `behaviorValue`',
'itemType': 'consumable',
'action': async (item: Item, user: User) => {
// todo
return false;
}
}
];
export async function getCustomItem(id: number) {
return await db<CustomItem>('customItems')
.where('id', id)
.first();
}
export async function getItem(id: number): Promise<Item | undefined> {
if (id >= 0) {
return await getCustomItem(id);
} else {
return defaultItems.find(item => item.id === id);
}
}
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 | undefined) {
if (!item) return '? **MISSINGNO**';
return `${item.emoji} **${item.name}**`;
}
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() }))
);
}