Compare commits
6 Commits
3b9c66ebfd
...
37af0ea68f
Author | SHA1 | Date |
---|---|---|
Jill | 37af0ea68f | |
Jill | 26903e03a8 | |
Jill | 7d3bf20eaa | |
Jill | fd3dbfa65b | |
Jill | 07e6e162ff | |
Jill | f2c1d0efa9 |
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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...');
|
||||
|
||||
|
|
|
@ -71,6 +71,7 @@ export interface CraftingStationCooldown {
|
|||
}
|
||||
export interface CustomCraftingRecipe {
|
||||
id: number,
|
||||
guild: string,
|
||||
station: string
|
||||
}
|
||||
export interface CustomCraftingRecipeItem {
|
||||
|
|
|
@ -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);
|
|
@ -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')),
|
||||
};
|
||||
}
|
10
src/web.ts
10
src/web.ts
|
@ -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}`));
|
||||
}
|
|
@ -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 => {
|
||||
|
|
|
@ -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 @@
|
|||
·
|
||||
<a href="https://git.oat.zone/dark-firepit/jillo-bot" target="_blank" rel="noopener">repo</a>
|
||||
</div>
|
||||
<div id="status">
|
||||
···
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -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 · ${status.guilds} guilds · 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());
|
|
@ -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; }
|
||||
|
|
Loading…
Reference in New Issue