last upload from radzathan

main
Mari 4 months ago
parent 0291c73e80
commit 00115a98cd
  1. 2
      .idea/codeStyles/codeStyleConfig.xml
  2. 7514
      package-lock.json
  3. 1
      package.json
  4. 144
      src/bin/bot.ts
  5. 6
      src/commands/character/create.ts
  6. 289
      src/commands/character/index.ts
  7. 23
      src/commands/index.spec.ts
  8. 750
      src/commands/index.ts
  9. 63
      src/database/index.ts
  10. 38
      src/database/users.spec.ts
  11. 20
      src/database/users.ts
  12. 22
      src/managers/characters.ts
  13. 7
      src/testing/integration.ts

@ -1,5 +1,5 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<state> <state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" /> <option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state> </state>
</component> </component>

7514
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -35,6 +35,7 @@
"@types/pg": "^8.6.5", "@types/pg": "^8.6.5",
"babel-jest": "^29.1.2", "babel-jest": "^29.1.2",
"jest": "^29.1.2", "jest": "^29.1.2",
"standard": "^17.0.0",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^4.8.4" "typescript": "^4.8.4"

@ -1,79 +1,80 @@
import {BaseInteraction, Client} from "discord.js" import {BaseInteraction, Client as DiscordClient} from "discord.js";
import {config} from "dotenv" import {config as loadEnvironment} from "dotenv";
import {isAutocomplete, isChatInputCommand} from "../types/interactions.js" import {isAutocomplete, isChatInputCommand} from "../types/interactions.js";
import {Commands} from "../commands/index.js" import {Commands} from "../commands";
import {checkIsRestart, reportFailed, reportReady, reportStarted} from "../ipc/restart.js" import {
import {defaultPresence} from "../defaultPresence.js" checkIsRestart,
import {Pool} from "pg" reportFailed,
import {Database, DatabaseImpl, makeTransact} from "../database" reportReady,
reportStarted,
} from "../ipc/restart.js";
import {defaultPresence} from "../defaultPresence.js";
import {Pool as PostgresPool} from "pg";
import {Database, DatabaseImpl, makeTransact} from "../database";
import {Managers} from "../managers";
async function bot() { async function bot() {
await checkIsRestart() await checkIsRestart();
config() loadEnvironment();
const c = new Client({ const c = new DiscordClient({
intents: [], intents: [],
}) });
const p = new Pool({
application_name: "VoreRPG Bot", const db: Database = DatabaseImpl.fromPool({
}) application_name: process.env.APP_NAME ?? "VoreRPG Bot"
p.on("error", (err) => {
console.log(err)
}) })
async function cleanUp() { async function cleanUp() {
await p.end() await db.tearDown();
c.destroy() c.destroy();
} }
const db: Database = new DatabaseImpl({ const mgr = new Managers(db);
query: p.query.bind(p), const cmd = new Commands({users: mgr.users, cleanUp});
transact: makeTransact(p),
})
const cmd = new Commands({users: db.users, cleanUp})
c.on("ready", async () => { c.on("ready", async () => {
try { try {
await db.users.createBotOwnerAsAdmin(process.env.BOT_OWNER_ID || "") await mgr.users.setSnowflakeAdmin(process.env.BOT_OWNER_ID ?? "");
} catch (ex) { } catch (ex) {
await reportFailed(c, ex) await reportFailed(c, ex);
return return;
} }
const app = c.application const app = c.application;
if (!app) { if (!app) {
c.destroy() c.destroy();
await reportFailed(c, "No application was given") await reportFailed(c, "No application was given");
return return;
} else { } else {
try { try {
cmd.setCache(await app.commands.set(cmd.definitions)) cmd.setCache(await app.commands.set(cmd.definitions));
const g = await c.guilds.fetch() const g = await c.guilds.fetch();
for (const guild of g.values()) { for (const guild of g.values()) {
cmd.setCache(await app.commands.set(cmd.definitions, guild.id)) cmd.setCache(await app.commands.set(cmd.definitions, guild.id));
} }
} catch (ex) { } catch (ex) {
c.destroy() c.destroy();
await reportFailed(c, ex) await reportFailed(c, ex);
return return;
} }
} }
const user = c.user const user = c.user;
if (!user) { if (!user) {
c.destroy() c.destroy();
await reportFailed(c, "No user found") await reportFailed(c, "No user found");
return return;
} else { } else {
user.setPresence(defaultPresence) user.setPresence(defaultPresence);
} }
try { try {
await reportReady(c) await reportReady(c);
} catch (ex) { } catch (ex) {
console.log(ex) console.log(ex);
} }
}) });
c.on("error", async (ex) => { c.on("error", async (ex) => {
c.destroy() c.destroy();
await reportFailed(c, ex) await reportFailed(c, ex);
}) });
c.on("interactionCreate", async (ev: BaseInteraction) => { c.on("interactionCreate", async (ev: BaseInteraction) => {
if (ev.client.user.presence.status !== "online") { if (ev.client.user.presence.status !== "online") {
if (ev.isRepliable()) { if (ev.isRepliable()) {
@ -81,62 +82,63 @@ async function bot() {
await ev.reply({ await ev.reply({
content: "Shhh... I'm sleeping!! Try again later.", content: "Shhh... I'm sleeping!! Try again later.",
ephemeral: true, ephemeral: true,
}) });
} catch (ex) { } catch (ex) {
console.log("failed sending busy reply", ex) console.log("failed sending busy reply", ex);
} }
} }
return return;
} }
if (isChatInputCommand(ev)) { if (isChatInputCommand(ev)) {
try { try {
await cmd.execute(ev) await cmd.execute(ev);
} catch (ex) { } catch (ex) {
console.log("failed executing command", ev, ex) console.log("failed executing command", ev, ex);
} }
if (!ev.replied) { if (!ev.replied) {
console.log("never replied to command", ev) console.log("never replied to command", ev);
try { try {
await ev.reply({ await ev.reply({
ephemeral: true, ephemeral: true,
content: "Uuguuu... I can't think straight... try again later, 'kay?", content:
}) "Uuguuu... I can't think straight... try again later, 'kay?",
});
} catch (ex) { } catch (ex) {
console.log("failed sending error reply", ex) console.log("failed sending error reply", ex);
} }
} }
} else if (isAutocomplete(ev)) { } else if (isAutocomplete(ev)) {
try { try {
await cmd.autocomplete(ev) await cmd.autocomplete(ev);
} catch (ex) { } catch (ex) {
console.log("failed autocompleting for command", ev, ex) console.log("failed autocompleting for command", ev, ex);
} }
if (!ev.responded) { if (!ev.responded) {
console.log("never autocompleted for command", ev) console.log("never autocompleted for command", ev);
try { try {
await ev.respond([]) await ev.respond([]);
} catch (ex) { } catch (ex) {
console.log("failed sending error response", ex) console.log("failed sending error response", ex);
} }
} }
} else if (ev.isRepliable()) { } else if (ev.isRepliable()) {
try { try {
console.log("unknown command", ev) console.log("unknown command", ev);
await ev.reply({ await ev.reply({
ephemeral: true, ephemeral: true,
content: "Huuuuuuuh? ... I don't know what to do with that yet.", content: "Huuuuuuuh? ... I don't know what to do with that yet.",
}) });
} catch (ex) { } catch (ex) {
console.log("failed sending unknown command reply", ex) console.log("failed sending unknown command reply", ex);
} }
} else { } else {
console.log("got an interaction but can't reply to it") console.log("got an interaction but can't reply to it");
} }
}) });
await reportStarted() await reportStarted();
await c.login(process.env.DISCORD_TOKEN || "") await c.login(process.env.DISCORD_TOKEN || "");
} }
bot().catch((ex) => { bot().catch((ex) => {
console.log("main thread failed", ex) console.log("main thread failed", ex);
}) });

@ -1,6 +1,6 @@
import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ChatInputCommandInteraction} from "discord.js" import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ChatInputCommandInteraction} from "discord.js"
import {SubcommandData} from "../types" import {SubcommandData} from "../types"
import {UserTable} from "../../database/users.js" import {UsersManager} from "../../managers/users";
export class CharacterCreateCommand extends SubcommandData { export class CharacterCreateCommand extends SubcommandData {
readonly definition: ApplicationCommandSubCommandData = { readonly definition: ApplicationCommandSubCommandData = {
@ -68,9 +68,9 @@ export class CharacterCreateCommand extends SubcommandData {
}, },
], ],
} }
private readonly _users: UserTable private readonly _users: UsersManager
constructor({users}: { users: UserTable }) { constructor({users}: { users: UsersManager }) {
super() super()
this._users = users this._users = users
} }

@ -1,143 +1,160 @@
import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ApplicationCommandType} from "discord.js" import {
import {BaseChatInputCommandData, CommandWithSubcommandsData, SubcommandData} from "../types" ApplicationCommandOptionType,
import {UserTable} from "../../database/users.js" ApplicationCommandSubCommandData,
import {CharacterCreateCommand} from "./create.js" ApplicationCommandType,
} from "discord.js";
import {
BaseChatInputCommandData,
CommandWithSubcommandsData,
SubcommandData,
} from "../types";
import { CharacterCreateCommand } from "./create.js";
import { UsersManager } from "../../managers/users";
export class CharacterCommand extends CommandWithSubcommandsData { export class CharacterCommand extends CommandWithSubcommandsData {
readonly create: CharacterCreateCommand readonly create: CharacterCreateCommand;
readonly subcommands: SubcommandData[] readonly subcommands: SubcommandData[];
private readonly _users: UserTable private readonly _users: UsersManager;
constructor({users}: { users: UserTable }) { constructor({ users }: { users: UsersManager }) {
super() super();
this._users = users this._users = users;
this.create = new CharacterCreateCommand({users}) this.create = new CharacterCreateCommand({ users });
this.subcommands = [ this.subcommands = [this.create];
this.create, }
]
}
readonly baseDefinition: BaseChatInputCommandData = { readonly baseDefinition: BaseChatInputCommandData = {
name: "character", name: "character",
type: ApplicationCommandType.ChatInput, type: ApplicationCommandType.ChatInput,
description: "Commands to manage your characters.", description: "Commands to manage your characters.",
} };
} }
const otherSubcommands: ApplicationCommandSubCommandData[] = [ const otherSubcommands: ApplicationCommandSubCommandData[] = [
{ {
name: "select", name: "select",
type: ApplicationCommandOptionType.Subcommand, type: ApplicationCommandOptionType.Subcommand,
description: "Selects (and activates, if necessary) a character for use with other commands used in this server.", description:
options: [ "Selects (and activates, if necessary) a character for use with other commands used in this server.",
{ options: [
name: "character", {
type: ApplicationCommandOptionType.String, name: "character",
description: "The character to switch to. If not supplied, shows you the current selected character.", type: ApplicationCommandOptionType.String,
autocomplete: true, description:
required: false, "The character to switch to. If not supplied, shows you the current selected character.",
}, autocomplete: true,
], required: false,
}, },
{ ],
name: "activate", },
type: ApplicationCommandOptionType.Subcommand, {
description: "Allows a character to be targeted on the current server.", name: "activate",
options: [ type: ApplicationCommandOptionType.Subcommand,
{ description: "Allows a character to be targeted on the current server.",
name: "character", options: [
type: ApplicationCommandOptionType.String, {
description: "The character to activate on this server.", name: "character",
autocomplete: true, type: ApplicationCommandOptionType.String,
required: false, description: "The character to activate on this server.",
}, autocomplete: true,
], required: false,
}, },
{ ],
name: "deactivate", },
type: ApplicationCommandOptionType.Subcommand, {
description: "Prevents a character from being targeted on the current server. Deselects them if they're selected.", name: "deactivate",
options: [ type: ApplicationCommandOptionType.Subcommand,
{ description:
name: "character", "Prevents a character from being targeted on the current server. Deselects them if they're selected.",
type: ApplicationCommandOptionType.String, options: [
description: "The character to deactivate on this server.", {
}, name: "character",
], type: ApplicationCommandOptionType.String,
}, description: "The character to deactivate on this server.",
{ },
name: "edit", ],
type: ApplicationCommandOptionType.Subcommand, },
description: "Edits an existing character. Note that characters currently in battle cannot be edited.", {
options: [ name: "edit",
{ type: ApplicationCommandOptionType.Subcommand,
name: "character", description:
type: ApplicationCommandOptionType.String, "Edits an existing character. Note that characters currently in battle cannot be edited.",
description: "An existing character of yours to edit. Omit to edit the current character.", options: [
autocomplete: true, {
required: false, name: "character",
}, type: ApplicationCommandOptionType.String,
], description:
}, "An existing character of yours to edit. Omit to edit the current character.",
{ autocomplete: true,
name: "list", required: false,
type: ApplicationCommandOptionType.Subcommand, },
description: "Lists your characters.", ],
options: [ },
{ {
name: "public", name: "list",
type: ApplicationCommandOptionType.Boolean, type: ApplicationCommandOptionType.Subcommand,
description: "True to share your character list with the current channel.", description: "Lists your characters.",
required: false, options: [
}, {
], name: "public",
}, type: ApplicationCommandOptionType.Boolean,
{ description:
name: "show", "True to share your character list with the current channel.",
type: ApplicationCommandOptionType.Subcommand, required: false,
description: "Shows the data about one of your characters.", },
options: [ ],
{ },
name: "character", {
type: ApplicationCommandOptionType.String, name: "show",
description: "An existing character of yours to display in this channel. Omit to display the current character.", type: ApplicationCommandOptionType.Subcommand,
autocomplete: true, description: "Shows the data about one of your characters.",
required: false, options: [
}, {
{ name: "character",
name: "public", type: ApplicationCommandOptionType.String,
type: ApplicationCommandOptionType.Boolean, description:
description: "True to share your character list with the current channel.", "An existing character of yours to display in this channel. Omit to display the current character.",
required: false, autocomplete: true,
}, required: false,
], },
}, {
{ name: "public",
name: "archive", type: ApplicationCommandOptionType.Boolean,
type: ApplicationCommandOptionType.Subcommand, description:
description: "Removes a character from all servers and hides it from the default list view.", "True to share your character list with the current channel.",
options: [ required: false,
{ },
name: "character", ],
type: ApplicationCommandOptionType.String, },
description: "An existing unarchived character of yours to add to the archive.", {
autocomplete: true, name: "archive",
required: false, type: ApplicationCommandOptionType.Subcommand,
}, description:
], "Removes a character from all servers and hides it from the default list view.",
}, options: [
{ {
name: "unarchive", name: "character",
type: ApplicationCommandOptionType.Subcommand, type: ApplicationCommandOptionType.String,
description: "Unarchives a character.", description:
options: [ "An existing unarchived character of yours to add to the archive.",
{ autocomplete: true,
name: "character", required: false,
type: ApplicationCommandOptionType.String, },
description: "An existing archived character of yours to take back out of the archive.", ],
autocomplete: true, },
required: false, {
}, name: "unarchive",
], type: ApplicationCommandOptionType.Subcommand,
}, description: "Unarchives a character.",
] options: [
{
name: "character",
type: ApplicationCommandOptionType.String,
description:
"An existing archived character of yours to take back out of the archive.",
autocomplete: true,
required: false,
},
],
},
];

@ -1,12 +1,17 @@
import {Commands} from "./index" import {Commands} from "./index";
import {describe, expect, test} from "@jest/globals" import {describe, expect, test} from "@jest/globals";
import {InMemoryDatabase} from "../database/inmemory/database.js" import {UsersManager} from "../managers/users";
import {UserTableInMemory} from "../database/users";
describe("command definitions", () => { describe("command definitions", () => {
test("has no descriptions over 100 characters", () => { test("has no descriptions over 100 characters", () => {
const db = new InMemoryDatabase() expect(
expect(new Commands({users: db.users, cleanUp: async () => undefined}).definitions) new Commands({
.not users: new UsersManager({users: new UserTableInMemory()}),
.toContain(expect.objectContaining({"description": expect.stringMatching(/.{101,}/)})) cleanUp: async () => undefined,
}) }).definitions
}) ).not.toContain(
expect.objectContaining({description: expect.stringMatching(/.{101,}/)})
);
});
});

@ -1,380 +1,414 @@
import { import {
ApplicationCommand, ApplicationCommand,
ApplicationCommandDataResolvable, ApplicationCommandDataResolvable,
ApplicationCommandOptionType, ApplicationCommandOptionType,
ApplicationCommandType, ApplicationCommandType,
AutocompleteInteraction, AutocompleteInteraction,
ChatInputCommandInteraction, ChatInputCommandInteraction,
Collection, Collection,
} from "discord.js" } from "discord.js";
import {CharacterCommand} from "./character/index" import { CharacterCommand } from "./character";
import {autocompleteNotImplementedError, CommandData} from "./types" import { autocompleteNotImplementedError, CommandData } from "./types";
import {BotCommand} from "./bot/index" import { BotCommand } from "./bot";
import {UserTable} from "../database/users.js" import { UserTable } from "../database/users.js";
import { UsersManager } from "../managers/users";
export const invalidCommandError = "No command by that name exists." export const invalidCommandError = "No command by that name exists.";
export class Commands { export class Commands {
readonly all: CommandData[] readonly all: CommandData[];
readonly character: CharacterCommand readonly character: CharacterCommand;
readonly bot: BotCommand readonly bot: BotCommand;
readonly definitions: ApplicationCommandDataResolvable[] readonly definitions: ApplicationCommandDataResolvable[];
private readonly _nameCache: Record<string, CommandData> private readonly _nameCache: Record<string, CommandData>;
constructor({users, cleanUp}: { users: UserTable, cleanUp: () => Promise<void> }) { constructor({
this.character = new CharacterCommand({users}) users,
this.bot = new BotCommand({users, cleanUp}) cleanUp,
this.all = [ }: {
this.character, users: UsersManager;
this.bot, cleanUp: () => Promise<void>;
] }) {
this.definitions = this.all.map((c) => c.definition) this.character = new CharacterCommand({ users });
this._nameCache = Object.fromEntries( this.bot = new BotCommand({ users, cleanUp });
this.all.map((c) => [c.definition.name, c]), this.all = [this.character, this.bot];
) this.definitions = this.all.map((c) => c.definition);
} this._nameCache = Object.fromEntries(
this.all.map((c) => [c.definition.name, c])
);
}
setCache(data: Collection<string, ApplicationCommand>) { setCache(data: Collection<string, ApplicationCommand>) {
for (const command of data.values()) { for (const command of data.values()) {
if (this._nameCache.hasOwnProperty(command.name)) { if (this._nameCache.hasOwnProperty(command.name)) {
this._nameCache[command.name].setCached(command) this._nameCache[command.name].setCached(command);
} else { } else {
console.log("No such command when caching commands: " + command.name) console.log("No such command when caching commands: " + command.name);
} }
}
} }
}
async execute(ev: ChatInputCommandInteraction) { async execute(ev: ChatInputCommandInteraction) {
const name = ev.commandName const name = ev.commandName;
if (!this._nameCache.hasOwnProperty(name)) { if (!this._nameCache.hasOwnProperty(name)) {
throw invalidCommandError throw invalidCommandError;
}
await this._nameCache[name].execute(ev)
} }
await this._nameCache[name].execute(ev);
}
async autocomplete(ev: AutocompleteInteraction) { async autocomplete(ev: AutocompleteInteraction) {
const name = ev.commandName const name = ev.commandName;
if (this._nameCache.hasOwnProperty(name)) { if (this._nameCache.hasOwnProperty(name)) {
const command = this._nameCache[name] const command = this._nameCache[name];
if (command.autocomplete) { if (command.autocomplete) {
await command.autocomplete(ev) await command.autocomplete(ev);
return return;
} }
}
throw autocompleteNotImplementedError
} }
throw autocompleteNotImplementedError;
}
} }
const otherCommands: ApplicationCommandDataResolvable[] = [ const otherCommands: ApplicationCommandDataResolvable[] = [
{ {
name: "playstyle", name: "playstyle",
type: ApplicationCommandType.ChatInput, type: ApplicationCommandType.ChatInput,
description: "Updates your play style.", description: "Updates your play style.",
options: [
{
name: "difficulty",
type: ApplicationCommandOptionType.Subcommand,
description:
"Alters or displays the reformation difficulty of your currently selected character.",
},
{
name: "preference",
type: ApplicationCommandOptionType.Subcommand,
description:
"Alters or displays the pred/prey preference of your currently selected character.",
},
],
},
{
name: "team",
type: ApplicationCommandType.ChatInput,
description: "Affects teams in battle.",
options: [
{
name: "join",
type: ApplicationCommandOptionType.Subcommand,
description:
"Leaves the current team, if any, and requests to join the same team (and battle) as another player.",
},
{
name: "leave",
type: ApplicationCommandOptionType.Subcommand,
description:
"Leaves the current team, if any, and strikes it out alone.",
},
],
},
{
name: "battle",
type: ApplicationCommandType.ChatInput,
description: "Takes actions in battle.",
options: [
{
name: "challenge",
type: ApplicationCommandOptionType.Subcommand,
description: "Challenges another character to a battle.",
options: [
{
name: "target",
type: ApplicationCommandOptionType.String,
description: "The character you want to challenge.",
autocomplete: true,
},
],
},
{
name: "menu",
type: ApplicationCommandOptionType.Subcommand,
description: "Opens the battle menu to select an action for this turn.",
},
{
name: "skill",
type: ApplicationCommandOptionType.Subcommand,
description: "Uses a skill.",
options: [
{
name: "name",
type: ApplicationCommandOptionType.String,
description: "The name of the skill to use. Omit to see a list.",
autocomplete: true,
required: false,
},
{
name: "target",
type: ApplicationCommandOptionType.String,
description:
"The target to use the skill on, if there are multiple options. Omit to see a list.",
autocomplete: true,
required: false,
},
],
},
{
name: "item",
type: ApplicationCommandOptionType.Subcommand,
description: "Uses an item.",
options: [
{
name: "name",
type: ApplicationCommandOptionType.String,
description: "The name of the item to use. Omit to see a list.",
autocomplete: true,
required: false,
},
{
name: "target",
type: ApplicationCommandOptionType.String,
description:
"The target to use the item on, if there are multiple options. Omit to see a list.",
autocomplete: true,
required: false,
},
],
},
{
name: "vore",
type: ApplicationCommandOptionType.Subcommand,
description:
"Devours your opponent. Requires your opponent to be at low Confidence.",
options: [
{
name: "style",
type: ApplicationCommandOptionType.String,
description: "The style of devouring to use. Omit to see a list.",
autocomplete: true,
required: false,
},
],
},
{
name: "rest",
type: ApplicationCommandOptionType.Subcommand,
description:
"Takes a breather, recovering your Confidence and Energy at a faster-than-normal rate for this turn.",
},
{
name: "escape",
type: ApplicationCommandOptionType.Subcommand,
description:
"Tries to escape from a battle or stomach. Requires your opponent to be at low Confidence.",
},
{
name: "surrender",
type: ApplicationCommandOptionType.Subcommand,
description:
"Lets your opponent do as they will with you by reducing your Confidence or Health to 0.",
},
{
name: "prey",
type: ApplicationCommandOptionType.SubcommandGroup,
description: "Commands for when you have a full belly.",
options: [ options: [
{ {
name: "difficulty", name: "release",
type: ApplicationCommandOptionType.Subcommand, type: ApplicationCommandOptionType.Subcommand,
description: "Alters or displays the reformation difficulty of your currently selected character.", description: "Releases your prey from your stomach.",
}, },
{ {
name: "preference", name: "squish",
type: ApplicationCommandOptionType.Subcommand, type: ApplicationCommandOptionType.Subcommand,
description: "Alters or displays the pred/prey preference of your currently selected character.", description:
}, "Squishes your prey, ending the combat. Can only be performed when the prey is at or below 0% Health.",
},
], ],
}, },
{ ],
name: "team", },
type: ApplicationCommandType.ChatInput, {
description: "Affects teams in battle.", name: "safety",
type: ApplicationCommandType.ChatInput,
description: "Safety commands for protecting consent and user data.",
options: [
{
name: "exit",
type: ApplicationCommandOptionType.SubcommandGroup,
description:
"Exits battle(s) and reverts its/their effects on you. To flee in-character, use /battle escape.",
},
{
name: "block",
type: ApplicationCommandOptionType.SubcommandGroup,
description: "Manages your block list.",
options: [ options: [
{ {
name: "join", name: "add",
type: ApplicationCommandOptionType.Subcommand, type: ApplicationCommandOptionType.Subcommand,
description: "Leaves the current team, if any, and requests to join the same team (and battle) as another player.", description:
}, "Prevents a user or character from challenging you or joining battles you're in.",
{ options: [
name: "leave", {
type: ApplicationCommandOptionType.Subcommand, name: "user",
description: "Leaves the current team, if any, and strikes it out alone.", type: ApplicationCommandOptionType.User,
}, description:
"The user to block from challenging you or joining battles you're in.",
required: false,
},
{
name: "character",
type: ApplicationCommandOptionType.String,
description:
"The character to block from challenging you or joining battles you're in.",
required: false,
autocomplete: true,
},
],
},
{
name: "remove",
type: ApplicationCommandOptionType.Subcommand,
description:
"Allows a user or character to challenge you and join battles you're in once more.",
options: [
{
name: "user",
type: ApplicationCommandOptionType.User,
description:
"The user to allow to challenge you and join battles you're in once more.",
required: false,
},
{
name: "character",
type: ApplicationCommandOptionType.String,
description:
"The character to allow to challenge you and join battles you're in once more.",
required: false,
autocomplete: true,
},
],
},
{
name: "list",
type: ApplicationCommandOptionType.Subcommand,
description: "Displays your current block list.",
},
], ],
}, },
{ {
name: "battle", name: "kick",
type: ApplicationCommandType.ChatInput, type: ApplicationCommandOptionType.SubcommandGroup,
description: "Takes actions in battle.", description:
"Manages the list of users kicked from the current battle.",
options: [ options: [
{ {
name: "challenge", name: "add",
type: ApplicationCommandOptionType.Subcommand, type: ApplicationCommandOptionType.Subcommand,
description: "Challenges another character to a battle.", description:
options: [ "Removes a user or character from the current battle and prevents them from rejoining.",
{ options: [
name: "target", {
type: ApplicationCommandOptionType.String, name: "user",
description: "The character you want to challenge.", type: ApplicationCommandOptionType.User,
autocomplete: true, description:
}, "The user to remove from the current battle and prevent from rejoining.",
], required: false,
}, },
{ {
name: "menu", name: "character",
type: ApplicationCommandOptionType.Subcommand, type: ApplicationCommandOptionType.String,
description: "Opens the battle menu to select an action for this turn.", description:
}, "The character to remove from the current battle and prevent from rejoining.",
{ required: false,
name: "skill", autocomplete: true,
type: ApplicationCommandOptionType.Subcommand, },
description: "Uses a skill.", ],
options: [ },
{ {
name: "name", name: "remove",
type: ApplicationCommandOptionType.String, type: ApplicationCommandOptionType.Subcommand,
description: "The name of the skill to use. Omit to see a list.", description:
autocomplete: true, "Allows a user or character to rejoin the current battle.",
required: false, options: [
}, {
{ name: "user",
name: "target", type: ApplicationCommandOptionType.User,
type: ApplicationCommandOptionType.String, description: "The user to allow to rejoin the current battle.",
description: "The target to use the skill on, if there are multiple options. Omit to see a list.", required: false,
autocomplete: true, },
required: false, {
}, name: "character",
], type: ApplicationCommandOptionType.String,
}, description: "The user to allow to rejoin the current battle.",
{ required: false,
name: "item", autocomplete: true,
type: ApplicationCommandOptionType.Subcommand, },
description: "Uses an item.", ],
options: [ },
{ {
name: "name", name: "list",
type: ApplicationCommandOptionType.String, type: ApplicationCommandOptionType.Subcommand,
description: "The name of the item to use. Omit to see a list.", description:
autocomplete: true, "Displays the list of users forbidden from rejoining the current battle.",
required: false, },
},
{
name: "target",
type: ApplicationCommandOptionType.String,
description: "The target to use the item on, if there are multiple options. Omit to see a list.",
autocomplete: true,
required: false,
},
],
},
{
name: "vore",
type: ApplicationCommandOptionType.Subcommand,
description: "Devours your opponent. Requires your opponent to be at low Confidence.",
options: [
{
name: "style",
type: ApplicationCommandOptionType.String,
description: "The style of devouring to use. Omit to see a list.",
autocomplete: true,
required: false,
},
],
},
{
name: "rest",
type: ApplicationCommandOptionType.Subcommand,
description: "Takes a breather, recovering your Confidence and Energy at a faster-than-normal rate for this turn.",
},
{
name: "escape",
type: ApplicationCommandOptionType.Subcommand,
description: "Tries to escape from a battle or stomach. Requires your opponent to be at low Confidence.",
},
{
name: "surrender",
type: ApplicationCommandOptionType.Subcommand,
description: "Lets your opponent do as they will with you by reducing your Confidence or Health to 0.",
},
{
name: "prey",
type: ApplicationCommandOptionType.SubcommandGroup,
description: "Commands for when you have a full belly.",
options: [
{
name: "release",
type: ApplicationCommandOptionType.Subcommand,
description: "Releases your prey from your stomach.",
},
{
name: "squish",
type: ApplicationCommandOptionType.Subcommand,
description: "Squishes your prey, ending the combat. Can only be performed when the prey is at or below 0% Health.",
},
],
},
], ],
}, },
{ {
name: "safety", name: "ban",
type: ApplicationCommandType.ChatInput, type: ApplicationCommandOptionType.SubcommandGroup,
description: "Safety commands for protecting consent and user data.", description: "Manages the ban list of the current server.",
options: [ options: [
{ {
name: "exit", name: "add",
type: ApplicationCommandOptionType.SubcommandGroup, type: ApplicationCommandOptionType.Subcommand,
description: "Exits battle(s) and reverts its/their effects on you. To flee in-character, use /battle escape.", description:
}, "Bans a user from participating in battles in the current server and kicks them from any they're in.",
{ options: [
name: "block", {
type: ApplicationCommandOptionType.SubcommandGroup, name: "user",
description: "Manages your block list.", type: ApplicationCommandOptionType.User,
options: [ description:
{ "The user to ban from participating in battles in the current server.",
name: "add", required: true,
type: ApplicationCommandOptionType.Subcommand, },
description: "Prevents a user or character from challenging you or joining battles you're in.", ],
options: [ },
{ {
name: "user", name: "remove",
type: ApplicationCommandOptionType.User, type: ApplicationCommandOptionType.Subcommand,
description: "The user to block from challenging you or joining battles you're in.", description:
required: false, "Allows a user to resume participating in battles in the current server.",
}, options: [
{ {
name: "character", name: "user",
type: ApplicationCommandOptionType.String, type: ApplicationCommandOptionType.User,
description: "The character to block from challenging you or joining battles you're in.", description:
required: false, "The user to allow to participate in battles in the current server once more.",
autocomplete: true, required: true,
}, },
], ],
}, },
{ {
name: "remove", name: "list",
type: ApplicationCommandOptionType.Subcommand, type: ApplicationCommandOptionType.Subcommand,
description: "Allows a user or character to challenge you and join battles you're in once more.", description: "Displays this server's current ban list.",
options: [ },
{
name: "user",
type: ApplicationCommandOptionType.User,
description: "The user to allow to challenge you and join battles you're in once more.",
required: false,
},
{
name: "character",
type: ApplicationCommandOptionType.String,
description: "The character to allow to challenge you and join battles you're in once more.",
required: false,
autocomplete: true,
},
],
},
{
name: "list",
type: ApplicationCommandOptionType.Subcommand,
description: "Displays your current block list.",
},
],
},
{
name: "kick",
type: ApplicationCommandOptionType.SubcommandGroup,
description: "Manages the list of users kicked from the current battle.",
options: [
{
name: "add",
type: ApplicationCommandOptionType.Subcommand,
description: "Removes a user or character from the current battle and prevents them from rejoining.",
options: [
{
name: "user",
type: ApplicationCommandOptionType.User,
description: "The user to remove from the current battle and prevent from rejoining.",
required: false,
},
{
name: "character",
type: ApplicationCommandOptionType.String,
description: "The character to remove from the current battle and prevent from rejoining.",
required: false,
autocomplete: true,
},
],
},
{
name: "remove",
type: ApplicationCommandOptionType.Subcommand,
description: "Allows a user or character to rejoin the current battle.",
options: [
{
name: "user",
type: ApplicationCommandOptionType.User,
description: "The user to allow to rejoin the current battle.",
required: false,
},
{
name: "character",
type: ApplicationCommandOptionType.String,
description: "The user to allow to rejoin the current battle.",
required: false,
autocomplete: true,
},
],
},
{
name: "list",
type: ApplicationCommandOptionType.Subcommand,
description: "Displays the list of users forbidden from rejoining the current battle.",
},
],
},
{
name: "ban",
type: ApplicationCommandOptionType.SubcommandGroup,
description: "Manages the ban list of the current server.",
options: [
{
name: "add",
type: ApplicationCommandOptionType.Subcommand,
description: "Bans a user from participating in battles in the current server and kicks them from any they're in.",
options: [
{
name: "user",
type: ApplicationCommandOptionType.User,
description: "The user to ban from participating in battles in the current server.",
required: true,
},
],
},
{
name: "remove",
type: ApplicationCommandOptionType.Subcommand,
description: "Allows a user to resume participating in battles in the current server.",
options: [
{
name: "user",
type: ApplicationCommandOptionType.User,
description: "The user to allow to participate in battles in the current server once more.",
required: true,
},
],
},
{
name: "list",
type: ApplicationCommandOptionType.Subcommand,
description: "Displays this server's current ban list.",
},
],
},
{
name: "delete",
type: ApplicationCommandOptionType.Subcommand,
description: "PERMANENTLY deletes a character, exiting any battles they were part of. See also /character archive.",
},
{
name: "wipe",
type: ApplicationCommandOptionType.Subcommand,
description: "PERMANENTLY deletes ALL data about you in the system, erasing you from the system's knowledge.",
},
], ],
}, },
] {
name: "delete",
type: ApplicationCommandOptionType.Subcommand,
description:
"PERMANENTLY deletes a character, exiting any battles they were part of. See also /character archive.",
},
{
name: "wipe",
type: ApplicationCommandOptionType.Subcommand,
description:
"PERMANENTLY deletes ALL data about you in the system, erasing you from the system's knowledge.",
},
],
},
];

@ -1,11 +1,15 @@
import {QueryResult, QueryResultRow} from "pg" import {Pool, PoolConfig, QueryResult, QueryResultRow} from "pg"
import {UserTable, UserTableImpl} from "./users.js" import {UserTable, UserTableImpl, UserTableInMemory} from "./users.js"
import {BattleTypeTable, BattleTypeTableImpl} from "./battle_types"; import {BattleTypeTable, BattleTypeTableImpl} from "./battle_types";
import {GenderTable, GenderTablesImpl} from "./genders"; import {GenderTable, GenderTablesImpl} from "./genders";
import {CharacterTable, CharacterTableImpl} from "./characters"; import {CharacterTable, CharacterTableImpl} from "./characters";
export interface PoolLike { export interface PoolLike extends Queryable {
connect(): Promise<PoolClientLike> connect(): Promise<PoolClientLike>
on(type: 'error', handler: (err: unknown) => void): void
end(): Promise<void>
} }
export interface PoolClientLike extends Queryable { export interface PoolClientLike extends Queryable {
@ -13,7 +17,10 @@ export interface PoolClientLike extends Queryable {
} }
export function makeTransact(p: PoolLike): TransactType { export function makeTransact(p: PoolLike): TransactType {
return async function transact({readonly=false, transaction}: {readonly?: boolean, transaction: (q: QueryType, attempts: number) => Promise<void>}): Promise<void> { return async function transact({
readonly = false,
transaction
}: { readonly?: boolean, transaction: (q: QueryType, attempts: number) => Promise<void> }): Promise<void> {
const client = await p.connect() const client = await p.connect()
const query = client.query.bind(client) const query = client.query.bind(client)
let committed = false let committed = false
@ -52,6 +59,19 @@ export interface Database {
readonly users: UserTable readonly users: UserTable
readonly genders: GenderTable readonly genders: GenderTable
readonly battleTypes: BattleTypeTable readonly battleTypes: BattleTypeTable
tearDown(): Promise<void>
}
export class DatabaseInMemory implements Database {
readonly users = new UserTableInMemory()
readonly characters = new CharacterTableInMemory()
readonly genders = new GenderTableInMemory()
readonly battleTypes = new BattleTypeTableInMemory()
tearDown(): Promise<void> {
return Promise.resolve()
}
} }
export interface Queryable { export interface Queryable {
@ -62,18 +82,49 @@ export interface Queryable {
} }
export type QueryType = Queryable["query"] export type QueryType = Queryable["query"]
export type TransactType = (config: {readonly?: boolean, transaction: (client: QueryType, attempts: number) => Promise<void>}) => Promise<void> export type TransactType = (config: { readonly?: boolean, transaction: (client: QueryType, attempts: number) => Promise<void> }) => Promise<void>
function isPoolLike(p: PoolLike | PoolConfig): p is PoolLike {
return Object.hasOwn(p, "query")
&& Object.hasOwn(p, "end")
&& Object.hasOwn(p, "connect")
&& Object.hasOwn(p, "on")
}
export class DatabaseImpl implements Database { export class DatabaseImpl implements Database {
static fromPool(p?: PoolLike | PoolConfig, errHandler?: (err: unknown) => void): DatabaseImpl {
if (typeof p === "undefined") {
p = {}
}
if (!isPoolLike(p)) {
p = new Pool(p)
}
if (typeof errHandler === "undefined") {
errHandler = (x) => console.log(x)
}
p.on("error", errHandler);
return new DatabaseImpl({
query: p.query.bind(p),
transact: makeTransact(p),
tearDown: p.end.bind(p)
})
}
readonly characters: CharacterTableImpl readonly characters: CharacterTableImpl
readonly users: UserTableImpl readonly users: UserTableImpl
readonly genders: GenderTable readonly genders: GenderTable
readonly battleTypes: BattleTypeTableImpl readonly battleTypes: BattleTypeTableImpl
readonly tearDown: () => Promise<void>
constructor({query, transact}: { query: QueryType, transact: TransactType }) { constructor({
query,
transact,
tearDown
}: { query: QueryType, transact: TransactType, tearDown: () => Promise<void> }) {
this.characters = new CharacterTableImpl({transact}) this.characters = new CharacterTableImpl({transact})
this.users = new UserTableImpl({query}) this.users = new UserTableImpl({query})
this.genders = new GenderTablesImpl({transact}) this.genders = new GenderTablesImpl({transact})
this.battleTypes = new BattleTypeTableImpl({transact}) this.battleTypes = new BattleTypeTableImpl({transact})
this.tearDown = tearDown
} }
} }

@ -0,0 +1,38 @@
import {describe, test, beforeAll, beforeEach, afterAll} from "@jest/globals"
import {UserTable, UserTableInMemory} from "./users";
import {integrationDescribe as xdescribe} from "../testing/integration"
import {Database, DatabaseImpl} from "./index";
import {v5 as uuidV5} from "uuid";
describe('UserTableInMemory', function () {
userTableSpec(() => new UserTableInMemory())
});
xdescribe('UserTableImpl', function () {
let db: Database
beforeAll(async () => {
db = DatabaseImpl.fromPool({
application_name: "VoreRPG Bot User Table Tests"
})
})
afterAll(async () => {
await db.tearDown()
})
userTableSpec(() => db.users)
})
function userTableSpec(setUp: () => UserTable) {
let table: UserTable
beforeEach(() => {
table = setUp()
})
test("", async () => {
const userId = uuidV5()
await table.createOrSetUserAsAdmin()
})
}

@ -8,6 +8,26 @@ export interface UserTable {
createOrSetUserAsAdmin(id: string): Promise<void> createOrSetUserAsAdmin(id: string): Promise<void>
} }
export class UserTableInMemory implements UserTable {
readonly users: { [id: string]: boolean } = {}
createOrSetUserAsAdmin(id: string): Promise<void> {
this.users[id] = true
return Promise.resolve();
}
createUserOrUpdateActiveTime(id: string): Promise<void> {
if (!Object.hasOwn(this.users, id)) {
this.users[id] = false
}
return Promise.resolve();
}
getUserExistsAndIsAdmin(id: string): Promise<boolean> {
return Promise.resolve(Object.hasOwn(this.users, id) && this.users[id]);
}
}
export class UserTableImpl implements UserTable { export class UserTableImpl implements UserTable {
private readonly _query: QueryType private readonly _query: QueryType

@ -2,19 +2,27 @@ import {Snowflake} from "discord-api-types/globals";
import {CharacterTable} from "../database/characters"; import {CharacterTable} from "../database/characters";
export class CharacterManager { export class CharacterManager {
private
constructor({characters}: {characters: CharacterTable}) { constructor({characters}: { characters: CharacterTable }) {
} }
async startNewCharacter(user: Snowflake, overwrite: boolean): Promise<{alreadyEditing: boolean, created: boolean}> { async startNewCharacter(
user: Snowflake,
overwrite: boolean
): Promise<{ alreadyEditing: boolean; created: boolean }> {
throw Error("not yet implemented");
} }
async abandonEditedCharacter(user: Snowflake): Promise<{abandoned: boolean}> { async abandonEditedCharacter(
user: Snowflake
): Promise<{ abandoned: boolean }> {
throw Error("not yet implemented");
} }
async saveEditedCharacter(user: Snowflake, asNew?: boolean): Promise<{ id: string, name: string, title: string }|{ id: null }> { async saveEditedCharacter(
user: Snowflake,
asNew?: boolean
): Promise<{ id: string; name: string; title: string } | { id: null }> {
throw Error("not yet implemented");
} }
} }

@ -0,0 +1,7 @@
import {describe} from "@jest/globals";
export const integrationDescribe = (process.env.RUN_INTEGRATION_TESTS === "1") ? describe : describe.skip
export function nonDiscordTestUserId() {
}
Loading…
Cancel
Save