diff --git a/migrations/20231115140015_craftingStationCooldowns.js b/migrations/20231115140015_craftingStationCooldowns.js new file mode 100644 index 0000000..b170c23 --- /dev/null +++ b/migrations/20231115140015_craftingStationCooldowns.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema + .createTable('craftingStationCooldowns', table => { + table.string('station').notNullable(); + table.string('user').notNullable(); + table.timestamp('usedAt').notNullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema + .dropTable('craftingStationCooldowns'); +}; diff --git a/src/commands/craft.ts b/src/commands/craft.ts new file mode 100644 index 0000000..158a081 --- /dev/null +++ b/src/commands/craft.ts @@ -0,0 +1,122 @@ +import { AutocompleteInteraction, GuildMember, Interaction, SlashCommandBuilder } from 'discord.js'; +import { craftingStations, defaultRecipes } from '../lib/rpg/data'; +import { canUseStation, formatItem, formatItems, formatItemsArray, formatRecipe, getItemQuantity, getMaxStack, getRecipe, getStation, giveItem } from '../lib/rpg/items'; +import { CraftingStationCooldown, db } from '../lib/db'; + +module.exports = { + data: new SlashCommandBuilder() + .setName('craft') + .setDescription('Craft an item with items you have') + .addStringOption(option => + option + .setName('station') + .setAutocomplete(true) + .setDescription('Which station to use') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('recipe') + .setAutocomplete(true) + .setDescription('What to craft') + .setRequired(true) + ) + .setDMPermission(false), + + execute: async (interaction: Interaction, member: GuildMember) => { + if (!interaction.isChatInputCommand()) return; + + const recipeID = parseInt(interaction.options.getString('recipe', true)); + + await interaction.deferReply({ephemeral: true}); + + const recipe = getRecipe(recipeID); + if (!recipe) return interaction.followUp('Recipe does not exist!'); + + const station = getStation(recipe.station)!; + if (!canUseStation(member.id, station)) return interaction.followUp(`${station.emoji} You need ${formatItem(station.requires)} to use this station!`); + + for (const input of recipe.inputs) { + const inv = await getItemQuantity(member.id, input.item.id); + if (inv.quantity < input.quantity) return interaction.followUp(`You need ${formatItems(input.item, input.quantity)} for this recipe! (You have ${formatItems(input.item, inv.quantity)})`); + } + for (const req of recipe.requirements) { + const inv = await getItemQuantity(member.id, req.item.id); + if (inv.quantity < req.quantity) return interaction.followUp(`You need ${formatItems(req.item, req.quantity)} to begin this recipe! (You have ${formatItems(req.item, inv.quantity)}. Don't worry, these items will not be consumed.)`); + } + for (const out of recipe.outputs) { + const inv = await getItemQuantity(member.id, out.item.id); + if (inv.quantity + out.quantity > getMaxStack(out.item)) return interaction.followUp(`You do not have enough inventory storage for this recipe! (${formatItems(out.item, inv.quantity + out.quantity)} is bigger than the stack size of ${getMaxStack(out.item)}.)`); + } + + let cooldown; + if (station.cooldown) { + cooldown = await db('craftingStationCooldowns') + .where('station', station.key) + .where('user', member.id) + .first(); + + if (cooldown && (cooldown.usedAt + station.cooldown * 1000) > Date.now()) + return interaction.followUp(`${station.emoji} You can use this station again !`); + } + + // proceed with crafting! + + for (const input of recipe.inputs) { + giveItem(member.id, input.item, -input.quantity); + } + const outputs = station.manipulateResults ? station.manipulateResults(recipe.outputs) : recipe.outputs; + for (const output of outputs) { + giveItem(member.id, output.item, output.quantity); + } + + let nextUsableAt; + if (station.cooldown) { + if (!cooldown) { + await db('craftingStationCooldowns') + .insert({ + station: station.key, + user: member.id, + usedAt: db.fn.now() + }); + } else { + await db('craftingStationCooldowns') + .where('station', station.key) + .where('user', member.id) + .update({ + usedAt: db.fn.now() + }); + } + + nextUsableAt = Date.now() + station.cooldown * 1000; + } + + return interaction.followUp(`${station.emoji} Crafted ${formatItemsArray(outputs)}!${nextUsableAt ? `\n${station.name} usable again ` : ''}`); + }, + + autocomplete: async (interaction: AutocompleteInteraction) => { + const focused = interaction.options.getFocused(true); + + if (focused.name === 'station') { + const found = craftingStations + .filter(station => canUseStation(interaction.user.id, station)) + .filter(station => station.name.toLowerCase().includes(focused.value.toLowerCase())) + .map(station => ({ + name: `${station.emoji} ${station.name}`, + value: station.key + })); + + return interaction.respond(found); + } else if (focused.name === 'recipe') { + const found = defaultRecipes + .filter(recipe => recipe.station === interaction.options.getString('station')) + .filter(recipe => recipe.outputs.filter(n => n.item.name.toLowerCase().includes(focused.value.toLowerCase())).length > 0) + .map(recipe => ({ + name: formatRecipe(recipe), + value: recipe.id + })); + + return interaction.respond(found); + } + } +}; \ No newline at end of file diff --git a/src/commands/item.ts b/src/commands/item.ts index a3b4f8e..9ac48a8 100644 --- a/src/commands/item.ts +++ b/src/commands/item.ts @@ -1,6 +1,7 @@ import { AutocompleteInteraction, Interaction, SlashCommandBuilder } from 'discord.js'; import { CustomItem, db } from '../lib/db'; -import { behaviors, formatItems, getItem, giveItem, itemAutocomplete } from '../lib/rpg/items'; +import { formatItems, getItem, giveItem, itemAutocomplete } from '../lib/rpg/items'; +import { behaviors } from '../lib/rpg/data'; //function extendOption(t: string) { // return {name: t, value: t}; diff --git a/src/lib/db.ts b/src/lib/db.ts index 6ce77c3..110a2a1 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -63,4 +63,9 @@ export interface ItemInventory { user: string, item: number, quantity: number +} +export interface CraftingStationCooldown { + station: string, + user: string, + usedAt: number } \ No newline at end of file diff --git a/src/lib/rpg/data.ts b/src/lib/rpg/data.ts index 9081962..bd5cb4a 100644 --- a/src/lib/rpg/data.ts +++ b/src/lib/rpg/data.ts @@ -1,4 +1,4 @@ -import { Behavior, CraftingStation, DefaultItem, Recipe, formatItems, getDefaultItem } from './items'; +import { Behavior, CraftingStation, DefaultItem, DefaultRecipe, formatItems, getDefaultItem } from './items'; export enum DefaultItems { COIN = 1, @@ -73,8 +73,9 @@ export const craftingStations: CraftingStation[] = [ } ]; -export const recipes: Recipe[] = [ +export const defaultRecipes: DefaultRecipe[] = [ { + id: -1, station: 'forage', inputs: [], requirements: [], @@ -83,6 +84,7 @@ export const recipes: Recipe[] = [ ] }, { + id: -2, station: 'workbench', inputs: [ { item: getDefaultItem(DefaultItems.PEBBLE), quantity: 2 } diff --git a/src/lib/rpg/items.ts b/src/lib/rpg/items.ts index b94eba5..c075524 100644 --- a/src/lib/rpg/items.ts +++ b/src/lib/rpg/items.ts @@ -1,6 +1,6 @@ import { AutocompleteInteraction, User } from 'discord.js'; import { CustomItem, ItemInventory, db } from '../db'; -import { DefaultItems, defaultItems } from './data'; +import { DefaultItems, craftingStations, defaultItems, defaultRecipes } from './data'; export type DefaultItem = Omit; // uses negative IDs export type Item = DefaultItem | CustomItem; @@ -37,12 +37,22 @@ export interface CraftingStation { manipulateResults?: (outputs: Items[]) => Items[] } -export interface Recipe { +export function getStation(key: string) { + return craftingStations.find(station => station.key === key); +} +export function formatRecipe(recipe: DefaultRecipe) { + const station = getStation(recipe.station); + return (station?.formatRecipe || defaultFormatRecipe)(recipe.inputs, recipe.requirements, recipe.outputs); +} + +export interface DefaultRecipe { + id: number, station: string, inputs: Items[], requirements: Items[], outputs: Items[] } +export type Recipe = DefaultRecipe export async function getCustomItem(id: number) { return await db('customItems') @@ -63,7 +73,21 @@ export async function getItem(id: number): Promise { } } -export async function getItemQuantity(user: string, itemID: number) { +export function getDefaultRecipe(id: number): DefaultRecipe | undefined { + return defaultRecipes.find(recipe => recipe.id === id); +} +export function getRecipe(id: number): Recipe | undefined { + return getDefaultRecipe(id); // currently just a stub +} + +export async function canUseStation(user: string, station: CraftingStation) { + if (!station.requires) return true; + + const inv = await getItemQuantity(user, station.requires.id); + return inv.quantity > 0; +} + +export async function getItemQuantity(user: string, itemID: number): Promise { return (await db('itemInventories') .where('item', itemID) .where('user', user)