Migrate to graphile-migrate and pg directly because prisma failed me.

main
Mari 3 years ago
parent 931dc488cd
commit 33b084b575
  1. 6
      .env.example
  2. 28
      .gitignore
  3. 134
      .gmrc
  4. 14
      .idea/dataSources.xml
  5. 6
      .idea/sqldialects.xml
  6. 43
      .idea/watcherTasks.xml
  7. 32
      migrations/committed/000001-discordchannel-table.sql
  8. 21
      migrations/committed/000002-gender-table-and-initial-values.sql
  9. 31
      migrations/committed/000003-player-table.sql
  10. 16
      migrations/committed/000004-discorduser-table.sql
  11. 16
      migrations/committed/000005-unittier-table.sql
  12. 24
      migrations/committed/000006-unit-table.sql
  13. 59
      migrations/committed/000007-summonedunit-table.sql
  14. 367
      migrations/committed/000008-all-the-procedures.sql
  15. 6
      migrations/committed/000009-fix-webhooktoken-size.sql
  16. 10
      migrations/committed/000010-fix-function-volatility.sql
  17. 197
      migrations/committed/000011-fix-some-more-procedures.sql
  18. 33
      migrations/committed/000012-fix-command_join-again.sql
  19. 66
      migrations/committed/000013-pull-command-skeleton.sql
  20. 542
      migrations/dump.sql
  21. 1533
      package-lock.json
  22. 18
      package.json
  23. 185
      prisma/schema.prisma
  24. 10
      src/BaseServer.ts
  25. 9
      src/CookieHelpers.ts
  26. 39
      src/GameServer.ts
  27. 15
      src/app.ts
  28. 46
      src/commands/debug/UnjoinCommand.ts
  29. 59
      src/commands/game/JoinCommand.ts
  30. 28
      src/commands/game/PullCommand.ts
  31. 26
      src/commands/permissions/ChannelPermissions.ts
  32. 95
      src/queries/ChannelManager.ts
  33. 48
      src/queries/ErrorCodes.ts
  34. 9
      src/queries/Prisma.ts
  35. 13
      src/queries/QueryHelpers.ts
  36. 133
      src/queries/UserManager.ts
  37. 44
      src/tools/LoadGenders.ts
  38. 62
      src/tools/LoadJsonWebhooks.ts
  39. 2
      tsconfig.json

@ -2,4 +2,8 @@ DISCORD_BOT_TOKEN=
DISCORD_PUBLIC_KEY= DISCORD_PUBLIC_KEY=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=
DISCORD_APP_ID= DISCORD_APP_ID=
HTTP_PORT=5244 COOKIE_SECRET=
HTTP_PORT=5244
DATABASE_URL=
SHADOW_DATABASE_URL=
ROOT_DATABASE_URL=

28
.gitignore vendored

@ -1,4 +1,30 @@
/build/ /build/
/node_modules/ /node_modules/
.env .env
/runtime/ /runtime/
/migrations/current.sql
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Editor-based Rest Client
.idea/httpRequests

134
.gmrc

@ -0,0 +1,134 @@
/*
* Graphile Migrate configuration.
*
* If you decide to commit this file (recommended) please ensure that it does
* not contain any secrets (passwords, etc) - we recommend you manage these
* with environmental variables instead.
*
* This file is in JSON5 format, in VSCode you can use "JSON with comments" as
* the file format.
*/
{
/*
* connectionString: this tells Graphile Migrate where to find the database
* to run the migrations against.
*
* RECOMMENDATION: use `DATABASE_URL` envvar instead.
*/
// "connectionString": "postgres://appuser:apppassword@host:5432/appdb",
/*
* shadowConnectionString: like connectionString, but this is used for the
* shadow database (which will be reset frequently).
*
* RECOMMENDATION: use `SHADOW_DATABASE_URL` envvar instead.
*/
// "shadowConnectionString": "postgres://appuser:apppassword@host:5432/appdb_shadow",
/*
* rootConnectionString: like connectionString, but this is used for
* dropping/creating the database in `graphile-migrate reset`. This isn't
* necessary, shouldn't be used in production, but helps during development.
*
* RECOMMENDATION: use `ROOT_DATABASE_URL` envvar instead.
*/
// "rootConnectionString": "postgres://adminuser:adminpassword@host:5432/postgres",
/*
* pgSettings: key-value settings to be automatically loaded into PostgreSQL
* before running migrations, using an equivalent of `SET LOCAL <key> TO
* <value>`
*/
"pgSettings": {
// "search_path": "app_public,app_private,app_hidden,public",
},
/*
* placeholders: substituted in SQL files when compiled/executed. Placeholder
* keys should be prefixed with a colon and in all caps, like
* `:COLON_PREFIXED_ALL_CAPS`. Placeholder values should be strings. They
* will be replaced verbatim with NO ESCAPING AT ALL (this differs from how
* psql handles placeholders) so should only be used with "safe" values. This
* is useful for committing migrations where certain parameters can change
* between environments (development, staging, production) but you wish to
* use the same signed migration files for all.
*
* The special value "!ENV" can be used to indicate an environmental variable
* of the same name should be used.
*
* Graphile Migrate automatically sets the `:DATABASE_NAME` and
* `:DATABASE_OWNER` placeholders, and you should not attempt to override
* these.
*/
"placeholders": {
// ":DATABASE_VISITOR": "!ENV", // Uses process.env.DATABASE_VISITOR
},
/*
* Actions allow you to run scripts or commands at certain points in the
* migration lifecycle. SQL files are ran against the database directly.
* "command" actions are ran with the following environmental variables set:
*
* - GM_DBURL: the PostgreSQL URL of the database being migrated
* - GM_DBNAME: the name of the database from GM_DBURL
* - GM_DBUSER: the user from GM_DBURL
* - GM_SHADOW: set to 1 if the shadow database is being migrated, left unset
* otherwise
*
* If "shadow" is unspecified, the actions will run on events to both shadow
* and normal databases. If "shadow" is true the action will only run on
* actions to the shadow DB, and if false only on actions to the main DB.
*/
/*
* afterReset: actions executed after a `graphile-migrate reset` command.
*/
"afterReset": [
// "afterReset.sql",
// { "_": "command", "command": "graphile-worker --schema-only" },
],
/*
* afterAllMigrations: actions executed once all migrations are complete.
*/
"afterAllMigrations": [
// {
// "_": "command",
// "shadow": true,
// "command": "if [ \"$IN_TESTS\" != \"1\" ]; then ./scripts/dump-db; fi",
// },
],
/*
* afterCurrent: actions executed once the current migration has been
* evaluated (i.e. in watch mode).
*/
"afterCurrent": [
// {
// "_": "command",
// "shadow": true,
// "command": "if [ \"$IN_TESTS\" = \"1\" ]; then ./scripts/test-seed; fi",
// },
],
/*
* blankMigrationContent: content to be written to the current migration
* after commit. NOTE: this should only contain comments.
*/
// "blankMigrationContent": "-- Write your migration here\n",
/****************************************************************************\
*** ***
*** You probably don't want to edit anything below here. ***
*** ***
\****************************************************************************/
/*
* manageGraphileMigrateSchema: if you set this false, you must be sure to
* keep the graphile_migrate schema up to date yourself. We recommend you
* leave it at its default.
*/
// "manageGraphileMigrateSchema": true,
/*
* migrationsFolder: path to the folder in which to store your migrations.
*/
// migrationsFolder: "./migrations",
"//generatedWith": "1.2.0"
}

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="Local Development Database" read-only="true" uuid="f45c1a42-afba-45e3-a87c-3a0e1ff84111">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<schema-control>AUTOMATIC</schema-control>
<rewrite-bounds>false</rewrite-bounds>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/gacha-prod</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>

@ -1,45 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="Pug/Jade"> <component name="ProjectTasksOptions" suppressed-tasks="Pug/Jade" />
<TaskOptions isEnabled="true">
<option name="arguments" value="format" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="prisma" />
<option name="immediateSync" value="true" />
<option name="name" value="Schema reformat" />
<option name="output" value="$PROJECT_DIR$/prisma/schema.prisma" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="$PROJECT_DIR$/node_modules/.bin/prisma" />
<option name="runOnExternalChanges" value="false" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="false" />
<option name="workingDir" value="$PROJECT_DIR$" />
<envs />
</TaskOptions>
<TaskOptions isEnabled="true">
<option name="arguments" value="generate" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="prisma" />
<option name="immediateSync" value="true" />
<option name="name" value="Schema Regeneration" />
<option name="output" value="$PROJECT_DIR$/node_modules/@prisma/client" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="$PROJECT_DIR$/node_modules/.bin/prisma" />
<option name="runOnExternalChanges" value="true" />
<option name="scopeName" value="prisma" />
<option name="trackOnlyRoot" value="false" />
<option name="workingDir" value="$PROJECT_DIR$" />
<envs />
</TaskOptions>
</component>
</project> </project>

@ -0,0 +1,32 @@
--! Previous: -
--! Hash: sha1:183d67042c756db13c07c3f3c6b8bd5301d47239
--! Message: DiscordChannel table
--- Table used to manage Discord channels known to the server and permissions thereon.
CREATE TABLE IF NOT EXISTS DiscordChannel
(
--- The ID of the channel in Discord, as a decimal string.
discordId VARCHAR(20) PRIMARY KEY NOT NULL,
--- The last known name of this channel.
name VARCHAR(100) NOT NULL,
--- True if this channel should be used to broadcast public game events.
broadcastGame BOOLEAN NOT NULL DEFAULT FALSE,
--- True if this channel should be used to send logs.
sendLogs BOOLEAN NOT NULL DEFAULT FALSE,
--- True if this channel can accept game commands.
acceptGameCommands BOOLEAN NOT NULL DEFAULT FALSE,
--- True if this channel can accept admin commands.
acceptAdminCommands BOOLEAN NOT NULL DEFAULT FALSE,
--- The priority of this channel when slowing down to account for rate limits. Higher is more important.
priority SMALLINT NOT NULL DEFAULT 0,
--- The snowflake ID of the guild in which this channel exists, if it's known.
guildId VARCHAR(20) DEFAULT NULL,
--- The snowflake ID of the webhook used to post to this channel. Nulled out if the webhook 404s.
webhookId VARCHAR(20) DEFAULT NULL,
--- The webhook token used to post to this channel. Nulled out if the webhook 404s.
webhookToken VARCHAR(20) DEFAULT NULL,
--- Verifies that the webhook ID and token are always set or unset together.
CONSTRAINT DiscordChannel_WebhookPair CHECK (
(webhookId IS NULL AND webhookToken IS NULL)
OR (webhookId IS NOT NULL AND webhookToken IS NOT NULL))
);

@ -0,0 +1,21 @@
--! Previous: sha1:183d67042c756db13c07c3f3c6b8bd5301d47239
--! Hash: sha1:848f1f09a7cd93047aa139d0c4fc203f9ab6ccc4
--! Message: Gender table and initial values
--- Table of user genders.
CREATE TABLE IF NOT EXISTS Gender
(
--- The internal ID, usually a few lower-case characters roughly representing this gender.
id VARCHAR(8) PRIMARY KEY NOT NULL,
--- The name of this gender for use in tables et al. This should be the name of the gender
--- (e.g., "Female", "Male", "Nonbinary") not the name for a person of that gender.
name VARCHAR(100) UNIQUE NOT NULL
);
--- Default genders.
INSERT INTO Gender
(id, name)
VALUES ('f', 'Female'),
('nb', 'Non-binary'),
('m', 'Male')
ON CONFLICT DO NOTHING;

@ -0,0 +1,31 @@
--! Previous: sha1:848f1f09a7cd93047aa139d0c4fc203f9ab6ccc4
--! Hash: sha1:d16f7d7c74ee0a1f58ac87a09de124d165088661
--! Message: Player table
--- Table of in-game user data structures.
CREATE TABLE IF NOT EXISTS Player
(
--- The internal ID associated with this account.
--- It's separate from the Discord ID associated with this account. This supports a few things:
--- 1) We can move the game off of Discord, or add the ability to play it as a separate phone app or webapp,
--- without losing our database.
--- 2) If necessary, we can support having multiple Discord users associated with the same user account, to
--- support multi-account play.
--- 3) If necessary, we can support changing the Discord user associated with a user account, if for any reason
--- they are no longer using the old account and want to switch to a new account.
id SERIAL PRIMARY KEY NOT NULL,
--- The user's name, for the purposes of the game. This is completely separate from both their username and nickname
--- as Discord sees it, though it defaults to their nickname at time of joining. It does not have to be unique, and
--- can be changed at any time.
name VARCHAR(100) NOT NULL,
--- The user's gender, for the purposes of the game. This is purely cosmetic and can be changed at any time.
genderId VARCHAR(8) NOT NULL REFERENCES Gender (id) ON DELETE RESTRICT ON UPDATE CASCADE,
--- The number of units of currency this user is currently carrying.
currency INT NOT NULL,
--- The time and date at which this user joined.
joinedAt TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
--- The last time this user used a command.
lastActive TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
--- The last time this user retrieved their daily resources.
lastDaily TIMESTAMP WITH TIME ZONE DEFAULT NULL
)

@ -0,0 +1,16 @@
--! Previous: sha1:d16f7d7c74ee0a1f58ac87a09de124d165088661
--! Hash: sha1:b34f19ae3e3ef87d0d7539803df087e886e1db1c
--! Message: DiscordUser table
--- Table of information known about Discord users.
CREATE TABLE IF NOT EXISTS DiscordUser
(
--- The Discord ID this record is for. A Discord snowflake.
discordId VARCHAR(20) PRIMARY KEY NOT NULL,
--- The last known username associated with this user.
username VARCHAR(32) NOT NULL,
--- The last known discriminator associated with this user.
discriminator VARCHAR(4) NOT NULL,
--- The Player that this DiscordUser is associated with.
playerId INT REFERENCES Player (id) ON DELETE SET NULL ON UPDATE CASCADE
)

@ -0,0 +1,16 @@
--! Previous: sha1:b34f19ae3e3ef87d0d7539803df087e886e1db1c
--! Hash: sha1:8243b031500fde9c022d6aada10a429496dc264d
--! Message: UnitTier table
--- Table of definitions of unit tiers.
CREATE TABLE IF NOT EXISTS UnitTier
(
--- The internal ID associated with this tier, a few short characters.
id VARCHAR(8) NOT NULL PRIMARY KEY,
--- The human-readable name of this tier. Unique among tiers.
name VARCHAR(100) NOT NULL UNIQUE,
--- The chance of pulling a unit of this tier.
pullWeight INT NOT NULL,
--- The cost of /recalling a unit of this tier.
recallCost INT NOT NULL
)

@ -0,0 +1,24 @@
--! Previous: sha1:8243b031500fde9c022d6aada10a429496dc264d
--! Hash: sha1:8102b0362d2ae5b73ed1d56214f7fffa445c469e
--! Message: Unit table
--- Table of definitions of units that can be summoned.
CREATE TABLE IF NOT EXISTS Unit
(
--- The internal ID associated with this unit.
id SERIAL NOT NULL PRIMARY KEY,
--- The name of this unit.
name VARCHAR(50) NOT NULL,
--- The subtitle of this unit.
subtitle VARCHAR(50) NOT NULL,
--- The description of this unit.
description TEXT NOT NULL,
--- The tier of this unit.
tierId VARCHAR(8) NOT NULL REFERENCES UnitTier (id) ON DELETE RESTRICT ON UPDATE CASCADE,
--- The unit's base health when summoned for the first time.
baseHealth INT NOT NULL CHECK ( baseHealth > 0 ),
--- The unit's base strength when summoned for the first time.
baseStrength INT NOT NULL CHECK ( baseStrength > 0 ),
--- The combination of Name and Subtitle is unique among units, allowing for multiple versions of a unit.
UNIQUE (name, subtitle)
)

@ -0,0 +1,59 @@
--! Previous: sha1:8102b0362d2ae5b73ed1d56214f7fffa445c469e
--! Hash: sha1:8a890a4c8807eb5a7aae90456ba1e7712cda502b
--! Message: SummonedUnit table
--- Connection between Players and Units, indicating how and when players have summoned this unit.
CREATE TABLE IF NOT EXISTS SummonedUnit
(
--- The ID of this summoning instance.
instanceId SERIAL PRIMARY KEY NOT NULL,
--- The Player that summoned this unit at some point.
playerId INT NOT NULL REFERENCES Player (id) ON DELETE CASCADE ON UPDATE CASCADE,
--- The Unit that was summoned by this Player at some point.
unitId INT NOT NULL REFERENCES Unit (id) ON DELETE CASCADE ON UPDATE CASCADE,
--- The time and date this instance was summoned by pulling or recalling.
summonedAt TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
--- True if this instance was summoned by /pull (as opposed to /recall).
wasPulled BOOLEAN NOT NULL DEFAULT FALSE,
--- True if this instance was summoned by /recall (as opposed to /pull).
wasRecalled BOOLEAN NOT NULL DEFAULT FALSE,
--- The timestamps when this unit was resummoned because it appeared in a /pull while already summoned.
--- Does not include the initial summoning if that summoning happened via /pull.
resummonings TIMESTAMP WITH TIME ZONE ARRAY DEFAULT NULL,
--- The time and date this instance was desummoned by digesting or releasing.
desummonedAt TIMESTAMP WITH TIME ZONE DEFAULT NULL,
--- The summoned unit (friendly or enemy) that digested this unit, if a summoned unit was responsible and that
--- summoned unit has not been deleted somehow.
digestedByInstanceId INT DEFAULT NULL REFERENCES SummonedUnit (instanceId) ON DELETE SET NULL ON UPDATE CASCADE,
--- True if this instance was desummoned by being digested rather than being released.
wasDigested BOOLEAN DEFAULT NULL,
--- True if this instance was desummoned by being released rather than being digested.
wasReleased BOOLEAN DEFAULT NULL,
--- The unit's current health. If 0, the unit is unconscious and cannot participate in fights.
--- At -MaxHealth, the unit has been fully digested and this record will be deleted.
currentHealth INT NOT NULL,
--- The unit's maximum health.
maxHealth INT NOT NULL CHECK (maxHealth > 0),
--- The unit's strength.
strength INT NOT NULL CHECK (strength > 0),
--- The unit's current health must be between maxHealth and -maxHealth (the latter of which means digestion).
CONSTRAINT SummonedUnit_CurrentHealthBounds CHECK (
currentHealth BETWEEN -maxHealth AND maxHealth),
--- Exactly one of wasPulled or wasRecalled must be TRUE.
CONSTRAINT SummonedUnit_ExactlyOneOrigin CHECK (
((wasPulled IS TRUE OR wasRecalled IS TRUE) AND NOT (wasPulled IS FALSE AND wasRecalled IS FALSE))),
--- Exactly one of wasDigested or wasReleased must be TRUE if desummonedAt is set,
--- and both must be NULL if desummonedAt is NULL.
CONSTRAINT SummonedUnit_ExactlyOneFate CHECK (
((wasDigested IS TRUE OR wasReleased IS TRUE) = (desummonedAt IS NOT NULL))
AND (wasDigested IS NULL OR wasReleased IS NULL) = (desummonedAt IS NULL)),
--- The digesting summoned unit's instance ID must be set only if wasDigested is TRUE.
CONSTRAINT SummonedUnit_DigesterForDigestedOnly CHECK (
digestedByInstanceId IS NULL OR wasDigested IS TRUE)
);
--- No more than one instance of a particular unit may be summoned by the same player.
--- Once the previous instance has been desummoned (by any method), the unit may be summoned again.
CREATE UNIQUE INDEX IF NOT EXISTS SummonedUnit_OneInstancePerUnitPerPlayer
ON SummonedUnit (playerId, unitId)
WHERE (desummonedAt IS NULL);

@ -0,0 +1,367 @@
--! Previous: sha1:8a890a4c8807eb5a7aae90456ba1e7712cda502b
--! Hash: sha1:d45df95e7f3a77f0a9f7a8bfc6a93cfbebc1c61e
--! Message: all the procedures
-- Create indices needed for ChannelManager commands.
CREATE INDEX IF NOT EXISTS DiscordChannel_GuildId ON DiscordChannel (guildId) WHERE guildId IS NOT NULL;
CREATE INDEX IF NOT EXISTS DiscordChannel_AcceptsGameCommands ON DiscordChannel (acceptGameCommands) WHERE acceptGameCommands IS TRUE;
CREATE INDEX IF NOT EXISTS DiscordChannel_AcceptsAdminCommands ON DiscordChannel (acceptAdminCommands) WHERE acceptAdminCommands IS TRUE;
ALTER TABLE DiscordUser
ADD COLUMN IF NOT EXISTS lastActive TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW();
--- Gets guild IDs where game commands can be used and thus guild commands must be pushed
CREATE OR REPLACE FUNCTION GetGuildIDsAbleToUseGameCommands()
RETURNS SETOF DiscordChannel.GuildId%TYPE
STABLE
ROWS 1
AS
$$
SELECT DISTINCT guildId
FROM DiscordChannel
WHERE guildId IS NOT NULL
AND acceptGameCommands IS TRUE;
$$
LANGUAGE 'sql';
--- Gets whether the users may use game commands in the current channel/guild.
--- Error codes:
--- VGBCG: Bad channel (game). This is not a valid channel to send game commands in.
--- VGBGG: Bad guild (game). This is not a valid guild to send game commands in.
CREATE OR REPLACE PROCEDURE CheckGameCommandIn(
IN requestedChannel DiscordChannel.DiscordId%TYPE,
IN requestedGuild DiscordChannel.GuildId%TYPE)
AS
$$
DECLARE
channelAcceptsGameCommands BOOLEAN;
channelAcceptsAdminCommands BOOLEAN;
channelSendsMessages BOOLEAN;
channelIsKnown BOOLEAN;
guildSupportsGameCommands BOOLEAN;
guildSupportsAdminCommands BOOLEAN;
guildSupportsMessages BOOLEAN;
guildIsKnown BOOLEAN;
recommendedChannelId DiscordChannel.DiscordId%TYPE;
BEGIN
SELECT acceptGameCommands, acceptAdminCommands, broadcastGame OR sendLogs, TRUE
INTO channelAcceptsGameCommands, channelAcceptsAdminCommands, channelSendsMessages, channelIsKnown
FROM DiscordChannel
WHERE discordId = requestedChannel
LIMIT 1;
IF channelAcceptsGameCommands IS NOT TRUE THEN
SELECT discordId, acceptGameCommands, acceptAdminCommands, broadcastGame OR sendLogs, TRUE
INTO recommendedChannelId, guildSupportsGameCommands, guildSupportsAdminCommands, guildSupportsMessages, guildIsKnown
FROM DiscordChannel
WHERE guildId = requestedGuild
ORDER BY CASE TRUE
WHEN acceptGameCommands THEN 3
WHEN acceptAdminCommands THEN 2
WHEN broadcastGame OR sendLogs THEN 1
ELSE 0
END DESC, priority DESC
LIMIT 1;
IF guildSupportsGameCommands IS TRUE THEN
RAISE EXCEPTION 'Can''t use game commands in this channel' USING
ERRCODE = 'VGBCG',
DETAIL = CASE TRUE
WHEN channelAcceptsGameCommands THEN format(
'This channel (<#%>) can only be used to send admin commands, not game commands.',
requestedChannel)
WHEN channelSendsMessages THEN format(
'This channel (<#%>) is only used to receive broadcasts, not send commands.',
requestedChannel)
WHEN channelIsKnown THEN format(
'This channel (<#%>) is unused.', requestedChannel)
ELSE format('This channel (<#%>) is not known to the system.', requestedChannel)
END,
HINT = format(
'Try sending messages to the channel <#%> in this guild, which does allow game commands.',
recommendedChannelId);
ELSE
RAISE EXCEPTION 'Can''t use game commands in this guild' USING
ERRCODE = 'VGBGG',
DETAIL = CASE TRUE
WHEN guildSupportsAdminCommands THEN
format('This guild (ID %) only has channels used to send admin commands, ' ||
'not game commands.', requestedGuild)
WHEN guildSupportsMessages THEN
format('This guild (ID %) only has channels used to receive broadcasts, ' ||
'not send commands.', requestedGuild)
WHEN guildIsKnown THEN
format('This guild (ID %) only has unused channels.', requestedGuild)
ELSE format('This guild (ID %) is not known to the system.', requestedGuild)
END,
HINT = 'As game commands are normally only visible when a guild allows them, ' ||
'this guild may have been removed from the system incorrectly. ' ||
'Ask an admin to check what''s going on.';
END IF;
END IF;
END;
$$ LANGUAGE 'plpgsql';
--- Gets guild IDs where admin commands can be used and thus guild commands must be pushed
CREATE OR REPLACE FUNCTION GetGuildIDsAbleToUseAdminCommands()
RETURNS SETOF DiscordChannel.GuildId%TYPE
STABLE
ROWS 1
AS
$$
SELECT DISTINCT guildId
FROM DiscordChannel
WHERE guildId IS NOT NULL
AND acceptAdminCommands IS TRUE;
$$
LANGUAGE 'sql';
--- Gets whether the users may use admin commands in the current channel/guild.
--- Error codes:
--- VGBCA: Bad channel (admin). This is not a valid channel to send admin commands in.
--- VGBGA: Bad guild (admin). This is not a valid guild to send admin commands in.
CREATE OR REPLACE PROCEDURE CheckAdminCommandIn(
IN requestedChannel DiscordChannel.DiscordId%TYPE,
IN requestedGuild DiscordChannel.GuildId%TYPE)
AS
$$
DECLARE
channelAcceptsAdminCommands BOOLEAN;
channelAcceptsGameCommands BOOLEAN;
channelSendsMessages BOOLEAN;
channelIsKnown BOOLEAN;
guildSupportsAdminCommands BOOLEAN;
guildSupportsGameCommands BOOLEAN;
guildSupportsMessages BOOLEAN;
guildIsKnown BOOLEAN;
recommendedChannelId DiscordChannel.DiscordId%TYPE;
BEGIN
SELECT acceptAdminCommands, acceptGameCommands, broadcastGame OR sendLogs, TRUE
INTO channelAcceptsAdminCommands, channelAcceptsGameCommands, channelSendsMessages, channelIsKnown
FROM DiscordChannel
WHERE discordId = requestedChannel
LIMIT 1;
IF channelAcceptsAdminCommands IS NOT TRUE THEN
SELECT discordId, acceptAdminCommands, acceptGameCommands, broadcastGame OR sendLogs, TRUE
INTO recommendedChannelId, guildSupportsAdminCommands, guildSupportsGameCommands, guildSupportsMessages, guildIsKnown
FROM DiscordChannel
WHERE guildId = requestedGuild
ORDER BY CASE TRUE
WHEN acceptAdminCommands THEN 3
WHEN acceptGameCommands THEN 2
WHEN broadcastGame OR sendLogs THEN 1
ELSE 0
END DESC, priority DESC
LIMIT 1;
IF guildSupportsAdminCommands IS TRUE THEN
RAISE EXCEPTION 'Can''t use admin commands in this channel' USING
ERRCODE = 'VGBCA',
DETAIL = CASE TRUE
WHEN channelAcceptsGameCommands THEN format(
'This channel (<#%>) can only be used to send admin commands, not game commands.',
requestedChannel)
WHEN channelSendsMessages THEN format(
'This channel (<#%>) is only used to receive broadcasts, not send commands.',
requestedChannel)
WHEN channelIsKnown THEN format(
'This channel (<#%>) is unused.', requestedChannel)
ELSE format('This channel (<#%>) is not known to the system.', requestedChannel)
END,
HINT = format(
'Try sending messages to the channel <#%> in this guild, which does allow game commands.',
recommendedChannelId);
ELSE
RAISE EXCEPTION 'Can''t use admin commands in this guild' USING
ERRCODE = 'VGBGA',
DETAIL = CASE TRUE
WHEN guildSupportsGameCommands THEN
format('This guild (ID %) only has channels used to send game commands, ' ||
'not admin commands.', requestedGuild)
WHEN guildSupportsMessages THEN
format('This guild (ID %) only has channels used to receive broadcasts, ' ||
'not send commands.', requestedGuild)
WHEN guildIsKnown THEN
format('This guild (ID %) only has unused channels.', requestedGuild)
ELSE format('This guild (ID %) is not known to the system.', requestedGuild)
END,
HINT = 'As admin commands are normally only visible when a guild allows them, ' ||
'this guild may have been removed from the system incorrectly. ' ||
'Ask an admin to check what''s going on.';
END IF;
END IF;
END;
$$ LANGUAGE 'plpgsql';
--- Gets the list of genders that can be used to register and their corresponding IDs.
CREATE OR REPLACE FUNCTION GetRegisterableGenders(
OUT id Gender.id%TYPE,
OUT name Gender.name%TYPE)
RETURNS SETOF RECORD
STRICT
STABLE
AS
$$
SELECT Gender.id, Gender.name
FROM Gender
$$
LANGUAGE 'sql';
--- Updates a Discord user's username and discriminator, as well as their last active timestamp.
--- Creates the DiscordUser if they weren't previously recorded in the system.
CREATE OR REPLACE FUNCTION GetInvokingDiscordUser(
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE)
RETURNS DiscordUser
STRICT
STABLE
AS
$$
INSERT INTO DiscordUser (discordId, username, discriminator, lastActive)
VALUES (forId, newUsername, newDiscriminator, NOW())
ON CONFLICT (discordId) DO UPDATE SET username = newUsername,
discriminator = newDiscriminator,
lastActive = NOW()
RETURNING *
$$
LANGUAGE 'sql';
--- Gets (and updates the lastActive timestamps of) the player corresponding to the Discord user given by ID.
--- If such a player does not exist, NULL will be returned and changes will only be made to the DiscordUser table.
CREATE OR REPLACE FUNCTION GetInvokingPlayer(
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE)
RETURNS Player
STRICT
STABLE
AS
$$
UPDATE Player
SET lastActive = NOW()
FROM GetInvokingDiscordUser(forId, newUsername, newDiscriminator) AS InvokingDiscordUser
WHERE id = InvokingDiscordUser.playerId
RETURNING ROW (Player.*)
$$ LANGUAGE 'sql';
--- Adds a new player, or updates the existing player's name and gender.
CREATE OR REPLACE FUNCTION UpdatePlayerRegistration(
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE,
IN newPlayerName Player.name%TYPE,
IN newGenderId Gender.id%TYPE,
OUT resultId Player.id%TYPE,
OUT wasCreated BOOLEAN)
RETURNS RECORD
STRICT
STABLE
AS
$$
DECLARE
playerId Player.id%TYPE;
BEGIN
SELECT InvokingDiscordUser.playerId
INTO STRICT playerId
FROM GetInvokingDiscordUser(forId, newUsername, newDiscriminator)
AS InvokingDiscordUser;
IF playerId IS NOT NULL THEN
wasCreated = FALSE;
UPDATE Player
SET name = newPlayerName,
genderId = newGenderId,
lastActive = NOW()
WHERE id = playerId
RETURNING id INTO resultId;
ELSE
wasCreated = TRUE;
INSERT INTO Player (name, genderId, currency, joinedAt, lastActive)
VALUES (newPlayerName, newGenderId, 100, NOW(), NOW())
RETURNING id INTO resultId;
END IF;
END;
$$
LANGUAGE 'plpgsql';
--- Removes the link between a DiscordUser and their Player.
--- Returns the previous player if one existed, or NULL if not.
CREATE OR REPLACE FUNCTION UnlinkDiscordUserFromPlayer(
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE)
RETURNS Player.id%TYPE
STRICT
STABLE
AS
$$
DECLARE
oldPlayerId Player.id%TYPE = NULL;
BEGIN
SELECT playerId
INTO oldPlayerId
FROM GetInvokingDiscordUser(forId, newUsername, newDiscriminator);
IF oldPlayerId IS NULL THEN
RETURN NULL;
END IF;
UPDATE DiscordUser
SET playerId = NULL
WHERE discordId = forId;
RETURN oldPlayerId;
END;
$$
LANGUAGE 'plpgsql';
--- Runs the full /join command.
--- Error codes:
--- VGBCG: Bad channel (game). This is not a valid channel to send game commands in.
--- VGBGG: Bad guild (game). This is not a valid guild to send game commands in.
CREATE OR REPLACE FUNCTION Command_Join(
IN requestedChannel DiscordChannel.DiscordId%TYPE,
IN requestedGuild DiscordChannel.GuildId%TYPE,
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE,
IN newPlayerName Player.name%TYPE,
IN newGenderId Gender.id%TYPE,
OUT resultId Player.id%TYPE,
OUT newPlayerName Player.name%TYPE,
OUT newGenderName Gender.name%TYPE,
OUT wasCreated BOOLEAN
)
RETURNS RECORD
STRICT
STABLE
AS
$$
CALL CheckGameCommandIn(requestedChannel, requestedGuild);
SELECT NewRegistration.resultId, Player.name, Gender.name, NewRegistration.wasCreated
FROM UpdatePlayerRegistration(forId, newUsername, newDiscriminator, newPlayerName, newGenderId) AS NewRegistration
INNER JOIN Player ON Player.id = NewRegistration.resultId
INNER JOIN Gender ON Gender.id = Player.genderId
$$
LANGUAGE 'sql';
--- Runs the full /unjoin command.
--- Error codes:
--- VGBCG: Bad channel (game). This is not a valid channel to send game commands in.
--- VGBGG: Bad guild (game). This is not a valid guild to send game commands in.
CREATE OR REPLACE FUNCTION Command_Unjoin(
IN requestedChannel DiscordChannel.DiscordId%TYPE,
IN requestedGuild DiscordChannel.GuildId%TYPE,
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE
)
RETURNS Player.id%TYPE
STRICT
STABLE
AS
$$
CALL CheckGameCommandIn(requestedChannel, requestedGuild);
SELECT UnlinkDiscordUserFromPlayer(forId, newUsername, newDiscriminator)
$$
LANGUAGE 'sql';

@ -0,0 +1,6 @@
--! Previous: sha1:d45df95e7f3a77f0a9f7a8bfc6a93cfbebc1c61e
--! Hash: sha1:c1cb70e8c0dfc171d8f21abb14f503e47fb891bc
--! Message: fix webhookToken size
ALTER TABLE DiscordChannel
ALTER COLUMN webhookToken TYPE VARCHAR(128);

@ -0,0 +1,10 @@
--! Previous: sha1:c1cb70e8c0dfc171d8f21abb14f503e47fb891bc
--! Hash: sha1:c549ad6cc4dc88d928043916e2696eeed3efa420
--! Message: fix function volatility
ALTER FUNCTION Command_Join(varchar, varchar, varchar, varchar, varchar, varchar, varchar) VOLATILE;
ALTER FUNCTION Command_Unjoin(varchar, varchar, varchar, varchar, varchar) VOLATILE;
ALTER FUNCTION GetInvokingDiscordUser(varchar, varchar, varchar) VOLATILE;
ALTER FUNCTION GetInvokingPlayer(varchar, varchar, varchar) VOLATILE;
ALTER FUNCTION UpdatePlayerRegistration(varchar, varchar, varchar, varchar, varchar) VOLATILE;
ALTER FUNCTION UnlinkDiscordUserFromPlayer(varchar, varchar, varchar) VOLATILE;

@ -0,0 +1,197 @@
--! Previous: sha1:c549ad6cc4dc88d928043916e2696eeed3efa420
--! Hash: sha1:defb8b93ce936f1bb553abb90aaa6ecd5a00b1bc
--! Message: Fix some more procedures
--- Gets whether the users may use game commands in the current channel/guild.
--- Error codes:
--- VGBCG: Bad channel (game). This is not a valid channel to send game commands in.
--- VGBGG: Bad guild (game). This is not a valid guild to send game commands in.
CREATE OR REPLACE PROCEDURE CheckGameCommandIn(
IN requestedChannel DiscordChannel.DiscordId%TYPE,
IN requestedGuild DiscordChannel.GuildId%TYPE)
AS
$$
DECLARE
channelAcceptsGameCommands BOOLEAN;
channelAcceptsAdminCommands BOOLEAN;
channelSendsMessages BOOLEAN;
channelIsKnown BOOLEAN;
guildSupportsGameCommands BOOLEAN;
guildSupportsAdminCommands BOOLEAN;
guildSupportsMessages BOOLEAN;
guildIsKnown BOOLEAN;
recommendedChannelId DiscordChannel.DiscordId%TYPE;
BEGIN
SELECT acceptGameCommands, acceptAdminCommands, broadcastGame OR sendLogs, TRUE
INTO channelAcceptsGameCommands, channelAcceptsAdminCommands, channelSendsMessages, channelIsKnown
FROM DiscordChannel
WHERE discordId = requestedChannel
LIMIT 1;
IF channelAcceptsGameCommands IS NOT TRUE THEN
SELECT discordId, acceptGameCommands, acceptAdminCommands, broadcastGame OR sendLogs, TRUE
INTO recommendedChannelId, guildSupportsGameCommands, guildSupportsAdminCommands, guildSupportsMessages, guildIsKnown
FROM DiscordChannel
WHERE guildId = requestedGuild
ORDER BY CASE TRUE
WHEN acceptGameCommands THEN 3
WHEN acceptAdminCommands THEN 2
WHEN broadcastGame OR sendLogs THEN 1
ELSE 0
END DESC, priority DESC
LIMIT 1;
IF guildSupportsGameCommands IS TRUE THEN
RAISE EXCEPTION 'Can''t use game commands in this channel' USING
ERRCODE = 'VGBCG',
DETAIL = CASE TRUE
WHEN channelAcceptsGameCommands THEN format(
'This channel (<#%s>) can only be used to send admin commands, not game commands.',
requestedChannel)
WHEN channelSendsMessages THEN format(
'This channel (<#%s>) is only used to receive broadcasts, not send commands.',
requestedChannel)
WHEN channelIsKnown THEN format(
'This channel (<#%s>) is unused.', requestedChannel)
ELSE format('This channel (<#%s>) is not known to the system.', requestedChannel)
END,
HINT = format(
'Try sending messages to the channel <#%s> in this guild, which does allow game commands.',
recommendedChannelId);
ELSE
RAISE EXCEPTION 'Can''t use game commands in this guild' USING
ERRCODE = 'VGBGG',
DETAIL = CASE TRUE
WHEN guildSupportsAdminCommands THEN
format('This guild (ID %) only has channels used to send admin commands, ' ||
'not game commands.', requestedGuild)
WHEN guildSupportsMessages THEN
format('This guild (ID %) only has channels used to receive broadcasts, ' ||
'not send commands.', requestedGuild)
WHEN guildIsKnown THEN
format('This guild (ID %) only has unused channels.', requestedGuild)
ELSE format('This guild (ID %) is not known to the system.', requestedGuild)
END,
HINT = 'As game commands are normally only visible when a guild allows them, ' ||
'this guild may have been removed from the system incorrectly. ' ||
'Ask an admin to check what''s going on.';
END IF;
END IF;
END;
$$ LANGUAGE 'plpgsql';
--- Gets whether the users may use admin commands in the current channel/guild.
--- Error codes:
--- VGBCA: Bad channel (admin). This is not a valid channel to send admin commands in.
--- VGBGA: Bad guild (admin). This is not a valid guild to send admin commands in.
CREATE OR REPLACE PROCEDURE CheckAdminCommandIn(
IN requestedChannel DiscordChannel.DiscordId%TYPE,
IN requestedGuild DiscordChannel.GuildId%TYPE)
AS
$$
DECLARE
channelAcceptsAdminCommands BOOLEAN;
channelAcceptsGameCommands BOOLEAN;
channelSendsMessages BOOLEAN;
channelIsKnown BOOLEAN;
guildSupportsAdminCommands BOOLEAN;
guildSupportsGameCommands BOOLEAN;
guildSupportsMessages BOOLEAN;
guildIsKnown BOOLEAN;
recommendedChannelId DiscordChannel.DiscordId%TYPE;
BEGIN
SELECT acceptAdminCommands, acceptGameCommands, broadcastGame OR sendLogs, TRUE
INTO channelAcceptsAdminCommands, channelAcceptsGameCommands, channelSendsMessages, channelIsKnown
FROM DiscordChannel
WHERE discordId = requestedChannel
LIMIT 1;
IF channelAcceptsAdminCommands IS NOT TRUE THEN
SELECT discordId, acceptAdminCommands, acceptGameCommands, broadcastGame OR sendLogs, TRUE
INTO recommendedChannelId, guildSupportsAdminCommands, guildSupportsGameCommands, guildSupportsMessages, guildIsKnown
FROM DiscordChannel
WHERE guildId = requestedGuild
ORDER BY CASE TRUE
WHEN acceptAdminCommands THEN 3
WHEN acceptGameCommands THEN 2
WHEN broadcastGame OR sendLogs THEN 1
ELSE 0
END DESC, priority DESC
LIMIT 1;
IF guildSupportsAdminCommands IS TRUE THEN
RAISE EXCEPTION 'Can''t use admin commands in this channel' USING
ERRCODE = 'VGBCA',
DETAIL = CASE TRUE
WHEN channelAcceptsGameCommands THEN format(
'This channel (<#%s>) can only be used to send admin commands, not game commands.',
requestedChannel)
WHEN channelSendsMessages THEN format(
'This channel (<#%s>) is only used to receive broadcasts, not send commands.',
requestedChannel)
WHEN channelIsKnown THEN format(
'This channel (<#%s>) is unused.', requestedChannel)
ELSE format('This channel (<#%s>) is not known to the system.', requestedChannel)
END,
HINT = format(
'Try sending messages to the channel <#%s> in this guild, which does allow game commands.',
recommendedChannelId);
ELSE
RAISE EXCEPTION 'Can''t use admin commands in this guild' USING
ERRCODE = 'VGBGA',
DETAIL = CASE TRUE
WHEN guildSupportsGameCommands THEN
format('This guild (ID %s) only has channels used to send game commands, ' ||
'not admin commands.', requestedGuild)
WHEN guildSupportsMessages THEN
format('This guild (ID %s) only has channels used to receive broadcasts, ' ||
'not send commands.', requestedGuild)
WHEN guildIsKnown THEN
format('This guild (ID %s) only has unused channels.', requestedGuild)
ELSE format('This guild (ID %s) is not known to the system.', requestedGuild)
END,
HINT = 'As admin commands are normally only visible when a guild allows them, ' ||
'this guild may have been removed from the system incorrectly. ' ||
'Ask an admin to check what''s going on.';
END IF;
END IF;
END;
$$ LANGUAGE 'plpgsql';
--- Adds a new player, or updates the existing player's name and gender.
CREATE OR REPLACE FUNCTION UpdatePlayerRegistration(
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE,
IN newPlayerName Player.name%TYPE,
IN newGenderId Gender.id%TYPE,
OUT resultId Player.id%TYPE,
OUT wasCreated BOOLEAN)
RETURNS RECORD
STRICT
VOLATILE
AS
$$
DECLARE
playerId Player.id%TYPE;
BEGIN
SELECT InvokingDiscordUser.playerId
INTO STRICT playerId
FROM GetInvokingDiscordUser(forId, newUsername, newDiscriminator)
AS InvokingDiscordUser;
IF playerId IS NOT NULL THEN
wasCreated = FALSE;
UPDATE Player
SET name = newPlayerName,
genderId = newGenderId,
lastActive = NOW()
WHERE id = playerId
RETURNING id INTO resultId;
ELSE
wasCreated = TRUE;
INSERT INTO Player (name, genderId, currency, joinedAt, lastActive)
VALUES (newPlayerName, newGenderId, 100, NOW(), NOW())
RETURNING id INTO resultId;
UPDATE DiscordUser
SET playerId = resultId
WHERE discordId = forId;
END IF;
END;
$$
LANGUAGE 'plpgsql';

@ -0,0 +1,33 @@
--! Previous: sha1:defb8b93ce936f1bb553abb90aaa6ecd5a00b1bc
--! Hash: sha1:00899815dc03fa792300ff69dde9d17756448635
--! Message: Fix Command_Join again
--- Runs the full /join command.
--- Error codes:
--- VGBCG: Bad channel (game). This is not a valid channel to send game commands in.
--- VGBGG: Bad guild (game). This is not a valid guild to send game commands in.
CREATE OR REPLACE FUNCTION Command_Join(
IN requestedChannel DiscordChannel.DiscordId%TYPE,
IN requestedGuild DiscordChannel.GuildId%TYPE,
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE,
IN newPlayerName Player.name%TYPE,
IN newGenderId Gender.id%TYPE,
OUT resultId Player.id%TYPE,
OUT newPlayerName Player.name%TYPE,
OUT newGenderName Gender.name%TYPE,
OUT wasCreated BOOLEAN
)
RETURNS RECORD
STRICT
VOLATILE
AS
$$
CALL CheckGameCommandIn(requestedChannel, requestedGuild);
SELECT NewRegistration.resultId, newPlayerName, Gender.name, NewRegistration.wasCreated
FROM UpdatePlayerRegistration(forId, newUsername, newDiscriminator, newPlayerName, newGenderId) AS NewRegistration
LEFT JOIN Gender ON Gender.id = newGenderId
$$
LANGUAGE 'sql';

@ -0,0 +1,66 @@
--! Previous: sha1:00899815dc03fa792300ff69dde9d17756448635
--! Hash: sha1:e7ff9a64813ea9e0a701219f6b2ba8ed88dcf240
--! Message: pull command skeleton
--- Runs the full /pull command.
--- Error codes:
--- VGBCG: Bad channel (game). This is not a valid channel to send game commands in.
--- VGBGG: Bad guild (game). This is not a valid guild to send game commands in.
--- VGNYJ: Not yet joined. The Discord user using has not joined the game yet.
--- VGNEC: Not enough currency.
CREATE OR REPLACE FUNCTION Command_Pull(
IN requestedChannel DiscordChannel.discordId%TYPE,
IN requestedGuild DiscordChannel.guildId%TYPE,
IN forId DiscordUser.discordId%TYPE,
IN newUsername DiscordUser.username%TYPE,
IN newDiscriminator DiscordUser.discriminator%TYPE,
IN count INT
)
RETURNS TABLE
(
summonedUnitInstanceId SummonedUnit.instanceId%TYPE,
summonedUnitId Unit.id%TYPE,
summonedUnitName Unit.name%TYPE,
summonedUnitSubtitle Unit.subtitle%TYPE,
summonedUnitTierName UnitTier.name%TYPE,
firstTimePull BOOLEAN,
wasAlreadySummoned BOOLEAN
)
STRICT
VOLATILE
ROWS 10
AS
$$
DECLARE
playerId Player.id%TYPE;
cost Player.currency%TYPE;
oldCurrency Player.currency%TYPE;
playerLastDaily Player.lastDaily%TYPE;
BEGIN
CALL CheckGameCommandIn(requestedChannel, requestedGuild);
SELECT InvokingPlayer.id, InvokingPlayer.currency, InvokingPlayer.lastDaily
INTO playerId, oldCurrency, playerLastDaily
FROM GetInvokingPlayer(forId, newUsername, newDiscriminator) AS InvokingPlayer;
IF playerId IS NULL THEN
RAISE EXCEPTION USING
ERRCODE = 'VGNYJ',
MESSAGE = 'Not yet joined',
DETAIL = 'You haven''t joined the game yet, and can''t use this command until you do.',
HINT = 'Use the /join command to join the game!';
END IF;
cost = 10 * count;
UPDATE Player SET currency = currency - cost WHERE id = playerId AND currency >= cost;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = 'VGNEC',
MESSAGE = 'Not enough currency',
DETAIL = format('Pulling %s heroines would cost %s currency, but you only have %s currency.',
count, cost, oldCurrency),
HINT = CASE playerLastDaily IS NULL OR playerLastDaily < NOW() - '1 day'::interval
WHEN TRUE THEN 'Try using the /daily command to get some more currency for today!'
ELSE format('Wait %s and you can use the /daily command to get some more currency!',
(playerLastDaily + '1 day'::interval) - NOW())
END;
END IF;
END;
$$ LANGUAGE 'plpgsql';

@ -0,0 +1,542 @@
create table if not exists discordchannel
(
discordid varchar(20) not null
constraint discordchannel_pkey
primary key,
name varchar(100) not null,
broadcastgame boolean default false not null,
sendlogs boolean default false not null,
acceptgamecommands boolean default false not null,
acceptadmincommands boolean default false not null,
priority smallint default 0 not null,
guildid varchar(20) default NULL::character varying,
webhookid varchar(20) default NULL::character varying,
webhooktoken varchar(128) default NULL::character varying,
constraint discordchannel_webhookpair
check (((webhookid IS NULL) AND (webhooktoken IS NULL)) OR
((webhookid IS NOT NULL) AND (webhooktoken IS NOT NULL)))
);
alter table discordchannel
owner to gacha;
create index if not exists discordchannel_guildid
on discordchannel (guildid)
where (guildid IS NOT NULL);
create index if not exists discordchannel_acceptsgamecommands
on discordchannel (acceptgamecommands)
where (acceptgamecommands IS TRUE);
create index if not exists discordchannel_acceptsadmincommands
on discordchannel (acceptadmincommands)
where (acceptadmincommands IS TRUE);
create table if not exists gender
(
id varchar(8) not null
constraint gender_pkey
primary key,
name varchar(100) not null
constraint gender_name_key
unique
);
alter table gender
owner to gacha;
create table if not exists player
(
id serial
constraint player_pkey
primary key,
name varchar(100) not null,
genderid varchar(8) not null
constraint player_genderid_fkey
references gender
on update cascade on delete restrict,
currency integer not null,
joinedat timestamp with time zone default now() not null,
lastactive timestamp with time zone default now() not null,
lastdaily timestamp with time zone
);
alter table player
owner to gacha;
create table if not exists discorduser
(
discordid varchar(20) not null
constraint discorduser_pkey
primary key,
username varchar(32) not null,
discriminator varchar(4) not null,
playerid integer
constraint discorduser_playerid_fkey
references player
on update cascade on delete set null,
lastactive timestamp with time zone default now() not null
);
alter table discorduser
owner to gacha;
create table if not exists unittier
(
id varchar(8) not null
constraint unittier_pkey
primary key,
name varchar(100) not null
constraint unittier_name_key
unique,
pullweight integer not null,
recallcost integer not null
);
alter table unittier
owner to gacha;
create table if not exists unit
(
id serial
constraint unit_pkey
primary key,
name varchar(50) not null,
subtitle varchar(50) not null,
description text not null,
tierid varchar(8) not null
constraint unit_tierid_fkey
references unittier
on update cascade on delete restrict,
basehealth integer not null
constraint unit_basehealth_check
check (basehealth > 0),
basestrength integer not null
constraint unit_basestrength_check
check (basestrength > 0),
constraint unit_name_subtitle_key
unique (name, subtitle)
);
alter table unit
owner to gacha;
create table if not exists summonedunit
(
instanceid serial
constraint summonedunit_pkey
primary key,
playerid integer not null
constraint summonedunit_playerid_fkey
references player
on update cascade on delete cascade,
unitid integer not null
constraint summonedunit_unitid_fkey
references unit
on update cascade on delete cascade,
summonedat timestamp with time zone default now() not null,
waspulled boolean default false not null,
wasrecalled boolean default false not null,
resummonings timestamp with time zone[],
desummonedat timestamp with time zone,
digestedbyinstanceid integer
constraint summonedunit_digestedbyinstanceid_fkey
references summonedunit
on update cascade on delete set null,
wasdigested boolean,
wasreleased boolean,
currenthealth integer not null,
maxhealth integer not null
constraint summonedunit_maxhealth_check
check (maxhealth > 0),
strength integer not null
constraint summonedunit_strength_check
check (strength > 0),
constraint summonedunit_currenthealthbounds
check ((currenthealth >= (- maxhealth)) AND (currenthealth <= maxhealth)),
constraint summonedunit_exactlyoneorigin
check (((waspulled IS TRUE) OR (wasrecalled IS TRUE)) AND
(NOT ((waspulled IS FALSE) AND (wasrecalled IS FALSE)))),
constraint summonedunit_exactlyonefate
check ((((wasdigested IS TRUE) OR (wasreleased IS TRUE)) = (desummonedat IS NOT NULL)) AND
(((wasdigested IS NULL) OR (wasreleased IS NULL)) = (desummonedat IS NULL))),
constraint summonedunit_digesterfordigestedonly
check ((digestedbyinstanceid IS NULL) OR (wasdigested IS TRUE))
);
alter table summonedunit
owner to gacha;
create unique index if not exists summonedunit_oneinstanceperunitperplayer
on summonedunit (playerid, unitid)
where (desummonedat IS NULL);
create or replace function getguildidsabletousegamecommands() returns SETOF character varying
stable
rows 1
language sql
as
$$
SELECT DISTINCT guildId
FROM DiscordChannel
WHERE guildId IS NOT NULL
AND acceptGameCommands IS TRUE;
$$;
alter function getguildidsabletousegamecommands() owner to gacha;
create or replace procedure checkgamecommandin(requestedchannel character varying, requestedguild character varying)
language plpgsql
as
$$
DECLARE
channelAcceptsGameCommands BOOLEAN;
channelAcceptsAdminCommands BOOLEAN;
channelSendsMessages BOOLEAN;
channelIsKnown BOOLEAN;
guildSupportsGameCommands BOOLEAN;
guildSupportsAdminCommands BOOLEAN;
guildSupportsMessages BOOLEAN;
guildIsKnown BOOLEAN;
recommendedChannelId DiscordChannel.DiscordId%TYPE;
BEGIN
SELECT acceptGameCommands, acceptAdminCommands, broadcastGame OR sendLogs, TRUE
INTO channelAcceptsGameCommands, channelAcceptsAdminCommands, channelSendsMessages, channelIsKnown
FROM DiscordChannel
WHERE discordId = requestedChannel
LIMIT 1;
IF channelAcceptsGameCommands IS NOT TRUE THEN
SELECT discordId, acceptGameCommands, acceptAdminCommands, broadcastGame OR sendLogs, TRUE
INTO recommendedChannelId, guildSupportsGameCommands, guildSupportsAdminCommands, guildSupportsMessages, guildIsKnown
FROM DiscordChannel
WHERE guildId = requestedGuild
ORDER BY CASE TRUE
WHEN acceptGameCommands THEN 3
WHEN acceptAdminCommands THEN 2
WHEN broadcastGame OR sendLogs THEN 1
ELSE 0
END DESC, priority DESC
LIMIT 1;
IF guildSupportsGameCommands IS TRUE THEN
RAISE EXCEPTION 'Can''t use game commands in this channel' USING
ERRCODE = 'VGBCG',
DETAIL = CASE TRUE
WHEN channelAcceptsGameCommands THEN format(
'This channel (<#%s>) can only be used to send admin commands, not game commands.',
requestedChannel)
WHEN channelSendsMessages THEN format(
'This channel (<#%s>) is only used to receive broadcasts, not send commands.',
requestedChannel)
WHEN channelIsKnown THEN format(
'This channel (<#%s>) is unused.', requestedChannel)
ELSE format('This channel (<#%s>) is not known to the system.', requestedChannel)
END,
HINT = format(
'Try sending messages to the channel <#%s> in this guild, which does allow game commands.',
recommendedChannelId);
ELSE
RAISE EXCEPTION 'Can''t use game commands in this guild' USING
ERRCODE = 'VGBGG',
DETAIL = CASE TRUE
WHEN guildSupportsAdminCommands THEN
format('This guild (ID %) only has channels used to send admin commands, ' ||
'not game commands.', requestedGuild)
WHEN guildSupportsMessages THEN
format('This guild (ID %) only has channels used to receive broadcasts, ' ||
'not send commands.', requestedGuild)
WHEN guildIsKnown THEN
format('This guild (ID %) only has unused channels.', requestedGuild)
ELSE format('This guild (ID %) is not known to the system.', requestedGuild)
END,
HINT = 'As game commands are normally only visible when a guild allows them, ' ||
'this guild may have been removed from the system incorrectly. ' ||
'Ask an admin to check what''s going on.';
END IF;
END IF;
END;
$$;
alter procedure checkgamecommandin(varchar, varchar) owner to gacha;
create or replace function getguildidsabletouseadmincommands() returns SETOF character varying
stable
rows 1
language sql
as
$$
SELECT DISTINCT guildId
FROM DiscordChannel
WHERE guildId IS NOT NULL
AND acceptAdminCommands IS TRUE;
$$;
alter function getguildidsabletouseadmincommands() owner to gacha;
create or replace procedure checkadmincommandin(requestedchannel character varying, requestedguild character varying)
language plpgsql
as
$$
DECLARE
channelAcceptsAdminCommands BOOLEAN;
channelAcceptsGameCommands BOOLEAN;
channelSendsMessages BOOLEAN;
channelIsKnown BOOLEAN;
guildSupportsAdminCommands BOOLEAN;
guildSupportsGameCommands BOOLEAN;
guildSupportsMessages BOOLEAN;
guildIsKnown BOOLEAN;
recommendedChannelId DiscordChannel.DiscordId%TYPE;
BEGIN
SELECT acceptAdminCommands, acceptGameCommands, broadcastGame OR sendLogs, TRUE
INTO channelAcceptsAdminCommands, channelAcceptsGameCommands, channelSendsMessages, channelIsKnown
FROM DiscordChannel
WHERE discordId = requestedChannel
LIMIT 1;
IF channelAcceptsAdminCommands IS NOT TRUE THEN
SELECT discordId, acceptAdminCommands, acceptGameCommands, broadcastGame OR sendLogs, TRUE
INTO recommendedChannelId, guildSupportsAdminCommands, guildSupportsGameCommands, guildSupportsMessages, guildIsKnown
FROM DiscordChannel
WHERE guildId = requestedGuild
ORDER BY CASE TRUE
WHEN acceptAdminCommands THEN 3
WHEN acceptGameCommands THEN 2
WHEN broadcastGame OR sendLogs THEN 1
ELSE 0
END DESC, priority DESC
LIMIT 1;
IF guildSupportsAdminCommands IS TRUE THEN
RAISE EXCEPTION 'Can''t use admin commands in this channel' USING
ERRCODE = 'VGBCA',
DETAIL = CASE TRUE
WHEN channelAcceptsGameCommands THEN format(
'This channel (<#%s>) can only be used to send admin commands, not game commands.',
requestedChannel)
WHEN channelSendsMessages THEN format(
'This channel (<#%s>) is only used to receive broadcasts, not send commands.',
requestedChannel)
WHEN channelIsKnown THEN format(
'This channel (<#%s>) is unused.', requestedChannel)
ELSE format('This channel (<#%s>) is not known to the system.', requestedChannel)
END,
HINT = format(
'Try sending messages to the channel <#%s> in this guild, which does allow game commands.',
recommendedChannelId);
ELSE
RAISE EXCEPTION 'Can''t use admin commands in this guild' USING
ERRCODE = 'VGBGA',
DETAIL = CASE TRUE
WHEN guildSupportsGameCommands THEN
format('This guild (ID %s) only has channels used to send game commands, ' ||
'not admin commands.', requestedGuild)
WHEN guildSupportsMessages THEN
format('This guild (ID %s) only has channels used to receive broadcasts, ' ||
'not send commands.', requestedGuild)
WHEN guildIsKnown THEN
format('This guild (ID %s) only has unused channels.', requestedGuild)
ELSE format('This guild (ID %s) is not known to the system.', requestedGuild)
END,
HINT = 'As admin commands are normally only visible when a guild allows them, ' ||
'this guild may have been removed from the system incorrectly. ' ||
'Ask an admin to check what''s going on.';
END IF;
END IF;
END;
$$;
alter procedure checkadmincommandin(varchar, varchar) owner to gacha;
create or replace function getregisterablegenders(OUT id character varying, OUT name character varying) returns SETOF record
stable
strict
language sql
as
$$
SELECT Gender.id, Gender.name
FROM Gender
$$;
alter function getregisterablegenders(out varchar, out varchar) owner to gacha;
create or replace function getinvokingdiscorduser(forid character varying, newusername character varying,
newdiscriminator character varying) returns discorduser
strict
language sql
as
$$
INSERT INTO DiscordUser (discordId, username, discriminator, lastActive)
VALUES (forId, newUsername, newDiscriminator, NOW())
ON CONFLICT (discordId) DO UPDATE SET username = newUsername,
discriminator = newDiscriminator,
lastActive = NOW()
RETURNING *
$$;
alter function getinvokingdiscorduser(varchar, varchar, varchar) owner to gacha;
create or replace function getinvokingplayer(forid character varying, newusername character varying,
newdiscriminator character varying) returns player
strict
language sql
as
$$
UPDATE Player
SET lastActive = NOW()
FROM GetInvokingDiscordUser(forId, newUsername, newDiscriminator) AS InvokingDiscordUser
WHERE id = InvokingDiscordUser.playerId
RETURNING ROW (Player.*)
$$;
alter function getinvokingplayer(varchar, varchar, varchar) owner to gacha;
create or replace function updateplayerregistration(forid character varying, newusername character varying,
newdiscriminator character varying, newplayername character varying,
newgenderid character varying, OUT resultid integer,
OUT wascreated boolean) returns record
strict
language plpgsql
as
$$
DECLARE
playerId Player.id%TYPE;
BEGIN
SELECT InvokingDiscordUser.playerId
INTO STRICT playerId
FROM GetInvokingDiscordUser(forId, newUsername, newDiscriminator)
AS InvokingDiscordUser;
IF playerId IS NOT NULL THEN
wasCreated = FALSE;
UPDATE Player
SET name = newPlayerName,
genderId = newGenderId,
lastActive = NOW()
WHERE id = playerId
RETURNING id INTO resultId;
ELSE
wasCreated = TRUE;
INSERT INTO Player (name, genderId, currency, joinedAt, lastActive)
VALUES (newPlayerName, newGenderId, 100, NOW(), NOW())
RETURNING id INTO resultId;
UPDATE DiscordUser
SET playerId = resultId
WHERE discordId = forId;
END IF;
END;
$$;
alter function updateplayerregistration(varchar, varchar, varchar, varchar, varchar, out integer, out boolean) owner to gacha;
create or replace function unlinkdiscorduserfromplayer(forid character varying, newusername character varying,
newdiscriminator character varying) returns integer
strict
language plpgsql
as
$$
DECLARE
oldPlayerId Player.id%TYPE = NULL;
BEGIN
SELECT playerId
INTO oldPlayerId
FROM GetInvokingDiscordUser(forId, newUsername, newDiscriminator);
IF oldPlayerId IS NULL THEN
RETURN NULL;
END IF;
UPDATE DiscordUser
SET playerId = NULL
WHERE discordId = forId;
RETURN oldPlayerId;
END;
$$;
alter function unlinkdiscorduserfromplayer(varchar, varchar, varchar) owner to gacha;
create or replace function command_join(requestedchannel character varying, requestedguild character varying,
forid character varying, newusername character varying,
newdiscriminator character varying, newplayername character varying,
newgenderid character varying, OUT resultid integer,
OUT newplayername character varying, OUT newgendername character varying,
OUT wascreated boolean) returns record
strict
language sql
as
$$
CALL CheckGameCommandIn(requestedChannel, requestedGuild);
SELECT NewRegistration.resultId, newPlayerName, Gender.name, NewRegistration.wasCreated
FROM UpdatePlayerRegistration(forId, newUsername, newDiscriminator, newPlayerName, newGenderId) AS NewRegistration
LEFT JOIN Gender ON Gender.id = newGenderId
$$;
alter function command_join(varchar, varchar, varchar, varchar, varchar, varchar, varchar, out integer, out varchar, out varchar, out boolean) owner to gacha;
create or replace function command_unjoin(requestedchannel character varying, requestedguild character varying,
forid character varying, newusername character varying,
newdiscriminator character varying) returns integer
strict
language sql
as
$$
CALL CheckGameCommandIn(requestedChannel, requestedGuild);
SELECT UnlinkDiscordUserFromPlayer(forId, newUsername, newDiscriminator)
$$;
alter function command_unjoin(varchar, varchar, varchar, varchar, varchar) owner to gacha;
create or replace function command_pull(requestedchannel character varying, requestedguild character varying,
forid character varying, newusername character varying,
newdiscriminator character varying, count integer)
returns TABLE
(
summonedunitinstanceid integer,
summonedunitid integer,
summonedunitname character varying,
summonedunitsubtitle character varying,
summonedunittiername character varying,
firsttimepull boolean,
wasalreadysummoned boolean
)
strict
rows 10
language plpgsql
as
$$
DECLARE
playerId Player.id%TYPE;
cost Player.currency%TYPE;
oldCurrency Player.currency%TYPE;
playerLastDaily Player.lastDaily%TYPE;
BEGIN
CALL CheckGameCommandIn(requestedChannel, requestedGuild);
SELECT InvokingPlayer.id, InvokingPlayer.currency, InvokingPlayer.lastDaily
INTO playerId, oldCurrency, playerLastDaily
FROM GetInvokingPlayer(forId, newUsername, newDiscriminator) AS InvokingPlayer;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = 'VGNYJ',
MESSAGE = 'Not yet joined',
DETAIL = 'You haven''t joined the game yet, and can''t use this command until you do.',
HINT = 'Use the /join command to join the game!';
END IF;
cost = 10 * count;
UPDATE Player SET currency = currency - cost WHERE id = playerId AND currency >= cost;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = 'VGNEC',
MESSAGE = 'Not enough currency',
DETAIL = format('Pulling %s heroines would cost %s currency, but you only have %s currency.',
count, cost, oldCurrency),
HINT = CASE playerLastDaily IS NULL OR playerLastDaily < NOW() - '1 day'::interval
WHEN TRUE THEN 'Try using the /daily command to get some more currency for today!'
ELSE format('Wait %s and you can use the /daily command to get some more currency!',
(playerLastDaily + '1 day'::interval) - NOW())
END;
END IF;
END;
$$;
alter function command_pull(varchar, varchar, varchar, varchar, varchar, integer) owner to gacha;

1533
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -2,10 +2,8 @@
"name": "vore-gacha", "name": "vore-gacha",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@prisma/client": "^3.6.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"chance": "^1.1.8", "chance": "^1.1.8",
"crypto-random-string": "^4.0.0",
"cuid": "^2.1.8", "cuid": "^2.1.8",
"detritus-client": "^0.16.3", "detritus-client": "^0.16.3",
"detritus-client-rest": "^0.10.5", "detritus-client-rest": "^0.10.5",
@ -13,6 +11,7 @@
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"fastify": "^3.24.1", "fastify": "^3.24.1",
"fastify-cookie": "^5.4.0", "fastify-cookie": "^5.4.0",
"pg": "^8.7.1",
"pino": "^7.5.1", "pino": "^7.5.1",
"pino-discord": "^1.0.2", "pino-discord": "^1.0.2",
"pug": "^3.0.2", "pug": "^3.0.2",
@ -26,19 +25,22 @@
"@types/pug": "^2.0.5", "@types/pug": "^2.0.5",
"@types/relateurl": "^0.2.29", "@types/relateurl": "^0.2.29",
"@types/simple-oauth2": "^4.1.1", "@types/simple-oauth2": "^4.1.1",
"dotenv-cli": "^4.1.1",
"graphile-migrate": "^1.2.0",
"pino-pretty": "^7.3.0", "pino-pretty": "^7.3.0",
"prisma": "^3.6.0",
"typescript": "^4.5.3" "typescript": "^4.5.3"
}, },
"type": "module",
"scripts": { "scripts": {
"build": "tsc --build", "build": "tsc --build",
"clean": "rm -rf build generated", "clean": "rm -rf build generated",
"start": "node build/app.js", "start": "node build/app.js",
"regenerate": "prisma generate", "dbReset": "dotenv -- graphile-migrate reset",
"dbMigrate": "dotenv -- graphile-migrate migrate",
"dbWatch": "dotenv -- graphile-migrate watch",
"dbCommit": "dotenv -- graphile-migrate commit --message",
"dbUncommit": "dotenv -- graphile-migrate uncommit",
"dbStep": "dotenv -- graphile-migrate watch --once",
"loadWebhooks": "node build/tools/LoadJsonWebhooks.js", "loadWebhooks": "node build/tools/LoadJsonWebhooks.js",
"loadGenders": "node build/tools/LoadGenders.js", "fullRebuild": "npm run clean && npm run -- dbReset --erase && npm run build && npm run loadWebhooks"
"pushDb": "prisma db push --skip-generate",
"fullRebuild": "npm run clean && npm run regenerate && npm run pushDb && npm run build && npm run loadWebhooks && npm run loadGenders"
} }
} }

@ -1,185 +0,0 @@
datasource db {
provider = "sqlite"
url = "file:../runtime/database/db.sqlite"
}
generator client {
provider = "prisma-client-js"
}
/// Discord channels known to the server.
model DiscordChannel {
/// The ID of the channel in Discord.
discordId String @id
/// The last known name of this channel.
name String
/// True if this channel should be used to broadcast public game events.
broadcastGame Boolean
/// True if this channel should be used to send logs.
sendLogs Boolean
/// True if this channel can accept game commands.
acceptGameCommands Boolean
/// True if this channel can accept admin commands.
acceptAdminCommands Boolean
/// The priority of this channel when slowing down to account for rate limits. Higher is more important.
priority Int
/// The guild in which this channel exists, if it's known.
guildId String?
/// The ID of the webhook used to post to this channel. Deleted if the webhook 404's.
webhookId String?
/// The webhook token used to post to this channel. Deleted if the webhook 404's.
token String?
}
/// User genders.
model Gender {
/// The internal ID associated with this gender.
id String @id @default(cuid())
/// The human-readable name of this gender. Unique among genders.
name String @unique
/// The users with this gender.
User User[]
}
/// In-game user data structure.
model User {
/// The internal ID associated with this account.
/// It's separate from the Discord ID associated with this account. This supports a few things:
/// 1) We can move the game off of Discord, or add the ability to play it as a separate phone app or webapp,
/// without losing our database.
/// 2) If necessary, we can support having multiple Discord users associated with the same user account, to
/// support multi-account play.
/// 3) If necessary, we can support changing the Discord user associated with a user account, if for any reason
/// they are no longer using the old account and want to switch to a new account.
id String @id @default(cuid())
/// The user's name, for the purposes of the game. This is completely separate from both their username and nickname
/// as Discord sees it, though it defaults to their nickname at time of joining. It does not have to be unique, and
/// can be changed at any time.
name String
/// The discord user associated with this account.
discordUser DiscordUser?
/// The user's gender, for the purposes of the game. This is purely cosmetic and can be changed at any time.
gender Gender @relation(fields: [genderId], references: [id])
/// Relation field for Gender
genderId String
/// The number of units of currency this user is currently carrying.
currency Int @default(100)
/// The time and date at which this user joined.
joinedAt DateTime @default(now())
/// The last time this user used a command.
lastActive DateTime @default(now())
/// The last time this user retrieved their daily resources.
lastDaily DateTime?
/// List of units this user has ever seen/pulled.
units UserUnit[]
/// List of units currently in this user's party.
summonedUnits SummonedUnit[]
}
/// Informmation about a Discord user.
model DiscordUser {
/// The Discord ID this record is for. A Discord snowflake.
discordId String @id
/// The last known username associated with this user.
username String
/// The last known discriminator associated with this user.
discriminator String
/// The User that this DiscordUser is associated with.
user User? @relation(fields: [userId], references: [id])
/// Relation field for User
userId String? @unique
}
/// Definitions of unit tiers.
model Tier {
/// The internal ID associated with this tier.
id String @id @default(cuid())
/// The human-readable name of this tier. Unique among tiers.
name String @unique
/// The chance of pulling a unit of this tier.
pullWeight Int
/// The cost of /recalling a unit of this tier.
recallCost Int
/// The list of units with this tier.
units Unit[]
}
/// An individual unit that can be summoned.
model Unit {
/// The combination of Name and Subtitle is unique among units, allowing for multiple versions of a unit.
/// The internal ID associated with this unit.
id String @id @default(cuid())
/// The name of this unit.
name String
/// The subtitle of this unit.
subtitle String
/// The description of this unit.
description String
/// The tier of this unit.
tier Tier @relation(fields: [tierId], references: [id])
/// Relation field for Tier
tierId String
/// The unit's base health when summoned for the first time.
baseHealth Int
/// The unit's base strength when summoned for the first time.
baseStrength Int
/// Information about the bonds with users this unit has been summoned by.
users UserUnit[]
/// Information about this unit's summoned forms.
summonedUnits SummonedUnit[]
@@unique([name, subtitle])
}
/// Connection between Users and Units, indicating how and when users have pulled this unit.
model UserUnit {
/// The User that summoned this unit at some point.
user User @relation(fields: [userId], references: [id])
/// Relation field for User
userId String
/// The Unit that was summoned by this User at some point.
unit Unit @relation(fields: [unitId], references: [id])
/// Relation field for Unit
unitId String
/// The first time this user pulled this unit.
firstPulled DateTime @default(now())
/// The number of times this unit has been /pulled by this user.
/// Greatly increases the user's bond with this unit.
/// Higher bond means higher stats on being resummoned with /pull or /recall.
timesPulled Int @default(1)
/// The number of times this unit has been digested in this user's party, either with /feed or in battle.
/// Slightly decreases the user's bond with this unit.
/// If the total bond reaches zero, this unit can no longer be resummoned with /recall until they appear in /pull.
timesDigested Int @default(0)
/// The number of times this unit has digested other units, either with /feed or in battle.
/// Does not influence bond, but may have an influence on other things (e.g., Talk lines).
timesDigesting Int @default(0)
/// The summoned form of this unit, if this unit is currently summoned.
/// Created on pulling or recalling, destroyed on digestion.
summonedUnit SummonedUnit?
@@id([userId, unitId])
}
/// Instances of summoned units.
model SummonedUnit {
/// The User this unit was summoned by.
user User @relation(fields: [userId], references: [id])
/// The Unit this summoned unit is an instance of.
unit Unit @relation(fields: [unitId], references: [id])
/// The user-unit pair this SummonedUnit originates with.
userUnit UserUnit @relation(fields: [userId, unitId], references: [userId, unitId])
/// Relation field for User and UserUnit
userId String
/// Relation field for Unit and UserUnit
unitId String
/// The unit's current health. If 0, the unit is unconscious and cannot participate in fights.
/// At -MaxHealth, the unit has been fully digested and this record will be deleted.
currentHealth Int
/// The unit's maximum health.
maxHealth Int
/// The unit's strength.
strength Int
@@id([userId, unitId])
}

@ -2,7 +2,7 @@ import fastify, {FastifyInstance} from "fastify";
import {SlashCreator, SlashCreatorOptions} from "slash-create"; import {SlashCreator, SlashCreatorOptions} from "slash-create";
import fastifyCookie from "fastify-cookie"; import fastifyCookie from "fastify-cookie";
import {FastifyServerButItWorksUnlikeTheRealOne} from "./FastifyHelpers.js"; import {FastifyServerButItWorksUnlikeTheRealOne} from "./FastifyHelpers.js";
import {ChannelManager} from "./queries/ChannelManager.js"; import {Pool} from "pg";
export interface BaseServerDeps { export interface BaseServerDeps {
appId: string appId: string
@ -12,7 +12,7 @@ export interface BaseServerDeps {
listenPort: number listenPort: number
listenAddress: string listenAddress: string
cookieSecret: string cookieSecret: string
channelManager: ChannelManager pool: Pool
} }
export class BaseServer { export class BaseServer {
@ -22,7 +22,7 @@ export class BaseServer {
readonly listenPort: number readonly listenPort: number
readonly listenAddress: string readonly listenAddress: string
readonly slashcmd: SlashCreator readonly slashcmd: SlashCreator
readonly channelManager: ChannelManager readonly pool: Pool
constructor({ constructor({
cookieSecret, cookieSecret,
@ -30,7 +30,7 @@ export class BaseServer {
clientSecret, clientSecret,
listenPort, listenPort,
listenAddress, listenAddress,
channelManager, pool,
publicKey, publicKey,
botToken, botToken,
slashCreatorOptions = {} slashCreatorOptions = {}
@ -57,7 +57,7 @@ export class BaseServer {
this.clientSecret = clientSecret this.clientSecret = clientSecret
this.listenPort = listenPort this.listenPort = listenPort
this.listenAddress = listenAddress this.listenAddress = listenAddress
this.channelManager = channelManager this.pool = pool
} }
async _initInternal(): Promise<void> { async _initInternal(): Promise<void> {

@ -1,6 +1,6 @@
import {FastifyReply, FastifyRequest} from "fastify"; import {FastifyReply, FastifyRequest} from "fastify";
import {RouteGenericInterface} from "fastify/types/route"; import {RouteGenericInterface} from "fastify/types/route";
import cryptoRandomString from "crypto-random-string"; import {randomBytes} from "crypto";
export interface XSRFRoute extends RouteGenericInterface { export interface XSRFRoute extends RouteGenericInterface {
Querystring: { [key in typeof XSRFParameter]: string | string[] | undefined } Querystring: { [key in typeof XSRFParameter]: string | string[] | undefined }
@ -9,11 +9,8 @@ export interface XSRFRoute extends RouteGenericInterface {
export const XSRFCookie = "__Host-XSRF-Cookie"; export const XSRFCookie = "__Host-XSRF-Cookie";
export const XSRFParameter = "state" as const; export const XSRFParameter = "state" as const;
export function generateXSRFCookie(res: FastifyReply): string { export async function generateXSRFCookie(res: FastifyReply): Promise<string> {
const newState = cryptoRandomString({ const newState = randomBytes(30).toString("base64url")
length: 32,
type: 'url-safe'
})
res.setCookie(XSRFCookie, newState, { res.setCookie(XSRFCookie, newState, {
path: "/", path: "/",
sameSite: "strict", sameSite: "strict",

@ -2,34 +2,39 @@ import {checkAndClearXSRFCookie, generateXSRFCookie, XSRFRoute} from "./CookieHe
import {renderError} from "./PugRenderer.js"; import {renderError} from "./PugRenderer.js";
import {getBaseUrl} from "./FastifyHelpers.js"; import {getBaseUrl} from "./FastifyHelpers.js";
import pug from "pug"; import pug from "pug";
import {BaseServer, BaseServerDeps} from "./BaseServer.js"; import {BaseServer} from "./BaseServer.js";
import {PullCommand} from "./commands/game/PullCommand.js"; import {PullCommand} from "./commands/game/PullCommand.js";
import {JoinCommand} from "./commands/game/JoinCommand.js"; import {JoinCommand} from "./commands/game/JoinCommand.js";
import {UserManager} from "./queries/UserManager.js"; import {singleColumnQueryResult} from "./queries/QueryHelpers.js";
import {UnjoinCommand} from "./commands/debug/UnjoinCommand.js";
export class GameServer extends BaseServer { export class GameServer extends BaseServer {
readonly userManager: UserManager
constructor(deps: BaseServerDeps & { userManager: UserManager }) {
super(deps)
this.userManager = deps.userManager
}
async _initInternal(): Promise<void> { async _initInternal(): Promise<void> {
const gameGuildIds = await this.channelManager.getGameCommandGuildIds() const promisedGameGuildIds = this.pool.query<[string]>({
const genders = await this.userManager.getGenders() text: `SELECT *
FROM GetGuildIdsAbleToUseGameCommands()`,
rowMode: "array",
})
const promisedGenders = this.pool.query<{ id: string, name: string }>({
text: `SELECT id, name
FROM GetRegisterableGenders()`,
})
this.slashcmd.registerCommand(new PullCommand(this.slashcmd, { this.slashcmd.registerCommand(new PullCommand(this.slashcmd, {
channelManager: this.channelManager, pool: this.pool,
gameGuildIds, gameGuildIds: singleColumnQueryResult(await promisedGameGuildIds),
})) }))
this.slashcmd.registerCommand(new JoinCommand(this.slashcmd, { this.slashcmd.registerCommand(new JoinCommand(this.slashcmd, {
channelManager: this.channelManager, pool: this.pool,
userManager: this.userManager, gameGuildIds: singleColumnQueryResult(await promisedGameGuildIds),
gameGuildIds, genders: (await promisedGenders).rows,
genders, }))
this.slashcmd.registerCommand(new UnjoinCommand(this.slashcmd, {
pool: this.pool,
gameGuildIds: singleColumnQueryResult(await promisedGameGuildIds),
})) }))
this.server.get("/game/started", async (req, res) => { this.server.get("/game/started", async (req, res) => {
const token = generateXSRFCookie(res) const token = await generateXSRFCookie(res)
res.code(200) res.code(200)
res.type("text/html") res.type("text/html")
res.send(pug.renderFile("static/pages/game/running.pug", { res.send(pug.renderFile("static/pages/game/running.pug", {

@ -1,10 +1,7 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import {GameServer} from "./GameServer.js"; import {GameServer} from "./GameServer.js";
import cryptoRandomString from "crypto-random-string";
import pino from "pino" import pino from "pino"
import {PrismaClient} from "./queries/Prisma.js"; import {Pool} from "pg";
import {ChannelManager} from "./queries/ChannelManager.js";
import {UserManager} from "./queries/UserManager.js";
const log = pino() const log = pino()
@ -19,17 +16,17 @@ async function main(): Promise<void> {
const publicKey = parsed["DISCORD_PUBLIC_KEY"] const publicKey = parsed["DISCORD_PUBLIC_KEY"]
const listenPort = parseInt(parsed["HTTP_PORT"] ?? "5244") const listenPort = parseInt(parsed["HTTP_PORT"] ?? "5244")
const listenAddress = parsed["HTTP_ADDRESS"] ?? "127.0.0.1" const listenAddress = parsed["HTTP_ADDRESS"] ?? "127.0.0.1"
const cookieSecret = parsed["COOKIE_SECRET"] ?? cryptoRandomString({length: 32, type: "base64"}) const cookieSecret = parsed["COOKIE_SECRET"] ?? "VERY SECRET STRING"
const client = new PrismaClient() const pool = new Pool({
await client.$connect() connectionString: parsed["DATABASE_URL"]
})
const deps = { const deps = {
appId, appId,
listenAddress, listenAddress,
listenPort, listenPort,
clientSecret, clientSecret,
cookieSecret, cookieSecret,
channelManager: new ChannelManager(client), pool,
userManager: new UserManager(client),
botToken, botToken,
publicKey, publicKey,
} }

@ -0,0 +1,46 @@
import {CommandContext, SlashCommand, SlashCreator} from "slash-create";
import {Snowflake} from "discord-api-types";
import {Pool} from "pg";
import {singleQueryResult} from "../../queries/QueryHelpers.js";
import {sendErrorMessage} from "../../queries/ErrorCodes.js";
export class UnjoinCommand extends SlashCommand {
readonly pool: Pool
constructor(creator: SlashCreator, {pool, gameGuildIds}: {
pool: Pool,
gameGuildIds: Snowflake[],
}) {
super(creator, {
name: "debug_unjoin",
guildIDs: gameGuildIds,
description: "Allows an existing player to quit the game.",
options: []
});
this.pool = pool
}
async run(ctx: CommandContext): Promise<void> {
try {
const result: number | undefined = singleQueryResult(await this.pool.query({
text: "SELECT Command_Unjoin($1, $2, $3, $4, $5)",
values: [ctx.channelID, ctx.guildID, ctx.user.id, ctx.user.username, ctx.user.discriminator],
rowMode: "array",
}))
if (typeof result === "number") {
await ctx.send({
content: `You got it! I've removed you from the register. Make sure to note down your old user ID: ${result}.`,
ephemeral: true,
})
} else {
await ctx.send({
content: `You're actually not on the register to begin with. So... mission accomplished?`,
ephemeral: true,
})
}
} catch (e) {
await sendErrorMessage(ctx, e)
}
}
}

@ -1,16 +1,14 @@
import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create"; import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create";
import {ChannelManager} from "../../queries/ChannelManager.js";
import {Snowflake} from "discord-api-types"; import {Snowflake} from "discord-api-types";
import {checkGameCommandAndRun} from "../permissions/ChannelPermissions.js"; import {singleRowQueryResult} from "../../queries/QueryHelpers.js";
import {UserManager} from "../../queries/UserManager.js"; import {sendErrorMessage} from "../../queries/ErrorCodes.js";
import {Pool} from "pg";
export class JoinCommand extends SlashCommand { export class JoinCommand extends SlashCommand {
readonly channelManager: ChannelManager readonly pool: Pool
readonly userManager: UserManager
constructor(creator: SlashCreator, {channelManager, userManager, gameGuildIds, genders}: { constructor(creator: SlashCreator, {pool, gameGuildIds, genders}: {
channelManager: ChannelManager, pool: Pool,
userManager: UserManager,
gameGuildIds: Snowflake[], gameGuildIds: Snowflake[],
genders: { id: string, name: string }[], genders: { id: string, name: string }[],
}) { }) {
@ -38,30 +36,41 @@ export class JoinCommand extends SlashCommand {
] ]
}); });
this.channelManager = channelManager this.pool = pool
this.userManager = userManager
} }
async run(ctx: CommandContext): Promise<any> { async run(ctx: CommandContext): Promise<void> {
return checkGameCommandAndRun(ctx, this.channelManager, async () => { const name = ctx.options.name ?? "Anonymous"
const result = await this.userManager.registerOrReregisterUserFromDiscord({ const gender = ctx.options.gender ?? "nb"
discordId: ctx.user.id, try {
username: ctx.user.username, const result =
discriminator: ctx.user.discriminator, singleRowQueryResult(await this.pool.query<{ resultid: number, newplayername: string, newgendername: string, wascreated: boolean }>({
name: ctx.options.name ?? "Anonymous", text: `SELECT resultId, newPlayerName, newGenderName, wasCreated
genderId: ctx.options.gender ?? "x", FROM Command_Join($1, $2, $3, $4, $5, $6, $7)`,
}) values: [
if (result.created) { ctx.channelID, ctx.guildID, ctx.user.id, ctx.user.username, ctx.user.discriminator,
return ctx.send({ name, gender],
content: `You got it! Welcome aboard, ${result.user.name}! I have you down in my records as ${result.user.gender.name}. If you ever want to change your name or gender, just /join again!`, }))
console.log(result)
if (typeof result === "undefined") {
await ctx.send({
content: "Unexpectedly got no results!!",
ephemeral: true,
})
console.log("Unexpectedly empty Command_Join result!")
} else if (result.wascreated) {
await ctx.send({
content: `You got it! Welcome aboard, ${result.newplayername}! I have you down in my records as ${result.newgendername}. If you ever want to change your name or gender, just /join again!`,
ephemeral: true, ephemeral: true,
}) })
} else { } else {
return ctx.send({ await ctx.send({
content: `Duly noted! I've updated your deets to have you down as ${result.user.name}, who is ${result.user.gender.name}. If you ever want to change your name or gender, just /join again!`, content: `Duly noted! I've updated your deets to have you down as ${result.newplayername}, who is ${result.newgendername}. If you ever want to change your name or gender, just /join again!`,
ephemeral: true, ephemeral: true,
}) })
} }
}) } catch (e) {
await sendErrorMessage(ctx, e)
}
} }
} }

@ -1,16 +1,16 @@
import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create"; import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create";
import {Chance} from "chance"; import {Chance} from "chance";
import {ChannelManager} from "../../queries/ChannelManager.js";
import {Snowflake} from "discord-api-types"; import {Snowflake} from "discord-api-types";
import {checkGameCommandAndRun} from "../permissions/ChannelPermissions.js"; import {Pool} from "pg";
import {sendErrorMessage} from "../../queries/ErrorCodes.js";
const rand = Chance() const rand = Chance()
export class PullCommand extends SlashCommand { export class PullCommand extends SlashCommand {
readonly channelManager: ChannelManager readonly pool: Pool
constructor(creator: SlashCreator, {channelManager, gameGuildIds}: { constructor(creator: SlashCreator, {pool, gameGuildIds}: {
channelManager: ChannelManager, pool: Pool,
gameGuildIds: Snowflake[] gameGuildIds: Snowflake[]
}) { }) {
super(creator, { super(creator, {
@ -29,12 +29,20 @@ export class PullCommand extends SlashCommand {
] ]
}); });
this.channelManager = channelManager this.pool = pool
} }
async run(ctx: CommandContext): Promise<any> { async run(ctx: CommandContext): Promise<any> {
return checkGameCommandAndRun(ctx, this.channelManager, async () => { try {
const count: number = typeof ctx.options.count === "number" && ctx.options.count >= 1 && ctx.options.count <= 10 ? Math.floor(ctx.options.count) : 1 const count: number =
typeof ctx.options.count === "number"
&& ctx.options.count >= 1
&& ctx.options.count <= 10 ? Math.floor(ctx.options.count) : 1
await this.pool.query({
text: `SELECT *
FROM Command_Pull($1, $2, $3, $4, $5, $6)`,
values: [ctx.channelID, ctx.guildID, ctx.user.id, ctx.user.username, ctx.user.discriminator, count],
})
const results: string[] = [] const results: string[] = []
for (let x = 0; x < count; x += 1) { for (let x = 0; x < count; x += 1) {
results.push(rand.weighted(["**Nicole**: D tier Predator Podcaster", results.push(rand.weighted(["**Nicole**: D tier Predator Podcaster",
@ -47,6 +55,8 @@ export class PullCommand extends SlashCommand {
content: `_${ctx.user.mention}_, you pulled...\n \\* ${results.join("\n \\* ")}`, content: `_${ctx.user.mention}_, you pulled...\n \\* ${results.join("\n \\* ")}`,
ephemeral: false, ephemeral: false,
}) })
}) } catch (e) {
await sendErrorMessage(ctx, e)
}
} }
} }

@ -1,26 +0,0 @@
import {CommandContext} from "slash-create";
import {ChannelManager} from "../../queries/ChannelManager.js";
export async function checkGameCommandAndRun(ctx: CommandContext, channelManager: ChannelManager, handler: (ctx: CommandContext) => Promise<any>): Promise<any> {
const guildID = ctx.guildID
try {
if (await channelManager.canUseGameCommandsInChannel(ctx.channelID)) {
return handler(ctx)
} else if (guildID !== undefined && await channelManager.canUseGameCommandsInGuild(guildID)) {
return ctx.send({
content: `Sorry, you can't do that in this channel.`,
ephemeral: true,
})
} else {
return ctx.send({
content: "Sorry, you can't do that in this guild.",
ephemeral: true,
})
}
} catch (e) {
return ctx.send({
content: `Uhhhhhh. Something went very wrong. If you see Reya, tell her I said ${e}.`,
ephemeral: true,
})
}
}

@ -1,95 +0,0 @@
import {Snowflake} from "discord-api-types";
import {PrismaClient} from "./Prisma.js";
export class ChannelManager {
readonly client: PrismaClient
constructor(client: PrismaClient) {
this.client = client
}
async getGameCommandGuildIds(): Promise<Snowflake[]> {
return (await this.client.discordChannel.findMany({
where: {
acceptGameCommands: true,
guildId: {
not: null
}
},
distinct: ["guildId"],
select: {
guildId: true,
},
})).map((item) => item.guildId as string)
// We know that the guild ID is not null because of the where condition.
}
async canUseGameCommandsInChannel(channelId: Snowflake): Promise<boolean> {
return ((await this.client.discordChannel.findUnique({
where: {
discordId: channelId,
},
select: {
acceptGameCommands: true,
},
rejectOnNotFound: false,
})) ?? {acceptGameCommands: false}).acceptGameCommands
}
async canUseGameCommandsInGuild(guildId: Snowflake): Promise<boolean> {
return (await this.client.discordChannel.findFirst({
where: {
guildId: guildId,
acceptGameCommands: true,
},
select: {
discordId: true
}
})) !== null
}
async getAdminCommandGuildIds(): Promise<Snowflake[]> {
return (await this.client.discordChannel.findMany({
where: {
acceptAdminCommands: true,
guildId: {
not: null
}
},
distinct: ["guildId"],
select: {
guildId: true,
},
})).map((item) => item.guildId as string)
// We know that the guild ID is not null because of the where condition.
}
async canUseAdminCommandsInChannel(channelId: Snowflake): Promise<boolean> {
return ((await this.client.discordChannel.findUnique({
where: {
discordId: channelId,
},
select: {
acceptAdminCommands: true,
},
rejectOnNotFound: false,
})) ?? {acceptAdminCommands: false}
).acceptAdminCommands
}
async canUseAdminCommandsInGuild(guildId: Snowflake): Promise<boolean> {
return (await this.client.discordChannel.findFirst({
where: {
guildId: guildId,
acceptAdminCommands: true,
},
select: {
discordId: true,
}
})) !== null
}
async isGameReady() {
return false;
}
}

@ -0,0 +1,48 @@
import {CommandContext} from "slash-create";
import {DatabaseError} from "pg";
export enum ErrorCodes {
BAD_CHANNEL_ADMIN = "VGBCA",
BAD_CHANNEL_GAME = "VGBCG",
BAD_GUILD_ADMIN = "VGBGA",
BAD_GUILD_GAME = "VGBGG",
NOT_YET_JOINED = "VGNYJ",
NOT_ENOUGH_CURRENCY = "VGNEC",
}
/** Checks if the error is a database error. */
export function isPostgresError(err: unknown): err is DatabaseError {
return err instanceof DatabaseError
}
/** Sends a message detailing the given error on the given command context. */
export async function sendErrorMessage(ctx: CommandContext, err: unknown): Promise<void> {
console.log(err)
if (isPostgresError(err)) {
switch (err.code) {
case ErrorCodes.BAD_CHANNEL_ADMIN:
case ErrorCodes.BAD_CHANNEL_GAME:
case ErrorCodes.BAD_GUILD_ADMIN:
case ErrorCodes.BAD_GUILD_GAME:
case ErrorCodes.NOT_YET_JOINED:
case ErrorCodes.NOT_ENOUGH_CURRENCY:
await ctx.send({
content: `**${err.message}**\n${err.detail}\n\n**Tip**: ${err.hint}`,
ephemeral: true,
})
return
default:
await ctx.send({
content: `**Unexpected Error (${err.code})**: ${err.message}\n${err.detail}\n\n**Tip**: ${err.hint}`,
ephemeral: true,
})
return
}
} else {
await ctx.send({
content: `**Unknown Error**: ${err}`,
ephemeral: true,
})
return
}
}

@ -1,9 +0,0 @@
import pkg from "@prisma/client";
export const {
PrismaClient,
Prisma: PrismaNS,
prisma
} = pkg
export type PrismaClient = InstanceType<typeof PrismaClient>;
export type {Prisma, DiscordUser, User, DiscordChannel} from "@prisma/client";

@ -0,0 +1,13 @@
import {QueryResult} from "pg";
export function singleQueryResult<T>(result: QueryResult<[T]>): T | undefined {
return singleColumnQueryResult(result)[0]
}
export function singleColumnQueryResult<T>(result: QueryResult<[T]>): T[] {
return result.rows.map(([item]) => item)
}
export function singleRowQueryResult<T>(result: QueryResult<T>): T | undefined {
return result.rows[0]
}

@ -1,133 +0,0 @@
import {DiscordUser, Prisma, PrismaClient} from "./Prisma.js";
import {Snowflake} from "discord-api-types";
import cuid from "cuid";
const userRegistrationSelect = {
id: true,
name: true,
discordUser: true,
gender: true,
joinedAt: true,
} as const
export type UserRegistrationData = Prisma.UserGetPayload<{ select: typeof userRegistrationSelect }>
const genderListSelect = {
id: true,
name: true,
} as const
export type GenderListData = Prisma.GenderGetPayload<{ select: typeof genderListSelect }>
export class UserManager {
readonly client: PrismaClient
constructor(client: PrismaClient) {
this.client = client
}
async registerOrUpdateDiscordUser({
discordId,
username,
discriminator,
}: { discordId: Snowflake, username: string, discriminator: string }): Promise<DiscordUser> {
return (await this.client.discordUser.upsert({
where: {
discordId,
},
create: {
discordId,
username,
discriminator,
userId: null,
},
update: {
username,
discriminator,
user: {
update: {
lastActive: new Date()
}
}
},
include: {
user: true,
}
}))
}
async registerOrReregisterUserFromDiscord({
discordId,
username,
discriminator,
name,
genderId
}: { discordId: Snowflake, username: string, discriminator: string, name: string, genderId: string }): Promise<{
user: UserRegistrationData, created: boolean
}> {
const userId = cuid()
const user = (await this.client.discordUser.upsert({
where: {
discordId,
},
update: {
username,
discriminator,
user: {
upsert: {
update: {
name,
gender: {
connect: {
id: genderId,
}
},
lastActive: new Date()
},
create: {
id: userId,
name,
gender: {
connect: {
id: genderId,
}
},
}
}
}
},
create: {
discordId,
username,
discriminator,
user: {
create: {
id: userId,
name,
gender: {
connect: {
id: genderId,
}
},
}
}
},
select: {
user: {
select: userRegistrationSelect
}
},
})).user
if (user === null) {
throw Error("...Somehow, there wasn't a user to return?!")
}
return {
user,
created: user.id === userId,
}
}
async getGenders(): Promise<GenderListData[]> {
return this.client.gender.findMany({
select: genderListSelect
})
}
}

@ -1,44 +0,0 @@
import {PrismaClient} from "../queries/Prisma.js";
async function main() {
const client = new PrismaClient()
await client.$connect()
await client.gender.upsert({
where: {
id: "f"
},
create: {
id: "f",
name: "Female"
},
update: {
name: "Female"
},
})
await client.gender.upsert({
where: {
id: "m"
},
create: {
id: "m",
name: "Male"
},
update: {
name: "Male"
},
})
await client.gender.upsert({
where: {
id: "x"
},
create: {
id: "x",
name: "Non-binary"
},
update: {
name: "Non-binary"
},
})
}
main()

@ -1,54 +1,30 @@
import {Prisma, PrismaClient} from "../queries/Prisma.js";
import {APIWebhook} from "discord-api-types"; import {APIWebhook} from "discord-api-types";
import {readFile} from "fs/promises"; import {readFile} from "fs/promises";
import {Pool} from "pg";
import dotenv from "dotenv";
type DiscordChannelPermissions = Prisma.DiscordChannelGetPayload<{ async function loadHooksIntoDatabase(client: Pool, gameHook: APIWebhook & { channelName: string }, adminHook: APIWebhook & { channelName: string }): Promise<void> {
select: { await client.query({
broadcastGame: true, text: `
sendLogs: true, INSERT INTO DiscordChannel (discordId, name, broadcastGame, sendLogs, acceptGameCommands,
acceptGameCommands: true, acceptAdminCommands, priority, guildId, webhookId, webhookToken)
acceptAdminCommands: true, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10),
} ($11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
}> ON CONFLICT DO NOTHING`,
values: [gameHook.channel_id, gameHook.channelName, true, false, true, false, 0, gameHook.guild_id, gameHook.id, gameHook.token,
async function loadHookIntoDatabase(client: PrismaClient, hook: APIWebhook, permissions: DiscordChannelPermissions): Promise<void> { adminHook.channel_id, adminHook.channelName, false, true, false, true, 0, adminHook.guild_id, adminHook.id, adminHook.token]
await client.discordChannel.upsert({
where: {discordId: hook.channel_id},
update: {
webhookId: hook.id,
token: hook.token,
...permissions
},
create: {
discordId: hook.channel_id,
guildId: hook.guild_id ?? null,
webhookId: hook.id,
token: hook.token,
name: "???",
priority: 0,
...permissions
}
}) })
} }
async function main() { async function main() {
const client = new PrismaClient() const {DATABASE_URL: connectionString} = dotenv.config().parsed ?? {}
await client.$connect() const client = new Pool({
connectionString
const gameHook: APIWebhook = JSON.parse(await readFile("runtime/webhooks/game.json", {encoding: "utf-8"}))
await loadHookIntoDatabase(client, gameHook, {
acceptAdminCommands: false,
acceptGameCommands: true,
sendLogs: false,
broadcastGame: true,
})
const adminHook: APIWebhook = JSON.parse(await readFile("runtime/webhooks/admin.json", {encoding: "utf-8"}))
await loadHookIntoDatabase(client, adminHook, {
acceptAdminCommands: true,
acceptGameCommands: false,
sendLogs: true,
broadcastGame: false,
}) })
const gameHook: APIWebhook & { channelName: string } = JSON.parse(await readFile("runtime/webhooks/game.json", {encoding: "utf-8"}))
const adminHook: APIWebhook & { channelName: string } = JSON.parse(await readFile("runtime/webhooks/admin.json", {encoding: "utf-8"}))
await loadHooksIntoDatabase(client, gameHook, adminHook)
} }
main() main()

@ -14,7 +14,7 @@
"strictNullChecks": true, "strictNullChecks": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"module": "ESNext", "module": "CommonJS",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,

Loading…
Cancel
Save