diff --git a/migrations/20231114153325_items.js b/migrations/20231114153325_items.js new file mode 100644 index 0000000..98ef104 --- /dev/null +++ b/migrations/20231114153325_items.js @@ -0,0 +1,34 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +exports.down = function(knex) { + return knex.schema + .dropTable('customItems') + .dropTable('itemInventories'); +}; diff --git a/src/commands/item.ts b/src/commands/item.ts new file mode 100644 index 0000000..83a850b --- /dev/null +++ b/src/commands/item.ts @@ -0,0 +1,257 @@ +import { AutocompleteInteraction, Interaction, SlashCommandBuilder } from 'discord.js'; +import { CustomItem, ItemInventory, db } from '../lib/db'; +import { behaviors, defaultItems, formatItems, getItem, getMaxStack } 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('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 storedItem = await db('itemInventories') + .where('user', user.id) + .where('item', itemID) + .first(); + + 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)}.`); + } + } + }, + + autocomplete: async (interaction: AutocompleteInteraction) => { + 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() })) + ); + } + } +}; \ No newline at end of file diff --git a/src/lib/db.ts b/src/lib/db.ts index d1ae0ef..73da31e 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -44,4 +44,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 } \ No newline at end of file diff --git a/src/lib/items.ts b/src/lib/items.ts new file mode 100644 index 0000000..ad4a160 --- /dev/null +++ b/src/lib/items.ts @@ -0,0 +1,65 @@ +import { User } from 'discord.js'; +import { CustomItem, db } from './db'; + +type DefaultItem = Omit; // 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 +} + +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('customItems') + .where('id', id) + .first(); +} + +export async function getItem(id: number): Promise { + if (id >= 0) { + return await getCustomItem(id); + } else { + return defaultItems.find(item => item.id === id); + } +} + +export function getMaxStack(item: Item) { + return item.type === 'weapon' ? 1 : item.maxStack; +} + +export function formatItem(item: Item) { + return `${item.emoji} **${item.name}**`; +} +export function formatItems(item: Item, quantity: number) { + return `${quantity}x ${formatItem(item)}`; +} \ No newline at end of file