Compare commits

...

6 Commits

Author SHA1 Message Date
Jill 37af0ea68f
fixes i would've caught if i tested this for more than 2 seconds 2023-11-18 17:12:27 +03:00
Jill 26903e03a8
delete recipes 2023-11-18 16:09:09 +03:00
Jill 7d3bf20eaa
delete items 2023-11-18 16:03:52 +03:00
Jill fd3dbfa65b
add more stuff to main page 2023-11-18 15:49:50 +03:00
Jill 07e6e162ff
CUSTOM RECIPES 🎉🎉🎉🎉 2023-11-18 15:24:56 +03:00
Jill f2c1d0efa9
wtf did i do there 2023-11-18 14:59:48 +03:00
14 changed files with 262 additions and 58 deletions

View File

View File

@ -6,6 +6,7 @@ exports.up = function(knex) {
return knex.schema
.createTable('customCraftingRecipes', (table) => {
table.increments('id');
table.string('guild');
table.string('station');
})
.createTable('customCraftingRecipeItems', (table) => {
@ -23,5 +24,5 @@ exports.up = function(knex) {
exports.down = function(knex) {
return knex.schema
.dropTable('customCraftingRecipes')
.dropTable('customCraftingRecipes');
.dropTable('customCraftingRecipeItems');
};

View File

@ -1,8 +1,8 @@
import { AutocompleteInteraction, GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { CraftingStationCooldown, db } from '../lib/db';
import { CraftingStationCooldown, CustomCraftingRecipe, db } from '../lib/db';
import { getStation, canUseStation, craftingStations, verb, CraftingStation } from '../lib/rpg/craftingStations';
import { formatItem, getItemQuantity, formatItems, getMaxStack, giveItem, formatItemsArray } from '../lib/rpg/items';
import { getRecipe, defaultRecipes, formatRecipe } from '../lib/rpg/recipes';
import { getRecipe, defaultRecipes, formatRecipe, resolveCustomRecipe } from '../lib/rpg/recipes';
import { Command } from '../types/index';
export default {
@ -34,7 +34,7 @@ export default {
await interaction.deferReply({ephemeral: true});
const recipe = getRecipe(recipeID);
const recipe = await getRecipe(recipeID);
if (!recipe) return interaction.followUp('Recipe does not exist!');
const station = getStation(recipe.station)!;
@ -118,15 +118,29 @@ export default {
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, true),
value: recipe.id.toString()
}));
const station = interaction.options.getString('station');
return interaction.respond(found);
const foundDefaultRecipes = defaultRecipes
.filter(recipe => recipe.station === station)
.filter(recipe => recipe.outputs.filter(n => n.item.name.toLowerCase().includes(focused.value.toLowerCase())).length > 0);
const customRecipes = await db<CustomCraftingRecipe>('customCraftingRecipes')
.where('station', station);
const resolvedCustomRecipes = await Promise.all(customRecipes.map(resolveCustomRecipe));
const foundCustomRecipes = resolvedCustomRecipes
.filter(recipe => recipe.outputs.filter(n => n.item.name.toLowerCase().includes(focused.value.toLowerCase())).length > 0);
const recipes = [...foundDefaultRecipes, ...foundCustomRecipes];
return interaction.respond(
recipes
.map(recipe => ({
name: formatRecipe(recipe, true),
value: recipe.id.toString()
}))
);
}
}
} satisfies Command;

View File

@ -1,8 +1,9 @@
import { AutocompleteInteraction, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { CustomItem, db } from '../lib/db';
import { formatItems, getItem, giveItem, itemAutocomplete } from '../lib/rpg/items';
import { CustomCraftingRecipeItem, CustomItem, db } from '../lib/db';
import { customItemAutocomplete, formatItem, formatItems, getItem, giveItem, itemAutocomplete } from '../lib/rpg/items';
import { behaviors } from '../lib/rpg/behaviors';
import { Command } from '../types/index';
import { formatRecipe, getCustomRecipe } from '../lib/rpg/recipes';
//function extendOption(t: string) {
// return {name: t, value: t};
@ -170,6 +171,18 @@ export default {
.setDescription('Amount of items to give')
)
)
.addSubcommand(cmd =>
cmd
.setName('delete')
.setDescription('[ADMIN] Delete a custom item')
.addStringOption(opt =>
opt
.setName('customitem')
.setDescription('The item')
.setAutocomplete(true)
.setRequired(true)
)
)
.setDefaultMemberPermissions('0')
.setDMPermission(false),
@ -209,6 +222,25 @@ export default {
const inv = await giveItem(user.id, item, quantity);
await interaction.followUp(`${user.toString()} now has ${formatItems(item, inv.quantity)}.`);
} else if (subcommand === 'delete') {
const itemID = parseInt(interaction.options.getString('customitem', true));
const item = await getItem(itemID);
if (!item) return interaction.followUp('No such item exists!');
const usedIn = await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.where('item', item.id);
if (usedIn.length > 0) {
const recipes = (await Promise.all(usedIn.map(i => getCustomRecipe(i.id)))).filter(r => r !== undefined);
return interaction.followUp(`⚠️ This item is used in the following recipes:\n${recipes.map(r => `- ${formatRecipe(r!)}`).join('\n')}`);
}
await db<CustomItem>('customItems')
.where('id', item.id)
.delete();
interaction.followUp(`${formatItem(item)} has been deleted.`);
}
}
},
@ -218,6 +250,8 @@ export default {
if (focused.name === 'item') {
return itemAutocomplete(interaction);
} else if (focused.name === 'customitem') {
return customItemAutocomplete(interaction);
}
}
} satisfies Command;

View File

@ -1,7 +1,7 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, ComponentType, Events, ModalBuilder, SlashCommandBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
import { Command } from '../types/index';
import { Items, getItem } from '../lib/rpg/items';
import { formatRecipe } from '../lib/rpg/recipes';
import { formatRecipe, getCustomRecipe, resolveCustomRecipe } from '../lib/rpg/recipes';
import { craftingStations, getStation } from '../lib/rpg/craftingStations';
import { CustomCraftingRecipe, CustomCraftingRecipeItem, db } from '../lib/db';
@ -14,6 +14,18 @@ export default {
.setName('create')
.setDescription('[ADMIN] Create a custom recipe')
)
.addSubcommand(sub =>
sub
.setName('delete')
.setDescription('[ADMIN] Delete a custom recipe')
.addStringOption(opt =>
opt
.setName('recipe')
.setAutocomplete(true)
.setDescription('Which recipe to remove')
.setRequired(true)
)
)
.setDMPermission(false)
.setDefaultMemberPermissions(0),
@ -33,6 +45,21 @@ export default {
content: `To create a recipe, go here: ${interaction.client.config.siteURL}/create-recipe/?guild=${interaction.guildId}\nOnce done, click the button below and paste the resulting string in.`,
components: [row]
});
} else if (sub === 'delete') {
const recipeID = interaction.options.getString('recipe', true);
const recipe = await getCustomRecipe(parseInt(recipeID));
if (!recipe) return interaction.followUp('Recipe does no exist!');
await db<CustomCraftingRecipe>('customCraftingRecipes')
.where('id', recipe.id)
.delete();
await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.where('id', recipe.id)
.delete();
await interaction.followUp(`Deleted recipe ${formatRecipe(recipe)}`);
}
},
@ -44,7 +71,7 @@ export default {
if (!interaction.member) return;
if (id.startsWith('recipe-create-')) {
//const guildID = id.split('-')[2];
const guildID = id.split('-')[2];
if (interaction.isMessageComponent()) {
const modal = new ModalBuilder()
@ -71,20 +98,22 @@ export default {
parsed = await Promise.all(
recipeString
.split('|')
.map(items =>
Promise.all(
items
.split(';')
.map(itemStack =>
itemStack.split(',')
)
.map(async ([itemID, quantity]) => (
{
item: (await getItem(parseInt(itemID)))!,
quantity: parseInt(quantity)
}
))
)
.map(async items =>
items === '' ?
[] :
await Promise.all(
items
.split(';')
.map(itemStack =>
itemStack.split(',')
)
.map(async ([itemID, quantity]) => (
{
item: (await getItem(parseInt(itemID)))!,
quantity: parseInt(quantity)
}
))
)
)
) as Items[][];
} catch (err) {
@ -164,6 +193,7 @@ export default {
const [customRecipe] = await db<CustomCraftingRecipe>('customCraftingRecipes')
.insert({
guild: guildID,
station: recipe.station
})
.returning('id');
@ -203,5 +233,26 @@ export default {
}
}
});
},
autocomplete: async (interaction) => {
const focused = interaction.options.getFocused(true);
if (focused.name === 'recipe') {
const customRecipes = await db<CustomCraftingRecipe>('customCraftingRecipes');
const resolvedCustomRecipes = await Promise.all(customRecipes.map(resolveCustomRecipe));
const foundCustomRecipes = resolvedCustomRecipes
.filter(recipe => recipe.outputs.filter(n => n.item.name.toLowerCase().includes(focused.value.toLowerCase())).length > 0);
return interaction.respond(
foundCustomRecipes
.map(recipe => ({
name: formatRecipe(recipe, true),
value: recipe.id.toString()
}))
);
}
}
} satisfies Command;

View File

@ -29,7 +29,7 @@ async function init() {
log.nonsense('booting chip...');
log.nonsense('starting up web interface...');
await startServer(sitePort);
await startServer(bot, sitePort);
log.nonsense('setting up connection...');

View File

@ -71,6 +71,7 @@ export interface CraftingStationCooldown {
}
export interface CustomCraftingRecipe {
id: number,
guild: string,
station: string
}
export interface CustomCraftingRecipeItem {

View File

@ -251,21 +251,31 @@ export function formatItemsArray(items: Items[], disableBold = false) {
return items.map(i => formatItems(i.item, i.quantity, disableBold)).join(' ');
}
export async function itemAutocomplete(interaction: AutocompleteInteraction) {
const focused = interaction.options.getFocused();
function createItemAutocomplete(onlyCustom: boolean) {
return async (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()));
let items;
if (onlyCustom) {
items = customItems;
} else {
items = [...foundDefaultItems, ...customItems];
}
await interaction.respond(
items.map(choice => ({ name: `${choice.emoji} ${choice.name}`, value: choice.id.toString() }))
);
};
}
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() }))
);
}
export const itemAutocomplete = createItemAutocomplete(false);
export const customItemAutocomplete = createItemAutocomplete(true);

View File

@ -1,5 +1,6 @@
import { CustomCraftingRecipe, CustomCraftingRecipeItem, db } from '../db';
import { getStation } from './craftingStations';
import { DefaultItems, Items, formatItemsArray, getDefaultItem } from './items';
import { DefaultItems, Items, formatItemsArray, getDefaultItem, getItem } from './items';
export interface DefaultRecipe {
id: number,
@ -8,7 +9,15 @@ export interface DefaultRecipe {
requirements: Items[],
outputs: Items[]
}
export type Recipe = DefaultRecipe
export interface ResolvedCustomRecipe {
id: number,
guild: string,
station: string,
inputs: Items[],
requirements: Items[],
outputs: Items[]
}
export type Recipe = DefaultRecipe | ResolvedCustomRecipe
export const defaultRecipes: DefaultRecipe[] = [
{
@ -147,14 +156,45 @@ export const defaultRecipes: DefaultRecipe[] = [
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 getCustomRecipe(id: number): Promise<ResolvedCustomRecipe | undefined> {
const recipe = await db<CustomCraftingRecipe>('customCraftingRecipes')
.where('id', id)
.first();
if (!recipe) return;
return await resolveCustomRecipe(recipe);
}
export async function getRecipe(id: number): Promise<Recipe | undefined> {
if (id >= 0) {
return await getCustomRecipe(id);
} else {
return getDefaultRecipe(id);
}
}
const defaultFormatRecipe = (inputs: Items[], requirements: Items[], outputs: Items[], disableBold = false) =>
`${formatItemsArray(inputs, disableBold)}${requirements.length === 0 ? '' : ` w/ ${formatItemsArray(requirements, disableBold)}`} => ${formatItemsArray(outputs, disableBold)}`;
export function formatRecipe(recipe: DefaultRecipe, disableBold = false) {
export function formatRecipe(recipe: Recipe, disableBold = false) {
const station = getStation(recipe.station);
return (station?.formatRecipe || defaultFormatRecipe)(recipe.inputs, recipe.requirements, recipe.outputs, disableBold);
}
function resolveItems(items: CustomCraftingRecipeItem[]) {
return Promise.all(items.map(async i => ({item: (await getItem(i.item))!, quantity: i.quantity})));
}
export async function resolveCustomRecipe(recipe: CustomCraftingRecipe): Promise<ResolvedCustomRecipe> {
const items = await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.where('id', recipe.id);
return {
id: recipe.id,
guild: recipe.guild,
station: recipe.station,
inputs: await resolveItems(items.filter(i => i.type === 'input')),
requirements: await resolveItems(items.filter(i => i.type === 'requirement')),
outputs: await resolveItems(items.filter(i => i.type === 'output')),
};
}

View File

@ -2,8 +2,9 @@ import express from 'express';
import * as log from './lib/log';
import { CustomItem, db } from './lib/db';
import { defaultItems } from './lib/rpg/items';
import { Client } from 'discord.js';
export async function startServer(port: number) {
export async function startServer(bot: Client, port: number) {
const app = express();
app.use(express.static('static/'));
@ -24,5 +25,12 @@ export async function startServer(port: number) {
res.json([...defaultItems, ...customItems]);
});
app.get('/api/status', async (_, res) => {
res.json({
guilds: bot.guilds.cache.size,
uptime: bot.uptime
});
});
app.listen(port, () => log.info(`web interface listening on ${port}`));
}

View File

@ -128,10 +128,10 @@ Promise.all([
fetch(`/api/items${document.location.search}`)
.then(res => res.json()),
loaded
]).then(items => {
]).then(([items]) => {
const itemsContainer = document.querySelector('.items');
itemsContainer.textContent = '';
items[0].forEach(item =>
items.forEach(item =>
itemsContainer.appendChild(renderItem(item))
);
document.querySelectorAll('.item-list').forEach(elem => {

View File

@ -12,6 +12,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Balsamiq+Sans&display=swap" rel="stylesheet">
<script src="/script.js"></script>
</head>
<body>
<div id="main">
@ -22,6 +24,9 @@
&middot;
<a href="https://git.oat.zone/dark-firepit/jillo-bot" target="_blank" rel="noopener">repo</a>
</div>
<div id="status">
&middot;&middot;&middot;
</div>
</div>
</body>
</html>

29
static/script.js Normal file
View File

@ -0,0 +1,29 @@
let resolveLoaded;
const loaded = new Promise(resolve => resolveLoaded = resolve);
function formatUptime(s) {
let units = [
['d', (s / (1000 * 60 * 60 * 24))],
['h', (s / (1000 * 60 * 60)) % 24],
['m', (s / (1000 * 60)) % 60],
['s', (s / 1000) % 60]
];
return units.filter(([_, t]) => t > 1).map(([sh, t]) => `${Math.ceil(t)}${sh}`).join(' ');
}
Promise.all([
fetch('/api/status')
.then(res => res.json()),
loaded
]).then(([status]) => {
document.querySelector('#status').innerHTML = `
<div class="status"></div> online &middot; ${status.guilds} guilds &middot; up for <span id="uptime">${formatUptime(status.uptime)}</span>
`;
const firstRender = Date.now();
setInterval(() => {
document.querySelector('#uptime').innerText = formatUptime(status.uptime + Date.now() - firstRender);
}, 1000);
});
document.addEventListener('DOMContentLoaded', () => resolveLoaded());

View File

@ -62,11 +62,11 @@ a:hover {
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
transition: transform 0.15s, opacity 0.1s;
}
#main img:active {
#main > img:active {
transform: scale(0.97);
opacity: 0.9;
}
#main :not(img) {
#main > :not(img) {
animation: 0.8s fadein;
}
#main h1 {
@ -74,6 +74,17 @@ a:hover {
margin-top: 0.5rem;
margin-bottom: 1rem;
}
#main #status {
color: var(--text-color-light);
font-size: 0.85rem;
}
#status .status {
width: 0.5rem;
height: 0.5rem;
background-color: #00a55e;
display: inline-block;
border-radius: 1rem;
}
@keyframes fadein {
0% { opacity: 0; }