Compare commits
67 Commits
cd0f7d5140
...
df97104294
Author | SHA1 | Date |
---|---|---|
Jill | df97104294 | |
Jill | 465c42437c | |
Jill | 1dee5ed060 | |
Jill | e02ecfba4b | |
Jill | 4e35d47854 | |
Jill | 1a8d49ae9f | |
Jill | 8f0fa740d7 | |
Jill | e5b049d0b2 | |
Jill | 3d5a7b041d | |
Jill | 7a4b9e1726 | |
Jill | ddb85d1d2f | |
Jill | ea24a931ca | |
Jill | 8ad18e9b19 | |
Jill | c4980da8b7 | |
Jill | f44f79a955 | |
Jill | b862028524 | |
Jill | 783af8652c | |
Jill | 5be9dbfe21 | |
Jill | 249390cf5d | |
Jill | 4ab739d681 | |
Jill | ef08ef020b | |
Jill | fcc7956b4d | |
Jill | cddcfee26e | |
Jill | 6d31321c14 | |
Jill | 679dd7a832 | |
Jill | a487fc2f4c | |
Jill | c5d9954fcf | |
Jill | 0cd2b02282 | |
Jill | 249cf02490 | |
Jill | 60a3823b47 | |
Jill | 6dc87caa10 | |
Jill | baa32191b4 | |
Jill | 37af0ea68f | |
Jill | 26903e03a8 | |
Jill | 7d3bf20eaa | |
Jill | fd3dbfa65b | |
Jill | 07e6e162ff | |
Jill | f2c1d0efa9 | |
Jill | 3b9c66ebfd | |
Jill | 3da36de9f6 | |
Jill | eb1dd27d6b | |
Jill | 77dbd8ee3f | |
Jill | c714596653 | |
Jill | 0594cc71db | |
Jill | a2a56f60f1 | |
Jill | 9eecec4894 | |
Jill | 9658dd81ee | |
Jill | f807baf3f2 | |
Jill | 20b914417f | |
Jill | 9af3499d3d | |
Jill | cb503e3cf9 | |
Jill | 8a935e00db | |
Jill | 39014d3b90 | |
Jill | 32cdaf5199 | |
Jill | 169b81ea06 | |
Jill | 41af7fa2e2 | |
Jill | 42d875a68f | |
Jill | 3fcfe5851b | |
Jill | 68d7e28335 | |
Jill | b0389f3e58 | |
Jill | c6e5b9a00f | |
Jill | 7f6607f3d9 | |
Jill | 2bb3512316 | |
Jill | 233a663d0c | |
Jill | a0c9b12da0 | |
Jill | 4351e6dfad | |
Jill | 2a2cdb8dff |
|
@ -1,3 +1,7 @@
|
|||
{
|
||||
"token": "token"
|
||||
"token": "token",
|
||||
"sitePort": 15385,
|
||||
"siteURL": "http://localhost:15385",
|
||||
"clientId": "",
|
||||
"clientSecret": ""
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
};
|
|
@ -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');
|
||||
};
|
|
@ -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');
|
||||
};
|
|
@ -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');
|
||||
};
|
|
@ -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');
|
||||
};
|
|
@ -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';
|
||||
};
|
25
package.json
25
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
1070
pnpm-lock.yaml
1070
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
22
src/index.ts
22
src/index.ts
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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([]);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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 }))
|
||||
);
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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);
|
||||
}
|
|
@ -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')),
|
||||
};
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -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('*');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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}`));
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -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>
|
||||
·
|
||||
<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>
|
|
@ -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());
|
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
|
@ -0,0 +1,29 @@
|
|||
let resolveLoaded;
|
||||
const loaded = new Promise(resolve => resolveLoaded = resolve);
|
||||
|
||||
function formatUptime(s) {
|
||||
let units = [
|
||||
['d', (s / (1000 * 60 * 60 * 24))],
|
||||
['h', (s / (1000 * 60 * 60)) % 24],
|
||||
['m', (s / (1000 * 60)) % 60],
|
||||
['s', (s / 1000) % 60]
|
||||
];
|
||||
|
||||
return units.filter(([_, t]) => t > 1).map(([sh, t]) => `${Math.ceil(t)}${sh}`).join(' ');
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
fetch('/api/status')
|
||||
.then(res => res.json()),
|
||||
loaded
|
||||
]).then(([status]) => {
|
||||
document.querySelector('#status').innerHTML = `
|
||||
<div class="status"></div> online · ${status.guilds} guilds · up for <span id="uptime">${formatUptime(status.uptime)}</span>
|
||||
`;
|
||||
const firstRender = Date.now();
|
||||
setInterval(() => {
|
||||
document.querySelector('#uptime').innerText = formatUptime(status.uptime + Date.now() - firstRender);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => resolveLoaded());
|
|
@ -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);
|
||||
}
|
|
@ -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(),
|
||||
};
|
|
@ -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>
|
||||
·
|
||||
<a href="https://git.oat.zone/dark-firepit/jillo-bot" target="_blank" rel="noopener">repo</a>
|
||||
</div>
|
||||
<div id="status">
|
||||
···
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,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>
|
||||
·
|
||||
<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>
|
|
@ -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 · {{jillo.items}} items · {{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>
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
});
|
Loading…
Reference in New Issue