Merge pull request 'the Crafting Update' (#2) from items-and-crafting into main

Reviewed-on: #2
This commit is contained in:
Jill 2023-11-25 17:26:45 +01:00
commit df97104294
64 changed files with 4938 additions and 595 deletions

View File

@ -1,3 +1,7 @@
{
"token": "token"
"token": "token",
"sitePort": 15385,
"siteURL": "http://localhost:15385",
"clientId": "",
"clientSecret": ""
}

View File

@ -17,7 +17,7 @@ rest
const commandFiles = fs.readdirSync("./dist/commands").filter((file) => file.endsWith(".js") && !file.startsWith('.'));
for (const file of commandFiles) {
const command = require(`./dist/commands/${file}`);
const command = require(`./dist/commands/${file}`).default;
commands.push(command);
}

View File

@ -19,12 +19,12 @@ exports.up = async function(knex) {
// awfulllllllllllllll
const rows = await knex('counters').select('*');
await knex('counters_').insert(rows);
if (rows.length > 0) await knex('counters_').insert(rows);
await knex.schema
.dropTable('counters')
.renameTable('counters_', 'counters');
await knex.schema
.alterTable('counterUserLink', table => {
table.integer('id').references('id').inTable('counters');

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

@ -0,0 +1,21 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
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<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTable('craftingStationCooldowns');
};

View File

@ -0,0 +1,28 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.createTable('customCraftingRecipes', (table) => {
table.increments('id');
table.string('guild');
table.string('station');
})
.createTable('customCraftingRecipeItems', (table) => {
table.integer('id').references('id').inTable('customCraftingRecipes').notNullable();
table.integer('item').notNullable();
table.integer('quantity').defaultTo(1);
table.enum('type', ['input', 'output', 'requirement']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTable('customCraftingRecipes')
.dropTable('customCraftingRecipeItems');
};

View File

@ -0,0 +1,23 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.createTable('sessions', table => {
table.string('id').notNullable();
table.string('tokenType').notNullable();
table.string('accessToken').notNullable();
table.string('refreshToken').notNullable();
table.timestamp('expiresAt').notNullable();
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTable('sessions');
};

View File

@ -0,0 +1,19 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.createTable('initHealth', table =>
table.string('user').notNullable().unique()
);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTable('initHealth');
};

View File

@ -0,0 +1,20 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.createTable('invincibleUsers', table => {
table.string('user').notNullable().unique();
table.timestamp('since').notNullable();
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTable('invincibleUsers');
};

View File

@ -0,0 +1,26 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.createTable('itemBehaviors', table => {
table.integer('item').notNullable();
table.string('behavior').notNullable();
table.float('value');
})
.alterTable('customItems', table => {
table.dropColumn('behavior');
table.dropColumn('behaviorValue');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
exports.down = function(knex) {
// no
throw 'Not implemented';
};

View File

@ -12,24 +12,33 @@
"author": "oatmealine",
"license": "AGPL-3.0",
"dependencies": {
"@discordjs/core": "^1.1.1",
"@discordjs/rest": "^2.2.0",
"chalk": "^4.1.2",
"d3-array": "^2.12.1",
"discord.js": "^14.13.0",
"discord.js": "^14.14.1",
"express": "^4.18.2",
"express-handlebars": "^7.1.2",
"got": "^11.8.6",
"knex": "^3.0.1",
"outdent": "^0.8.0",
"parse-color": "^1.0.0",
"pretty-bytes": "^5.6.0",
"random-seed": "^0.3.0",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"tough-cookie": "^4.1.3",
"uid-safe": "^2.1.5"
},
"devDependencies": {
"@types/d3-array": "^3.0.9",
"@types/parse-color": "^1.0.2",
"@typescript-eslint/eslint-plugin": "^6.9.0",
"@typescript-eslint/parser": "^6.9.0",
"discord-api-types": "^0.37.50",
"eslint": "^8.52.0",
"@types/d3-array": "^3.2.1",
"@types/express": "^4.17.21",
"@types/parse-color": "^1.0.3",
"@types/tough-cookie": "^4.0.5",
"@types/uid-safe": "^2.1.5",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"discord-api-types": "^0.37.63",
"eslint": "^8.53.0",
"typescript": "5.2.2"
}
}

File diff suppressed because it is too large Load Diff

79
src/commands/attack.ts Normal file
View File

@ -0,0 +1,79 @@
import { GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { getItem, getItemQuantity, formatItems, formatItem, weaponInventoryAutocomplete } from '../lib/rpg/items';
import { Command } from '../types/index';
import { initHealth, dealDamage, BLOOD_ITEM, BLOOD_ID, resetInvincible, INVINCIBLE_TIMER, getInvincibleMs } from '../lib/rpg/pvp';
import { getBehavior, getBehaviors } from '../lib/rpg/behaviors';
import { Right } from '../lib/util';
export default {
data: new SlashCommandBuilder()
.setName('attack')
.setDescription('Attack someone using a weapon you have')
.addStringOption(option =>
option
.setName('weapon')
.setAutocomplete(true)
.setDescription('The weapon to use')
.setRequired(true)
)
.addUserOption(option =>
option
.setName('user')
.setRequired(true)
.setDescription('Who to attack with the weapon')
)
.setDMPermission(false),
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
await initHealth(member.id);
const weaponID = parseInt(interaction.options.getString('weapon', true));
const target = interaction.options.getUser('user', true);
await interaction.deferReply({ephemeral: true});
const weapon = await getItem(weaponID);
if (!weapon) return interaction.followUp('No such item exists!');
if (weapon.type !== 'weapon') return interaction.followUp('That is not a weapon!');
const inv = await getItemQuantity(member.id, weapon.id);
if (inv.quantity === 0) return interaction.followUp('You do not have this weapon!');
const invinTimer = await getInvincibleMs(target.id);
if (invinTimer > 0) return interaction.followUp(`You can only attack this user <t:${Math.floor((Date.now() + invinTimer) / 1000)}:R> (or if they perform an action first)!`);
let dmg = weapon.maxStack;
const messages = [];
const behaviors = await getBehaviors(weapon);
for (const itemBehavior of behaviors) {
const behavior = getBehavior(itemBehavior.behavior);
if (!behavior) continue;
if (!behavior.onAttack) continue;
const res = await behavior.onAttack({
value: itemBehavior.value,
damage: dmg,
item: weapon,
user: member.id,
target: target.id,
});
if (res instanceof Right) {
await interaction.followUp(`You tried to attack with ${formatItem(weapon)}... but failed!\n${res.getValue()}`);
return;
} else {
const { message, damage } = res.getValue();
if (message) messages.push(message);
if (damage) dmg = damage;
}
}
await dealDamage(target.id, dmg);
const newHealth = await getItemQuantity(target.id, BLOOD_ID);
if (target.id !== member.id) await resetInvincible(member.id);
await interaction.followUp(`You hit ${target} with ${formatItem(weapon)} for ${BLOOD_ITEM.emoji} **${dmg}** damage!\n${messages.map(m => `_${m}_\n`).join('')}They are now at ${formatItems(BLOOD_ITEM, newHealth.quantity)}.\nYou can attack them again <t:${Math.floor((Date.now() + INVINCIBLE_TIMER) / 1000)}:R> (or if they perform an action first).`);
},
autocomplete: weaponInventoryAutocomplete,
} satisfies Command;

View File

@ -1,5 +1,6 @@
import { CategoryChannel, GuildMember, EmbedBuilder, TextChannel, Interaction, ChannelType, SlashCommandBuilder } from 'discord.js';
import { CategoryChannel, GuildMember, EmbedBuilder, TextChannel, CommandInteraction, ChannelType, SlashCommandBuilder } from 'discord.js';
import { knownServers } from '../lib/knownServers';
import { Command } from '../types/index';
const rand = [
'This change has no significance.',
@ -38,7 +39,7 @@ const nicknames = [
'goobert'
];
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('change')
.setDescription('Change')
@ -51,9 +52,11 @@ module.exports = {
serverWhitelist: [...knownServers.firepit, ...knownServers.fbi],
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
const what = interaction.options.getString('what', true);
let title = `**${member.displayName}** changed the **${what}**`;
let response;
@ -108,4 +111,4 @@ module.exports = {
ephemeral: false,
});
}
};
} satisfies Command;

View File

@ -1,8 +1,9 @@
import { RoleCreateOptions, GuildMember, Interaction, EmbedBuilder, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from 'discord.js';
import { RoleCreateOptions, GuildMember, CommandInteraction, EmbedBuilder, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from 'discord.js';
import { default as parseColor, Color } from 'parse-color';
import { isColorRole, COLOR_ROLE_SEPERATOR } from '../lib/assignableRoles';
import { knownServers } from '../lib/knownServers';
import * as log from '../lib/log';
import { Command } from '../types/index';
const PREVIEW_DURATION = 1000 * 60;
@ -35,7 +36,7 @@ async function applyColor(member: GuildMember, color: Color) {
await member.roles.add(role);
}
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('color')
.setDescription('Change your role color.')
@ -49,9 +50,11 @@ module.exports = {
serverWhitelist: [...knownServers.firepit],
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
const color = interaction.options.getString('color');
const preview = interaction.options.getBoolean('preview');
@ -129,4 +132,4 @@ module.exports = {
});
}
}
};
} satisfies Command;

View File

@ -1,7 +1,10 @@
import { AutocompleteInteraction, Interaction, SlashCommandBuilder } from 'discord.js';
import { AutocompleteInteraction, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { Counter, CounterUserLink, db } from '../lib/db';
import { counterAutocomplete, counterConfigs, findCounter, getCounterConfigRaw, getOptions, parseConfig, setCounterConfig, toStringConfig, updateCounter } from '../lib/counter';
import { counterAutocomplete, counterConfigs, findCounter, getCounterConfigRaw, getOptions, parseConfig, setCounterConfig, toStringConfig, updateCounter } from '../lib/rpg/counter';
import { outdent } from 'outdent';
import { formatItem, formatItems, getItem, itemAutocomplete } from '../lib/rpg/items';
import { Command } from '../types/index';
import { set } from '../lib/autocomplete';
function extendOption(t: string) {
return {name: t, value: t};
@ -9,7 +12,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**.
@ -27,7 +30,7 @@ const help = new Map([
`]
]);
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('counter')
.setDescription('[ADMIN] Counter management')
@ -143,7 +146,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,10 +218,29 @@ 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),
execute: async (interaction: Interaction) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({ephemeral: true});
@ -233,10 +255,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 +295,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 +364,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 +379,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 +389,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,35 +401,64 @@ 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.`);
}
}
},
autocomplete: async (interaction: AutocompleteInteraction) => {{
const focused = interaction.options.getFocused(true);
autocomplete: set({
type: counterAutocomplete,
item: itemAutocomplete,
value: async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();
if (focused.name === 'type') {
return counterAutocomplete(interaction);
} else if (focused.name === 'value') {
const type = interaction.options.getString('type', true);
const counter = await findCounter(type, interaction.guildId!);
const config = await getCounterConfigRaw(counter);
const key = interaction.options.getString('key');
if (!key) return interaction.respond([]);
if (!key) return [];
const defaultConfig = counterConfigs.get(key);
if (!defaultConfig) return interaction.respond([]);
if (!defaultConfig) return [];
const defaultOptions = getOptions(defaultConfig.type);
@ -432,20 +471,20 @@ module.exports = {
value: `${toStringConfig(defaultConfig.default, defaultConfig.type)}`,
name: `Default: ${toStringConfig(defaultConfig.default, defaultConfig.type)}`
},
...defaultOptions.filter(s => s.startsWith(focused.value)).map(extendOption)
...defaultOptions.filter(s => s.startsWith(focused)).map(extendOption)
];
if (focused.value !== '' && !options.find(opt => opt.value === focused.value)) {
if (focused !== '' && !options.find(opt => opt.value === focused)) {
options = [
{
value: focused.value,
name: focused.value
value: focused,
name: focused
},
...options
];
}
await interaction.respond(options);
return options;
}
}}
};
}),
} satisfies Command;

146
src/commands/craft.ts Normal file
View File

@ -0,0 +1,146 @@
import { AutocompleteInteraction, GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
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, resolveCustomRecipe } from '../lib/rpg/recipes';
import { Command } from '../types/index';
import { initHealth, resetInvincible } from '../lib/rpg/pvp';
import { set } from '../lib/autocomplete';
export default {
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: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
await initHealth(member.id);
const recipeID = parseInt(interaction.options.getString('recipe', true));
await interaction.deferReply({ephemeral: true});
const recipe = await 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)}x.)`);
}
let cooldown;
if (station.cooldown) {
cooldown = await db<CraftingStationCooldown>('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 <t:${Math.floor(cooldown.usedAt / 1000 + station.cooldown)}:R>!`);
}
// 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<CraftingStationCooldown>('craftingStationCooldowns')
.insert({
station: station.key,
user: member.id,
usedAt: Date.now()
});
} else {
await db<CraftingStationCooldown>('craftingStationCooldowns')
.where('station', station.key)
.where('user', member.id)
.update({
usedAt: Date.now()
});
}
nextUsableAt = Date.now() + station.cooldown * 1000;
}
await resetInvincible(member.id);
return interaction.followUp(`${station.emoji} ${verb(station)} ${formatItemsArray(outputs)}!${outputs.length === 1 ? `\n_${outputs[0].item.description}_` : ''}${nextUsableAt ? `\n${station.name} usable again <t:${Math.floor(nextUsableAt / 1000)}:R>` : ''}`);
},
autocomplete: set({
station: async (interaction: AutocompleteInteraction) =>
(await Promise.all(
craftingStations
.map(async station => [station, await canUseStation(interaction.user.id, station)])
))
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_station, usable]) => usable)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([station, _]) => station as CraftingStation)
.filter(station => station.name.toLowerCase().includes(interaction.options.getFocused().toLowerCase()))
.map(station => ({
name: `${station.emoji} ${station.name}`,
value: station.key
})),
recipe: async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();
const station = interaction.options.getString('station');
const foundDefaultRecipes = defaultRecipes
.filter(recipe => recipe.station === station)
.filter(recipe => recipe.outputs.filter(n => n.item.name.toLowerCase().includes(focused.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.toLowerCase())).length > 0);
const recipes = [...foundDefaultRecipes, ...foundCustomRecipes];
return recipes
.map(recipe => ({
name: formatRecipe(recipe, true),
value: recipe.id.toString()
}));
}
}),
} satisfies Command;

View File

@ -1,7 +1,8 @@
import { GuildMember, Interaction, SlashCommandBuilder } from 'discord.js';
import { changeCounterInteraction, counterAutocomplete } from '../lib/counter';
import { GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { changeCounterInteraction, counterAutocomplete } from '../lib/rpg/counter';
import { Command } from '../types/index';
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('decrease')
.setDescription('Decrease a counter')
@ -21,9 +22,11 @@ module.exports = {
)
.setDMPermission(false),
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
const amount = Math.trunc(interaction.options.getInteger('amount') || 1);
const type = interaction.options.getString('type')!;
@ -32,5 +35,5 @@ module.exports = {
changeCounterInteraction(interaction, member, -amount, type);
},
autocomplete: counterAutocomplete
};
autocomplete: counterAutocomplete,
} satisfies Command;

64
src/commands/eat.ts Normal file
View File

@ -0,0 +1,64 @@
import { GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { Command } from '../types/index';
import { initHealth, resetInvincible } from '../lib/rpg/pvp';
import { consumableInventoryAutocomplete, formatItem, formatItems, getItem, getItemQuantity, giveItem } from '../lib/rpg/items';
import { getBehavior, getBehaviors } from '../lib/rpg/behaviors';
import { Right } from '../lib/util';
export default {
data: new SlashCommandBuilder()
.setName('eat')
.setDescription('Eat an item from your inventory')
.addStringOption(option =>
option
.setName('item')
.setAutocomplete(true)
.setDescription('The item to eat')
.setRequired(true)
)
.setDMPermission(false),
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
await initHealth(member.id);
await interaction.deferReply({ ephemeral: true });
const itemID = parseInt(interaction.options.getString('item', true));
const item = await getItem(itemID);
if (!item) return await interaction.followUp('Item does not exist!');
const itemInv = await getItemQuantity(member.id, item.id);
if (itemInv.quantity <= 0) return await interaction.followUp(`You do not have ${formatItem(item)}!`);
const behaviors = await getBehaviors(item);
const messages = [];
for (const itemBehavior of behaviors) {
const behavior = getBehavior(itemBehavior.behavior);
if (!behavior) continue;
if (!behavior.onUse) continue;
const res = await behavior.onUse({
value: itemBehavior.value,
item,
user: member.id
});
if (res instanceof Right) {
await interaction.followUp(`You tried to eat ${formatItems(item, 1)}... but failed!\n${res.getValue()}`);
return;
} else {
messages.push(res.getValue());
}
}
await resetInvincible(member.id);
const newInv = await giveItem(member.id, item, -1);
return await interaction.followUp(`You ate ${formatItems(item, 1)}!\n${messages.map(m => `_${m}_`).join('\n')}\nYou now have ${formatItems(item, newInv.quantity)}`);
},
autocomplete: consumableInventoryAutocomplete,
} satisfies Command;

View File

@ -1,7 +1,8 @@
import { GuildMember, EmbedBuilder, SlashCommandBuilder, Interaction } from 'discord.js';
import { EmbedBuilder, SlashCommandBuilder, CommandInteraction } from 'discord.js';
import { writeTmpFile } from '../lib/util';
import { Command } from '../types/index';
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('emotedump')
.setDescription('Dump every emote in the server for Gitea')
@ -14,11 +15,11 @@ module.exports = {
.setMaxValue(512)
),
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const size = interaction.options.getInteger('size') || 64;
const emojis = member.guild.emojis;
const emojis = interaction.guild!.emojis;
const embed = new EmbedBuilder()
.setDescription(`names: \`${emojis.cache.map(emote => emote.name).join(',')}\``);
@ -38,4 +39,4 @@ module.exports = {
ephemeral: true
});
}
};
} satisfies Command;

View File

@ -1,6 +1,7 @@
import { Guild, GuildMember, Interaction, Role, SlashCommandBuilder } from 'discord.js';
import { Guild, GuildMember, CommandInteraction, Role, SlashCommandBuilder } from 'discord.js';
import { isColorRole, isPronounRole } from '../lib/assignableRoles';
import { knownServers } from '../lib/knownServers';
import { Command } from '../types/index';
async function fetchRoleMembers(role: Role) {
const members = await role.guild.members.fetch();
@ -21,7 +22,7 @@ async function garbageCollectRoles(guild: Guild, dryRun: boolean): Promise<Role[
return (await Promise.all(deletedRoles)).filter(r => r) as Role[];
}
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('garbage-collect-roles')
.setDescription('Garbage collect unused roles for colors and pronouns.')
@ -30,7 +31,7 @@ module.exports = {
serverWhitelist: [...knownServers.firepit],
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({
@ -38,7 +39,7 @@ module.exports = {
});
const dryrun = interaction.options.getBoolean('dry-run');
const colorRoles = await garbageCollectRoles(member.guild, dryrun || false);
const colorRoles = await garbageCollectRoles(interaction.guild!, dryrun || false);
if (dryrun) {
interaction.followUp({
@ -52,4 +53,4 @@ module.exports = {
});
}
}
};
} satisfies Command;

View File

@ -1,7 +1,8 @@
import { GuildMember, Interaction, SlashCommandBuilder } from 'discord.js';
import { changeCounterInteraction, counterAutocomplete } from '../lib/counter';
import { GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { changeCounterInteraction, counterAutocomplete } from '../lib/rpg/counter';
import { Command } from '../types/index';
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('increase')
.setDescription('Increase a counter')
@ -21,9 +22,11 @@ module.exports = {
)
.setDMPermission(false),
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
const amount = Math.trunc(interaction.options.getInteger('amount') || 1);
const type = interaction.options.getString('type')!;
@ -32,5 +35,5 @@ module.exports = {
changeCounterInteraction(interaction, member, amount, type);
},
autocomplete: counterAutocomplete
};
autocomplete: counterAutocomplete,
} satisfies Command;

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

@ -0,0 +1,33 @@
import { GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { ItemInventory, db } from '../lib/db';
import { formatItems, getItem } from '../lib/rpg/items';
import { initHealth } from '../lib/rpg/pvp';
import { Command } from '../types/index';
export default {
data: new SlashCommandBuilder()
.setName('inventory')
.setDescription('Check your inventory')
.setDMPermission(false),
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
await initHealth(member.id);
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 && i.quantity !== 0);
await interaction.followUp(
`Your inventory:\n${items.length === 0 ? '_Your inventory is empty!_' : items.map(i => `- ${formatItems(i.item!, i.quantity)}\n_${i.item!.description}_`).join('\n')}`
);
}
} satisfies Command;

View File

@ -1,4 +1,5 @@
import { GuildMember, EmbedBuilder, SlashCommandBuilder, Interaction } from 'discord.js';
import { GuildMember, EmbedBuilder, SlashCommandBuilder, CommandInteraction } from 'discord.js';
import { Command } from '../types/index';
const rand = require('random-seed').create();
const results = [
@ -19,16 +20,18 @@ function seperate(l: string[]): string {
return l.slice(0, -1).join(', ') + ' or ' + l.slice(-1);
}
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('investigate')
.setDescription('Investigate someone.')
.addUserOption((option) => option.setName('who').setDescription('Investigate who?').setRequired(true))
.addBooleanOption((option) => option.setName('sheriff').setDescription('Switch to Sheriff-style investigation').setRequired(false)),
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
const who = interaction.options.getUser('who', true);
const sheriff = interaction.options.getBoolean('sheriff');
let response;
@ -69,4 +72,4 @@ module.exports = {
ephemeral: true,
});
}
};
} satisfies Command;

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

@ -0,0 +1,359 @@
import { AutocompleteInteraction, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { Counter, CustomCraftingRecipeItem, CustomItem, ItemBehavior, db } from '../lib/db';
import { customItemAutocomplete, formatItem, formatItems, getCustomItem, getItem, giveItem, isDefaultItem, itemAutocomplete } from '../lib/rpg/items';
import { Command } from '../types/index';
import { formatRecipe, getCustomRecipe } from '../lib/rpg/recipes';
import { behaviors, formatBehavior, getBehavior } from '../lib/rpg/behaviors';
import { set } from '../lib/autocomplete';
//function extendOption(t: string) {
// return {name: t, value: t};
//}
export default {
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')
)
.addBooleanOption(opt =>
opt
.setName('untradable')
.setDescription('Can you give this item to other people?')
)
)
.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')
)
.addBooleanOption(opt =>
opt
.setName('untradable')
.setDescription('Can you give this item to other people?')
)
)
.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')
)
.addBooleanOption(opt =>
opt
.setName('untradable')
.setDescription('Can you give this item to other people?')
)
)
)
.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')
)
)
.addSubcommand(cmd =>
cmd
.setName('delete')
.setDescription('[ADMIN] Delete a custom item')
.addStringOption(opt =>
opt
.setName('customitem')
.setDescription('The item')
.setAutocomplete(true)
.setRequired(true)
)
)
.addSubcommandGroup(grp =>
grp
.setName('behavior')
.setDescription('[ADMIN] Item behavior management')
.addSubcommand(cmd =>
cmd
.setName('add')
.setDescription('[ADMIN] Give an item a behavior')
.addStringOption(opt =>
opt
.setName('customitem')
.setDescription('The item')
.setAutocomplete(true)
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('behavior')
.setDescription('The behavior to add')
.setAutocomplete(true)
.setRequired(true)
)
.addNumberOption(opt =>
opt
.setName('value')
.setDescription('A value to assign the behavior, not always applicable')
)
)
.addSubcommand(cmd =>
cmd
.setName('remove')
.setDescription('[ADMIN] Rid an item of a behavior')
.addStringOption(opt =>
opt
.setName('customitem')
.setDescription('The item')
.setAutocomplete(true)
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('removebehavior')
.setDescription('The behavior to remove')
.setAutocomplete(true)
.setRequired(true)
)
)
)
.setDefaultMemberPermissions('0')
.setDMPermission(false),
execute: async (interaction: CommandInteraction) => {
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),
'untradable': interaction.options.getBoolean('untradable') || false,
})
.returning('*');
await interaction.followUp(`${formatItem(item[0])} has been successfully created! 🎉\nYou can now use \`/item behavior\` to give it some custom functionality.`);
} else if (group === 'behavior') {
const itemID = parseInt(interaction.options.getString('customitem', true));
const item = await getCustomItem(itemID);
if (!item) return await interaction.followUp('No such item exists!');
if (item.guild !== interaction.guildId) return await interaction.followUp('This item is from a different server! Nice try though');
if (subcommand === 'add') {
const behaviorName = interaction.options.getString('behavior', true);
const value = interaction.options.getNumber('value');
const behavior = getBehavior(behaviorName);
if (!behavior) return await interaction.followUp(`No such behavior ${behaviorName}!`);
const existingBehavior = await db<ItemBehavior>('itemBehaviors')
.where('item', item.id)
.where('behavior', behavior.name)
.first();
if (existingBehavior) {
return await interaction.followUp(`${formatItem(item)} already has **${formatBehavior(behavior, existingBehavior.value)}**!`);
}
await db<ItemBehavior>('itemBehaviors')
.insert({
item: item.id,
behavior: behavior.name,
value: value || undefined,
});
return await interaction.followUp(`${formatItem(item)} now has **${formatBehavior(behavior, value || undefined)}**`);
} else if (subcommand === 'remove') {
const behaviorName = interaction.options.getString('removebehavior', true);
const behavior = getBehavior(behaviorName);
if (!behavior) return await interaction.followUp(`No such behavior ${behaviorName}!`);
const existingBehavior = await db<ItemBehavior>('itemBehaviors')
.where('item', item.id)
.where('behavior', behavior.name)
.first();
if (!existingBehavior) {
return await interaction.followUp(`${formatItem(item)} does not have behavior \`${behaviorName}\`!`);
}
await db<ItemBehavior>('itemBehaviors')
.where('item', item.id)
.where('behavior', behavior.name)
.delete();
return await interaction.followUp(`Deleted behavior ${formatBehavior(behavior, existingBehavior.value)} from ${formatItem(item)}.`);
}
} 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!');
if (!isDefaultItem(item)) {
if (item.guild !== interaction.guildId) return await interaction.followUp('This item is from a different server! Nice try though');
}
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')}`);
}
const linkedWith = await db<Counter>('counters')
.where('linkedItem', item.id);
if (linkedWith.length > 0) {
return interaction.followUp(`⚠️ This item is used in the following counters:\n${linkedWith.map(c => `- ${c.key} ${c.value} in <#${c.channel}>`).join('\n')}`);
}
await db<CustomItem>('customItems')
.where('id', item.id)
.delete();
interaction.followUp(`${formatItem(item)} has been deleted.`);
}
}
},
autocomplete: set({
item: itemAutocomplete,
customitem: customItemAutocomplete,
behavior: async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();
let foundBehaviors = behaviors.filter(b => b.name.toLowerCase().includes(focused.toLowerCase()));
const itemID = interaction.options.getString('customitem');
if (itemID) {
const item = await getItem(parseInt(itemID));
if (item) {
foundBehaviors = foundBehaviors.filter(b => b.type === item.type);
}
}
return foundBehaviors.map(b => ({name: `${b.type}:${b.name} - ${b.description}`, value: b.name}));
},
removebehavior: async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();
const itemID = interaction.options.getString('customitem');
if (!itemID) return [];
const behaviors = await db<ItemBehavior>('itemBehaviors')
.where('item', itemID);
const foundBehaviors = behaviors
.map(b => ({ behavior: getBehavior(b.behavior)!, value: b.value }))
.filter(b => b.behavior)
.filter(b => b.behavior.name.toLowerCase().includes(focused.toLowerCase()));
return foundBehaviors.map(b => ({
name: `${b.behavior.type}:${formatBehavior(b.behavior, b.value)} - ${b.behavior.description}`,
value: b.behavior.name
}));
},
}),
} satisfies Command;

View File

@ -1,5 +1,6 @@
import { GuildMember, SlashCommandBuilder, Interaction, messageLink } from 'discord.js';
import { GuildMember, SlashCommandBuilder, CommandInteraction, messageLink } from 'discord.js';
import { getTextResponsePrettyPlease, randomWord, sendSegments, startGame } from '../lib/game';
import { Command } from '../types/index';
const END_TEMPLATES = [
'Alright! Here\'s the messages you all conjured:',
@ -8,7 +9,7 @@ const END_TEMPLATES = [
'That does it! Here\'s what you\'ve all cooked up together:'
];
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('markov')
.setDescription('Play a Markov chain game')
@ -28,9 +29,11 @@ module.exports = {
.setMinValue(1)
.setMaxValue(100)),
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
const context = interaction.options.getInteger('context') || 3;
const maxIterations = interaction.options.getInteger('iterations') || 10;
@ -72,4 +75,4 @@ module.exports = {
});
});
}
};
} satisfies Command;

View File

@ -1,12 +1,14 @@
import { EmbedBuilder, Interaction, SlashCommandBuilder } from 'discord.js';
import { EmbedBuilder, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import got from 'got';
import { knownServers } from '../lib/knownServers';
import { Command } from '../types/index';
const rand = require('random-seed').create();
const imagesEndpoint = 'https://commons.wikimedia.org/w/api.php?action=query&cmlimit=500&cmtitle=Category%3ALiminal_spaces&cmtype=file&list=categorymembers&format=json';
const imageEndpoint = 'https://commons.wikimedia.org/w/api.php?action=query&piprop=thumbnail&pithumbsize=200&prop=pageimages&titles={}&format=json';
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('monitor')
.setDescription('Monitor')
@ -19,7 +21,7 @@ module.exports = {
serverWhitelist: [...knownServers.firepit_extended, ...knownServers.fbi],
execute: async (interaction: Interaction) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({ephemeral: false});
@ -49,4 +51,4 @@ module.exports = {
embeds: [embed]
});
}
};
} satisfies Command;

View File

@ -1,13 +1,14 @@
import { RoleCreateOptions, GuildMember, Interaction, SlashCommandBuilder } from 'discord.js';
import { RoleCreateOptions, GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { pronouns, PRONOUN_ROLE_SEPERATOR } from '../lib/assignableRoles';
import { knownServers } from '../lib/knownServers';
import * as log from '../lib/log';
import { Command } from '../types/index';
function extendOption(t: string) {
return {name: t, value: t};
}
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('pronoun')
.setDescription('Give yourself a pronoun or two.')
@ -21,9 +22,11 @@ module.exports = {
serverWhitelist: [...knownServers.firepit],
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
await interaction.deferReply({
ephemeral: true
});
@ -57,4 +60,4 @@ module.exports = {
});
}
}
};
} satisfies Command;

42
src/commands/put.ts Normal file
View File

@ -0,0 +1,42 @@
import { GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { changeLinkedCounterInteraction, linkedCounterAutocomplete } from '../lib/rpg/counter';
import { Command } from '../types/index';
import { initHealth } from '../lib/rpg/pvp';
export default {
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: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
await initHealth(member.id);
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,
} satisfies Command;

254
src/commands/recipe.ts Normal file
View File

@ -0,0 +1,254 @@
import { ActionRowBuilder, AutocompleteInteraction, 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, getCustomRecipe, resolveCustomRecipe } from '../lib/rpg/recipes';
import { craftingStations, getStation } from '../lib/rpg/craftingStations';
import { CustomCraftingRecipe, CustomCraftingRecipeItem, db } from '../lib/db';
export default {
data: new SlashCommandBuilder()
.setName('recipe')
.setDescription('[ADMIN] Manage custom recipes for items')
.addSubcommand(sub =>
sub
.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),
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({ ephemeral: true });
const sub = interaction.options.getSubcommand(true);
if (sub === 'create') {
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`recipe-create-${interaction.guildId}`).setLabel('I\'ve got my string ready!').setStyle(ButtonStyle.Primary)
);
await interaction.followUp({
ephemeral: true,
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)}`);
}
},
async onClientReady(bot) {
bot.on(Events.InteractionCreate, async (interaction) => {
if (!('customId' in interaction)) return;
const id = interaction.customId;
if (!id.startsWith('recipe-')) return;
if (!interaction.member) return;
if (id.startsWith('recipe-create-')) {
const guildID = id.split('-')[2];
if (interaction.isMessageComponent()) {
const modal = new ModalBuilder()
.setCustomId(interaction.customId)
.setTitle('Recipe Creator');
const input = new TextInputBuilder()
.setCustomId('recipe-create-textbox')
.setLabel('Paste in your recipe string here:')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true);
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
modal.addComponents(row);
interaction.showModal(modal);
} else if (interaction.isModalSubmit()) {
const field = interaction.fields.getField('recipe-create-textbox', ComponentType.TextInput);
const recipeString = field.value.trim();
await interaction.deferReply({ ephemeral: true });
let parsed;
try {
parsed = await Promise.all(
recipeString
.split('|')
.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) {
await interaction.followUp(`This is not a valid string!: \`${(err as Error).message}\``);
return;
}
const
inputs = parsed[0] || [],
requirements = parsed[1] || [],
outputs = parsed[2] || [];
const recipe = {
inputs, requirements, outputs,
station: 'hands',
id: 0
};
const components = [
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.addOptions(
...craftingStations
.map(station => ({
label: `${station.emoji} ${station.name}`,
value: station.key,
description: station.description
}))
)
.setMinValues(1)
.setMaxValues(1)
.setCustomId('recipe-select-station')
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('recipe-select-done')
.setLabel('Done')
.setStyle(ButtonStyle.Primary)
.setDisabled(true)
)
];
const msg = await interaction.followUp({
content: `${formatRecipe(recipe)}\n_Select a crafting station, and you're good to go!_`,
components: components,
fetchReply: true
});
const selectCollector = msg.createMessageComponentCollector({
componentType: ComponentType.StringSelect,
time: 60_000 * 5,
});
selectCollector.on('collect', selectInteraction => {
const newStation = selectInteraction.values[0];
recipe.station = newStation;
components[1].components[0].setDisabled(false);
interaction.editReply({
content: `${formatRecipe(recipe)}\n_Select a crafting station, and you're good to go!_`,
components: components
});
const station = getStation(newStation);
selectInteraction.reply({
content: `Set station to ${station?.emoji} **${station?.name}**`,
ephemeral: true
});
});
selectCollector.on('end', () => {
interaction.editReply({
content: msg.content,
components: []
});
});
const buttonInteraction = await msg.awaitMessageComponent({ componentType: ComponentType.Button, time: 60_000 * 5 });
selectCollector.stop();
const [customRecipe] = await db<CustomCraftingRecipe>('customCraftingRecipes')
.insert({
guild: guildID,
station: recipe.station
})
.returning('id');
for (const input of recipe.inputs) {
await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.insert({
id: customRecipe.id,
item: input.item.id,
quantity: input.quantity,
type: 'input'
});
}
for (const req of recipe.requirements) {
await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.insert({
id: customRecipe.id,
item: req.item.id,
quantity: req.quantity,
type: 'requirement'
});
}
for (const output of recipe.outputs) {
await db<CustomCraftingRecipeItem>('customCraftingRecipeItems')
.insert({
id: customRecipe.id,
item: output.item.id,
quantity: output.quantity,
type: 'output'
});
}
buttonInteraction.reply({
ephemeral: true,
content: 'Your recipe has been created 🎉'
});
}
}
});
},
autocomplete: async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();
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.toLowerCase())).length > 0);
return foundCustomRecipes
.map(recipe => ({
name: formatRecipe(recipe, true),
value: recipe.id.toString()
}));
},
} satisfies Command;

View File

@ -1,7 +1,8 @@
import { Interaction, SlashCommandBuilder } from 'discord.js';
import { CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { isSubscribed, subscribe, timeAnnouncements, unsubscribe } from '../lib/subscriptions';
import { Command } from '../types/index';
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('subscribe')
.setDescription('[ADMIN] Subscribe/unsubscribe to a time announcement')
@ -14,7 +15,7 @@ module.exports = {
)
.setDefaultMemberPermissions('0'),
execute: async (interaction: Interaction) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({ephemeral: true});
@ -34,4 +35,4 @@ module.exports = {
});
}
}
};
} satisfies Command;

View File

@ -1,6 +1,7 @@
import { CommandInteraction, GuildMember, ActionRowBuilder, ButtonBuilder, Client, Collection, MessageComponentInteraction, StringSelectMenuBuilder, ModalBuilder, TextChannel, TextInputStyle, Message, ButtonStyle, ComponentType, APIButtonComponentWithCustomId, Events, TextInputBuilder, SlashCommandBuilder } from 'discord.js';
import * as fs from 'fs/promises';
import { knownServers } from '../lib/knownServers';
import { Command } from '../types/index';
const RESPONSES_CHANNEL = '983762973858361364';
const GENERAL_CHANNEL = '587108210683412493';
@ -599,7 +600,7 @@ async function advanceSurvey(userId: string, dontAdvanceProgress = false) {
}
}
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('createsurvey')
.setDescription('Re-create the survey button'),
@ -621,7 +622,7 @@ module.exports = {
});
},
onClientReady: (bot: Client) => {
onClientReady: async (bot: Client) => {
bot.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isMessageComponent()) return;
if (interaction.isModalSubmit()) return;
@ -732,6 +733,7 @@ module.exports = {
});
bot.on(Events.InteractionCreate, interaction => {
if (!interaction.isModalSubmit()) return;
if (!interaction.customId.startsWith('survey-')) return;
if (!interaction.member) return;
const member = interaction.member as GuildMember;
@ -758,4 +760,4 @@ module.exports = {
advanceSurvey(member.id);
});
}
};
} satisfies Command;

42
src/commands/take.ts Normal file
View File

@ -0,0 +1,42 @@
import { GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { changeLinkedCounterInteraction, linkedCounterAutocomplete } from '../lib/rpg/counter';
import { initHealth } from '../lib/rpg/pvp';
import { Command } from '../types/index';
export default {
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: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
await initHealth(member.id);
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,
} satisfies Command;

View File

@ -1,6 +1,7 @@
import { GuildMember, SlashCommandBuilder, Interaction, messageLink, User } from 'discord.js';
import { GuildMember, SlashCommandBuilder, CommandInteraction, messageLink, User } from 'discord.js';
import { getTextResponsePrettyPlease, sendSegments, startGame } from '../lib/game';
import { shuffle } from 'd3-array';
import { Command } from '../types/index';
const horrorStarters = [
'I was playing with my boobs.',
@ -59,13 +60,15 @@ function shift<T>(arr: T[]): T[] {
return [...arr.slice(1), arr[0]];
}
module.exports = {
export default {
data: new SlashCommandBuilder()
.setName('twosentencehorror')
.setDescription('Communally create the worst horror stories known to man'),
execute: async (interaction: Interaction, member: GuildMember) => {
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
startGame(interaction, member.user, 'Two Sentence Horror', async (players, channel) => {
players = shuffle(players);
@ -106,4 +109,4 @@ module.exports = {
});
});
}
};
} satisfies Command;

62
src/commands/use.ts Normal file
View File

@ -0,0 +1,62 @@
import { GuildMember, CommandInteraction, SlashCommandBuilder } from 'discord.js';
import { Command } from '../types/index';
import { initHealth, resetInvincible } from '../lib/rpg/pvp';
import { formatItem, getItem, getItemQuantity, plainInventoryAutocomplete } from '../lib/rpg/items';
import { getBehavior, getBehaviors } from '../lib/rpg/behaviors';
import { Right } from '../lib/util';
export default {
data: new SlashCommandBuilder()
.setName('use')
.setDescription('Use an item from your inventory')
.addStringOption(option =>
option
.setName('item')
.setAutocomplete(true)
.setDescription('The item to use')
.setRequired(true)
)
.setDMPermission(false),
execute: async (interaction: CommandInteraction) => {
if (!interaction.isChatInputCommand()) return;
const member = interaction.member! as GuildMember;
await initHealth(member.id);
await interaction.deferReply({ ephemeral: true });
const itemID = parseInt(interaction.options.getString('item', true));
const item = await getItem(itemID);
if (!item) return await interaction.followUp('Item does not exist!');
const itemInv = await getItemQuantity(member.id, item.id);
if (itemInv.quantity <= 0) return await interaction.followUp(`You do not have ${formatItem(item)}!`);
const behaviors = await getBehaviors(item);
const messages = [];
for (const itemBehavior of behaviors) {
const behavior = getBehavior(itemBehavior.behavior);
if (!behavior) continue;
if (!behavior.onUse) continue;
const res = await behavior.onUse({
value: itemBehavior.value,
item,
user: member.id
});
if (res instanceof Right) {
await interaction.followUp(`You tried to use ${formatItem(item)}... but failed!\n${res.getValue()}`);
return;
} else {
messages.push(res.getValue());
}
}
await resetInvincible(member.id);
return await interaction.followUp(`You used ${formatItem(item)}!\n${messages.map(m => `_${m}_`).join('\n')}`);
},
autocomplete: plainInventoryAutocomplete,
} satisfies Command;

View File

@ -1,11 +1,15 @@
import { Client, GatewayIntentBits, Events, Collection, CommandInteraction, CommandInteractionOption, ApplicationCommandOptionType } from 'discord.js';
import * as fs from 'fs';
const { token } = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
const { token, sitePort, siteURL, clientId, clientSecret } = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
import * as path from 'path';
import { initializeAnnouncements } from './lib/subscriptions';
import * as log from './lib/log';
import chalk from 'chalk';
import prettyBytes from 'pretty-bytes';
import { Command } from './types/index';
import { startServer } from './web/web';
import { init as initPVP } from './lib/rpg/pvp';
import { autocomplete } from './lib/autocomplete';
const bot = new Client({
intents: [
@ -19,9 +23,16 @@ const bot = new Client({
],
});
bot.config = {
token, sitePort, siteURL, clientId, clientSecret
};
async function init() {
log.nonsense('booting chip...');
log.nonsense('starting up web interface...');
await startServer(bot, sitePort);
log.nonsense('setting up connection...');
try {
@ -31,6 +42,8 @@ async function init() {
log.error(`${chalk.bold('emergency mode could not be established.')} shutting down.`);
process.exit(1);
}
initPVP(bot);
}
bot.on(Events.ClientReady, async () => {
@ -43,7 +56,7 @@ bot.on(Events.ClientReady, async () => {
bot.commands = new Collection();
const cmdFiles = fs.readdirSync(path.join(__dirname, './commands')).filter((file) => file.endsWith('.js'));
for (const file of cmdFiles) {
const cmd = (await import(`./commands/${file}`));
const cmd = (await import(`./commands/${file}`)).default as Command;
bot.commands.set(cmd.data.name, cmd);
if (cmd.onClientReady) cmd.onClientReady(bot);
}
@ -90,7 +103,7 @@ bot.on(Events.InteractionCreate, async (interaction) => {
log.nonsense(stringifyCommand(interaction));
try {
await command.execute(interaction, interaction.member);
await command.execute(interaction);
} catch (error) {
if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) interaction.reply({ content: `\`ERROR\`\n\`\`\`\n${error}\n\`\`\``, ephemeral: true });
if (interaction.deferred) interaction.followUp(`\`ERROR\`\n\`\`\`\n${error}\n\`\`\``);
@ -101,7 +114,8 @@ bot.on(Events.InteractionCreate, async (interaction) => {
if (!command) return;
try {
await command.autocomplete(interaction);
if (!command.autocomplete) throw `Trying to invoke autocomplete for command ${interaction.commandName} which does not have it defined`;
await autocomplete(command.autocomplete)(interaction);
} catch (error) {
log.error(error);
}

27
src/lib/autocomplete.ts Normal file
View File

@ -0,0 +1,27 @@
import { AutocompleteInteraction, ApplicationCommandOptionChoiceData } from 'discord.js';
import * as log from './log';
export type Autocomplete = (interaction: AutocompleteInteraction) => Promise<ApplicationCommandOptionChoiceData<string | number>[]>
export function set(fns: Record<string, Autocomplete>): Autocomplete {
return async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused(true);
const fn = fns[focused.name];
if (!fn) return [];
return fn(interaction);
};
}
export function autocomplete(fn: Autocomplete): (interaction: AutocompleteInteraction) => Promise<void> {
return async (interaction: AutocompleteInteraction) => {
try {
const arr = await fn(interaction);
if (arr.length > 25) log.warn(`Autocomplete for ${interaction.options.getFocused(true).name} exceeded limit of 25 autocomplete results`);
return interaction.respond(arr.slice(0, 25));
} catch (err) {
log.error(err);
return interaction.respond([]);
}
};
}

View File

@ -1,314 +0,0 @@
import { Client, CommandInteraction, GuildMember, EmbedBuilder, TextChannel, AutocompleteInteraction } from 'discord.js';
import { getSign } from './util';
import { Counter, CounterConfiguration, CounterUserLink, db } from './db';
export async function getCounter(id: number) {
const counter = await db<Counter>('counters')
.select('value')
.where('id', id)
.first();
if (!counter) throw 'No such counter';
return counter.value;
}
export async function changeCounter(id: number, delta: number) {
const value = await getCounter(id);
const newValue = value + delta;
await db<Counter>('counters')
.where('id', id)
.update({
'value': newValue
});
return newValue;
}
export async function getCounterData(id: number) {
const counter = await db<Counter>('counters')
.select('*')
.where('id', id)
.first();
if (!counter) throw 'No such counter';
return counter;
}
export async function findCounter(key: string, guild: string) {
const counter = await db<Counter>('counters')
.select('*')
.where('key', key)
.where('guild', guild)
.first();
if (!counter) throw 'No such counter';
return counter;
}
export async function getCounterConfigRaw(counter: Counter) {
const configs = await db<CounterConfiguration>('counterConfigurations')
.select('configName', 'value')
.where('id', counter.id);
const config = new Map<string, string>();
configs.forEach(({ configName, value }) => {
config.set(configName, value);
});
// just the ugly way of life
config.set('emoji', counter.emoji);
return config;
}
export async function getCounterConfig(id: number, key: string) {
const config = await db<CounterConfiguration>('counterConfigurations')
.select('value')
.where('id', id)
.where('configName', key)
.first();
const valueStr = config?.value;
let value;
if (valueStr) {
value = parseConfig(valueStr, counterConfigs.get(key)!.type);
} else {
value = counterConfigs.get(key)!.default;
}
return value;
}
export async function setCounterConfig(id: number, option: string, value: string) {
// just the ugly way of life
if (option === 'emoji') {
await db<Counter>('counters')
.where('id', id)
.update({
'emoji': value
});
return;
}
const updated = await db<CounterConfiguration>('counterConfigurations')
.update({
value: value
})
.where('id', id)
.where('configName', option);
if (updated === 0) {
await db<CounterConfiguration>('counterConfigurations')
.insert({
'id': id,
'configName': option,
'value': value
});
}
}
export enum ConfigType {
Bool,
String,
Number
}
export function parseConfig(str: string, type: ConfigType.Bool): boolean
export function parseConfig(str: string, type: ConfigType.String): string
export function parseConfig(str: string, type: ConfigType.Number): number
export function parseConfig(str: string, type: ConfigType): boolean | string | number
export function parseConfig(str: string, type: ConfigType) {
switch(type) {
case ConfigType.Bool:
return str === 'true';
case ConfigType.String:
return str.trim();
case ConfigType.Number: {
const n = parseInt(str);
if (isNaN(n)) throw 'Not a valid number';
return n;
}
}
}
export function getOptions(type: ConfigType): string[] {
switch(type) {
case ConfigType.Bool:
return ['true', 'false'];
case ConfigType.String:
return [];
case ConfigType.Number:
return [];
}
}
export function toStringConfig(value: boolean | string | number, type: ConfigType): string {
switch(type) {
case ConfigType.Bool:
return value ? 'true' : 'false';
case ConfigType.String:
return (value as string);
case ConfigType.Number:
return (value as number).toString();
}
}
export const counterConfigs = new Map([
['anonymous', {
type: ConfigType.Bool,
default: false
}],
['messageTemplate', {
type: ConfigType.String,
default: '**%user** has %action the counter by **%amt**.'
}],
['messageTemplateIncrease', {
type: ConfigType.String,
default: 'null'
}],
['messageTemplateDecrease', {
type: ConfigType.String,
default: 'null'
}],
// these ones are fake and are just stand-ins for values defined inside the actual counters table
['emoji', {
type: ConfigType.String,
default: ''
}]
]);
export async function updateCounter(bot: Client, counter: Counter, value: number) {
const channel = await bot.channels.fetch(counter.channel) as TextChannel;
const messageID = counter.message;
const content = `[${counter.emoji}] x${value}`;
// bit janky
// yeah you don't say
try {
if (messageID) {
const message = await channel.messages.fetch(messageID);
if (!message) throw new Error();
await message.edit(content);
} else {
throw new Error();
}
} catch(err) {
const message = await channel.send(content);
message.pin();
await db<Counter>('counters')
.where('id', counter.id)
.update({
'message': message.id
});
}
}
export async function announceCounterUpdate(bot: Client, member: GuildMember, delta: number, counter: Counter, value: number) {
const channel = await bot.channels.fetch(counter.channel) as TextChannel;
let template = await getCounterConfig(counter.id, 'messageTemplate') as string;
const templateIncrease = await getCounterConfig(counter.id, 'messageTemplateIncrease') as string;
if (templateIncrease !== 'null' && delta > 0) template = templateIncrease;
const templateDecrease = await getCounterConfig(counter.id, 'messageTemplateDecrease') as string;
if (templateDecrease !== 'null' && delta < 0) template = templateDecrease;
const anonymous = await getCounterConfig(counter.id, 'anonymous') as boolean;
const embed = new EmbedBuilder()
//.setDescription(`**${member.toString()}** has ${delta > 0 ? 'increased' : 'decreased'} the counter by **${Math.abs(delta)}**.`)
.setDescription(
template
.replaceAll('%user', anonymous ? 'someone' : member.toString())
.replaceAll('%action', delta > 0 ? 'increased' : 'decreased')
.replaceAll('%amt', Math.abs(delta).toString())
.replaceAll('%total', value.toString())
)
.setTimestamp()
.setFooter({
text: `[${counter.emoji}] x${value}`
});
if (!anonymous) {
embed
.setAuthor({
name: member.displayName,
iconURL: member.displayAvatarURL()
})
.setColor(member.displayColor);
}
await channel.send({
embeds: [embed]
});
}
export async function changeCounterInteraction(interaction: CommandInteraction, member: GuildMember, amount: number, type: string) {
try {
const counter = await findCounter(type, member.guild.id);
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()
});
}
}
export async function counterAutocomplete(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);
}
const foundCounters = await query;
await interaction.respond(
foundCounters.map(choice => ({ name: choice.emoji, value: choice.key }))
);
}

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,55 @@ 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,
untradable: boolean,
}
export interface ItemInventory {
user: string,
item: number,
quantity: number
}
export interface CraftingStationCooldown {
station: string,
user: string,
usedAt: number
}
export interface CustomCraftingRecipe {
id: number,
guild: string,
station: string
}
export interface CustomCraftingRecipeItem {
id: number,
item: number,
quantity: number,
type: 'input' | 'output' | 'requirement'
}
export interface Session {
id: string,
tokenType: string,
accessToken: string,
refreshToken: string,
expiresAt: number,
}
export interface InitHealth {
user: string,
}
export interface InvincibleUser {
user: string,
since: number,
}
export interface ItemBehavior {
item: number,
behavior: string,
value?: number
}

106
src/lib/rpg/behaviors.ts Normal file
View File

@ -0,0 +1,106 @@
import { giveItem, type Item, isDefaultItem, formatItems } from './items';
import { Either, Left, Right } from '../util';
import { ItemBehavior, db } from '../db';
import { BLOOD_ITEM, dealDamage } from './pvp';
interface BehaviorContext {
value: number | undefined,
}
type ItemContext = BehaviorContext & {
item: Item,
user: string,
}
type AttackContext = ItemContext & {
target: string,
damage: number,
}
export interface Behavior {
name: string,
description: string,
type: 'plain' | 'weapon' | 'consumable',
// make it look fancy
format?: (value?: number) => string,
// triggers upon use
// for 'weapons', this is on attack
// for 'consumable' and `plain`, this is on use
// returns Left upon success with an optional message, the reason for failure otherwise (Right)
onUse?: (ctx: ItemContext) => Promise<Either<string | null, string>>,
// triggers upon `weapons` attack
// returns the new damage value if applicable upon success and an optional message (Left), the reason for failure otherwise (Right)
onAttack?: (ctx: AttackContext) => Promise<Either<{damage?: number, message?: string}, string>>,
}
const defaultFormat = (behavior: Behavior, value?: number) => `${behavior.name}${value ? ' ' + value.toString() : ''}`;
export const behaviors: Behavior[] = [
{
name: 'heal',
description: 'Heals the user by `value`',
type: 'consumable',
async onUse(ctx) {
if (!ctx.value) return new Right('A value is required for this behavior');
await dealDamage(ctx.user, -Math.floor(ctx.value));
return new Left(`You were healed by ${formatItems(BLOOD_ITEM, ctx.value)}!`);
},
},
{
name: 'damage',
description: 'Damages the user by `value',
type: 'consumable',
async onUse(ctx) {
if (!ctx.value) return new Right('A value is required for this behavior');
await dealDamage(ctx.user, Math.floor(ctx.value));
return new Left(`You were damaged by ${formatItems(BLOOD_ITEM, ctx.value)}!`);
},
},
{
name: 'random_up',
description: 'Randomizes the attack value up by a maximum of `value`',
type: 'weapon',
format: (value) => `random +${value}`,
async onAttack(ctx) {
if (!ctx.value) return new Right('A value is required for this behavior');
return new Left({ damage: ctx.damage + Math.round(Math.random() * ctx.value) });
},
},
{
name: 'random_down',
description: 'Randomizes the attack value down by a maximum of `value`',
type: 'weapon',
format: (value) => `random -${value}`,
async onAttack(ctx) {
if (!ctx.value) return new Right('A value is required for this behavior');
return new Left({ damage: ctx.damage - Math.round(Math.random() * ctx.value) });
},
},
{
name: 'lifesteal',
description: 'Gain blood by stabbing your foes, scaled by `value`x',
type: 'weapon',
format: (value) => `lifesteal ${value}x`,
async onAttack(ctx) {
if (!ctx.value) return new Right('A value is required for this behavior');
const amt = Math.floor(ctx.damage * ctx.value);
giveItem(ctx.user, BLOOD_ITEM, amt);
return new Left({
message: `You gained ${formatItems(BLOOD_ITEM, amt)} from your target!`
});
},
}
];
export async function getBehaviors(item: Item) {
if (isDefaultItem(item)) {
return item.behaviors || [];
} else {
return await db<ItemBehavior>('itemBehaviors')
.where('item', item.id);
}
}
export function getBehavior(behavior: string) {
return behaviors.find(b => b.name === behavior);
}
export function formatBehavior(behavior: Behavior, value: number | undefined) {
return behavior.format ? behavior.format(value) : defaultFormat(behavior, value);
}

406
src/lib/rpg/counter.ts Normal file
View File

@ -0,0 +1,406 @@
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';
import { resetInvincible } from './pvp';
import { Autocomplete } from '../autocomplete';
export async function getCounter(id: number) {
const counter = await db<Counter>('counters')
.select('value')
.where('id', id)
.first();
if (!counter) throw 'No such counter';
return counter.value;
}
export async function changeCounter(id: number, delta: number) {
const value = await getCounter(id);
const newValue = value + delta;
await db<Counter>('counters')
.where('id', id)
.update({
'value': newValue
});
return newValue;
}
export async function getCounterData(id: number) {
const counter = await db<Counter>('counters')
.select('*')
.where('id', id)
.first();
if (!counter) throw 'No such counter';
return counter;
}
export async function findCounter(key: string, guild: string) {
const counter = await db<Counter>('counters')
.select('*')
.where('key', key)
.where('guild', guild)
.first();
if (!counter) throw 'No such counter';
return counter;
}
export async function getCounterConfigRaw(counter: Counter) {
const configs = await db<CounterConfiguration>('counterConfigurations')
.select('configName', 'value')
.where('id', counter.id);
const config = new Map<string, string>();
configs.forEach(({ configName, value }) => {
config.set(configName, value);
});
// just the ugly way of life
config.set('emoji', counter.emoji);
return config;
}
export async function getCounterConfig(id: number, key: string) {
const config = await db<CounterConfiguration>('counterConfigurations')
.select('value')
.where('id', id)
.where('configName', key)
.first();
const valueStr = config?.value;
let value;
if (valueStr) {
value = parseConfig(valueStr, counterConfigs.get(key)!.type);
} else {
value = counterConfigs.get(key)!.default;
}
return value;
}
export async function setCounterConfig(counter: Counter, option: string, value: string) {
// just the ugly way of life
if (option === 'emoji' && !counter.linkedItem) {
await db<Counter>('counters')
.where('id', counter.id)
.update({
emoji: value
});
return;
}
const updated = await db<CounterConfiguration>('counterConfigurations')
.update({
value: value
})
.where('id', counter.id)
.where('configName', option);
if (updated === 0) {
await db<CounterConfiguration>('counterConfigurations')
.insert({
id: counter.id,
configName: option,
value: value
});
}
}
export enum ConfigType {
Bool,
String,
Number
}
export function parseConfig(str: string, type: ConfigType.Bool): boolean
export function parseConfig(str: string, type: ConfigType.String): string
export function parseConfig(str: string, type: ConfigType.Number): number
export function parseConfig(str: string, type: ConfigType): boolean | string | number
export function parseConfig(str: string, type: ConfigType) {
switch(type) {
case ConfigType.Bool:
return str === 'true';
case ConfigType.String:
return str.trim();
case ConfigType.Number: {
const n = parseInt(str);
if (isNaN(n)) throw 'Not a valid number';
return n;
}
}
}
export function getOptions(type: ConfigType): string[] {
switch(type) {
case ConfigType.Bool:
return ['true', 'false'];
case ConfigType.String:
return [];
case ConfigType.Number:
return [];
}
}
export function toStringConfig(value: boolean | string | number, type: ConfigType): string {
switch(type) {
case ConfigType.Bool:
return value ? 'true' : 'false';
case ConfigType.String:
return (value as string);
case ConfigType.Number:
return (value as number).toString();
}
}
export const counterConfigs = new Map([
['anonymous', {
type: ConfigType.Bool,
default: false
}],
['messageTemplate', {
type: ConfigType.String,
default: '**%user** has %action the counter by **%amt**.'
}],
['messageTemplateIncrease', {
type: ConfigType.String,
default: 'null'
}],
['messageTemplateDecrease', {
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', {
type: ConfigType.String,
default: ''
}]
]);
export async function updateCounter(bot: Client, counter: Counter, value: number) {
const channel = await bot.channels.fetch(counter.channel) as TextChannel;
const messageID = counter.message;
const content = `[${counter.emoji}] x${value}`;
// bit janky
// yeah you don't say
try {
if (messageID) {
const message = await channel.messages.fetch(messageID);
if (!message) throw new Error();
await message.edit(content);
} else {
throw new Error();
}
} catch(err) {
const message = await channel.send(content);
message.pin();
await db<Counter>('counters')
.where('id', counter.id)
.update({
message: message.id
});
}
}
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;
const templateIncrease = await getCounterConfig(counter.id, 'messageTemplateIncrease') as string;
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;
const embed = new EmbedBuilder()
//.setDescription(`**${member.toString()}** has ${delta > 0 ? 'increased' : 'decreased'} the counter by **${Math.abs(delta)}**.`)
.setDescription(
template
.replaceAll('%user', anonymous ? 'someone' : member.toString())
.replaceAll('%action', delta > 0 ? (linked ? 'put into' : 'increased') : (linked ? 'taken from' : 'decreased'))
.replaceAll('%amt', Math.abs(delta).toString())
.replaceAll('%total', value.toString())
)
.setTimestamp()
.setFooter({
text: `[${counter.emoji}] x${value}`
});
if (!anonymous) {
embed
.setAuthor({
name: member.displayName,
iconURL: member.displayAvatarURL()
})
.setColor(member.displayColor);
}
await channel.send({
embeds: [embed]
});
}
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();
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;
}
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 canUse = await canUseCounter(member.user, counter, amount, linked);
if (!canUse) {
return interaction.followUp(`You cannot **${amount > 0 ? (linked ? 'put' : 'produce') : (linked ? 'take' : 'consume')}** ${counter.emoji}.`);
}
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)!`);
}
let item;
let newInv;
if (linked) {
const inv = await getItemQuantity(member.id, counter.linkedItem!);
item = (await getItem(counter.linkedItem!))!;
// 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);
}
await resetInvincible(member.id);
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): Autocomplete {
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;
return foundCounters.map(choice => ({ name: `${choice.emoji} ${choice.key}`, value: choice.key }));
};
}
export const counterAutocomplete = counterAutocompleteBuilder(false);
export const linkedCounterAutocomplete = counterAutocompleteBuilder(true);

View File

@ -0,0 +1,127 @@
import { pickRandom } from '../util';
import { DefaultItems, Item, Items, formatItem, formatItems, formatItemsArray, getDefaultItem, getItemQuantity } from './items';
export interface CraftingStation {
key: string,
name: string,
verb?: string,
description: string,
emoji: string,
requires?: Item,
// in seconds
cooldown?: number,
formatRecipe?: (inputs: Items[], requirements: Items[], outputs: Items[], disableBold?: boolean) => string,
manipulateResults?: (outputs: Items[]) => Items[]
}
export function getStation(key: string) {
return craftingStations.find(station => station.key === key);
}
export const defaultVerb = 'Crafted';
const rollBunch = (outputs: Items[]) => {
const totalItems = outputs.reduce((a, b) => a + b.quantity, 0);
// grab from 1/3 to the entire pool, ensure it never goes below 1
const rolledItems = Math.max(Math.round(totalItems/3 + Math.random() * totalItems*2/3), 1);
const res: Items[] = [];
for (let i = 0; i < rolledItems; i++) {
const rolled = pickRandom(outputs);
const r = res.find(r => r.item.id === rolled.item.id);
if (r) {
if (r.quantity === rolled.quantity) {
// don't roll more than can be rolled
i--;
} else {
r.quantity = r.quantity + 1;
}
} else {
res.push({ item: rolled.item, quantity: 1 });
}
}
return res;
};
const formatMaybeCountable = (inputs: Items[], requirements: Items[], outputs: Items[], disableBold = false) =>
`${inputs.length > 0 ? formatItemsArray(inputs, disableBold) : ''} ${requirements.length > 0 ? `w/ ${formatItemsArray(requirements, disableBold)}: ` : ''}${outputs.map(i => formatItems(i.item, i.quantity, disableBold) + '?').join(' ')}`;
const formatMaybe = (inputs: Items[], requirements: Items[], outputs: Items[], disableBold = false) =>
`${inputs.length > 0 ? formatItemsArray(inputs, disableBold) : ''} ${requirements.length > 0 ? `w/ ${formatItemsArray(requirements, disableBold)} ` : ''}=> ${outputs.map(i => formatItem(i.item, disableBold) + '?').join(' ')}`;
export const craftingStations: CraftingStation[] = [
{
key: 'forage',
name: 'Forage',
verb: 'Foraged',
description: 'Pick up various sticks and stones from the forest',
emoji: '🌲',
cooldown: 60 * 5,
formatRecipe: formatMaybeCountable,
manipulateResults: rollBunch
},
{
key: 'hand',
name: 'Hand',
verb: 'Made',
description: 'You can use your hands to make a small assortment of things',
emoji: '✋'
},
{
key: 'workbench',
name: 'Workbench',
description: 'A place for you to work with tools, for simple things',
emoji: '🛠️',
requires: getDefaultItem(DefaultItems.WORKBENCH)
},
{
key: 'fishing',
name: 'Fishing',
verb: 'Fished up',
description: 'fish gaming wednesday',
emoji: '🎣',
cooldown: 60 * 60 * 2,
requires: getDefaultItem(DefaultItems.FISHING_ROD),
formatRecipe: formatMaybe,
// weighted random
manipulateResults: (outputs) => {
const pool: Item[] = [];
for (const out of outputs) {
for (let i = 0; i < out.quantity; i++) {
pool.push(out.item);
}
}
return [{ item: pickRandom(pool), quantity: 1 }];
}
},
{
key: 'mining',
name: 'Mining',
verb: 'Mined',
description: 'mine diamonds',
emoji: '⛏️',
cooldown: 60 * 30,
requires: getDefaultItem(DefaultItems.MINESHAFT),
formatRecipe: formatMaybeCountable,
manipulateResults: rollBunch,
},
{
key: 'smelting',
name: 'Smelting',
verb: 'Smelt',
description: 'Smelt ores, minerals, food, whatever you please',
emoji: '🔥',
cooldown: 30,
requires: getDefaultItem(DefaultItems.FURNACE),
},
];
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 function verb(station: CraftingStation) {
return station.verb || defaultVerb;
}

427
src/lib/rpg/items.ts Normal file
View File

@ -0,0 +1,427 @@
import { AutocompleteInteraction } from 'discord.js';
import { CustomItem, ItemBehavior, ItemInventory, db } from '../db';
import { Autocomplete } from '../autocomplete';
// uses negative IDs
export type DefaultItem = Omit<CustomItem, 'guild'> & { behaviors?: Omit<ItemBehavior, 'item'>[] };
export type Item = DefaultItem | CustomItem;
export interface Items {
item: Item,
quantity: number
}
export enum DefaultItems {
COIN = 1,
WORKBENCH = 2,
PEBBLE = 3,
TWIG = 4,
APPLE = 5,
BERRIES = 6,
LOG = 7,
AXE = 8,
BLOOD = 9,
BAIT = 10,
FISHING_ROD = 11,
CARP = 12,
PUFFERFISH = 13,
EXOTIC_FISH = 14,
SHOVEL = 15,
DIRT = 16,
MINESHAFT = 17,
PICKAXE = 18,
IRON_PICKAXE = 19,
COAL = 20,
IRON_ORE = 21,
IRON_INGOT = 22,
DIAMOND = 23,
RUBY = 24,
EMERALD = 25,
FURNACE = 26,
FRIED_FISH = 27,
}
export const defaultItems: DefaultItem[] = [
{
id: -1,
name: 'Coin',
emoji: '🪙',
type: 'plain',
maxStack: 9999,
untradable: false
},
{
id: -2,
name: 'Workbench',
description: 'A place for you to work with tools, for simple things',
emoji: '🛠️',
type: 'plain',
maxStack: 1,
untradable: false
},
{
id: -3,
name: 'Pebble',
description: 'If you get 5 of them you will instantly ! !!!',
emoji: '🪨',
type: 'plain',
maxStack: 64,
untradable: false
},
{
id: -4,
name: 'Twig',
description: 'Just a tiny bit of wood',
emoji: '🌿',
type: 'plain',
maxStack: 64,
untradable: false
},
{
id: -5,
name: 'Apple',
description: 'A forager\'s snack',
emoji: '🍎',
type: 'consumable',
maxStack: 16,
untradable: false,
behaviors: [{ behavior: 'heal', value: 10 }],
},
{
id: -6,
name: 'Berries',
description: 'A little treat for the road!',
emoji: '🍓',
type: 'consumable',
maxStack: 16,
untradable: false,
behaviors: [{ behavior: 'heal', value: 4 }],
},
{
id: -7,
name: 'Log',
description: '㏒',
emoji: '🪵',
type: 'plain',
maxStack: 64,
untradable: false
},
{
id: -8,
name: 'Axe',
description: 'You could chop trees with this. Or commit murder! The choice is up to you',
emoji: '🪓',
type: 'weapon',
maxStack: 1,
untradable: false
},
{
id: -9,
name: 'Blood',
description: 'ow',
emoji: '🩸',
type: 'plain',
maxStack: 50,
untradable: false
},
{
id: -10,
name: 'Bait',
description: 'I guess you could eat this.',
emoji: '🪱',
type: 'consumable',
maxStack: 128,
untradable: false,
behaviors: [{ behavior: 'heal', value: 1 }],
},
{
id: -11,
name: 'Fishing Rod',
description: 'Give a man a fish, and he will eat for a day',
emoji: '🎣',
type: 'plain',
maxStack: 1,
untradable: false
},
{
id: -12,
name: 'Carp',
description: 'wow',
emoji: '🐟️',
type: 'plain',
maxStack: 16,
untradable: false
},
{
id: -13,
name: 'Pufferfish',
description: 'yummy!',
emoji: '🐡',
type: 'plain',
maxStack: 16,
untradable: false
},
{
id: -14,
name: 'Exotic Fish',
description: 'lucky!',
emoji: '🐠',
type: 'plain',
maxStack: 16,
untradable: false,
},
{
id: -15,
name: 'Shovel',
description: 'Did you know there\'s no shovel emoji',
emoji: '♠️',
type: 'plain',
maxStack: 1,
untradable: false,
},
{
id: -16,
name: 'Dirt',
description: 'https://media.discordapp.net/attachments/819472665291128873/1081454188325785650/ezgif-2-5ccc7dedf8.gif',
emoji: '🟫',
type: 'consumable',
maxStack: 64,
untradable: false,
},
{
id: -17,
name: 'Mineshaft',
description: 'A place for you to mine ores and minerals!',
emoji: '⛏️',
type: 'plain',
maxStack: 1,
untradable: true
},
{
id: -18,
name: 'Pickaxe',
description: 'Basic mining equipment',
emoji: '⛏️',
type: 'plain',
maxStack: 8,
untradable: false
},
{
id: -19,
name: 'Iron Pickaxe',
description: 'More durable and strong mining equipment',
emoji: '⚒️',
type: 'plain',
maxStack: 8,
untradable: false
},
{
id: -20,
name: 'Coal',
description: 'Fuel, NOT EDIBLE',
emoji: '◾️',
type: 'plain',
maxStack: 128,
untradable: false
},
{
id: -21,
name: 'Iron Ore',
description: 'Unsmelted iron',
emoji: '◽️',
type: 'plain',
maxStack: 128,
untradable: false
},
{
id: -22,
name: 'Iron Ingot',
description: 'A sturdy material',
emoji: '◻️',
type: 'plain',
maxStack: 128,
untradable: false
},
{
id: -23,
name: 'Diamond',
description: 'Shiny rock!',
emoji: '💎',
type: 'plain',
maxStack: 128,
untradable: false
},
{
id: -24,
name: 'Ruby',
description: 'Reference to the progarmiing......g.',
emoji: '🔻',
type: 'plain',
maxStack: 128,
untradable: false
},
{
id: -25,
name: 'Emerald',
description: 'A currency in some other world',
emoji: '🟩',
type: 'plain',
maxStack: 128,
untradable: false
},
{
id: -26,
name: 'Furnace',
description: 'A smeltery for your own needs',
emoji: '🔥',
type: 'plain',
maxStack: 1,
untradable: false
},
{
id: -27,
name: 'Fried Fish',
description: 'A very nice and filling meal',
emoji: '🍱',
type: 'consumable',
maxStack: 16,
untradable: false,
behaviors: [{ behavior: 'heal', value: 35 }],
},
];
export function getDefaultItem(id: DefaultItems): Item
export function getDefaultItem(id: number): Item | undefined {
return defaultItems.find(item => Math.abs(item.id) === Math.abs(id));
}
export async function getItem(id: number): Promise<Item | undefined> {
if (id >= 0) {
return await getCustomItem(id);
} else {
return getDefaultItem(id);
}
}
export async function getCustomItem(id: number) {
return await db<CustomItem>('customItems')
.where('id', id)
.first();
}
export async function getItemQuantity(user: string, itemID: number): Promise<ItemInventory> {
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): Promise<ItemInventory> {
const storedItem = await db<ItemInventory>('itemInventories')
.where('user', user)
.where('item', item.id)
.first();
let inv;
if (storedItem) {
if (storedItem.quantity + quantity === 0 && item.id !== DefaultItems.BLOOD) { // let blood show as 0x
await db<ItemInventory>('itemInventories')
.delete()
.where('user', user)
.where('item', item.id);
return {
user: user,
item: item.id,
quantity: 0
};
}
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: item.id,
quantity: Math.min(quantity, getMaxStack(item)),
})
.returning('*');
}
return inv[0];
}
export function getMaxStack(item: Item) {
return item.type === 'weapon' ? 1 : item.maxStack;
}
export function isDefaultItem(item: Item): item is DefaultItem {
return item.id < 0;
}
export function formatItem(item: Item | undefined, disableBold = false) {
if (!item) return disableBold ? '? MISSINGNO' : '? **MISSINGNO**';
return disableBold ? `${item.emoji} ${item.name}` : `${item.emoji} **${item.name}**`;
}
export function formatItems(item: Item | undefined, quantity: number, disableBold = false) {
return `${quantity}x ${formatItem(item, disableBold)}`;
}
export function formatItemsArray(items: Items[], disableBold = false) {
if (items.length === 0) return disableBold ? 'nothing' : '**nothing**';
return items.map(i => formatItems(i.item, i.quantity, disableBold)).join(' ');
}
function createItemAutocomplete(onlyCustom: boolean, filterType: 'plain' | 'weapon' | 'consumable' | null, inventory: boolean = false): Autocomplete {
return async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();
const itemQuery = 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);
if (filterType) itemQuery.where('type', filterType);
if (inventory) itemQuery
.innerJoin('itemInventories', 'itemInventories.item', '=', 'customItems.id')
.where('itemInventories.user', '=', interaction.member!.user.id)
.where('itemInventories.quantity', '>', '0');
const customItems = await itemQuery;
let foundDefaultItems = defaultItems.filter(item => item.name.toUpperCase().includes(focused.toUpperCase()));
if (filterType) foundDefaultItems = foundDefaultItems.filter(i => i.type === filterType);
if (inventory) foundDefaultItems = (await Promise.all(foundDefaultItems.map(async i => ({...i, inv: await getItemQuantity(interaction.member!.user.id, i.id)})))).filter(i => i.inv.quantity > 0);
let items;
if (onlyCustom) {
items = customItems;
} else {
items = [...foundDefaultItems, ...customItems];
}
return items.map(choice => ({ name: `${choice.emoji} ${choice.name}`, value: choice.id.toString() }));
};
}
export const itemAutocomplete = createItemAutocomplete(false, null);
export const customItemAutocomplete = createItemAutocomplete(true, null);
export const plainAutocomplete = createItemAutocomplete(false, 'plain');
export const weaponAutocomplete = createItemAutocomplete(false, 'weapon');
export const consumableAutocomplete = createItemAutocomplete(false, 'consumable');
export const plainInventoryAutocomplete = createItemAutocomplete(false, 'plain', true);
export const weaponInventoryAutocomplete = createItemAutocomplete(false, 'weapon', true);
export const consumableInventoryAutocomplete = createItemAutocomplete(false, 'consumable', true);

83
src/lib/rpg/pvp.ts Normal file
View File

@ -0,0 +1,83 @@
import { InitHealth, InvincibleUser, ItemInventory, db } from '../db';
import { DefaultItems, getDefaultItem, giveItem, getItemQuantity, formatItems } from './items';
import { Client } from 'discord.js';
export const BLOOD_ID = -DefaultItems.BLOOD;
export const BLOOD_ITEM = getDefaultItem(BLOOD_ID);
export const MAX_HEALTH = BLOOD_ITEM.maxStack;
const BLOOD_GAIN_PER_HOUR = 2;
export const INVINCIBLE_TIMER = 1_000 * 60 * 30;
export async function initHealth(user: string) {
const isInitialized = await db<InitHealth>('initHealth')
.where('user', user)
.first();
if (!isInitialized) {
giveItem(user, BLOOD_ITEM, MAX_HEALTH);
await db<InitHealth>('initHealth').insert({ user });
}
}
export async function getInvincibleMs(user: string) {
const invincible = await db<InvincibleUser>('invincibleUsers')
.where('user', user)
.first();
if (!invincible) return 0;
return Math.max((invincible.since + INVINCIBLE_TIMER) - Date.now(), 0);
}
export async function resetInvincible(user: string) {
await db<InvincibleUser>('invincibleUsers')
.where('user', user)
.delete();
}
export async function applyInvincible(user: string) {
const exists = await db<InvincibleUser>('invincibleUsers')
.where('user', user)
.first();
if (exists) {
await db<InvincibleUser>('invincibleUsers')
.update({ since: Date.now() });
} else {
await db<InvincibleUser>('invincibleUsers')
.insert({ since: Date.now(), user });
}
}
export async function getHealth(user: string) {
await initHealth(user);
return await getItemQuantity(user, BLOOD_ID);
}
export async function dealDamage(user: string, dmg: number) {
await initHealth(user);
await applyInvincible(user);
return await giveItem(user, BLOOD_ITEM, -dmg);
}
async function healthCron(bot: Client) {
await db<ItemInventory>('itemInventories')
.where('item', BLOOD_ID)
.update({
quantity: db.raw('MIN(quantity + ?, ?)', [BLOOD_GAIN_PER_HOUR, MAX_HEALTH])
});
const debtedUsers = await db<ItemInventory>('itemInventories')
.select('user', 'quantity')
.where('quantity', '<', '0');
for (const debted of debtedUsers) {
const user = await bot.users.fetch(debted.user);
if (!user) continue;
await user.send(`${formatItems(BLOOD_ITEM, debted.quantity)} You are bleeding out to death`);
}
}
export function init(bot: Client) {
healthCron(bot);
setInterval(() => healthCron(bot), 1_000 * 60 * 60);
}

301
src/lib/rpg/recipes.ts Normal file
View File

@ -0,0 +1,301 @@
import { CustomCraftingRecipe, CustomCraftingRecipeItem, db } from '../db';
import { getStation } from './craftingStations';
import { DefaultItems, Items, formatItemsArray, getDefaultItem, getItem } from './items';
export interface DefaultRecipe {
id: number,
station: string,
inputs: Items[],
requirements: Items[],
outputs: Items[]
}
export interface ResolvedCustomRecipe {
id: number,
guild: string,
station: string,
inputs: Items[],
requirements: Items[],
outputs: Items[]
}
export type Recipe = DefaultRecipe | ResolvedCustomRecipe
export const defaultRecipes: DefaultRecipe[] = [
{
id: -1,
station: 'forage',
inputs: [],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 4 },
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 2 },
{ item: getDefaultItem(DefaultItems.BERRIES), quantity: 2 }
]
},
{
id: -2,
station: 'hand',
inputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 2 },
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 2 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.WORKBENCH), quantity: 1 }
]
},
{
id: -3,
station: 'forage',
inputs: [],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 2 },
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 4 },
{ item: getDefaultItem(DefaultItems.APPLE), quantity: 1 }
]
},
{
id: -4,
station: 'forage',
inputs: [],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 1 },
{ item: getDefaultItem(DefaultItems.COIN), quantity: 1 },
{ item: getDefaultItem(DefaultItems.APPLE), quantity: 4 },
{ item: getDefaultItem(DefaultItems.BERRIES), quantity: 6 },
]
},
{
id: -5,
station: 'forage',
inputs: [],
requirements: [
{ item: getDefaultItem(DefaultItems.AXE), quantity: 1 },
],
outputs: [
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 1 },
{ item: getDefaultItem(DefaultItems.LOG), quantity: 3 },
]
},
{
id: -6,
station: 'workbench',
inputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 4 },
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 2 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.AXE), quantity: 1 },
]
},
{
id: -7,
station: 'forage',
inputs: [],
requirements: [
{ item: getDefaultItem(DefaultItems.AXE), quantity: 1 },
],
outputs: [
{ item: getDefaultItem(DefaultItems.BLOOD), quantity: 6 },
]
},
{
id: -8,
station: 'fishing',
inputs: [
{ item: getDefaultItem(DefaultItems.BAIT), quantity: 1 }
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.CARP), quantity: 12 },
{ item: getDefaultItem(DefaultItems.PUFFERFISH), quantity: 4 },
{ item: getDefaultItem(DefaultItems.EXOTIC_FISH), quantity: 1 },
]
},
{
id: -9,
station: 'workbench',
inputs: [
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 2 },
{ item: getDefaultItem(DefaultItems.BAIT), quantity: 1 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.FISHING_ROD), quantity: 1 }
]
},
{
id: -10,
station: 'forage',
inputs: [],
requirements: [
{ item: getDefaultItem(DefaultItems.SHOVEL), quantity: 1 },
],
outputs: [
{ item: getDefaultItem(DefaultItems.BAIT), quantity: 4 },
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 1 },
{ item: getDefaultItem(DefaultItems.DIRT), quantity: 3 },
],
},
{
id: -11,
station: 'workbench',
inputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 3 },
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 2 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.SHOVEL), quantity: 1 },
]
},
{
id: -12,
station: 'hand',
inputs: [
{ item: getDefaultItem(DefaultItems.DIRT), quantity: 4 },
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 4 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.MINESHAFT), quantity: 1 },
]
},
{
id: -13,
station: 'mining',
inputs: [
{ item: getDefaultItem(DefaultItems.PICKAXE), quantity: 1 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 10 },
{ item: getDefaultItem(DefaultItems.COAL), quantity: 5 },
{ item: getDefaultItem(DefaultItems.IRON_ORE), quantity: 5 },
]
},
{
id: -14,
station: 'mining',
inputs: [
{ item: getDefaultItem(DefaultItems.IRON_PICKAXE), quantity: 1 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 10 },
{ item: getDefaultItem(DefaultItems.COAL), quantity: 5 },
{ item: getDefaultItem(DefaultItems.IRON_ORE), quantity: 5 },
{ item: getDefaultItem(DefaultItems.DIAMOND), quantity: 1 },
{ item: getDefaultItem(DefaultItems.EMERALD), quantity: 1 },
{ item: getDefaultItem(DefaultItems.RUBY), quantity: 1 },
]
},
{
id: -15,
station: 'workbench',
inputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 4 },
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 3 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.PICKAXE), quantity: 1 },
]
},
{
id: -16,
station: 'workbench',
inputs: [
{ item: getDefaultItem(DefaultItems.IRON_INGOT), quantity: 4 },
{ item: getDefaultItem(DefaultItems.TWIG), quantity: 3 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.IRON_PICKAXE), quantity: 1 },
]
},
{
id: -17,
station: 'smelting',
inputs: [
{ item: getDefaultItem(DefaultItems.IRON_ORE), quantity: 2 },
{ item: getDefaultItem(DefaultItems.COAL), quantity: 1 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.IRON_INGOT), quantity: 1 },
]
},
{
id: -18,
station: 'smelting',
inputs: [
{ item: getDefaultItem(DefaultItems.CARP), quantity: 1 },
{ item: getDefaultItem(DefaultItems.COAL), quantity: 1 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.FRIED_FISH), quantity: 1 },
]
},
{
id: -19,
station: 'workbench',
inputs: [
{ item: getDefaultItem(DefaultItems.PEBBLE), quantity: 4 },
{ item: getDefaultItem(DefaultItems.COAL), quantity: 1 },
],
requirements: [],
outputs: [
{ item: getDefaultItem(DefaultItems.FURNACE), quantity: 1 },
]
}
];
export function getDefaultRecipe(id: number): DefaultRecipe | undefined {
return defaultRecipes.find(recipe => recipe.id === id);
}
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: 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

@ -23,4 +23,21 @@ export async function writeTmpFile(data: string | Buffer, filename?: string, ext
const path = join(tmpdir(), file);
await fsp.writeFile(path, data);
return path;
}
}
export function pickRandom<T>(list: T[]): T {
return list[Math.floor(Math.random() * list.length)];
}
// WE OUT HERE
export type Either<L,R> = Left<L> | Right<R>
export class Left<L> {
constructor(private readonly value: L) {}
public getValue() { return this.value; }
}
export class Right<R> {
constructor(private readonly value: R) {}
public getValue() { return this.value; }
}

22
src/types/index.d.ts vendored
View File

@ -1,7 +1,25 @@
import { Collection } from 'discord.js';
import { Collection, SlashCommandBuilder, CommandInteraction, Client } from 'discord.js';
import { Autocomplete } from '../lib/autocomplete';
export interface Command {
data: Pick<SlashCommandBuilder, 'toJSON' | 'name'>,
execute: (interaction: CommandInteraction) => Promise<unknown>,
autocomplete?: Autocomplete,
onClientReady?: (client: Client) => Promise<unknown>,
serverWhitelist?: string[],
}
export interface Config {
token: string,
sitePort: number,
siteURL: string,
clientId: string,
clientSecret: string,
}
declare module 'discord.js' {
export interface Client {
commands: Collection<unknown, any>;
config: Config,
commands: Collection<string, Command>;
}
}

117
src/web/oauth2.ts Normal file
View File

@ -0,0 +1,117 @@
import type { Response } from 'express';
import type { IncomingHttpHeaders } from 'http';
import * as log from '../lib/log';
import { Cookie, parse as parseCookie } from 'tough-cookie';
import uid from 'uid-safe';
import { Client, RESTPostOAuth2AccessTokenResult, RESTPostOAuth2AccessTokenURLEncodedData, RESTPostOAuth2RefreshTokenURLEncodedData, Routes } from 'discord.js';
import got from 'got';
import { Session, db } from '../lib/db';
export const DISCORD_ENDPOINT = 'https://discord.com/api/v10';
const UID_BYTE_LENGTH = 18;
const UID_STRING_LENGTH = 24; // why?
const COOKIE_KEY = 'PHPSESSID';
const COOKIE_EXPIRES = 1_000 * 60 * 60 * 24 * 365;
export async function getSessionString(cookieStr: string) {
const cookies = cookieStr.split('; ').map(s => parseCookie(s)!).filter(c => c !== null);
const sessionCookie = cookies.find(c => c.key === COOKIE_KEY);
if (!sessionCookie || sessionCookie.value.length !== UID_STRING_LENGTH) {
return await uid(UID_BYTE_LENGTH);
} else {
return sessionCookie.value;
}
}
export function updateCookie(res: Response, sessionId: string) {
const cookie = new Cookie({
key: COOKIE_KEY,
value: sessionId,
expires: new Date(Date.now() + COOKIE_EXPIRES),
sameSite: 'strict'
});
res.setHeader('Set-Cookie', cookie.toString());
}
export async function getToken(bot: Client, code: string) {
try {
return await got.post(DISCORD_ENDPOINT + Routes.oauth2TokenExchange(), {
form: {
client_id: bot.config.clientId,
client_secret: bot.config.clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: bot.config.siteURL,
} satisfies RESTPostOAuth2AccessTokenURLEncodedData
// if you're looking to change this then you are blissfully unaware of the past
// and have learnt 0 lessons
}).json() as RESTPostOAuth2AccessTokenResult;
} catch(err) {
log.error(err);
return;
}
}
async function refreshToken(bot: Client, sessionId: string, refreshToken: string) {
let resp;
try {
resp = await got.post(DISCORD_ENDPOINT + Routes.oauth2TokenExchange(), {
form: {
client_id: bot.config.clientId,
client_secret: bot.config.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
} satisfies RESTPostOAuth2RefreshTokenURLEncodedData
}).json() as RESTPostOAuth2AccessTokenResult;
} catch(err) {
log.error(err);
return;
}
const sessionData = {
tokenType: resp.token_type,
accessToken: resp.access_token,
refreshToken: resp.refresh_token,
expiresAt: Date.now() + resp.expires_in * 1000,
};
return (await db<Session>('sessions')
.where('id', sessionId)
.update(sessionData)
.returning('*'))[0];
}
export async function getSession(bot: Client, headers: IncomingHttpHeaders) {
const cookie = headers['cookie'];
if (!cookie) return;
const sessionStr = await getSessionString(cookie);
const session = await db<Session>('sessions')
.where('id', sessionStr)
.first();
if (!session) return;
if (Date.now() < session.expiresAt) return session;
const newSession = refreshToken(bot, session.id, session.refreshToken);
return newSession;
}
export async function setSession(sessionId: string, sessionData: Omit<Session, 'id'>) {
const session = await db<Session>('sessions')
.where('id', sessionId)
.first();
if (session) {
await db<Session>('sessions')
.where('id', sessionId)
.update(sessionData);
} else {
await db<Session>('sessions')
.insert({id: sessionId, ...sessionData})
.returning('*');
}
}

32
src/web/user.ts Normal file
View File

@ -0,0 +1,32 @@
import { Session } from '../lib/db';
import * as log from '../lib/log';
import got from 'got';
import { APIPartialGuild, APIUser, Routes } from 'discord.js';
import { DISCORD_ENDPOINT } from './oauth2';
export async function getUser(session: Session | undefined) {
if (!session) return null;
try {
return await got(DISCORD_ENDPOINT + Routes.user(), {
headers: {
authorization: `${session.tokenType} ${session.accessToken}`
}
}).json() as APIUser;
} catch(err) {
log.error(err);
return null;
}
}
export async function getGuilds(session: Session | undefined) {
if (!session) return null;
try {
return await got(DISCORD_ENDPOINT + Routes.userGuilds(), {
headers: {
authorization: `${session.tokenType} ${session.accessToken}`
}
}).json() as APIPartialGuild[];
} catch(err) {
log.error(err);
return null;
}
}

130
src/web/web.ts Normal file
View File

@ -0,0 +1,130 @@
import express from 'express';
import { create } from 'express-handlebars';
import * as log from '../lib/log';
import { CustomItem, Counter, CustomCraftingRecipe, db } from '../lib/db';
import { defaultItems } from '../lib/rpg/items';
import { Client, CDN } from 'discord.js';
import { getToken, getSessionString, getSession, setSession, updateCookie } from './oauth2';
import { getUser, getGuilds } from './user';
async function getGuildInfo(bot: Client, id: string) {
const guild = await bot.guilds.cache.get(id);
if (!guild) return;
const items = await db<CustomItem>('customItems')
.where('guild', guild.id)
.count({count: '*'});
const counters = await db<Counter>('counters')
.where('guild', guild.id)
.count({count: '*'});
const recipes = await db<CustomCraftingRecipe>('customCraftingRecipes')
.where('guild', guild.id)
.count({count: '*'});
return {
items: items[0].count as number,
counters: counters[0].count as number,
recipes: recipes[0].count as number,
};
}
export async function startServer(bot: Client, port: number) {
const app = express();
const cdn = new CDN();
const hbs = create({
helpers: {
avatar: (id: string, hash: string) => (id && hash) ? cdn.avatar(id, hash, { size: 128 }) : '/assets/avatar.png',
icon: (id: string, hash: string) => (id && hash) ? cdn.icon(id, hash, { size: 128, forceStatic: true }) : '/assets/avatar.png',
}
});
app.engine('handlebars', hbs.engine);
app.set('view engine', 'handlebars');
app.set('views', './views');
app.get('/api/items', async (req, res) => {
const guildID = req.query.guild;
let customItems : Partial<CustomItem>[];
if (guildID) {
customItems = await db<CustomItem>('customItems')
.select('emoji', 'name', 'id', 'description')
.where('guild', guildID)
.limit(25);
} else {
customItems = [];
}
res.json([...defaultItems, ...customItems]);
});
app.get('/api/status', async (_, res) => {
res.json({
guilds: bot.guilds.cache.size,
uptime: bot.uptime
});
});
app.get('/', async (req, res) => {
const code = req.query.code as string;
if (code) {
try {
const resp = await getToken(bot, code);
if (!resp) return res.status(400).send('Invalid code provided');
const sessionId = await getSessionString(decodeURIComponent(req.headers.cookie || ''));
setSession(sessionId, {
tokenType: resp.token_type,
accessToken: resp.access_token,
refreshToken: resp.refresh_token,
expiresAt: Date.now() + resp.expires_in * 1000,
});
updateCookie(res, sessionId);
return res.redirect('/profile');
} catch (err) {
log.error(err);
return res.status(500);
}
}
const session = await getSession(bot, req.headers);
const user = await getUser(session);
res.render('home', {
signedIn: session !== undefined,
user: user,
layout: false,
});
});
app.get('/profile', async (req, res) => {
const session = await getSession(bot, req.headers);
if (!session) return res.redirect(`https://discord.com/api/oauth2/authorize?client_id=${bot.config.clientId}&redirect_uri=${encodeURIComponent(bot.config.siteURL)}&response_type=code&scope=identify%20guilds`);
const user = await getUser(session);
if (!user) return;
const guilds = await getGuilds(session);
if (!guilds) return;
//res.sendFile('profile/index.html', { root: 'static/' });
res.render('profile', {
user,
guilds: await Promise.all(
guilds.map(async guild =>
({...guild, jillo: await getGuildInfo(bot, guild.id)})
)
),
});
});
app.use(express.static('static/'));
app.listen(port, () => log.info(`web interface listening on ${port}`));
}

BIN
static/assets/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/assets/jillo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jillo</title>
<meta name="theme-color" content="light dark">
<link href="/style.css" rel="stylesheet">
<link rel="shortcut icon" href="/favicon.ico">
<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="/create-recipe/script.js"></script>
</head>
<body>
<div id="content">
<div class="header">
<div class="bg"></div>
<div class="left">
<a href="/">jillo</a>
</div>
<div class="links">
<a href="https://discord.com/oauth2/authorize?client_id=898850107892596776&scope=bot" target="_blank" rel="noopener">invite</a>
&middot;
<a href="https://git.oat.zone/dark-firepit/jillo-bot" target="_blank" rel="noopener">repo</a>
<img src="/assets/jillo_small.png">
</div>
</div>
<h1>Recipe Creator</h1>
<div class="items">
loading available items...
</div>
<p class="note">Drag items from above into the below lists:</p>
<h2>Inputs <span class="subtitle">Ingredients necessary to create the outputs</span></h2>
<div class="item-list" data-type="inputs">
</div>
<h2>Requirements <span class="subtitle">Unlike inputs, these are not consumed, but are necessary</span></h2>
<div class="item-list" data-type="requirements">
</div>
<h2>Outputs <span class="subtitle">The result of the recipe</span></h2>
<div class="item-list" data-type="outputs">
</div>
<p class="note">Drag an item out of the list in order to remove it</p>
<h1>Recipe string</h1>
<pre id="recipe-string"></pre>
Copy-paste this into Jillo to create your recipe
</div>
</body>
</html>

View File

@ -0,0 +1,145 @@
let resolveLoaded;
const loaded = new Promise(resolve => resolveLoaded = resolve);
function e(unsafeText) {
let div = document.createElement('div');
div.innerText = unsafeText;
return div.innerHTML;
}
/**
* @type {import('../../src/lib/rpg/items').Item | null}
*/
let draggedItem = null;
/**
* @type {Record<string, import('../../src/lib/rpg/items').Items[]>}
*/
let itemLists = {};
function listToString(list) {
return list.map(stack => `${stack.item.id},${stack.quantity}`).join(';');
}
function updateString() {
document.querySelector('#recipe-string').innerText = [
listToString(itemLists['inputs'] || []),
listToString(itemLists['requirements'] || []),
listToString(itemLists['outputs'] || []),
].join('|');
}
/**
* @param {import('../../src/lib/rpg/items').Item} item
*/
function renderItem(item) {
const i = document.createElement('div');
i.innerHTML = `
<div class="icon">
${e(item.emoji)}
</div>
<div class="right">
<div class="name">${e(item.name)}</div>
<div class="description">${item.description ? e(item.description) : '<i>No description</i>'}</div>
</div>
`;
i.classList.add('item');
i.draggable = true;
i.addEventListener('dragstart', event => {
draggedItem = item;
event.target.classList.add('dragging');
});
i.addEventListener('dragend', event => {
draggedItem = null;
event.target.classList.remove('dragging');
});
return i;
}
function renderItemStack(item, quantity, type) {
const i = document.createElement('div');
i.innerHTML = `
<div class="icon">
${e(item.emoji)}
</div>
<div class="right">
x<b>${quantity}</b>
</div>
`;
i.classList.add('itemstack');
i.draggable = true;
i.addEventListener('dragstart', event => {
event.target.classList.add('dragging');
});
i.addEventListener('dragend', event => {
event.target.classList.remove('dragging');
itemLists[type] = itemLists[type] || [];
const items = itemLists[type];
const stackIdx = items.findIndex(n => n.item.id === item.id);
if (stackIdx !== -1) items.splice(stackIdx, 1);
document.querySelector(`.item-list[data-type="${type}"]`).replaceWith(renderItemList(items, type));
updateString();
});
return i;
}
function renderItemList(items, type) {
const i = document.createElement('div');
i.textContent = '';
items.forEach(itemStack => {
i.appendChild(renderItemStack(itemStack.item, itemStack.quantity, type));
});
i.dataset.type = type;
i.classList.add('item-list');
// prevent default to allow drop
i.addEventListener('dragover', (event) => event.preventDefault(), false);
i.addEventListener('dragenter', event => draggedItem && event.target.classList.add('dropping'));
i.addEventListener('dragleave', event => draggedItem && event.target.classList.remove('dropping'));
i.addEventListener('drop', (event) => {
event.preventDefault();
event.target.classList.remove('dropping');
if (!draggedItem) return;
itemLists[type] = itemLists[type] || [];
const items = itemLists[type];
const itemStack = items.find(v => v.item.id === draggedItem.id);
if (!itemStack) {
items.push({
item: draggedItem,
quantity: 1
});
} else {
itemStack.quantity = itemStack.quantity + 1;
}
updateString();
draggedItem = null;
event.target.replaceWith(renderItemList(items, type));
});
return i;
}
Promise.all([
fetch(`/api/items${document.location.search}`)
.then(res => res.json()),
loaded
]).then(([items]) => {
const itemsContainer = document.querySelector('.items');
itemsContainer.textContent = '';
items.forEach(item =>
itemsContainer.appendChild(renderItem(item))
);
document.querySelectorAll('.item-list').forEach(elem => {
const type = elem.dataset.type;
elem.replaceWith(renderItemList([], type));
});
updateString();
});
document.addEventListener('DOMContentLoaded', () => resolveLoaded());

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

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());

353
static/style.css Normal file
View File

@ -0,0 +1,353 @@
:root {
--accent-color: #f17d10;
}
body {
padding: 0px;
margin: 0px;
overflow-x: hidden;
color: var(--text-color);
background-color: var(--background-color);
font-family: 'Balsamiq Sans', sans-serif;
font-weight: 300;
width: 100%;
min-height: 100vh;
text-underline-offset: 3px;
font-size: 16px;
color-scheme: light dark;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
:root {
--text-color: #111;
--text-color-light: #444;
--background-color: #fefefd;
--background-color-dark: #fafafb;
--background-color-dark-2: #f8f8f9;
}
@media (prefers-color-scheme: dark) {
:root {
--text-color: #eee;
--text-color-light: #aaa;
--background-color: #111110;
--background-color-dark: #151514;
--background-color-dark-2: #171718;
}
}
a {
text-decoration: none;
color: var(--accent-color);
}
a:hover {
text-decoration: underline;
}
#main {
display: flex;
text-align: center;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100%;
flex: 1 0 auto;
}
#main img {
display: block;
height: 18rem;
width: auto;
animation: 1s popup;
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
transition: transform 0.15s, opacity 0.1s;
}
#main > img:active {
transform: scale(0.97);
opacity: 0.9;
}
#main > :not(img) {
animation: 0.8s fadein;
}
#main h1 {
font-size: 4rem;
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; }
100% { opacity: 1; }
}
@keyframes popup {
0% { transform: scale(0) rotate(40deg) }
100% { transform: scale(1) rotate(0deg) }
}
#login {
display: flex;
flex-direction: row;
width: fit-content;
height: 2rem;
border: 1px solid var(--text-color-light);
border-radius: 2rem;
align-items: center;
padding: 0.25em 0.5em;
margin: 0.5rem;
gap: 0.5em;
cursor: pointer;
transition: 0.1s color, 0.1s background-color;
font-size: 1.2rem;
align-self: flex-end;
}
#login:hover {
border-color: var(--accent-color);
background-color: var(--accent-color);
}
#login .avatar {
display: block;
aspect-ratio: 1 / 1;
border-radius: 2rem;
width: auto;
height: 100%;
}
#login:not(:hover) .username.logged-out {
color: var(--text-color-light);
}
#content {
max-width: 1000px;
width: 100%;
margin: 0 auto;
margin-bottom: 6rem;
}
.header {
height: 3rem;
display: flex;
align-items: stretch;
padding: 0rem 1rem;
flex-direction: row;
text-shadow: 2px 2px 2px #000000;
margin-bottom: 2rem;
}
.header .bg {
position: absolute;
left: 0%;
right: 0%;
height: 3rem;
background: linear-gradient(#444, #222);
z-index: -1;
border-bottom: 2px ridge #aaa;
}
.header .left {
font-size: 1.5rem;
flex: 1 1 0px;
display: flex;
align-items: center;
}
.header .left a {
text-decoration: none !important;
color: #fff;
}
.header .links {
flex: 0 0 auto;
text-align: right;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
}
.header .links img {
display: block;
width: auto;
height: 100%;
}
.items {
max-width: 500px;
padding: 1em;
height: 200px;
overflow: auto;
background: linear-gradient(var(--background-color-dark), var(--background-color-dark-2));
border-radius: 1em;
display: flex;
flex-direction: column;
}
.item {
display: flex;
flex-direction: row;
height: 3rem;
gap: 0.5rem;
outline: 0px solid rgba(255, 255, 255, 0.0);
transition: outline 0.1s;
padding: 0.5rem;
border-radius: 2rem;
cursor: grab;
}
.item:hover {
outline: 1px solid var(--text-color-light);
}
.item.dragging {
outline: 2px dashed var(--text-color-light);
transition: none;
background-color: var(--background-color);
}
.item .icon {
flex: 0 0 auto;
font-size: 1rem;
line-height: 1;
background-color: rgba(199, 199, 199, 0.25);
border-radius: 4rem;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
user-select: none;
}
.item .right {
flex: 1 1 0px;
display: flex;
flex-direction: column;
line-height: 1;
}
.item .right, .item .right > * {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.item .right .description {
font-size: 75%;
color: var(--text-color-light);
}
.item-list {
min-height: 2rem;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.item-list.dropping::after {
display: flex;
align-items: center;
justify-content: center;
content: '+';
height: 1.5rem;
width: 1.5rem;
outline: 2px dashed var(--text-color-light);
border-radius: 2rem;
padding: 0.5rem;
margin: 0.25rem;
}
.itemstack {
display: flex;
align-items: center;
height: 1.5rem;
line-height: 1;
padding: 0.5rem;
margin: 0.25rem;
outline: 1px solid var(--text-color-light);
background-color: var(--background-color-dark);
border-radius: 2rem;
gap: 0.5rem;
}
.itemstack.dragging {
outline: 2px dashed var(--text-color-light);
transition: none;
background-color: var(--background-color);
}
.subtitle {
color: var(--text-color-light);
font-size: 1rem;
font-weight: normal;
margin-left: 0.5rem;
}
.subtitle::before {
content: '·';
margin-right: 0.5rem;
}
pre {
background: linear-gradient(var(--background-color-dark), var(--background-color-dark-2));
overflow: auto;
word-break: normal;
padding: 0.5rem;
}
.note {
font-style: italic;
color: var(--text-color-light);
}
.user {
display: flex;
flex-direction: row;
height: 2rem;
padding: 0.5em;
align-items: center;
width: fit-content;
margin: 0 auto;
}
.user .avatar {
display: block;
aspect-ratio: 1 / 1;
border-radius: 2rem;
width: auto;
height: 100%;
margin-right: 0.5em;
}
.guilds {
display: flex;
flex-direction: column;
align-items: center;
}
.guild {
order: 0;
display: flex;
width: 600px;
max-width: 100%;
height: 3rem;
padding: 0.5rem;
gap: 0.5rem;
margin: 0.5rem;
background-color: var(--background-color-dark);
}
.guild.unavailable {
order: 1;
}
.guild.unavailable .icon {
filter: grayscale(100%);
}
.guild .icon {
flex: 0 0 auto;
display: block;
aspect-ratio: 1 / 1;
border-radius: 2rem;
width: auto;
height: 100%;
}
.guild .right {
display: flex;
flex-direction: column;
}
.guild .info {
color: var(--text-color-light);
}

7
svelte.config.mjs Normal file
View File

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
};

40
views/home.handlebars Normal file
View File

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jillo</title>
<meta name="theme-color" content="light dark">
<link href="/style.css" rel="stylesheet">
<link rel="shortcut icon" href="/favicon.ico">
<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="login" onclick="window.location = '/profile'">
{{#if signedIn}}
<div class="username">{{user.global_name}}</div>
{{else}}
<div class="username logged-out">log in</div>
{{/if}}
<img class="avatar" src="{{avatar user.id user.avatar}}" width="128" height="128">
</div>
<div id="main">
<img src="/assets/jillo.png" width="150" height="200">
<h1>jillo!</h1>
<div>
<a href="https://discord.com/oauth2/authorize?client_id=898850107892596776&scope=bot" target="_blank" rel="noopener">invite</a>
&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>

View File

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jillo</title>
<meta name="theme-color" content="light dark">
<link href="/style.css" rel="stylesheet">
<link rel="shortcut icon" href="/favicon.ico">
<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">
</head>
<body>
<div id="content">
<div class="header">
<div class="bg"></div>
<div class="left">
<a href="/">jillo</a>
</div>
<div class="links">
<a href="https://discord.com/oauth2/authorize?client_id=898850107892596776&scope=bot" target="_blank" rel="noopener">invite</a>
&middot;
<a href="https://git.oat.zone/dark-firepit/jillo-bot" target="_blank" rel="noopener">repo</a>
<img src="/assets/jillo_small.png">
</div>
</div>
{{{body}}}
</div>
</body>
</html>

27
views/profile.handlebars Normal file
View File

@ -0,0 +1,27 @@
<div class="user">
<img class="avatar" src="{{avatar user.id user.avatar}}" width="128" height="128">
<div class="username">Logged in as <b>{{user.global_name}}</b></div>
</div>
<div class="guilds">
<h2>Guilds</h2>
{{#each guilds}}
{{#if jillo}}
<div class="guild">
<img src="{{icon id icon}}" width="128" height="128" class="icon">
<div class="right">
<div class="name">{{name}}</div>
<div class="info">{{jillo.counters}} counters &middot; {{jillo.items}} items &middot; {{jillo.recipes}} recipes</div>
</div>
</div>
{{else}}
<div class="guild unavailable">
<img src="{{icon id icon}}" width="128" height="128" class="icon">
<div class="right">
<div class="name">{{name}}</div>
<div class="info">Jillo is not in this server</div>
</div>
</div>
{{/if}}
{{/each}}
</div>

7
vite.config.mjs Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
});