Compare commits

...

2 Commits

  1. 4
      .gitignore
  2. 8
      .idea/.gitignore
  3. 66
      .idea/codeStyles/Project.xml
  4. 5
      .idea/codeStyles/codeStyleConfig.xml
  5. 17
      .idea/dataSources.xml
  6. 8
      .idea/modules.xml
  7. 12
      .idea/ncc-gen.iml
  8. 7
      .idea/sqldialects.xml
  9. 6
      .idea/vcs.xml
  10. 4
      .idea/watcherTasks.xml
  11. 7
      LICENSE
  12. 67
      README.md
  13. 215
      migrations/0002_load_rollable_tables_from_the_database_too.sql
  14. 422
      migrations/0003_split_responses_table_to_allow_for_custom_lists.sql
  15. 1640
      migrations/0004_add_authorship_info_to_the_original_globals.sql
  16. 11
      migrations/0005_fix_emoji_for_autocomplete.sql
  17. 5683
      package-lock.json
  18. 32
      package.json
  19. 16
      src/build/bundle-client-with-source-map.ts
  20. 208
      src/build/bundler.ts
  21. 31
      src/build/check-source-map-and-bundle-client.ts
  22. 16
      src/build/tsconfig.json
  23. 91
      src/client/attribution.less
  24. 151
      src/client/basic-look.less
  25. 10
      src/client/combined-generator-responses-entrypoint.less
  26. 10
      src/client/combined-generator-responses-entrypoint.ts
  27. 100
      src/client/generator-entrypoint.less
  28. 244
      src/client/generator-entrypoint.ts
  29. 4
      src/client/noscript-entrypoint.less
  30. 7
      src/client/onload.ts
  31. 46
      src/client/popup.less
  32. 19
      src/client/popup.ts
  33. 15
      src/client/pulse.less
  34. 6
      src/client/pulse.ts
  35. 101
      src/client/responses-entrypoint.less
  36. 16
      src/client/responses-entrypoint.ts
  37. 244
      src/client/scraper.ts
  38. 102
      src/client/tsconfig.json
  39. 376
      src/commands.ts
  40. 6
      src/common/bbcode.ts
  41. 26
      src/common/bundle.ts
  42. 529
      src/common/rolltable.ts
  43. 170
      src/common/template.ts
  44. 11
      src/common/tsconfig.json
  45. 180
      src/dbAccess.ts
  46. 233
      src/generated.ts
  47. 128
      src/index.ts
  48. 43
      src/rolltable.ts
  49. 380
      src/server/db/database.ts
  50. 614
      src/server/db/queries.ts
  51. 138
      src/server/db/querytypes.ts
  52. 66
      src/server/db/transformers.ts
  53. 112
      src/server/db/validators.ts
  54. 588
      src/server/discord/commands.ts
  55. 209
      src/server/discord/embed.ts
  56. 101
      src/server/discord/router.ts
  57. 51
      src/server/entrypoint.ts
  58. 20
      src/server/request/query.ts
  59. 4
      src/server/tsconfig.json
  60. 17
      src/server/web/bundles/sourcemaps.ts
  61. 94
      src/server/web/router.ts
  62. 101
      wrangler.toml

4
.gitignore vendored

@ -170,3 +170,7 @@ dist
.dev.vars
.wrangler/
# generated bundles from src/client
src/server/web/bundles/client.generated.ts

8
.idea/.gitignore vendored

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,66 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="FALSE" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="FALSE" />
<option name="USE_IMPORT_TYPE" value="ALWAYS" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="140" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="140" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="140" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="140" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

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

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="D1 JDBC Data Source" uuid="0d7460e5-a5b0-427c-ba12-108e8ff70b34">
<data-source source="LOCAL" name="Production DB" read-only="true" uuid="0d7460e5-a5b0-427c-ba12-108e8ff70b34">
<driver-ref>java.sql.Driver</driver-ref>
<synchronize>true</synchronize>
<configured-by-url>true</configured-by-url>
@ -9,5 +9,20 @@
<jdbc-url>jdbc:d1://d09d3c74-c75f-4418-8f1b-2fe7f21637e6</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="Local" uuid="3146fd0c-cf69-4ce2-a595-d79ffbf6d952">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/b4240d7bbd9ebbe9af8e673ea45d2ce60b229fada9c1b835fe686c02ef96be7f.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="Staging DB" uuid="1e5ac821-723f-4849-88c7-6dcd7e51f941">
<driver-ref>java.sql.Driver</driver-ref>
<synchronize>true</synchronize>
<configured-by-url>true</configured-by-url>
<jdbc-driver>org.isaacmcfadyen.D1Driver</jdbc-driver>
<jdbc-url>jdbc:d1://e0ce391d-e34c-45d2-9bc0-521970f077c5</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ncc-gen.iml" filepath="$PROJECT_DIR$/.idea/ncc-gen.iml" />
</modules>
</component>
</project>

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/0000_initialize_responses_table.sql" dialect="SQLite" />
<file url="PROJECT" dialect="SQLite" />
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="Less" />
</project>

@ -0,0 +1,7 @@
Copyright (c) 2024 DeliciousReya
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -12,30 +12,48 @@ The word of the day is Millionaire](example.png)
It does exactly what it says on the tin: it generates random vore scenarios. For you!
[The list of default responses are here if you want to check it in advance.](https://scenario-generator.deliciousreya.net/responses)
## Usage
## Web Usage
### Generation
* Visit [the web interface](https://scenario-generator.deliciousreya.net/generator) to generate a random scenario.
* Don't like what you got? Click the lock next to the components you don't like to change it to a die, then click
"Reroll Selected" to generate fresh ones.
* Think the scenario is beyond saving? Click "New Scenario" to create an entirely new scenario.
* Satisfied with the results? Click "Get Scenario Link" to get a new
* You can download the web interface by simply using the Save command in your browser to generate scenarios offline!
* You can view the possible responses with
the ["View Possible Responses" button](https://scenario-generator.deliciousreya.net/responses)
### Customization
Coming soon!
## Discord Usage
### Installation
* Visit [this Discord OAuth link](https://discord.com/api/oauth2/authorize?client_id=1192326191189864458&permissions=0&scope=applications.commands)
and give permission to use application commands in your server. Share it with the admin of your server if you don't
have permission yourself!
* Visit
[this Discord OAuth link](https://discord.com/api/oauth2/authorize?client_id=1192326191189864458&permissions=0&scope=applications.commands)
and give permission to use application commands in your server. Share it with the admin of your server if you don't
have permission yourself!
* Don't worry - not only does this bot not do anything nefarious with your messages, but it actually can't read them at
all. It only reads the commands you give it and its own messages! If you're concerned, though, please go through the
code. Kink stuff is very personal and private! Use caution when running random horny software!
all. It only reads the commands you give it and its own messages! If you're concerned, though, please go through the
code. Kink stuff is very personal and private! Use caution when running random horny software!
### Generation
* Run the `/generate` command to generate a random scenario.
* Don't like what you got? Pick the components you don't like from the select box,
then click "Reroll Selected" to generate fresh ones.
* Don't like what you got? Pick the components you don't like from the select box, then click "Reroll Selected" to
generate fresh ones.
* Think the scenario is beyond saving? Click "Trash it." to delete the message.
* Satisfied with the results? Click "Looks good!" to remove the reroll commands.
### Customization
Note that changes to custom responses will be sent to your current channel and will be visible to the admin (that's me)
and everyone in the server, as well as anyone who knows or can guess your server ID. Assume your responses are not
private.
**Privacy note**: Note that changes to custom responses will be sent to your current channel and will be visible to the
admin (that's me) and everyone in the server, as well as anyone who knows or can guess your server ID. Assume your
responses are not private.
* Run `/response add [table] [text]` to add a new custom response. Use the table listing in the Discord command.
* Run `/response delete [table] [text]` to remove a custom response. Give the response text exactly!
@ -46,10 +64,27 @@ private.
## Credits
* Icon source: [obsid1an on DeviantArt](https://www.deviantart.com/obsid1an/art/Slot-Machine-Game-Icon-341475642)
* Writing for default responses by [Ssublissive](https://aryion.com/g4/gallery/Ssublissive), with additional writing by
[DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), [Seina](https://aryion.com/g4/user/RediQ) and 1 other.
* Development by [DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), using
[slash-create](https://slash-create.js.org/#/) and
[discord-snowflake](https://github.com/ianmitchell/interaction-kit/tree/main/packages/discord-snowflake).
* Writing for default response sets by [Ssublissive](https://aryion.com/g4/gallery/Ssublissive), with additional writing
by [DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), [Seina](https://aryion.com/g4/user/RediQ),
and [sushisama](https://arsenicteacups.carrd.co/).
* UX testing by a :dolphin: friend.
* Development by [DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya) in [TypeScript](https://www.typescriptlang.org/), using
* [slash-create](https://slash-create.js.org/#/) and [discord-snowflake](https://github.com/ianmitchell/interaction-kit/tree/main/packages/discord-snowflake#readme) for Discord management,
* [itty-router](https://github.com/kwhitley/itty-router#readme), [escape-html](https://github.com/component/escape-html#readme),
[markdown-escape](https://github.com/kemitchell/markdown-escape.js#readme), [slug](https://github.com/Trott/slug#readme), and [collapse-white-space](https://github.com/wooorm/collapse-white-space#readme) for response generation,
* [less](https://lesscss.org) with [clean-css](https://github.com/clean-css/clean-css) and [babel](https://babeljs.io/) with [terser](https://terser.org/) for pretty and small client code,
* [rollup](https://rollupjs.org/) with [plugin-commonjs](https://github.com/rollup/plugins/tree/master/packages/commonjs/#readme)/[plugin-node-resolve](https://github.com/rollup/plugins/tree/master/packages/node-resolve/#readme)/[plugin-ts](https://github.com/wessberg/rollup-plugin-ts#readme)/[plugin-terser](https://github.com/rollup/plugins/tree/master/packages/terser#readme) for complete client bundles,
* and [camelcase](https://github.com/sindresorhus/camelcase#readme) and [fast-deep-equal](https://github.com/epoberezkin/fast-deep-equal#readme) for keeping the client bundles and their source maps up to date.
* Hosted on [CloudFlare Workers](https://developers.cloudflare.com/workers/)
with [D1](https://developers.cloudflare.com/d1/).
## License (MIT)
Copyright (c) 2024 DeliciousReya and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -0,0 +1,215 @@
-- Migration number: 0002 2024-01-06T01:17:06.949Z
CREATE TABLE IF NOT EXISTS rollableTables
(
id INTEGER PRIMARY KEY,
identifier TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
title TEXT NOT NULL UNIQUE,
emoji TEXT NOT NULL UNIQUE,
header TEXT NOT NULL GENERATED ALWAYS AS (emoji || ' ' || title),
badge TEXT NOT NULL GENERATED ALWAYS AS (emoji || ' ' || name),
ordinal INTEGER NOT NULL UNIQUE DEFAULT id
) STRICT;
CREATE TRIGGER IF NOT EXISTS responseInserted
AFTER INSERT
ON responses
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
CREATE TRIGGER IF NOT EXISTS responseTableIdUpdated
AFTER UPDATE OF tableId
ON responses
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
CREATE TABLE IF NOT EXISTS rollableTableHeaders
(
id INTEGER PRIMARY KEY,
header TEXT NOT NULL UNIQUE,
tableId INTEGER NOT NULL
-- FOREIGN KEY REFERENCES rollableTables(id) ON DELETE RESTRICT ON UPDATE CASCADE
) STRICT;
CREATE TRIGGER IF NOT EXISTS rollableTableHeaderInsert
AFTER INSERT
ON rollableTableHeaders
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
CREATE TRIGGER IF NOT EXISTS rollableTableHeaderTableIdUpdate
AFTER UPDATE OF tableId
ON rollableTableHeaders
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
CREATE TABLE IF NOT EXISTS rollableTableBadges
(
id INTEGER PRIMARY KEY,
badge TEXT NOT NULL UNIQUE,
tableId INTEGER NOT NULL
-- FOREIGN KEY REFERENCES rollableTables(id) ON DELETE RESTRICT ON UPDATE CASCADE
) STRICT;
CREATE TRIGGER IF NOT EXISTS rollableTableBadgeInsert
AFTER INSERT
ON rollableTableBadges
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
CREATE TRIGGER IF NOT EXISTS rollableTableBadgeTableIdUpdate
AFTER UPDATE OF tableId
ON rollableTableHeaders
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
CREATE TABLE IF NOT EXISTS rollableTableIdentifiers
(
id INTEGER PRIMARY KEY,
identifier TEXT NOT NULL UNIQUE,
tableId INTEGER NOT NULL -- FOREIGN KEY REFERENCES rollableTables(id) ON DELETE RESTRICT ON UPDATE CASCADE
) STRICT;
CREATE TRIGGER IF NOT EXISTS rollableTableIdentifierInsert
AFTER INSERT
ON rollableTableIdentifiers
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
CREATE TRIGGER IF NOT EXISTS rollableTableIdentifierTableIdUpdate
AFTER UPDATE OF tableId
ON rollableTableIdentifiers
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
-- rollableTables triggers for Identifiers and Headers
CREATE TRIGGER IF NOT EXISTS rollableTableInsert
AFTER INSERT
ON rollableTables
FOR EACH ROW
BEGIN
INSERT INTO rollableTableHeaders (header, tableId) VALUES (NEW.header, NEW.id);
INSERT INTO rollableTableBadges (badge, tableId) VALUES (NEW.badge, NEW.id);
INSERT INTO rollableTableIdentifiers (identifier, tableId) VALUES (NEW.identifier, NEW.id);
END;
CREATE TRIGGER IF NOT EXISTS rollableTableIdUpdate
AFTER UPDATE OF id
ON rollableTables
FOR EACH ROW
BEGIN
UPDATE rollableTableHeaders SET tableId = NEW.id WHERE tableId = OLD.id;
UPDATE rollableTableBadges SET tableId = NEW.id WHERE tableId = OLD.id;
UPDATE rollableTableIdentifiers SET tableId = NEW.id WHERE tableId = OLD.id;
UPDATE responses SET tableId = NEW.id WHERE tableId = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS rollableTableBadgeUpdate
AFTER UPDATE
ON rollableTables
FOR EACH ROW
WHEN OLD.badge != NEW.badge AND NOT EXISTS (SELECT id
FROM rollableTableBadges
WHERE badge = NEW.badge
AND tableId = NEW.id)
BEGIN
INSERT INTO rollableTableBadges (badge, tableId)
VALUES (NEW.badge, NEW.id);
END;
CREATE TRIGGER IF NOT EXISTS rollableTableHeaderUpdate
AFTER UPDATE
ON rollableTables
FOR EACH ROW
WHEN OLD.header != NEW.header AND NOT EXISTS (SELECT id
FROM rollableTableHeaders
WHERE header = NEW.header
AND tableId = NEW.id)
BEGIN
INSERT INTO rollableTableHeaders (header, tableId) VALUES (NEW.header, NEW.id);
END;
CREATE TRIGGER IF NOT EXISTS rollableTableIdentifierUpdate
AFTER UPDATE OF identifier
ON rollableTables
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTableIdentifiers
WHERE identifier = NEW.identifier
AND tableId = NEW.id)
BEGIN
INSERT INTO rollableTableIdentifiers (identifier, tableId) VALUES (NEW.identifier, NEW.id);
END;
CREATE TRIGGER IF NOT EXISTS rollableTableDeletedButReferenced
AFTER DELETE
ON rollableTables
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'rollable table is still referenced')
FROM (SELECT *
FROM rollableTableHeaders
WHERE rollableTableHeaders.tableId = OLD.id
UNION ALL
SELECT *
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.tableId = OLD.id
UNION ALL
SELECT *
FROM rollableTableBadges
WHERE rollableTableBadges.tableId = OLD.id);
DELETE FROM responses WHERE tableId = OLD.id;
END;
INSERT INTO rollableTables (id, ordinal, identifier, name, title, emoji)
VALUES (0, 0, 'setting', 'Setting', 'The action takes place...', CHAR(0x1f3d9, 0xfe0f)),
(1, 1, 'theme', 'Theme', 'The encounter is themed around...', CHAR(0x1f4d4)),
(2, 2, 'start', 'Inciting Incident', 'The action begins when...', CHAR(0x25b6, 0xfe0f)),
(3, 3, 'challenge', 'Challenge', 'Things are more difficult because...', CHAR(0x1f613)),
(4, 4, 'twist', 'Twist', 'Partway through, unexpectedly...', CHAR(0x1f500)),
(5, 5, 'focus', 'Vore Scene Focus', 'The vore scene is focused on...', CHAR(0x1f444)),
(6, 6, 'word', 'Word of the Day', 'The word of the day is...', CHAR(0x2728));
CREATE INDEX IF NOT EXISTS responses_table_text ON responses (tableId, text);

@ -0,0 +1,422 @@
-- Migration number: 0003 2024-01-08T04:03:32.751Z
CREATE TABLE IF NOT EXISTS rollableResults
(
id INTEGER PRIMARY KEY,
tableId INTEGER NOT NULL, -- FOREIGN KEY REFERENCES rollableTables (id) ON UPDATE CASCADE ON DELETE CASCADE
text TEXT NOT NULL,
UNIQUE (tableId, text)
) STRICT;
CREATE TRIGGER IF NOT EXISTS rollableResultInsert
AFTER INSERT
ON rollableResults
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
CREATE TRIGGER IF NOT EXISTS rollableResultTableIdUpdate
AFTER UPDATE OF tableid
ON rollableResults
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableTables
WHERE id = NEW.tableId)
BEGIN
SELECT RAISE(ABORT, 'rollable table does not exist');
END;
INSERT OR IGNORE INTO rollableResults (tableId, text)
SELECT DISTINCT responses.tableId, responses.text
FROM responses;
CREATE TABLE IF NOT EXISTS authorshipTypes
(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
relationPrefix TEXT NOT NULL,
defaultAuthor TEXT NOT NULL
) STRICT;
INSERT INTO authorshipTypes (id, name, relationPrefix, defaultAuthor)
VALUES (0, 'Discord contributor', 'contributed by', 'an anonymous Discord user'),
(1, 'Web contributor', 'contributed by', 'an anonymous web user'),
(2, 'author', 'written by', 'an anonymous author'),
(3, 'source', 'from', 'an unknown source');
CREATE TABLE IF NOT EXISTS authors
(
id INTEGER PRIMARY KEY,
name TEXT,
url TEXT,
discordUsername TEXT,
discordSnowflake TEXT UNIQUE,
authorshipTypeId INTEGER NOT NULL,
-- FOREIGN KEY REFERENCES authorshipTypes(id) ON DELETE RESTRICT ON UPDATE CASCADE
CONSTRAINT onlyUrlIfNameIsGiven CHECK (NOT (name IS NULL AND url IS NOT NULL))
) STRICT;
CREATE TRIGGER IF NOT EXISTS authorInsert
AFTER INSERT
ON authors
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM authorshipTypes
WHERE id = NEW.authorshipTypeId)
BEGIN
SELECT RAISE(ABORT, 'authorship type does not exist');
END;
CREATE TRIGGER IF NOT EXISTS authorAuthorshipTypeIdUpdate
AFTER UPDATE OF authorshipTypeId
ON authors
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM authorshipTypes
WHERE id = NEW.authorshipTypeId)
BEGIN
SELECT RAISE(ABORT, 'authorship type does not exist');
END;
CREATE TRIGGER IF NOT EXISTS authorshipTypeIdUpdate
AFTER UPDATE OF id
ON authorshipTypes
FOR EACH ROW
BEGIN
UPDATE authors SET authorshipTypeId = NEW.id WHERE authorshipTypeId = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS authorshipTypeDeleted
AFTER DELETE
ON authorshipTypes
FOR EACH ROW
WHEN EXISTS (SELECT id
FROM authors
WHERE authorshipTypeId = OLD.id)
BEGIN
SELECT RAISE(ABORT, 'authorship type is still used1');
END;
INSERT OR IGNORE INTO authors (discordSnowflake, authorshipTypeId)
SELECT snowflake, 0
FROM (SELECT DISTINCT responses.userSnowflake as snowflake FROM responses);
CREATE TABLE IF NOT EXISTS resultSets
(
id INTEGER PRIMARY KEY,
name TEXT,
description TEXT,
creatorId INTEGER,
discordSnowflake TEXT,
global INTEGER NOT NULL DEFAULT FALSE CHECK (global IN (TRUE, FALSE))
) STRICT;
CREATE UNIQUE INDEX idx_resultSets_global ON resultSets (global, discordSnowflake);
INSERT OR IGNORE INTO resultSets (discordSnowflake, global)
SELECT DISTINCT COALESCE(responses.serverSnowflake, responses.userSnowflake) AS discordSnowflake,
responses.access = 0 AS global
FROM responses;
CREATE TABLE IF NOT EXISTS resultMappings
(
id INTEGER PRIMARY KEY,
resultId INTEGER NOT NULL, -- FOREIGN KEY REFERENCES rollableResults (id) ON DELETE CASCADE ON UPDATE CASCADE
setId INTEGER NOT NULL, -- FOREIGN KEY REFERENCES resultSets (id) ON DELETE CASCADE ON UPDATE CASCADE
authorId INTEGER, -- FOREIGN KEY REFERENCES authors (id) ON DELETE SET NULL ON UPDATE CASCADE
created INTEGER NOT NULL,
updated INTEGER NOT NULL
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_resultMapping_setId_resultId_resultOncePerSet ON resultMappings (setId, resultId);
INSERT OR IGNORE INTO resultMappings (resultId, setId, authorId, created, updated)
SELECT rollableResults.id, resultSets.id, authors.id, responses.id, responses.timestamp
FROM responses
LEFT JOIN rollableResults
ON rollableResults.tableId = responses.tableId
AND rollableResults.text = responses.text
LEFT JOIN resultSets
ON (responses.access = 2 AND
resultSets.discordSnowflake = responses.userSnowflake AND
resultSets.global = 0)
OR (responses.access = 1 AND
resultSets.discordSnowflake = responses.serverSnowflake AND
resultSets.global = 0)
OR (responses.access = 0 AND
resultSets.discordSnowflake = COALESCE(responses.serverSnowflake, responses.userSnowflake) AND
resultSets.global = 1)
LEFT JOIN authors
ON authors.discordSnowflake = responses.userSnowflake;
CREATE TRIGGER IF NOT EXISTS resultAuthorInsert
AFTER INSERT
ON resultMappings
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableResults
WHERE id = NEW.resultId)
OR NOT EXISTS (SELECT id
FROM resultSets
WHERE id = NEW.setId)
OR NEW.authorId IS NOT NULL AND NOT EXISTS (SELECT id
FROM authors
WHERE id = NEW.authorId)
BEGIN
SELECT RAISE(ABORT, 'all of resultId, setId, authorId must exist if given');
END;
CREATE TRIGGER IF NOT EXISTS resultAuthorResultIdUpdate
AFTER UPDATE OF resultId
ON resultMappings
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM rollableResults
WHERE id = NEW.resultId)
BEGIN
SELECT RAISE(ABORT, 'result must exist');
END;
CREATE TRIGGER IF NOT EXISTS resultAuthorSetIdUpdate
AFTER UPDATE OF setId
ON resultMappings
FOR EACH ROW
WHEN NOT EXISTS (SELECT id
FROM resultSets
WHERE id = NEW.setId)
BEGIN
SELECT RAISE(ABORT, 'setId must exist');
END;
CREATE TRIGGER IF NOT EXISTS resultAuthorAuthorIdUpdate
AFTER UPDATE OF authorId
ON resultMappings
FOR EACH ROW
WHEN NEW.authorId IS NOT NULL AND NOT EXISTS (SELECT id
FROM authors
WHERE id = NEW.authorId)
BEGIN
SELECT RAISE(ABORT, 'author must exist if given');
END;
CREATE TRIGGER IF NOT EXISTS rollableResultIdUpdate
AFTER UPDATE OF id
ON rollableResults
FOR EACH ROW
BEGIN
UPDATE resultMappings SET resultId = NEW.id WHERE resultId = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS resultSetIdUpdate
AFTER UPDATE OF id
ON resultSets
FOR EACH ROW
BEGIN
UPDATE resultMappings SET setId = NEW.id WHERE setId = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS authorIdUpdate
AFTER UPDATE OF id
ON authors
FOR EACH ROW
BEGIN
UPDATE resultMappings SET authorId = NEW.id WHERE authorId = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS rollableResultIdDelete
AFTER DELETE
ON rollableResults
FOR EACH ROW
BEGIN
DELETE FROM resultMappings WHERE resultId = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS resultSetIdDelete
AFTER DELETE
ON resultSets
FOR EACH ROW
BEGIN
DELETE FROM resultMappings WHERE setId = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS authorIdDelete
AFTER DELETE
ON authors
FOR EACH ROW
BEGIN
UPDATE resultMappings SET authorId = NULL WHERE authorId = OLD.id;
END;
DROP TRIGGER IF EXISTS rollableTableIdUpdate;
DROP TRIGGER IF EXISTS rollableTableDelete;
DROP TRIGGER IF EXISTS responseInserted;
DROP TRIGGER IF EXISTS responseTableIdUpdated;
ALTER TABLE responses
RENAME TO responsesOriginal;
CREATE TRIGGER IF NOT EXISTS rollableTableIdUpdate
AFTER UPDATE OF id
ON rollableTables
FOR EACH ROW
BEGIN
UPDATE rollableTableIdentifiers SET tableId = NEW.id WHERE tableId = OLD.id;
UPDATE rollableTableHeaders SET tableId = NEW.id WHERE tableId = OLD.id;
UPDATE rollableTableBadges SET tableId = NEW.id WHERE tableId = OLD.id;
UPDATE rollableResults SET tableId = NEW.id WHERE tableId = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS rollableTableDelete
AFTER DELETE
ON rollableTables
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'rollable table is still referenced')
FROM (SELECT NULL
FROM rollableTableHeaders
WHERE rollableTableHeaders.tableId = OLD.id
UNION ALL
SELECT NULL
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.tableId = OLD.id
UNION ALL
SELECT NULL
FROM rollableTableBadges
WHERE rollableTableBadges.tableId = OLD.id);
DELETE FROM rollableResults WHERE tableId = OLD.id;
END;
CREATE VIEW IF NOT EXISTS responses AS
SELECT resultMappings.created AS id,
rollableResults.tableId AS tableId,
rollableResults.text AS text,
resultMappings.updated AS timestamp,
authors.discordSnowflake AS userSnowflake,
(CASE
WHEN resultSets.discordSnowflake = authors.discordSnowflake THEN NULL
ELSE resultSets.discordSnowflake
END) AS serverSnowflake,
(CASE
WHEN resultSets.global = 1 THEN 0
WHEN resultSets.discordSnowflake = authors.discordSnowflake THEN 2
ELSE 1
END) AS access
FROM resultMappings
INNER JOIN rollableResults ON resultMappings.resultId = rollableResults.id
INNER JOIN authors ON resultMappings.authorId = authors.id
INNER JOIN resultSets ON resultMappings.setId = resultSets.id
WHERE resultSets.discordSnowflake IS NOT NULL;
-- crash if we have any differences
CREATE TABLE intentionallyCrash
(
differences TEXT CHECK (differences = 'existing between the view and the original table')
) STRICT;
INSERT INTO intentionallyCrash (differences)
SELECT 'uh oh'
FROM (SELECT *
FROM (SELECT * FROM responsesOriginal EXCEPT SELECT * FROM responses)
UNION ALL
SELECT *
FROM (SELECT * FROM responses EXCEPT SELECT * FROM responsesOriginal));
DROP TABLE intentionallyCrash;
DROP TABLE responsesOriginal;
CREATE TRIGGER IF NOT EXISTS responsesInserted
INSTEAD OF INSERT
ON responses
FOR EACH ROW
BEGIN
INSERT OR IGNORE INTO resultSets (discordSnowflake, global)
VALUES (COALESCE(NEW.serverSnowflake, NEW.userSnowflake),
CASE
WHEN NEW.access = 0 THEN 1
WHEN NEW.access = 1 THEN 0
WHEN NEW.access = 2 THEN 0
ELSE RAISE(ABORT, 'access must be in 0, 1, 2')
END);
INSERT OR IGNORE INTO rollableResults (tableId, text)
VALUES (CASE
WHEN NEW.tableId IN (SELECT id FROM rollableTables) THEN NEW.tableId
ELSE RAISE(ABORT, 'tableId must belong to an existing table')
END, NEW.text);
INSERT OR IGNORE INTO authors (discordSnowflake, authorshipTypeId)
VALUES (NEW.userSnowflake,
(SELECT authorshipTypes.id FROM authorshipTypes WHERE authorshipTypes.name = 'Discord contributor'));
INSERT OR ABORT INTO resultMappings (resultId, setId, authorId, created, updated)
VALUES ((SELECT id FROM rollableResults WHERE tableId = NEW.tableId AND text = NEW.text),
(SELECT id
FROM resultSets
WHERE discordSnowflake = CASE
WHEN NEW.access = 0 THEN ''
WHEN NEW.access = 1 THEN NEW.serverSnowflake
WHEN NEW.access = 2 THEN NEW.userSnowflake
ELSE RAISE(ABORT, 'access must be in 0, 1, 2')
END),
(SELECT id FROM authors WHERE discordSnowflake = NEW.userSnowflake),
NEW.id,
NEW.timestamp);
END;
CREATE TRIGGER IF NOT EXISTS responsesUpdated
INSTEAD OF UPDATE
ON responses
FOR EACH ROW
BEGIN
INSERT OR IGNORE INTO resultSets (discordSnowflake, global)
VALUES (COALESCE(NEW.serverSnowflake, NEW.userSnowflake),
CASE
WHEN NEW.access = 0 THEN 1
WHEN NEW.access = 1 THEN 0
WHEN NEW.access = 2 THEN 0
ELSE RAISE(ABORT, 'access must be in 0, 1, 2')
END);
INSERT OR IGNORE INTO rollableResults (tableId, text)
VALUES (CASE
WHEN NEW.tableId IN (SELECT id FROM rollableTables) THEN NEW.tableId
ELSE RAISE(ABORT, 'tableId must belong to an existing table')
END, NEW.text);
INSERT OR IGNORE INTO authors (discordSnowflake, authorshipTypeId)
VALUES (NEW.userSnowflake,
(SELECT authorshipTypes.id FROM authorshipTypes WHERE authorshipTypes.name = 'Discord contributor'));
UPDATE OR ABORT resultMappings
SET resultId = (SELECT id FROM rollableResults WHERE tableId = NEW.tableId AND text = NEW.text),
setId = (SELECT id
FROM resultSets
WHERE discordSnowflake = COALESCE(NEW.serverSnowflake, NEW.userSnowflake)
AND global = CASE
WHEN NEW.access = 0 THEN 1
WHEN NEW.access = 1 THEN 0
WHEN NEW.access = 2 THEN 0
ELSE RAISE(ABORT, 'access must be in 0, 1, 2')
END),
authorId = (SELECT id FROM authors WHERE discordSnowflake = NEW.userSnowflake),
created = NEW.id,
updated = NEW.timestamp
WHERE resultId = (SELECT id FROM rollableResults WHERE tableId = OLD.tableId AND text = OLD.text)
AND setId = (SELECT id
FROM resultSets
WHERE discordSnowflake = COALESCE(OLD.serverSnowflake, OLD.userSnowflake)
AND global = CASE
WHEN OLD.access = 0 THEN 1
WHEN OLD.access = 1 THEN 0
WHEN OLD.access = 2 THEN 0
END)
AND authorId = (SELECT id FROM authors WHERE discordSnowflake = OLD.userSnowflake);
END;
CREATE TRIGGER IF NOT EXISTS responsesDeleted
INSTEAD OF DELETE
ON responses
FOR EACH ROW
BEGIN
DELETE
FROM resultMappings
WHERE resultId = (SELECT id FROM rollableResults WHERE tableId = OLD.tableId AND text = OLD.text)
AND setId = (SELECT id
FROM resultSets
WHERE discordSnowflake = COALESCE(OLD.serverSnowflake, OLD.userSnowflake)
AND global = CASE
WHEN OLD.access = 0 THEN 1
WHEN OLD.access = 1 THEN 0
WHEN OLD.access = 2 THEN 0
END)
AND authorId = (SELECT id FROM authors WHERE discordSnowflake = OLD.userSnowflake);
END;

@ -0,0 +1,11 @@
-- Migration number: 0005 2024-01-14T02:47:29.285Z
-- Clapperboard in place of Play Button for Inciting Incident - same color as shuffle in Twemoji, requires two characters
UPDATE rollableTables
SET emoji = CHAR(0x1f3ac)
WHERE rollableTables.emoji = CHAR(0x25b6, 0xfe0f);
-- House in place of Cityscape for Setting - requires two characters
UPDATE rollableTables
SET emoji = CHAR(0x1f3e0)
WHERE rollableTables.emoji = CHAR(0x1f3d9, 0xfe0f);

5683
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -4,16 +4,44 @@
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"stage": "wrangler deploy --env staging",
"dev": "wrangler dev",
"start": "wrangler dev"
"start": "wrangler dev",
"generate": "tsx src/build/bundle-client.ts"
},
"devDependencies": {
"@babel/core": "^7.23.7",
"@babel/plugin-transform-runtime": "^7.23.7",
"@babel/preset-env": "^7.23.8",
"@babel/preset-typescript": "^7.23.3",
"@babel/runtime": "^7.23.8",
"@cloudflare/workers-types": "^4.20231218.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@types/clean-css": "^4.2.11",
"@types/escape-html": "^1.0.4",
"@types/less": "^3.0.6",
"@types/markdown-escape": "^1.1.3",
"@types/slug": "^5.0.7",
"camelcase": "^8.0.0",
"clean-css": "^5.3.3",
"fast-deep-equal": "^3.1.3",
"less": "^4.2.0",
"rollup": "^4.9.5",
"rollup-plugin-ts": "^3.4.5",
"tsx": "^4.7.0",
"typescript": "^5.0.4",
"wrangler": "^3.0.0"
},
"dependencies": {
"collapse-white-space": "^2.1.0",
"discord-snowflake": "^2.0.0",
"slash-create": "^6.0.2"
"escape-html": "^1.0.3",
"itty-router": "^4.0.26",
"markdown-escape": "^2.0.0",
"slash-create": "^6.0.2",
"slug": "^8.2.3"
}
}

@ -0,0 +1,16 @@
import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler';
async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) {
const bundle = await getBundle(inPath)
await writeBundle(bundle, outPath, true)
}
main(...process.argv.slice(2)).then(() => {
console.info('generated client helpers');
}).catch((err) => {
console.error('could not generate client helpers');
console.error(err && 'stack' in err ? err.stack : err);
throw err;
}).catch(() => {
process.exit(1);
});

@ -0,0 +1,208 @@
import {
createPrinter,
parseJsonText,
factory,
NewLineKind,
NodeFlags,
type PropertyAssignment,
SyntaxKind,
type VariableDeclaration,
type VariableStatement
} from 'typescript';
import typescriptModule from 'typescript';
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { basename, dirname, join, normalize } from 'node:path';
import {createHash} from 'node:crypto';
import camelcase from 'camelcase';
import { render as renderLess } from 'less';
import CleanCSS from 'clean-css';
import type {
HashedBundled,
SourceMappedHashedBundled,
SourceMappedBundled,
Bundled,
MaybeSourceMappedHashedBundled, SourceMap
} from '../common/bundle';
import { rollup, type RollupCache } from 'rollup';
import typescript from 'rollup-plugin-ts';
import terser from '@rollup/plugin-terser';
import nodeResolve from '@rollup/plugin-node-resolve';
import commonJs from '@rollup/plugin-commonjs';
function* assignProperties(pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): Generator<PropertyAssignment> {
for (const [identifier, { bundled, hash, sourceMap }] of pairs) {
yield factory.createPropertyAssignment(
factory.createIdentifier(identifier),
factory.createObjectLiteralExpression([
factory.createPropertyAssignment(
factory.createIdentifier("bundled"),
factory.createNoSubstitutionTemplateLiteral(bundled)
),
factory.createPropertyAssignment(
factory.createIdentifier("hash"),
factory.createStringLiteral(hash)
),
...(includeSourceMap && sourceMap ? [factory.createPropertyAssignment(
factory.createIdentifier("sourceMap"),
parseJsonText(hash + ".map", JSON.stringify(sourceMap)).statements[0].expression,
)] : [])
], true));
}
}
function declareObjectLiteral(identifier: string, pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): VariableDeclaration {
return factory.createVariableDeclaration(
factory.createIdentifier(identifier),
undefined,
undefined,
factory.createSatisfiesExpression(
factory.createAsExpression(
factory.createObjectLiteralExpression(Array.from(assignProperties(pairs, includeSourceMap)), true),
factory.createTypeReferenceNode(factory.createIdentifier('const'))),
factory.createTypeReferenceNode(
factory.createIdentifier("Record"),
[
factory.createTypeReferenceNode(factory.createIdentifier("string")),
factory.createTypeReferenceNode(factory.createIdentifier("MaybeSourceMappedHashedBundled"))])));
}
function exportObjectLiteral(identifier: string, pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): VariableStatement {
return factory.createVariableStatement(
[factory.createToken(SyntaxKind.ExportKeyword)],
factory.createVariableDeclarationList([declareObjectLiteral(identifier, pairs, includeSourceMap)], NodeFlags.Const)
);
}
async function processLess(atPath: string): Promise<SourceMappedBundled> {
const fileBase = basename(atPath.substring(0, atPath.length - LESS_SUFFIX.length));
const { css: lessCss, map: lessMap } = await renderLess(await readFile(atPath, { encoding: 'utf-8' }), {
paths: [dirname(atPath)],
math: 'strict',
strictUnits: true,
filename: fileBase + '.less',
strictImports: true,
sourceMap: {
outputSourceFiles: true,
}
});
const { styles, sourceMap } = await new CleanCSS({
sourceMap: true,
sourceMapInlineSources: true,
returnPromise: true,
level: 2,
format: false,
inline: ['all'],
rebase: false,
compatibility: '*',
fetch(uri): never {
throw Error(`external files are unexpected after less compilation, but found ${uri}`)
},
}).minify({
[fileBase + '.css']: {
styles: lessCss,
sourceMap: lessMap
}
})
return { bundled: styles, sourceMap: {...JSON.parse(sourceMap!.toString()), file: fileBase + ".css"} as SourceMap };
}
async function processTypescript(atPath: string, inDir: string, cache?: RollupCache): Promise<{cache: RollupCache, bundle: SourceMappedBundled}> {
const build = await rollup({
cache: cache ?? true,
input: atPath,
plugins: [
nodeResolve({
}),
commonJs({
}),
typescript({
transpiler: "babel",
typescript: typescriptModule,
tsconfig: join(inDir, 'tsconfig.json')
}),
terser({})
]
})
const {output: [chunk]} = await build.generate({
name: camelcase(basename(atPath.substring(0, atPath.length - TS_SUFFIX.length))),
sourcemap: 'hidden',
sourcemapFile: join(inDir, 'sourcemap.map'),
format: 'iife',
compact: true,
})
return {
cache: build.cache!,
bundle: {
bundled: chunk.code,
sourceMap: chunk.map!
}
}
}
const LESS_SUFFIX = '-entrypoint.less';
const TS_SUFFIX = '-entrypoint.ts';
function hashBundled<T extends Bundled>(value: T & {readonly hash?: never}): T & HashedBundled {
const hash = createHash('sha256').update(value.bundled).digest('hex')
return {
...value,
hash,
}
}
export async function getBundle(inDir: string): Promise<{ css: Map<string, SourceMappedHashedBundled>, js: Map<string, SourceMappedHashedBundled> }> {
const css = new Map<string, SourceMappedHashedBundled>();
const js = new Map<string, SourceMappedHashedBundled>();
const dir = await readdir(inDir, { withFileTypes: true });
let cache: RollupCache|undefined = undefined
for (const ent of dir) {
if (!ent.isFile()) {
continue;
}
if (ent.name.endsWith(LESS_SUFFIX)) {
css.set(camelcase(ent.name.substring(0, ent.name.length - LESS_SUFFIX.length)), hashBundled(await processLess(join(inDir, ent.name))));
} else if (ent.name.endsWith(TS_SUFFIX)) {
const {cache: newCache, bundle} = await processTypescript(join(inDir, ent.name), inDir, cache)
cache = newCache
js.set(camelcase(ent.name.substring(0, ent.name.length - TS_SUFFIX.length)), hashBundled(bundle));
} else {
// continue;
}
}
return { css, js };
}
export const DEFAULT_IN_PATH = normalize(join(__dirname, '../../src/client/'))
export const DEFAULT_OUT_PATH = normalize(join(__dirname, '../../src/server/web/bundles/client.generated.ts'))
export async function writeBundle({ css, js }: {css: Map<string, SourceMappedHashedBundled>, js: Map<string, SourceMappedHashedBundled>}, outFile: string, includeSourceMap: true): Promise<void>
export async function writeBundle({ css, js }: {css: Map<string, HashedBundled>, js: Map<string, HashedBundled>}, outFile: string, includeSourceMap: false): Promise<void>
export async function writeBundle({ css, js }: {css: Map<string, MaybeSourceMappedHashedBundled>, js: Map<string, MaybeSourceMappedHashedBundled>}, outFile: string, includeSourceMap: boolean): Promise<void>
export async function writeBundle({ css, js }: {css: Map<string, MaybeSourceMappedHashedBundled>, js: Map<string, MaybeSourceMappedHashedBundled>}, outFile: string, includeSourceMap: boolean): Promise<void> {
const printer = createPrinter({
newLine: NewLineKind.LineFeed,
omitTrailingSemicolon: true
});
await writeFile(outFile, printer.printFile(factory.createSourceFile([
factory.createImportDeclaration(
undefined,
factory.createImportClause(
false,
undefined,
factory.createNamedImports([
factory.createImportSpecifier(
true,
undefined,
factory.createIdentifier( "MaybeSourceMappedHashedBundled")),
])
),
factory.createStringLiteral("../../common/bundle.js")),
exportObjectLiteral('CSS', css, includeSourceMap),
exportObjectLiteral('JS', js, includeSourceMap)
], factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None)), {
encoding: 'utf-8',
mode: 0o644
});
}

@ -0,0 +1,31 @@
import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from '../server/web/bundles/sourcemaps';
import deepEqual from 'fast-deep-equal';
async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) {
const bundle = await getBundle(inPath)
const errors: string[] = []
for (const [name, {hash, sourceMap}] of bundle.css) {
const filename = getSourceMapFileName(name, hash, SourceMapExtension.CSS)
const existingMap = SourceMaps.get(filename)
if (!existingMap) {
errors.push(`source map for ${filename} is missing; add this line to server/web/bundles/sourcemaps.ts:\n\t\t${JSON.stringify([filename, sourceMap])},\n\n`)
} else if (!deepEqual(sourceMap, existingMap)) {
errors.push(`source map for ${filename} is incorrect; replace this line in server/web/bundles/sourcemaps.ts:\n\t\t${JSON.stringify([filename, existingMap])},\n\nwith this line:\n\t\t${JSON.stringify([filename, sourceMap])},\n\n`)
}
}
if (errors.length > 0) {
throw Error(errors.join('\n'))
}
await writeBundle(bundle, outPath, false)
}
main(...process.argv.slice(2)).then(() => {
console.info('generated client helpers and confirmed sourcemaps are present');
}).catch((err) => {
console.error('could not generate client helpers or confirm sourcemaps are present');
console.error(err && 'stack' in err ? err.stack : err);
throw err;
}).catch(() => {
process.exit(1);
});

@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "ESNext",
"noEmit": true,
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"baseUrl": "./"
},
"include": [
"*"
]
}

@ -0,0 +1,91 @@
.attributed {
position: relative;
}
.attribution {
display: flex;
position: absolute;
bottom: calc(100% + 0.2rem);
left: 0;
right: 0;
z-index: 2;
pointer-events: none;
justify-content: center;
user-select: none;
}
.attributionBubble {
display: flex;
flex-flow: column;
opacity: 0;
background-color: black;
color: white;
position: relative;
margin-bottom: 0.5rem;
font-size: 1rem;
padding: 0.5rem;
border-radius: 0.5rem;
box-sizing: border-box;
transform: scale(0);
transform-origin: bottom center;
transition: opacity 0.25s ease, transform 0.25s ease;
transition-delay: 250ms;
pointer-events: initial;
user-select: none;
}
.attribution .attributionBubble * {
user-select: none;
}
.attributionBubble::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -0.5rem;
border-width: 0.5rem;
border-style: solid;
border-color: black transparent transparent transparent;
}
.attributed:hover, .attributed:focus-within {
user-select: text;
}
.attributed:hover .attributionBubble {
transition-delay: 1.0s;
}
.attributed:focus-within .attributionBubble {
transition-delay: 0s;
}
.attributed:hover .attributionBubble, .attributed:focus-within .attributionBubble {
opacity: 100%;
transform: none;
user-select: text;
}
.attributed:hover .attributionBubble *, .attributed:focus-within .attributionBubble * {
user-select: text;
}
.attributionBubble a {
transition: color 300ms ease;
}
.attributionBubble a:link {
color: aquamarine;
}
.attributionBubble a:visited {
color: mediumaquamarine;
}
.attributionBubble a:focus, .attributionBubble a:hover {
color: lightcyan;
}
.attributionBubble a:active {
color: aqua;
}

@ -0,0 +1,151 @@
body {
background-color: deepskyblue;
font-family: sans-serif;
padding: 0;
margin: 0;
}
.window {
background-color: #f8f7e0;
padding: 1rem;
border: 0.1rem solid black;
border-radius: 0.5rem;
box-sizing: border-box;
}
.tableHeader {
font-size: 1.25rem;
font-weight: bold;
display: flex;
justify-content: stretch;
align-items: baseline;
margin-bottom: 0;
}
.tableEmoji {
font-size: 1.75rem;
padding-right: 0.5rem;
user-select: text;
}
.page {
user-select: contain;
}
.page * {
user-select: none;
}
.readable {
width: 35rem;
}
ul {
padding: 0;
}
li {
list-style: none;
}
.buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: stretch;
& > * {
flex: 1 0 auto;
margin: 0.2rem 0 0 0.3rem
}
}
.button {
outline: none;
border: none;
padding: 0.5rem;
font-size: 1rem;
text-align: center;
text-decoration: none;
color: inherit;
font-family: inherit;
background-color: lightgray;
cursor: pointer;
user-select: none;
border-radius: 0.8rem 0.4rem;
box-shadow: 0 0 black;
transform: none;
transition: background-color 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease;
&:hover, &:focus {
background-color: darkgray;
box-shadow: -0.2rem 0.2rem black;
transform: translate(0.2rem, -0.2rem);
}
&:active {
box-shadow: 0 0 black;
transform: none;
}
}
footer {
display: block;
margin: 0.75rem 0 0 0;
font-size: 0.75rem;
user-select: none;
}
.resultText {
flex: 1 1 auto;
appearance: none;
background-color: transparent;
color: inherit;
font-size: inherit;
font-family: inherit;
outline: 0;
border: 0;
padding: 0.2rem 0.5rem;
cursor: pointer;
text-align: left;
word-wrap: normal;
width: 100%;
box-sizing: border-box;
white-space: normal;
user-select: text;
transition: background-color 0.2s ease;
border-radius: 0.3rem;
}
.resultText:hover {
background-color: #BFBFBF60;
}
.resultText:active, .resultText:focus {
background-color: #9F9FFF90;
}
footer {
text-align: center;
}
@keyframes popup {
from {
transform: scale(0);
opacity: 0;
}
10% {
transform: none;
opacity: 100%;
}
75% {
transform: none;
opacity: 100%;
}
to {
transform: scale(0);
opacity: 0;
}
}

@ -0,0 +1,10 @@
@import "generator-entrypoint";
@import "responses-entrypoint";
#generator:not(:target) {
display: none;
}
#generator:target ~ #responses {
display: none;
}

@ -0,0 +1,10 @@
import './generator-entrypoint'
function updateHash(): void {
if (location.hash === "" || location.hash === "#" || !location.hash) {
location.replace("#generator")
}
}
window.addEventListener("hashchange", updateHash)
updateHash()

@ -0,0 +1,100 @@
@import "basic-look";
@import "attribution";
@import "popup";
@import "pulse";
#generator {
position: absolute;
top: 0;
min-height: 100dvh;
left: 0;
right: 0;
margin: 0;
padding: 2rem;
display: flex;
box-sizing: border-box;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
}
#generatorHead {
margin-top: 0;
user-select: text;
}
#generatedScenario {
}
.generatedHead {
user-select: text;
margin: 0.5rem 0 0 0;
display: flex;
flex-flow: row nowrap;
}
.generatedHead .generatedLabel span {
display: inline;
user-select: text;
}
.generatedLabel {
flex: 1 1 auto;
display: inline-flex;
flex-flow: row nowrap;
align-items: center;
justify-content: left;
cursor: pointer;
padding-right: 0.2rem;
user-select: text;
}
.generated {
margin: 0;
padding: 0;
appearance: none;
font: inherit;
outline: 0;
border: 0;
}
.generatedSelect {
flex: 0 0 auto;
appearance: none;
cursor: pointer;
font-size: 1.5rem;
margin: 0;
transition: filter 0.3s ease, transform 0.3s ease;
width: 2rem;
height: 2rem;
text-align: center;
line-height: 2rem;
border-radius: 1rem;
}
#generator .buttons {
margin-left: -0.3rem;
}
.generatedHead:hover .generatedSelect, .generatedHead .generatedSelect:focus {
filter: brightness(120%) saturate(80%);
transform: scale(120%);
}
.generatedHead .generatedSelect:active {
filter: brightness(80%) saturate(110%);
transform: scale(80%);
}
.generatedSelect::after {
content: '🔒'
}
.generatedSelect:checked::after {
content: '🎲';
}
#copyButtons::before {
content: "Copy as:";
margin: 0.2rem 0 0 0.3rem
}

@ -0,0 +1,244 @@
import {
ExportFormat,
exportScenario,
type GeneratedState,
generatedStateToString, getResultFrom,
RolledValues,
RollSelections,
type RollTable,
RollTableDatabase,
type RollTableResult
} from '../common/rolltable';
import {
buildGeneratedElement, copyBBID, copyEmojiTextID,
copyMDID, copyTextID,
htmlTableIdentifier, rerollAllId, rerollId,
selectAllId,
selectedIdPrefix,
selectNoneId
} from '../common/template';
import { DOMLoaded } from './onload';
import { scrapeGeneratedScenario } from './scraper';
import { showPopup } from './popup';
import { pulseElement } from './pulse';
export class Generator {
readonly generator: HTMLElement;
readonly scenario: HTMLUListElement;
readonly copyButtons: HTMLElement;
readonly rollButtons: HTMLElement;
readonly db: RollTableDatabase | undefined;
private readonly rolled = new RolledValues();
private readonly selected = new RollSelections();
get state(): GeneratedState {
return {
final: false,
rolled: this.rolled,
selected: this.selected,
}
}
getTableWithHtmlId(id: string, prefix?: string): RollTable | undefined {
return Array.from(this.rolled.keys()).find(t => id === ((prefix ?? '') + htmlTableIdentifier(t)));
}
selectAll(): this {
this.selected.clear();
for (const check of this.scenario.querySelectorAll('input[type=checkbox]') as Iterable<HTMLInputElement>) {
check.checked = true;
pulseElement(check);
const table = this.getTableWithHtmlId(check.id, selectedIdPrefix);
if (table) {
this.selected.add(table);
}
}
return this
}
selectNone(): this {
this.selected.clear();
for (const check of this.scenario.querySelectorAll('input[type=checkbox]') as Iterable<HTMLInputElement>) {
check.checked = false;
pulseElement(check);
}
return this
}
loadValuesFromDOM(): this {
this.rolled.clear()
this.selected.clear()
const scenario = scrapeGeneratedScenario(this.scenario)
if (!scenario) {
throw Error("Failed to load generated values from DOM")
}
for (const [scrapedTable, scrapedResult] of scenario.rolled) {
const table = this.db?.getTableMatching(scrapedTable) ?? scrapedTable
const result = getResultFrom(table, scrapedResult)
if (scenario.selected.has(scrapedTable)) {
this.selected.add(table)
}
this.rolled.add(result)
}
return this
}
attachHandlers(): this {
this.generator.addEventListener('click', (e) => this.clickHandler(e));
this.generator.addEventListener('change', (e) => this.changeHandler(e));
return this;
}
async copy(format: ExportFormat): Promise<void> {
const exported = exportScenario(Array.from(this.rolled.values()), format)
return navigator.clipboard.writeText(exported)
}
private clickHandler(e: Event): void {
if (e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement) {
switch (e.target.id) {
case selectNoneId:
this.selectNone()
break
case selectAllId:
this.selectAll()
break
case copyMDID:
this.copy(ExportFormat.Markdown)
.then(() => showPopup(this.copyButtons, `Copied Markdown to clipboard!`, 'success'))
.catch((e) => {
console.error("Failed while copying Markdown:", e)
showPopup(this.copyButtons, `Failed to copy Markdown to clipboard`, 'error')
})
break
case copyBBID:
this.copy(ExportFormat.BBCode)
.then(() => showPopup(this.copyButtons, `Copied BBCode to clipboard!`, 'success'))
.catch((e) => {
console.error("Failed while copying BBCode:", e)
showPopup(this.copyButtons, `Failed to copy BBCode to clipboard`, 'error')
})
break
case copyEmojiTextID:
this.copy(ExportFormat.TextEmoji)
.then(() => showPopup(this.copyButtons, `Copied text (with emojis) to clipboard!`, 'success'))
.catch((e) => {
console.error("Failed while copying text (with emojis):", e)
showPopup(this.copyButtons, `Failed to copy text (with emojis) to clipboard`, 'error')
})
break
case copyTextID:
this.copy(ExportFormat.TextOnly)
.then(() => showPopup(this.copyButtons, `Copied text to clipboard!`, 'success'))
.catch((e) => {
console.error("Failed while copying text:", e)
showPopup(this.copyButtons, `Failed to copy text to clipboard`, 'error')
})
break
case rerollId:
for (const row of this.scenario.querySelectorAll(".generatedElement")) {
if (row.querySelector("input[type=checkbox]:checked")) {
const text = row.querySelector<HTMLElement>(".resultText")
if (text) {
pulseElement(text)
}
}
}
showPopup(this.rollButtons, `only pretending to reroll`, 'warning')
break
case rerollAllId:
for (const row of this.scenario.querySelectorAll(".generatedElement")) {
const check = row.querySelector<HTMLInputElement>("input[type=checkbox]:checked")
if (check) {
check.checked = false
pulseElement(check)
}
const text = row.querySelector<HTMLElement>(".resultText")
if (text) {
pulseElement(text)
}
}
showPopup(this.rollButtons, `only pretending to reroll all`, 'warning')
break
default:
if (e.target.classList.contains("resultText")) {
for (let target: HTMLElement|null = e.target; target && target !== this.generator; target = target.parentElement) {
if (target.classList.contains("generatedElement")) {
const check = target.querySelector<HTMLInputElement>(".generatedSelect")
if (check) {
check.click()
}
}
}
} else {
return
}
}
e.preventDefault()
}
}
private changeHandler(e: Event): void {
if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox' && e.target.id.startsWith(selectedIdPrefix)) {
const check = e.target
const table = this.getTableWithHtmlId(check.id, selectedIdPrefix);
if (table) {
if (check.checked) {
this.selected.add(table);
} else {
this.selected.delete(table);
}
pulseElement(check)
}
}
}
private animationendHandler(e: AnimationEvent): void {
if (e.animationName === "pulse" && e.target instanceof HTMLElement && e.target.classList.contains("pulse")) {
e.target.classList.remove("pulse")
}
}
constructor(generator: HTMLElement, generatorForm: HTMLUListElement, copyButtons: HTMLElement, rollButtons: HTMLElement, db?: RollTableDatabase) {
this.generator = generator;
this.scenario = generatorForm;
this.copyButtons = copyButtons;
this.rollButtons = rollButtons;
this.db = db;
}
}
function initGenerator(db?: RollTableDatabase): Generator {
const generatorFound = document.getElementById('generator');
if (!generatorFound) {
throw Error('generator was not found');
}
const generatedScenarioFound = document.getElementById('generatedScenario');
if (!generatedScenarioFound || !(generatedScenarioFound instanceof HTMLUListElement)) {
throw Error('generated scenario was not found');
}
const copyButtons = document.getElementById("copyButtons")
if (!copyButtons) {
throw Error('copy buttons were not found')
}
const rollButtons = document.getElementById("rollButtons")
if (!rollButtons) {
throw Error('copy buttons were not found')
}
return new Generator(generatorFound, generatedScenarioFound, copyButtons, rollButtons, db).loadValuesFromDOM().attachHandlers();
}
let pendingGenerator: Promise<Generator>|undefined = undefined
export async function prepareGenerator(db?: Promise<RollTableDatabase>): Promise<Generator> {
if (pendingGenerator) {
throw Error(`prepareGenerator should only be called once`)
}
pendingGenerator = DOMLoaded.then(() => db)
.then((promisedDb) => initGenerator(promisedDb))
return pendingGenerator
}
DOMLoaded.then(() => pendingGenerator ?? prepareGenerator())
.then(g => console.info(`loaded generator: ${generatedStateToString(g.state)}`))
.catch(e => console.error('failed to load generator', e))

@ -0,0 +1,4 @@
.requiresJs, .jsPopup {
display: none !important;
flex: 0 1 0 !important;
}

@ -0,0 +1,7 @@
export const DOMLoaded = new Promise<void>((resolve) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => resolve())
} else {
resolve()
}
})

@ -0,0 +1,46 @@
.jsPopup {
bottom: calc(100% + 1rem);
animation: 1.5s ease 0s 1 popup;
transform-origin: 50% 70%;
user-select: none;
border: 0.1rem solid #303030BF;
box-shadow: 0.2rem 0.2rem #00000090;
border-radius: 0.5rem;
font-weight: bold;
font-size: 1rem;
padding: 0.3rem;
background-color: #f8f7e0;
}
.jsPopup.info {
background-color: paleturquoise;
}
.jsPopup.success {
background-color: palegreen;
}
.jsPopup.warning {
background-color: palegoldenrod;
}
.jsPopup.error {
background-color: palevioletred;
}
.jsPopup:hover {
animation-play-state: paused;
}
.jsPopupContainer {
position: absolute;
bottom: calc(100% + 1rem);
left: 0;
right: 0;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
}
.jsPopupHost {
position: relative;
}

@ -0,0 +1,19 @@
export function showPopup(parent: HTMLElement, text: string, className?: 'success'|'info'|'warning'|'error'): void {
if (!parent.classList.contains("jsPopupHost")) {
console.log(parent, "should be jsPopupHost")
}
const container = parent.ownerDocument.createElement("div")
container.classList.add("jsPopupContainer")
parent.appendChild(container)
const popup = parent.ownerDocument.createElement("div")
popup.classList.add("jsPopup")
if (className) {
popup.classList.add(className)
}
popup.innerText = text
container.appendChild(popup)
popup.addEventListener('animationend', () => {
container.removeChild(popup)
parent.removeChild(container)
})
}

@ -0,0 +1,15 @@
@keyframes pulse-bg {
from {
background-color: transparent;
}
10% {
background-color: #60606060;
}
to {
background-color: transparent;
}
}
.pulse {
animation: 1.5s ease 0s 1 pulse-bg;
}

@ -0,0 +1,6 @@
export function pulseElement(element: HTMLElement) {
element.classList.add("pulse")
element.style.animation = "none";
getComputedStyle(element).animation
setTimeout(element.style.animation = "")
}

@ -0,0 +1,101 @@
@import "basic-look";
@import "attribution";
@import "popup";
#responsesHeader {
position: sticky;
display: flex;
flex-flow: column;
align-items: center;
border-top: 0;
border-left: 0;
border-right: 0;
border-radius: 0;
margin: 0;
top: 0;
left: 0;
right: 0;
height: 9.5rem;
z-index: 2;
}
#responsesHeader .buttons {
display: flex;
flex-flow: row wrap;
padding-top: 0.2rem;
padding-left: 0.3rem;
padding-right: 0.3rem;
margin: 0;
overflow-y: auto;
overflow-x: visible;
}
#returnToGenerator {
flex-basis: 50%;
}
.responseNavEmoji {
margin-right: 0.2rem;
}
#responsesHead {
margin-top: 0;
margin-bottom: 0;
font-size: 1.5rem;
}
#responseLists {
display: flex;
flex-flow: row wrap;
padding: 0.1rem;
justify-content: center;
}
.responseType {
list-style: none;
padding: 1rem;
scroll-margin-top: 10rem;
margin-top: 0.5rem;
margin-left: 1rem;
margin-bottom: 0.5rem;
}
.responseType > h2 {
margin-top: 0;
}
.responseTypeHead {
position: sticky;
top: 9.4rem;
background-color: inherit;
z-index: 1;
padding-bottom: 0.2rem;
}
.responseTypeTitle {
flex: 1 1 auto;
}
.response {
margin-top: 0.3rem;
display: flex;
align-items: baseline;
flex-flow: row nowrap;
}
.response.active {
position: relative;
min-height: 1.5rem;
&::before {
content: "▶";
flex: 0 0 auto;
font-size: 1.25rem;
margin-right: 0.4rem;
line-height: 1.5rem;
}
& .resultText {
font-weight: bold;
}
}

@ -0,0 +1,16 @@
import type { RollTable, RollTableDatabase } from '../common/rolltable';
import { DOMLoaded } from './onload';
class ResponseList {
readonly db: RollTableDatabase
constructor(db: RollTableDatabase) {
this.db = db
}
}
function initResponseList(): ResponseList {
throw Error("not yet implemented")
}
export const responseList: Promise<ResponseList> = DOMLoaded.then(() => initResponseList())
export const db: Promise<RollTableDatabase> = responseList.then(r => r.db)

@ -0,0 +1,244 @@
import {
type InProgressGeneratedState, RolledValues, RollSelections,
type RollTableAuthor,
type RollTableDetailsNoResults,
type RollTableLimited,
type RollTableResult,
type RollTableResultSet
} from '../common/rolltable';
export function asBoolean(s: string|undefined): boolean|undefined {
if (typeof s === "undefined") {
return
}
switch (s.toLowerCase()) {
case "true":
return true
case "false":
return false
default:
return
}
}
export function asInteger(s: string|undefined): number|undefined {
if (typeof s === "undefined") {
return
}
const result = parseInt(s)
if (Number.isNaN(result)) {
return
}
return result
}
export function asTimestamp(s: string|undefined): Date|undefined {
const i = asInteger(s)
if (typeof i === "undefined") {
return
}
const date = new Date(i)
if (Number.isNaN(date.valueOf())) {
return
}
return date
}
export function textFrom(e: HTMLElement|null): string|undefined {
if (!e) {
return
}
return e.innerText.trim()
}
export function hrefFrom(e: HTMLAnchorElement|null): string | null {
if (!e) {
return null
}
return e.href
}
export function checkedFrom(e: HTMLInputElement|null): boolean | null {
if (!e) {
return null
}
return e.checked
}
// element to find here is .author
export function scrapeAuthor(author: HTMLElement|null): RollTableAuthor|null|undefined {
if (!author) {
return null
}
const id = asInteger(author.dataset["id"])
const name = textFrom(author.querySelector(".authorName"))
const url = hrefFrom(author.querySelector<HTMLAnchorElement>("a[href]"))
const relation = textFrom(author.querySelector(".authorRelation"))
if (typeof id === "undefined" || typeof name === "undefined" || typeof relation === 'undefined') {
return
}
return {
id,
name,
url,
relation
}
}
// element to find here is .resultSet
export function scrapeResultSet(set: HTMLElement|null): RollTableResultSet|null|undefined {
if (!set) {
return null
}
const id = asInteger(set.dataset["id"])
const name = textFrom(set.querySelector(".setName"))
const global = asBoolean(set.dataset["global"])
if (typeof id === "undefined" || typeof global === "undefined") {
return
}
return {
id,
name: name ?? null,
description: null,
global,
}
}
// element to find here is .tableHeader
export function scrapeTableHeader(head: HTMLElement|null): RollTableLimited|RollTableDetailsNoResults|null|undefined {
if (!head) {
return null
}
const emoji = textFrom(head.querySelector(".tableEmoji"))
const title = textFrom(head.querySelector(".tableTitle"))
const ordinal = asInteger(head.dataset["ordinal"])
const id = asInteger(head.dataset["id"])
const identifier = head.dataset["identifier"]
const name = head.dataset["name"]
if (typeof emoji === 'undefined' || typeof title === 'undefined' || typeof ordinal === 'undefined') {
return
}
const header = `${emoji} ${title}`
if (typeof id === 'undefined' || typeof identifier === 'undefined' || typeof name === 'undefined') {
return {
full: false,
emoji,
title,
header,
ordinal,
}
}
return {
full: 'details',
id,
identifier,
emoji,
title,
header,
ordinal,
name,
}
}
export function scrapeGeneratedHead(head: HTMLElement|null): {table: RollTableLimited|RollTableDetailsNoResults, selected: boolean|null}|null|undefined {
if (!head) {
return null
}
const table = scrapeTableHeader(head.querySelector(".tableHeader"))
if (!table) {
return
}
const selected = checkedFrom(head.querySelector("input[type=checkbox].generatedSelect"))
return {
table,
selected,
}
}
// element to find here is .resultText
export function scrapeResultText(result: HTMLElement|null): {full: false, text: string}|{full: true, mappingId: number, textId: number, updated: Date, text: string}|undefined|null {
if (!result) {
return null
}
const text = textFrom(result)
const mappingId = asInteger(result.dataset["mappingid"])
const textId = asInteger(result.dataset["textid"])
const updated = asTimestamp(result.dataset["updated"])
if (typeof text === 'undefined') {
return
}
if (typeof mappingId === 'undefined' || typeof textId === 'undefined' || typeof updated == 'undefined') {
return {
full: false,
text,
}
}
return {
full: true,
text,
textId,
mappingId,
updated: new Date(updated)
}
}
// element to find here is .generatedElement
export function scrapeGeneratedElement(generated: HTMLElement|null): {result: RollTableResult, selected: boolean|null}|null|undefined {
if (!generated) {
return null
}
const result = scrapeResultText(generated.querySelector(".resultText"))
const author = scrapeAuthor(generated.querySelector(".author"))
const set = scrapeResultSet(generated.querySelector(".resultSet"))
const header = scrapeGeneratedHead(generated.querySelector(".generatedHead"))
if (!header || !result) {
return
}
const {table, selected} = header
if (!set || typeof author === "undefined" || !result.full) {
return {
result: {
full: false,
table,
text: result.text,
},
selected
}
}
return {
result: {
...result,
author,
set,
table,
},
selected,
}
}
export function scrapeGeneratedScenario(scenario: HTMLElement): InProgressGeneratedState|undefined
export function scrapeGeneratedScenario(scenario: null): null
// element to find here is #generatedScenario
export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressGeneratedState|null|undefined {
if (!scenario) {
return null
}
const rolls = new RolledValues()
const selection = new RollSelections()
for (const item of scenario.querySelectorAll<HTMLElement>(".generatedElement")) {
const element = scrapeGeneratedElement(item)
if (!element) {
return
}
const {result, selected} = element
rolls.add(result)
if (selected) {
selection.add(result.table)
}
}
return {
final: false,
rolled: rolls,
selected: selection,
}
}

@ -0,0 +1,102 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["dom", "dom.iterable", "ES2015"]
/* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "elements.createElement", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
// "module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": ["@cloudflare/workers-types/2023-07-01"] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true /* Enable importing .json files */,
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
// "checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
"noEmit": true /* Disable emitting files from a compilation. */,
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

@ -1,376 +0,0 @@
import { CommandContext, CommandOptionType, ComponentContext, SlashCommand, type SlashCreator } from 'slash-create/web';
import {
calculateUnlockedValues,
DELETE_ID,
DONE_ID,
generateErrorMessageFor,
generateFieldFor,
generateMessageFor,
generateValuesFor,
getEmbedFrom,
loadEmbed,
populateLocksFor,
REROLL_ID,
RollTableNames,
SELECT_ID,
selectUnlockedFrom
} from './generated.js';
import { type DbAccess, DeleteResult, UpdateResult } from './dbAccess.js';
import { isTable, RollTableOrder, ValueAccess } from './rolltable.js';
import { getTimestamp, isSnowflake, type Snowflake } from 'discord-snowflake';
export class ResponseCommand extends SlashCommand {
private readonly db: DbAccess
private readonly baseUrl: string;
constructor(creator: SlashCreator, db: DbAccess, baseUrl: string, forGuilds?: Snowflake|Snowflake[]) {
super(creator, {
name: "response",
description: "Modifies the responses available in the generator.",
nsfw: false,
guildIDs: forGuilds,
dmPermission: true,
options: [
{
type: CommandOptionType.SUB_COMMAND,
name: "add",
description: "Adds a new response to the generator.",
options: [
{
type: CommandOptionType.INTEGER,
name: "table",
description: "The table to insert the response into.",
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})),
required: true,
},
{
type: CommandOptionType.STRING,
name: "text",
description: "The text to use as the response.",
required: true,
}
]
},
{
type: CommandOptionType.SUB_COMMAND,
name: "list",
description: "Lists responses that will appear in /generate in the current context."
},
{
type: CommandOptionType.SUB_COMMAND,
name: "edit",
description: "Modifies a response that was previously created.",
options: [
{
type: CommandOptionType.INTEGER,
name: "table",
description: "The table to update the response from.",
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})),
required: true,
},
{
type: CommandOptionType.STRING,
name: "old_text",
description: "The text of the response to edit.",
required: true,
},
{
type: CommandOptionType.STRING,
name: "new_text",
description: "The text to replace the response with.",
required: true,
}
]
},
{
type: CommandOptionType.SUB_COMMAND,
name: "delete",
description: "Deletes a response that was previously created.",
options: [
{
type: CommandOptionType.INTEGER,
name: "table",
description: "The table to delete the response from.",
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})),
required: true,
},
{
type: CommandOptionType.STRING,
name: "text",
description: "The text of the response to delete.",
required: true,
},
]
},
]
});
this.baseUrl = baseUrl
this.db = db
}
async run(ctx: CommandContext): Promise<void> {
switch (ctx.subcommands[0]) {
case "add":
try {
await this.onAdd(ctx)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "add a new response"))
}
break
case "list":
try {
await this.onList(ctx)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "get the list URL"))
}
break
case "edit":
try {
await this.onEdit(ctx)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "edit a response"))
}
break
case "delete":
try {
await this.onDelete(ctx)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "delete a response"))
}
break
default:
await ctx.send(generateErrorMessageFor(Error("I don't know what command you want"), "manage responses"))
break
}
}
private async onAdd(ctx: CommandContext): Promise<void> {
const guildId = ctx.guildID ?? null
const userId = ctx.user.id
const id = ctx.interactionID
if (!isSnowflake(id)) {
throw Error("the snowflake wasn't a snowflake")
}
const timestamp = getTimestamp(id)
const table = ctx.options['add']['table']
if (!isTable(table)) {
throw Error(`there's no table number ${table}`)
}
const text = ctx.options['add']['text']
const { timestamp: insertedTimestamp, access, inserted } = await this.db.putResponse(timestamp, table, text, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server)
await ctx.send({
embeds: [{
title: `${inserted ? 'Your new' : 'An existing'}${access === ValueAccess.Global ? " global" : ""} response`,
fields: [generateFieldFor(table, text)],
timestamp: new Date(insertedTimestamp),
}],
ephemeral: !inserted,
})
}
private async onList(ctx: CommandContext) {
if (ctx.guildID) {
await ctx.send({
embeds: [{
title: `Response list for this server`,
description: "Shows all global and server-local responses.",
url: `${this.baseUrl}/responses?server=${ctx.guildID}`,
}]
})
} else {
await ctx.send({
embeds: [{
title: `Response list for DMs`,
description: "It's not supported right now, so please just hang tight."
}]
})
}
}
private async onEdit(ctx: CommandContext): Promise<void> {
const guildId = ctx.guildID ?? null
const userId = ctx.user.id
const id = ctx.interactionID
if (!isSnowflake(id)) {
throw Error("the snowflake wasn't a snowflake")
}
const timestamp = getTimestamp(id)
const table = ctx.options['edit']['table']
if (!isTable(table)) {
throw Error(`there's no table number ${table}`)
}
const oldText = ctx.options['edit']['old_text']
const newText = ctx.options['edit']['new_text']
const result = await this.db.updateResponse(timestamp, table, oldText, newText, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server)
switch (result.result) {
case UpdateResult.Updated:
await ctx.send({
embeds: [{
title: `Your updated response`,
fields: [generateFieldFor(table, oldText), generateFieldFor(table, newText)],
timestamp: new Date(timestamp).toISOString()
}],
})
break
case UpdateResult.NewConflict:
await ctx.send({
embeds: [{
title: `An existing${result.access === ValueAccess.Global ? " global" : ""} response`,
fields: [generateFieldFor(table, newText)],
timestamp: new Date(result.timestamp).toISOString(),
}],
ephemeral: true,
})
break
case UpdateResult.NoOldText:
await ctx.send({
embeds: [{
title: `A nonexistent response`,
fields: [generateFieldFor(table, oldText)],
}],
ephemeral: true,
})
break
case UpdateResult.OldGlobal:
await ctx.send({
embeds: [{
title: `An uneditable global response`,
fields: [generateFieldFor(table, oldText)],
}],
ephemeral: true,
})
break
}
}
private async onDelete(ctx: CommandContext): Promise<void> {
const guildId = ctx.guildID ?? null
const userId = ctx.user.id
const id = ctx.interactionID
if (!isSnowflake(id)) {
throw Error("the snowflake wasn't a snowflake")
}
const timestamp = getTimestamp(id)
const table = ctx.options['delete']['table']
if (!isTable(table)) {
throw Error(`there's no table number ${table}`)
}
const text = ctx.options['delete']['text']
const result = await this.db.deleteResponse(table, text, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server)
switch (result.result) {
case DeleteResult.Deleted:
await ctx.send({
embeds: [{
title: 'Your deleted response',
fields: [generateFieldFor(table, text)],
timestamp: new Date(result.timestamp).toISOString(),
}]
})
break
case DeleteResult.OldGlobal:
await ctx.send({
embeds: [{
title: 'An undeletable global response',
fields: [generateFieldFor(table, text)],
timestamp: new Date(result.timestamp).toISOString(),
}],
ephemeral: true
})
break
case DeleteResult.NoOldText:
await ctx.send({
embeds: [{
title: 'A nonexistent response',
fields: [generateFieldFor(table, text)],
}],
ephemeral: true
})
break
}
}
}
export class GenerateCommand extends SlashCommand {
private readonly db: DbAccess
constructor(creator: SlashCreator, db: DbAccess, forGuilds?: Snowflake|Snowflake[]) {
super(creator, {
name: "generate",
description: "Generates a new scenario to play with and sends it to the current channel.",
nsfw: false,
dmPermission: true,
guildIDs: forGuilds,
throttling: {
duration: 5,
usages: 1,
}
});
this.db = db
if (!forGuilds) {
creator.registerGlobalComponent(DONE_ID, this.onDone.bind(this))
creator.registerGlobalComponent(REROLL_ID, this.onReroll.bind(this))
creator.registerGlobalComponent(SELECT_ID, this.onSelect.bind(this))
creator.registerGlobalComponent(DELETE_ID, this.onDelete.bind(this))
}
}
async run(ctx: CommandContext): Promise<void> {
try {
const tables = calculateUnlockedValues()
const responses = await (ctx.guildID
? this.db.getResponsesInServer(ctx.guildID)
: this.db.getResponsesInDMWith(ctx.user.id))
const values = generateValuesFor(tables, responses)
const locks = populateLocksFor(values)
await ctx.send(generateMessageFor(values, locks))
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "generate a scenario for you"))
}
}
async onSelect(ctx: ComponentContext): Promise<void> {
try {
const oldEmbed = getEmbedFrom(ctx.message)
const {values, locked: oldLocks} = loadEmbed(oldEmbed)
const newLocks = selectUnlockedFrom(ctx.values, oldLocks)
await ctx.editParent(generateMessageFor(values, newLocks))
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "change the selected components"))
}
}
async onDone(ctx: ComponentContext): Promise<void> {
try {
const oldEmbed = getEmbedFrom(ctx.message)
const { values } = loadEmbed(oldEmbed)
await ctx.editParent(generateMessageFor(values, undefined))
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "finish this scenario"))
}
}
async onReroll(ctx: ComponentContext): Promise<void> {
try {
const oldEmbed = getEmbedFrom(ctx.message)
const { values: oldValues, locked: locks } = loadEmbed(oldEmbed)
const selected = calculateUnlockedValues(oldValues, locks)
const responses = await (ctx.guildID
? this.db.getResponsesInServer(ctx.guildID)
: this.db.getResponsesInDMWith(ctx.user.id))
const newValues = generateValuesFor(selected, responses, oldValues)
await ctx.editParent(generateMessageFor(newValues, locks))
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "reroll this scenario"))
throw e
}
}
async onDelete(ctx: ComponentContext): Promise<void> {
try {
await ctx.delete(ctx.messageID)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "delete this scenario"))
}
}
}

@ -0,0 +1,6 @@
const bracketRegexp = /\[/g
export function bbcodeEscape(text: string): string {
// Add a zero-width non-joiner to make BBCode parsing fail
return text.replace(bracketRegexp, "[\u200c")
}

@ -0,0 +1,26 @@
export interface Bundled {
readonly bundled: string,
}
export interface HashedBundled extends Bundled {
readonly hash: string,
}
export interface SourceMap {
readonly version: number
readonly file?: string
readonly sourceRoot?: string
readonly sources: readonly string[]
readonly sourcesContent?: readonly (string|null)[]
readonly names: readonly string[]
readonly mappings: string
readonly x_google_ignoreList?: readonly number[]
}
export interface SourceMappedBundled extends Bundled {
readonly sourceMap: SourceMap,
}
export interface SourceMappedHashedBundled extends SourceMappedBundled, HashedBundled {}
export interface MaybeSourceMappedHashedBundled extends HashedBundled, Partial<Omit<SourceMappedBundled, keyof HashedBundled>> {}

@ -0,0 +1,529 @@
import markdownEscape from 'markdown-escape';
import { bbcodeEscape } from './bbcode';
export interface RollTableLimited {
readonly full: false,
readonly emoji: string,
readonly title: string,
readonly header: string,
readonly ordinal: number,
}
export interface RollTableDetailsBase {
readonly id: number,
readonly identifier: string,
readonly emoji: string,
readonly name: string,
readonly title: string,
readonly header: string,
readonly ordinal: number,
}
export type RollTable = RollTableLimited | RollTableDetails
export type RollTableOrInput = RollTable | RollTableDetailsInputResults
export function rollTableToString(v: RollTable) {
if (v.full) {
return `${v.header} (${v.id}/${v.identifier}/${v.name}/${v.emoji}/${v.title}/#${v.ordinal})${v.full === 'results' ? ` [${v.resultsById.size} results]` : '' }`
} else {
return `${v.header} (???#${v.ordinal})`
}
}
export function rollTableToStringShort(v: RollTable) {
if (v.full) {
return v.identifier
} else {
return v.header
}
}
export const MAX_RESULT_LENGTH = 150;
export const MAX_IDENTIFIER_LENGTH = 20;
export const MAX_NAME_LENGTH = 50;
export const MAX_URL_LENGTH = 100;
export interface RollTableAuthor {
readonly id: number;
readonly name: string;
readonly url: string | null;
readonly relation: string;
}
export interface RollTableResultSet {
readonly id: number;
readonly name: string | null;
readonly description: string | null;
readonly global: boolean;
}
export interface RollTableResultLimited<T extends RollTableOrInput = RollTable> {
readonly full: false,
readonly text: string,
readonly table: T,
}
export interface RollTableResultFull<T extends RollTableOrInput = RollTableDetails> {
readonly full: true,
readonly textId: number,
readonly mappingId: number,
readonly table: T,
readonly tableId?: never
readonly text: string,
readonly set: RollTableResultSet,
readonly author: RollTableAuthor | null,
readonly updated: Date,
}
export type RollTableResult<T extends RollTableOrInput = RollTable> = RollTableResultLimited<T> | RollTableResultFull<T>
export type RollTableResultOrLookup<T extends RollTableOrInput = RollTable> = RollTableResultFull<T>|RollTableResultLookup
export function setToString(v: RollTableResultSet): string {
return `${v.global ? 'global' : 'local'} ${v.name ?? 'set'}`
}
export function authorToString(v: RollTableAuthor): string {
return `${v.relation} ${v.name} (${v.id})`
}
export function rollResultToString(v: RollTableResult) {
if (v.full) {
return `${v.text} (${v.mappingId}: ${v.textId}/${rollTableToStringShort(v.table)}/${setToString(v.set)}/${v.author ? authorToString(v.author) : 'no author'})`
} else {
return `${v.text} (???: ${rollTableToStringShort(v.table)})`
}
}
export interface RollTableResultLookup {
readonly textId: number,
readonly mappingId: number,
readonly tableId: number,
readonly table?: never,
readonly text: string,
readonly setId: number,
readonly authorId: number | null,
readonly updated: Date,
}
export interface RollTableDetailsInputResults extends RollTableDetailsBase {
readonly full: 'input'
readonly resultsById: Iterable<RollTableResultOrLookup<RollTableDetailsInputResults>|readonly [number, RollTableResultOrLookup<RollTableDetailsInputResults>]>;
}
function isResultArray(v: unknown): v is readonly [unknown, RollTableResultOrLookup<RollTableDetailsOrInput>] {
return Array.isArray(v) && isRollTableResult(v[1])
}
export type RollTableDetailsOrInput = RollTableDetails | RollTableDetailsInputResults
export interface RollTableDetailsNoResults extends RollTableDetailsBase {
readonly full: 'details'
}
export interface RollTableDetailsAndResults extends RollTableDetailsBase {
readonly full: 'results'
readonly resultsById: ReadonlyMap<number, RollTableResultFull<this>>
readonly resultsByText: ReadonlyMap<string, RollTableResultFull<this>>
}
interface RollTableDetailsAndResultsInternal extends RollTableDetailsBase {
readonly full: 'results'
readonly resultsById: Map<number, RollTableResultFull<this>>
readonly resultsByText: Map<string, RollTableResultFull<this>>
}
export type RollTableDetails = RollTableDetailsNoResults|RollTableDetailsAndResults
function compareRollTables(a: RollTableOrInput, b: RollTableOrInput): number {
return (a.ordinal - b.ordinal) ||
("id" in a !== "id" in b ? "id" in a ? -1 : 1 : 0) ||
("id" in a && "id" in b ? a.id - b.id : 0) ||
(a.header > b.header ? 1 : a.header < b.header ? -1 : 0);
}
// <0: a is a better fit
// >0: b is a better fit
// =0: they're the same
function compareRollTableResults(a: RollTableResult|null|undefined, b: RollTableResult|null|undefined): number {
const preferA = -1
const preferB = 1
const equalPreference = 0
if (a && a.full) {
if (b && b.full) {
if (a.set.global === b.set.global) {
return a.updated.getDate() < b.updated.getDate() ? preferA : preferB
} else {
return !a.set.global ? preferA : preferB
}
} else {
return preferA
}
} else {
if (b && b.full) {
return preferB
} else {
return equalPreference
}
}
}
function isRollTableResult(result: unknown): result is RollTableResult<RollTableDetailsOrInput> {
return (typeof result === "object" && result !== null && 'table' in result
&& !('tableId' in result && typeof result.tableId !== 'undefined') && 'full' in result);
}
export function getResultFrom(table: RollTable, originalResult: RollTableResult): RollTableResult {
const dbResult = table.full === "results" ? table.resultsByText.get(originalResult.text) : null
return dbResult ?? {
full: false,
table,
text: originalResult.text
}
}
export class RollTableMap<T extends RollTableOrInput> extends Map<T extends RollTable ? number : (number|string), T> {
[Symbol.iterator](): IterableIterator<[T extends RollTable ? number : (number|string), T]> {
return this.entries();
}
set(key: T extends RollTable ? number : (number|string), table: T): this
set(table: T): this
set(keyOrTable: (T extends RollTable ? number : (number|string))|T, table?: T): this {
if (typeof keyOrTable === "object") {
if ("id" in keyOrTable) {
return super.set(keyOrTable.id, keyOrTable)
} else {
return super.set(keyOrTable.header as (T extends RollTable ? number : (number|string)), keyOrTable)
}
} else {
return super.set(keyOrTable, table!)
}
}
entries(): IterableIterator<[T extends RollTable ? number : (number|string), T]> {
return Array.from(super.entries()).sort(([, a], [, b]) => compareRollTables(a, b))[Symbol.iterator]();
}
keys(): IterableIterator<T extends RollTable ? number : (number|string)> {
return Array.from(this.entries()).map(([id]) => id)[Symbol.iterator]();
}
values(): IterableIterator<T> {
return Array.from(this.entries()).map(([, value]) => value)[Symbol.iterator]();
}
}
export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
private readonly tablesById: RollTableMap<RollTableDetailsAndResultsInternal> = new RollTableMap<RollTableDetailsAndResultsInternal>();
private readonly setsById: Map<number, RollTableResultSet> =
new Map<number, RollTableResultSet>();
private readonly authorsById: Map<number, RollTableAuthor> =
new Map<number, RollTableAuthor>;
private readonly mappingsByMappingId: Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>> =
new Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>>();
private readonly mappingsByTextId: Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>> =
new Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>>();
constructor({ tables = [], results = [], authors = [], sets = [] }: {
tables?: Iterable<RollTableDetailsOrInput>,
results?: Iterable<RollTableResultFull<RollTableDetailsOrInput> | RollTableResultLookup>,
authors?: Iterable<RollTableAuthor>,
sets?: Iterable<RollTableResultSet>
} = {}) {
for (const table of tables) {
this.addTable(table);
}
for (const author of authors) {
this.addAuthor(author);
}
for (const set of sets) {
this.addSet(set);
}
for (const result of results) {
this.addResult(result);
}
}
[Symbol.iterator](): IterableIterator<RollTableDetailsAndResults> {
return this.tablesById.values();
}
get tables(): ReadonlyMap<number | string, RollTableDetailsAndResults> {
return this.tablesById;
}
getTableMatching(table: RollTableOrInput): RollTableDetailsAndResults|undefined {
if (table.full) {
return this.tables.get(table.id)
} else {
return Array.from(this.tables.values()).find(t => (t.header === table.header))
}
}
get sets(): ReadonlyMap<number, RollTableResultSet> {
return this.setsById;
}
get authors(): ReadonlyMap<number, RollTableAuthor> {
return this.authorsById;
}
get mappings(): ReadonlyMap<number, RollTableResultFull<RollTableDetailsAndResults>> {
return this.mappingsByMappingId;
}
get results(): ReadonlyMap<number, RollTableResultFull<RollTableDetailsAndResults>> {
return this.mappingsByTextId;
}
addTable(table: RollTableDetailsOrInput): RollTableDetailsAndResults {
return this.addTableInternal(table);
}
private addTableInternal(table: RollTableDetailsOrInput): RollTableDetailsAndResultsInternal {
const existingTable = this.tablesById.get(table.id);
if (existingTable) {
if (table.full === 'input' || table.full === 'results') {
for (const result of table.resultsById) {
this.addResult(result);
}
}
return existingTable;
}
const internalTable: RollTableDetailsAndResultsInternal = {
...table,
full: 'results',
resultsById: new Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>>(),
resultsByText: new Map<string, RollTableResultFull<RollTableDetailsAndResultsInternal>>(),
};
if (table.full === 'input' || table.full === 'results') {
for (const result of table.resultsById) {
this.addResult(result);
}
}
this.tablesById.set(table.id, internalTable);
return internalTable;
}
addAuthor(author: RollTableAuthor): RollTableAuthor {
const existingAuthor = this.authorsById.get(author.id);
if (existingAuthor) {
return existingAuthor;
} else {
const result = { ...author };
this.authorsById.set(author.id, author);
return result;
}
}
addSet(set: RollTableResultSet): RollTableResultSet {
const existingSet = this.setsById.get(set.id);
if (existingSet) {
return existingSet;
} else {
const result = { ...set };
this.setsById.set(set.id, set);
return result;
}
}
addResult(result: RollTableResultOrLookup<RollTableDetailsOrInput>|readonly [number, RollTableResultOrLookup<RollTableDetailsOrInput>]): RollTableResultFull {
if (isResultArray(result)) {
const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>];
return this.addResult(innerResult);
} else if (isRollTableResult(result)) {
if (!this.tables.has(result.table.id)) {
this.addTableInternal({... result.table, full: 'details'})
}
if (result.author && !this.authors.has(result.author.id)) {
this.addAuthor(result.author)
}
if (!this.sets.has(result.set.id)) {
this.addSet(result.set)
}
return this.addResult({
tableId: result.table.id,
authorId: result.author?.id ?? null,
setId: result.set.id,
textId: result.textId,
text: result.text,
mappingId: result.mappingId,
updated: result.updated
})
} else {
const internalTable = this.tablesById.get(result.tableId);
const internalAuthor = typeof result.authorId === 'number' ? this.authorsById.get(result.authorId) : null;
const internalSet = this.setsById.get(result.setId);
if (typeof internalTable === 'undefined') {
throw Error(`no known table with ID ${result.tableId}`);
} else if (typeof internalAuthor === 'undefined') {
throw Error(`no known author with ID ${result.authorId}`);
} else if (typeof internalSet === 'undefined') {
throw Error(`no known set with ID ${result.setId}`);
}
const oldText = internalTable.resultsByText.get(result.text)
const oldId = internalTable.resultsById.get(result.textId)
const out: RollTableResultFull<RollTableDetailsAndResultsInternal> = {
full: true,
textId: result.textId,
mappingId: result.mappingId,
text: result.text,
table: internalTable,
author: internalAuthor,
set: internalSet,
updated: result.updated
};
if (compareRollTableResults(oldText, out) > 0) {
internalTable.resultsByText.set(out.text, out);
}
if (compareRollTableResults(oldId, out) > 0) {
internalTable.resultsById.set(out.textId, out);
}
this.mappingsByTextId.set(out.textId, out);
this.mappingsByMappingId.set(out.mappingId, out);
return out;
}
}
}
export function rollOn(table: RollTableDetailsAndResults): RollTableResult<RollTableDetailsAndResults> {
const results = Array.from(table.resultsById.values());
if (results.length === 0) {
throw Error(`no results for table ${table.identifier}`);
}
return results[Math.floor(results.length * Math.random())];
}
export function rollOnAll(tables: Iterable<RollTableDetailsAndResults>): RolledValues<RollTableDetailsAndResults> {
const result = new RolledValues<RollTableDetailsAndResults>();
for (const table of tables) {
result.set(table, rollOn(table));
}
return result;
}
export function rerollOn<T extends RollTable>(tables: Iterable<RollTableDetailsAndResults>, original: Iterable<[T, RollTableResult<T>]>): RolledValues<T|RollTableDetailsAndResults> {
const result = new RolledValues<T|RollTableDetailsAndResults>();
const tableSet = new Set<RollTable>(tables);
for (const [table, originalValue] of original) {
if (tableSet.has(table) && table.full === 'results') {
const newValue = rollOn(table);
result.set(table, newValue);
} else {
result.set(table, originalValue);
}
}
return result;
}
export interface FinalGeneratedState<T extends RollTableOrInput = RollTable> {
readonly final: true,
readonly rolled: ReadonlyMap<T, RollTableResult<T>>
}
export interface InProgressGeneratedState<T extends RollTableOrInput = RollTable> {
readonly final: false,
readonly rolled: ReadonlyMap<T, RollTableResult<T>>
readonly selected: ReadonlySet<T>
}
export enum ExportFormat {
Markdown = "md",
BBCode = "bb",
TextEmoji = "emoji",
TextOnly = "text",
}
export function exportResult(result: RollTableResult, format: ExportFormat): string {
switch (format) {
case ExportFormat.Markdown:
return `**${markdownEscape(result.table.header)}**\n${markdownEscape(result.text)}`
case ExportFormat.BBCode:
return `[b]${bbcodeEscape(result.table.title)}[/b]\n${bbcodeEscape(result.text)}`
case ExportFormat.TextEmoji:
return `${result.table.header}\n${result.text}`
case ExportFormat.TextOnly:
return `${result.table.title}\n${result.text}`
}
}
export function exportScenario(contents: RollTableResult[], format: ExportFormat): string {
return contents.map(r => exportResult(r, format)).join("\n\n")
}
export function generatedStateToString(contents: GeneratedState): string {
if (contents.final) {
return `Final state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}`
} else {
return `Current state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}. Selection: ${Array.from(contents.selected).map(v => `${rollTableToStringShort(v)}`).join(", ")}`
}
}
export type GeneratedState = FinalGeneratedState | InProgressGeneratedState
export interface FinalGeneratedContents {
readonly final: true,
readonly rolled: ReadonlyMap<string, string>
}
export interface InProgressGeneratedContents {
readonly final: false,
readonly rolled: ReadonlyMap<string, string>;
readonly selected: ReadonlySet<string>;
}
export type GeneratedContents = FinalGeneratedContents | InProgressGeneratedContents
export function generatedContentsToString(contents: GeneratedContents): string {
if (contents.final) {
return `Final contents: ${Array.from(contents.rolled).map(([key, value]) => `${key} : ${value}`).join(" ::: ")}`
} else {
return `Current contents: ${Array.from(contents.rolled).map(([key, value]) => `${key} : ${value}`).join(" ::: ")}. Selection: ${Array.from(contents.selected).join(", ")}`
}
}
export class RolledValues<T extends RollTable = RollTable, U extends RollTableResult<T> = RollTableResult<T>> extends Map<T, U> {
[Symbol.iterator](): IterableIterator<[T, U]> {
return this.entries();
}
add(v: U): this {
return this.set(v.table, v)
}
hasResult(v: U): boolean {
return this.get(v.table) === v
}
entries(): IterableIterator<[T, U]> {
return Array.from(super.entries())
.sort(([a], [b]) =>
compareRollTables(a, b))[Symbol.iterator]();
}
keys(): IterableIterator<T> {
return Array.from(this.entries()).map(([key]) => key)[Symbol.iterator]();
}
values(): IterableIterator<U> {
return Array.from(this.entries()).map(([, value]) => value)[Symbol.iterator]();
}
}
export class RollSelections<T extends RollTable = RollTable> extends Set<T> {
[Symbol.iterator](): IterableIterator<T> {
return this.values();
}
entries(): IterableIterator<[T, T]> {
return Array.from(this.entries()).sort(([a], [b]) => compareRollTables(a, b))[Symbol.iterator]();
}
keys(): IterableIterator<T> {
return Array.from(this.entries()).map(([key]) => key)[Symbol.iterator]();
}
values(): IterableIterator<T> {
return super.values();
}
}

@ -0,0 +1,170 @@
import {
type RollTable, type RollTableAuthor, RollTableDatabase, type RollTableDetails,
type RollTableDetailsAndResults,
type RollTableResult, type RollTableResultFull, type RollTableResultSet
} from './rolltable';
import escapeHTML from 'escape-html';
import slug from 'slug';
export function htmlTableIdentifier(table: RollTable): string {
if (table.full) {
return slug(table.identifier);
} else {
return slug(table.header);
}
}
export function buildFooter({ creditsUrl, includesResponses, includesGenerator }: { readonly creditsUrl: string, readonly includesResponses: boolean, readonly includesGenerator: boolean }): string {
return `
<footer>
${includesGenerator ? `<noscript><p>⚠ Certain features - copy, select-all/select-none${ includesResponses ? ', reroll offline, select response' : ''} - are currently disabled because JavaScript is disabled.</p></noscript>` : '' }
${includesGenerator && includesResponses ? `<p class="requiresJs">💡 You can save this page to be able to generate scenarios offline!</p>` : ''}
<p>
<a href="${encodeURI(creditsUrl)}" rel="external help noreferrer">Project credits/instructions/source code</a>
</p>
</footer>`;
}
export function buildAuthor({ author }: { readonly author: RollTableAuthor }): string {
if (author.url) {
return `<div class="author" data-id="${escapeHTML(`${author.id}`)}"><span class="authorRelation">${escapeHTML(author.relation)}</span> <span class="authorName"><a href="${encodeURI(author.url)}" rel="external nofollow noreferrer">${escapeHTML(author.name)}</a></span></div>`;
} else {
return `<div class="author" data-id="${escapeHTML(`${author.id}`)}"><span class="authorRelation">${escapeHTML(author.relation)}</span> <span class="authorName">${escapeHTML(author.name)}</span></div>`;
}
}
export function buildSet({ resultSet }: { readonly resultSet: RollTableResultSet }): string {
return `<div class="resultSet" data-id="${escapeHTML(`${resultSet.id}`)}" data-global="${resultSet.global ? 'true' : 'false'}"><span class="setRelation">in ${resultSet.name ? 'the' : 'a'} ${resultSet.global ? 'global' : 'server-local'} set</span>${resultSet.name ? ` <span class="setName">${escapeHTML(resultSet.name)}</span>` : ''}</div>`;
}
export function buildResultAttribution({ result }: { readonly result: RollTableResultFull<RollTable> }): string {
return `<div class="attribution"><div class="attributionBubble">${result.author ? buildAuthor({ author: result.author }) : ''}${buildSet({ resultSet: result.set })}</div></div>`;
}
export const selectedIdPrefix = 'selected-'
export function buildGeneratedElement({ result, selected }: { readonly result: RollTableResult, readonly selected: boolean|null }): string {
return (
`<li class="generatedElement">
<h2 class="generatedHead"><label class="generatedLabel tableHeader" ${buildTableData(result.table)} ${result.table.full === 'results' ? `for="${selectedIdPrefix}${htmlTableIdentifier(result.table)}"` : ''}><span class="tableEmoji">${escapeHTML(result.table.emoji)}</span> <span class="tableTitle">${escapeHTML(result.table.title)}</span></label>${selected !== null ? `<input class="generatedSelect" id="${selectedIdPrefix}${htmlTableIdentifier(result.table)}" name="${selectedIdPrefix}${htmlTableIdentifier(result.table)}" type="checkbox" ${selected ? 'checked' : ''} />` : ''}</h2>
<div class="generated${result.full ? ' attributed' : ''}"><button type="button" class="resultText" ${buildResultData(result)}>${escapeHTML(result.text)}</button>${result.full ? buildResultAttribution({ result }) : ''}</div>
</li>`)
}
export const submitName = "submit"
export const rerollId = "reroll"
export const rerollAllId = "rerollAll"
export const saveScenarioId = "saveScenario"
export const selectAllId = "selectAll"
export const selectNoneId = "selectNone"
export const copyMDID = "copyMD"
export const copyBBID = "copyBB"
export const copyEmojiTextID = "copyEmojiText"
export const copyTextID = "copyText"
export function buildGeneratorPage(
{ results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses }:
{ readonly results: ReadonlyMap<RollTable, RollTableResult>, readonly generatorTargetUrl: string, readonly clientId: string, readonly creditsUrl: string, readonly editable: boolean, readonly selected: ReadonlySet<RollTable>, readonly includesResponses: boolean }): string {
return `
<div id="generator" class="page">
<form method="post" action="${encodeURI(generatorTargetUrl)}" id="generatorWindow" class="window readable">
<h2 id="generatorHead">Your generated scenario</h2>
<ul id="generatedScenario">${Array.from(results.values()).map(result => buildGeneratedElement({ result, selected: (editable && includesResponses && result.table.full === 'results') ? selected.has(result.table) : null })).join('')}</ul>
<div id="generatorControls">
<div id="copyButtons" class="buttons requiresJs jsPopupHost">
<button type="button" class="button" id="${copyMDID}">Markdown</button>
<button type="button" class="button" id="${copyBBID}">BBCode</button>
<button type="button" class="button" id="${copyEmojiTextID}">Text + Emoji</button>
<button type="button" class="button" id="${copyTextID}">Text Only</button>
</div>
${editable ? `<div id="rollButtons" class="buttons jsPopupHost">
<button type="submit" class="button" id="${rerollId}" name="${submitName}" value="${rerollId}">Reroll Selected</button>
<button type="button" class="button requiresJs" id="${selectAllId}">Select All</button>
<button type="button" class="button requiresJs" id="${selectNoneId}">Select None</button>
</div>` : ''}
<div id="scenarioButtons" class="buttons jsPopupHost">
${editable
? `<a href="${encodeURI(generatorTargetUrl)}" class="button" id="${rerollAllId}" draggable="false">New Scenario</a>
<button type="submit" class="button" id="${saveScenarioId}" name="${submitName}" value="${saveScenarioId}">Get Scenario Link</button>`
: `<a href="${encodeURI(generatorTargetUrl)}" class="button" draggable="false">Open in Generator</a>`}
</div>
${clientId !== '' || includesResponses ?
`<div id="generatorLinks" class="buttons jsPopupHost">
${clientId !== '' ? `<a href="https://discord.com/api/oauth2/authorize?client_id=${encodeURIComponent(clientId)}&permissions=0&scope=applications.commands" class="button" rel="external nofollow noreferrer" draggable="false">Add to Discord</a>` : ''}
${includesResponses ? `<a href="#responses" class="button" id="responsesLink" draggable="false">View Possible Responses</a>` : ''}
</div>` : ''}
</div>
</form>
${buildFooter({ includesResponses: includesResponses, includesGenerator: true, creditsUrl })}
</div>`;
}
export function buildResponseTypeButton({table}: {readonly table: RollTableDetails}) {
return `<a href="#responses-${htmlTableIdentifier(table)}" class="button" draggable="false">${escapeHTML(table.emoji)} ${escapeHTML(table.name)}</a>`
}
export function buildResultData(result: RollTableResult): string {
return result.full ? `data-mappingid="${result.mappingId}" data-textid="${result.textId}" data-updated="${result.updated.getTime()}"` : ''
}
export function buildTableData(table: RollTable): string {
return `data-ordinal="${table.ordinal}" ${table.full
? `data-id="${table.id}" data-identifier="${escapeHTML(table.identifier)}" data-name="${escapeHTML(table.name)}"`
: ''}`
}
export function buildResponse({result, active}: {readonly result: RollTableResult, readonly active: boolean}) {
return `<li class="response${active ? ' active' : ''}${result.full ? ' attributed' : ''} jsPopupHost">
<button type="button" class="resultText" ${buildResultData(result)}>${escapeHTML(result.text)}</button>
${result.full ? buildResultAttribution({result}) : ''}
</li>`
}
export function buildResponseList({table, activeResult}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult}) {
return `<li class="responseType window readable" id="responses-${htmlTableIdentifier(table)}">
<h2 class="responseTypeHead tableHeader" ${buildTableData(table)}><span class="tableEmoji">${escapeHTML(table.emoji)}</span> <span class="tableTitle">${escapeHTML(table.title)}</span></h2>
<ul>
${Array.from(table.resultsById.values()).map(result => buildResponse({result, active: result === activeResult})).join('')}
</ul>
</li>`
}
export function buildResponsesPage(
{ tables, results, creditsUrl, includesGenerator }: {
readonly tables: Iterable<RollTableDetailsAndResults>,
readonly results?: ReadonlyMap<RollTable, RollTableResult>,
readonly creditsUrl: string,
readonly includesGenerator: boolean}): string {
return `
<div id="responses" class="page">
<header id="responsesHeader" class="window head">
<h1 id="responsesHead">Possible Responses</h1>
<nav class="buttons" id="responsesHeaderNav">
${Array.from(tables).map(table => buildResponseTypeButton({table})).join('')}
<a id="returnToGenerator" href="#generator" class="button" draggable="false">Return to Generator</a>
</nav>
</header>
<ul id="responseLists">
${Array.from(tables).map(table => buildResponseList({table, activeResult: results?.get(table)})).join('')}
</ul>
${buildFooter({ includesResponses: true, includesGenerator, creditsUrl })}
</div>
</body>
</html>`;
}
export function wrapPage(
{ title, bodyContent, script, styles, noscriptStyles }:
{ readonly title: string, readonly bodyContent: string, readonly script: string, readonly styles: string, readonly noscriptStyles: string }): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title}</title>
<script>${script}</script>
<style>${styles}</style>
<noscript><style>${noscriptStyles}</style></noscript>
</head>
<body>
${bodyContent}
</body>
</html>`;
}

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2015",
"module": "ES2015",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true
}
}

@ -1,180 +0,0 @@
import { type RollableTables, RollTable, RollTableOrder, ValueAccess } from './rolltable.js';
interface DbResponse {
tableId: RollTable,
text: string,
}
export enum UpdateResult {
Updated = 0,
NewConflict = 1,
OldGlobal = 2,
NoOldText = 3,
}
export enum DeleteResult {
Deleted = 0,
OldGlobal = 2,
NoOldText = 3,
}
function buildRollableTables(responses: DbResponse[]): RollableTables {
const out: {[key in RollTable]?: string[]} = {}
for (const table of RollTableOrder) {
out[table] = []
}
for (const { tableId, text } of responses) {
out[tableId]?.push(text)
}
return out as RollableTables
}
export class DbAccess {
private readonly getResponsesInServerQuery: D1PreparedStatement
private readonly getResponsesInDMQuery: D1PreparedStatement;
private readonly putResponseQuery: D1PreparedStatement;
private readonly checkResponseAlreadyExistsQuery: D1PreparedStatement;
private readonly getResponsesGlobal: D1PreparedStatement;
private readonly updateResponseQuery: D1PreparedStatement;
private readonly deleteResponseQuery: D1PreparedStatement;
constructor(db: D1Database) {
this.getResponsesGlobal = db.prepare(
`SELECT DISTINCT tableId, text FROM responses
WHERE access = ${ValueAccess.Global}`)
this.getResponsesInServerQuery = db.prepare(
`SELECT DISTINCT tableId, text FROM responses
WHERE access = ${ValueAccess.Global}
OR (access = ${ValueAccess.Server} AND serverSnowflake = ?);`)
this.getResponsesInDMQuery = db.prepare(
`SELECT DISTINCT tableId, text FROM responses
WHERE access = ${ValueAccess.Global}
OR (access = ${ValueAccess.CreatorDM} AND userSnowflake = ?);`)
this.putResponseQuery = db.prepare(
`INSERT OR IGNORE INTO responses (id, tableId, text, timestamp, userSnowflake, serverSnowflake, access) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING timestamp, access;`)
this.updateResponseQuery = db.prepare(
`UPDATE responses SET text = ?3, timestamp = ?4, userSnowflake = ?5, serverSnowflake = ?6
WHERE tableId = ?1 AND text = ?2
AND ((?7 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?5)
OR (?7 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?6))
RETURNING timestamp, access;`)
this.deleteResponseQuery = db.prepare(
`DELETE FROM responses
WHERE tableId = ?1 AND text = ?2
AND ((?5 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?3)
OR (?5 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?4))
RETURNING timestamp, access;`)
this.checkResponseAlreadyExistsQuery = db.prepare(
`SELECT timestamp, access FROM responses
WHERE tableId = ?1 AND text = ?2
AND (access = ${ValueAccess.Global}
OR (?3 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?4)
OR (?3 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?4));`)
}
async getGlobalResponses(): Promise<RollableTables> {
const {results} = await this.getResponsesGlobal.all<DbResponse>()
return buildRollableTables(results)
}
async getResponsesInServer(inServerSnowflake: string): Promise<RollableTables> {
const statement = this.getResponsesInServerQuery.bind(inServerSnowflake)
const {results} = await statement.all<DbResponse>()
return buildRollableTables(results)
}
async getResponsesInDMWith(withUserSnowflake: string): Promise<RollableTables> {
const statement = this.getResponsesInDMQuery.bind(withUserSnowflake)
const {results} = await statement.all<DbResponse>()
return buildRollableTables(results)
}
async putResponse(requestTimestamp: number, table: RollTable, text: string, fromUserSnowflake: string, inServerSnowflake: string|null, access?: ValueAccess): Promise<{
timestamp: number,
access: ValueAccess,
inserted: boolean
}> {
const effectiveAccess = access ?? (inServerSnowflake ? ValueAccess.Server : ValueAccess.CreatorDM)
const relevantSnowflake = access === ValueAccess.Server ? inServerSnowflake : access === ValueAccess.CreatorDM ? fromUserSnowflake : null
const existingResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, text, effectiveAccess, relevantSnowflake)
const existingResponse = await existingResponseStatement.first<{timestamp: number, access: ValueAccess}>()
if (existingResponse) {
return {
timestamp: existingResponse.timestamp,
access: existingResponse.access,
inserted: false,
}
}
const statement = this.putResponseQuery.bind(
requestTimestamp, table, text, requestTimestamp, fromUserSnowflake, inServerSnowflake, effectiveAccess)
const result = await statement.first<{timestamp: number, access: ValueAccess}>()
if (!result) {
throw Error("no response from insert")
}
return {
timestamp: result.timestamp,
access: result.access,
inserted: true
}
}
async updateResponse(timestamp: number, table: RollTable, oldText: string, newText: string, userId: string, guildId: string | null, access?: ValueAccess): Promise<{result: UpdateResult.NoOldText|UpdateResult.Updated} | {result: UpdateResult.NewConflict, timestamp: number, access: ValueAccess} | {result: UpdateResult.OldGlobal, timestamp: number, access: ValueAccess.Global}> {
const effectiveAccess = access ?? (guildId ? ValueAccess.Server : ValueAccess.CreatorDM)
const relevantSnowflake = access === ValueAccess.Server ? guildId : access === ValueAccess.CreatorDM ? userId : null
const existingOldResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, oldText, effectiveAccess, relevantSnowflake)
const existingOldResponse = await existingOldResponseStatement.first<{timestamp: number, access: ValueAccess}>()
if (!existingOldResponse) {
return {
result: UpdateResult.NoOldText
}
} else if (existingOldResponse.access === ValueAccess.Global) {
return {
timestamp: existingOldResponse.timestamp,
access: existingOldResponse.access,
result: UpdateResult.OldGlobal,
}
}
const existingNewResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, newText, effectiveAccess, relevantSnowflake)
const existingNewResponse = await existingNewResponseStatement.first<{timestamp: number, access: ValueAccess}>()
if (existingNewResponse) {
return {
result: UpdateResult.NewConflict,
timestamp: existingNewResponse.timestamp,
access: existingNewResponse.access,
}
}
const statement = this.updateResponseQuery.bind(
table, oldText, newText, timestamp, userId, guildId, effectiveAccess)
await statement.run()
return {result: UpdateResult.Updated}
}
async deleteResponse(table: RollTable, text: string, userId: string, guildId: string | null, access?: ValueAccess): Promise<
{result: DeleteResult.Deleted, timestamp: number, access: ValueAccess} |
{result: DeleteResult.NoOldText} |
{result: DeleteResult.OldGlobal, timestamp: number, access: ValueAccess.Global}
> {
const effectiveAccess = access ?? (guildId ? ValueAccess.Server : ValueAccess.CreatorDM)
const relevantSnowflake = access === ValueAccess.Server ? guildId : access === ValueAccess.CreatorDM ? userId : null
const existingOldResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, text, effectiveAccess, relevantSnowflake)
const existingOldResponse = await existingOldResponseStatement.first<{timestamp: number, access: ValueAccess}>()
if (!existingOldResponse) {
return {
result: DeleteResult.NoOldText
}
} else if (existingOldResponse.access === ValueAccess.Global) {
return {
timestamp: existingOldResponse.timestamp,
access: existingOldResponse.access,
result: DeleteResult.OldGlobal,
}
}
const statement = this.deleteResponseQuery.bind(
table, text, userId, guildId, effectiveAccess)
const deleted = await statement.first<{timestamp: number, access: ValueAccess}>()
if (!deleted) {
throw Error("no response from delete")
}
return {result: DeleteResult.Deleted, timestamp: deleted.timestamp, access: deleted.access}
}
}

@ -1,233 +0,0 @@
import { type RollableTables, rollOn, RollTable, RollTableOrder } from './rolltable.js';
import {
ButtonStyle,
type ComponentActionRow,
type ComponentButton,
type ComponentSelectMenu,
type ComponentSelectOption,
ComponentType,
EmbedField,
MessageEmbed, type MessageEmbedOptions, type MessageOptions
} from 'slash-create/web';
export type ComponentValues = { [key in RollTable]?: string }
export type ComponentLocks = { [Key in RollTable]?: boolean }
export interface GeneratedMessage {
values: ComponentValues
locked?: ComponentLocks
}
export const RollTableEmoji = {
[RollTable.Setting]: '\u{1f3d9}\ufe0f',
[RollTable.Theme]: '\u{1f4d4}',
[RollTable.Start]: '\u25b6\ufe0f',
[RollTable.Challenge]: '\u{1f613}',
[RollTable.Twist]: '\u{1f500}',
[RollTable.Focus]: '\u{1f444}',
[RollTable.Word]: '\u{2728}'
} as const satisfies {readonly [key in RollTable]: string}
export const RollTableEmbedTitles = {
[RollTable.Setting]: 'The action takes place...',
[RollTable.Theme]: 'The encounter is themed around...',
[RollTable.Start]: 'The action begins when...',
[RollTable.Challenge]: 'Things are more difficult because...',
[RollTable.Twist]: 'Partway through, unexpectedly...',
[RollTable.Focus]: 'The vore scene is focused on...',
[RollTable.Word]: 'The word of the day is...'
} as const satisfies {readonly [key in RollTable]: string}
export const RollTableNames = {
[RollTable.Setting]: 'Setting',
[RollTable.Theme]: 'Theme',
[RollTable.Start]: 'Inciting Incident',
[RollTable.Challenge]: 'Challenge',
[RollTable.Twist]: 'Twist',
[RollTable.Focus]: 'Vore Scene Focus',
[RollTable.Word]: 'Word of the Day'
} as const satisfies {readonly [key in RollTable]: string}
export const RollTableEmbedsReversed = {
"\u{1f3d9}\ufe0f The action takes place...": RollTable.Setting,
"\u{1f4d4} The encounter is themed around...": RollTable.Theme,
"\u25b6\ufe0f The action begins when...": RollTable.Start,
"\u{1f613} Things are more difficult because...": RollTable.Challenge,
"\u{1f500} Partway through, unexpectedly...": RollTable.Twist,
"\u{1f444} The vore scene is focused on...": RollTable.Focus,
"\u{2728} The word of the day is...": RollTable.Word,
} as const satisfies {readonly [key in RollTable as `${typeof RollTableEmoji[key]} ${typeof RollTableEmbedTitles[key]}`]: key} & {[other: string]: RollTable}
export function calculateUnlockedValues(original?: ComponentValues|undefined, locks?: ComponentLocks|undefined): RollTable[] {
if (!original && !locks) {
return RollTableOrder
}
const existingItems = original ? RollTableOrder.filter(v => typeof original[v] !== "undefined") : RollTableOrder
return locks ? existingItems.filter(v => locks[v] !== true) : existingItems
}
export function generateValuesFor(selected: readonly RollTable[], tables: RollableTables, original: ComponentValues = {}): ComponentValues {
const result: ComponentValues = Object.assign({}, original)
for (const table of selected) {
result[table] = rollOn(table, tables)
}
return result
}
export const LOCK_SUFFIX = " \u{1f512}"
export const UNLOCK_SUFFIX = " \u{1f513}"
export function generateFieldFor(field: RollTable, value: string, lock: boolean|null = null) {
return {
name: RollTableEmoji[field] + " " + RollTableEmbedTitles[field] + (lock !== null ? (lock ? LOCK_SUFFIX : UNLOCK_SUFFIX) : ""),
value,
}
}
export function generateEmbedFor(values: ComponentValues, locks: ComponentLocks|undefined): MessageEmbedOptions {
const fields: EmbedField[] = []
const usableLocks = locks ?? {}
for (const field of RollTableOrder) {
const value = values[field]
if (value) {
fields.push(generateFieldFor(field, value, usableLocks.hasOwnProperty(field) ? usableLocks[field] : null))
}
}
return {
title: 'Your generated scenario',
fields,
timestamp: new Date().toISOString()
}
}
export function getEmbedFrom({embeds}: {embeds?: MessageEmbed[]|undefined}): MessageEmbed {
const result = embeds && embeds.length >= 1 ? embeds[0] : null
if (!result) {
throw Error("there were no embeds on the message to read")
}
return result
}
export function loadEmbed(embed: MessageEmbed): GeneratedMessage {
const result: {values: ComponentValues, locked: ComponentLocks} = {
values: {},
locked: {},
}
if (!embed.fields || embed.fields.length === 0) {
throw Error("there were no fields on the embed to read")
}
for (const field of embed.fields!) {
let locked: boolean|undefined,
name = field.name
if (name.endsWith(LOCK_SUFFIX)) {
locked = true
name = name.substring(0, name.length - LOCK_SUFFIX.length)
} else if (name.endsWith(UNLOCK_SUFFIX)) {
locked = false
name = name.substring(0, name.length - UNLOCK_SUFFIX.length)
} else {
throw Error(`there was no lock or unlock suffix on ${name}`)
}
const value = field.value
if (RollTableEmbedsReversed.hasOwnProperty(name)) {
const table = RollTableEmbedsReversed[name as keyof typeof RollTableEmbedsReversed]
if (typeof locked !== "undefined") {
result.locked[table] = locked
}
result.values[table] = value
} else {
throw Error(`I don't know a field named ${name}`)
}
}
return result
}
export function populateLocksFor(values: ComponentValues, original?: ComponentLocks|undefined): ComponentLocks {
const result = Object.assign({}, original)
for (const table of RollTableOrder) {
if (typeof values[table] !== "undefined") {
result[table] = result[table] ?? true
}
}
return result
}
export function selectUnlockedFrom(values: string[], oldLocks?: ComponentLocks | undefined): ComponentLocks {
const result = Object.assign({}, oldLocks ?? {})
for (const table of RollTableOrder) {
if (result.hasOwnProperty(table)) {
result[table] = !values.includes(`${table}`)
}
}
return result
}
export const SELECT_ID = "selected"
export const REROLL_ID = "reroll"
export const DONE_ID = "done"
export const DELETE_ID = "delete"
export function generateActionsFor(values: ComponentValues, locks: ComponentLocks|undefined): ComponentActionRow[] {
if (!locks) {
return []
}
const items = RollTableOrder.filter((v) => values.hasOwnProperty(v))
const lockedItems = items.filter((v) => locks[v] === true)
const selectOptions: ComponentSelectOption[] = items.map((v) => ({
default: !(locks[v] ?? false),
value: `${v}`,
label: RollTableNames[v],
emoji: {name: RollTableEmoji[v]}
}))
if (selectOptions.length === 0) {
return []
}
const select: ComponentSelectMenu = {
type: ComponentType.STRING_SELECT,
custom_id: SELECT_ID,
disabled: false,
max_values: selectOptions.length,
min_values: 0,
options: selectOptions,
placeholder: 'Components to reroll'
}
const selectRow: ComponentActionRow = { type: ComponentType.ACTION_ROW, components: [ select ] }
const rerollButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: REROLL_ID,
disabled: lockedItems.length === items.length,
emoji: {name: '\u{1f3b2}'},
label: (lockedItems.length === 0 ? "Reroll ALL" : "Reroll Selected"),
style: ButtonStyle.PRIMARY
}
const doneButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: DONE_ID,
disabled: false,
emoji: { name: '\u{1f44d}' },
label: 'Looks good!',
style: ButtonStyle.SUCCESS,
}
const deleteButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: DELETE_ID,
disabled: false,
emoji: { name: '\u{1f5d1}\ufe0f' },
label: 'Trash it.',
style: ButtonStyle.DESTRUCTIVE,
}
const buttonRow: ComponentActionRow = { type: ComponentType.ACTION_ROW, components: [rerollButton, doneButton, deleteButton] }
return [selectRow, buttonRow]
}
export function generateMessageFor(values: ComponentValues, locks: ComponentLocks|undefined): MessageOptions {
return { embeds: [generateEmbedFor(values, locks)], components: generateActionsFor(values, locks), ephemeral: false }
}
export function generateErrorMessageFor(e: unknown, context?: string): MessageOptions {
console.error(`Error when trying to ${context ?? "do something (unknown context)"}`, e)
return {
content: `I wasn't able to ${context ?? "do that"}. Thing is, ${e}...`,
ephemeral: true,
}
}

@ -1,128 +0,0 @@
/**
* Welcome to Cloudflare Workers! This is your first worker.
*
* - Run `npm run dev` in your terminal to start a development server
* - Open a browser tab at http://localhost:8787/ to see your worker in action
* - Run `npm run deploy` to publish your worker
*
* Learn more at https://developers.cloudflare.com/workers/
*/
import { CloudflareWorkerServer, SlashCreator } from 'slash-create/web';
import { GenerateCommand, ResponseCommand } from './commands.js';
import { DbAccess } from './dbAccess.js';
import { isSnowflake, type Snowflake } from 'discord-snowflake';
import { RollTableOrder } from './rolltable.js';
import { RollTableEmbedTitles, RollTableEmoji, RollTableNames } from './generated.js';
export interface Env {
BASE_URL: string;
DISCORD_APP_ID: string
DISCORD_APP_SECRET: string
DISCORD_PUBLIC_KEY: string
DISCORD_DEV_GUILD_IDS: string
DB: D1Database
}
function getHandler(env: Env, token?: string) {
const dbAccess = new DbAccess(env.DB)
const server = new CloudflareWorkerServer()
const creator = new SlashCreator({
allowedMentions: {everyone: false, roles: false, users: false},
applicationID: env.DISCORD_APP_ID,
componentTimeouts: true,
defaultImageSize: 0,
disableTimeouts: false,
endpointPath: '/discord/interactions',
handleCommandsManually: false,
publicKey: env.DISCORD_PUBLIC_KEY,
unknownCommandResponse: true,
token: token,
})
const withGuilds: Snowflake[] = env.DISCORD_DEV_GUILD_IDS ? env.DISCORD_DEV_GUILD_IDS.split(",").flatMap(v => isSnowflake(v) ? [v] : []) : []
creator.withServer(server)
creator.registerCommand(new GenerateCommand(creator, dbAccess))
creator.registerCommand(new ResponseCommand(creator, dbAccess, env.BASE_URL))
creator.registerCommand(new GenerateCommand(creator, dbAccess, withGuilds))
creator.registerCommand(new ResponseCommand(creator, dbAccess, env.BASE_URL, withGuilds))
return {
fetch: server.fetch.bind(server),
syncCommands: creator.syncCommands.bind(creator),
db: dbAccess,
}
}
function getAuthorization(username: string, password: string): string {
return btoa(username + ":" + password)
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const tokenRequest = new Request(`https://discord.com/api/v10/oauth2/token`, {
headers: new Headers({
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Basic ${getAuthorization(env.DISCORD_APP_ID, env.DISCORD_APP_SECRET)}`,
}),
body: new URLSearchParams({"grant_type": "client_credentials", "scope": "applications.commands.update"}),
method: "POST"
})
const tokenResponse = await fetch(tokenRequest)
if (tokenResponse.status !== 200) {
const text = await tokenResponse.text()
console.error(`Failed getting token`, text)
return new Response(`Could not sync commands: Failed getting token: ${tokenResponse.status} ${tokenResponse.statusText}\n${text}`, {status: 500})
}
const json = await tokenResponse.json() as {access_token: string}
const handler = getHandler(env, "Bearer " + json.access_token)
const url = new URL(request.url)
if (url.pathname === "/discord/interactions") {
try {
return handler.fetch(request, env, ctx)
} catch (e) {
console.error("Failed to respond to interactions endpoint", e);
return new Response(`Could not respond to interaction: ${e}`, {
status: 500
})
}
} else if (url.pathname === "/discord/sync") {
try {
await handler.syncCommands({
deleteCommands: true,
syncGuilds: true,
})
} catch (e) {
console.error("Failed to respond to sync endpoint", e)
return new Response(`Could not sync commands: ${e}`, {
status: 500,
})
}
return new Response(`Synced commands!`, {
status: 200,
})
} else if (url.pathname === "/responses") {
try {
const response = []
const server = url.searchParams.get("server")
const tables = await (server === null
? handler.db.getGlobalResponses()
: handler.db.getResponsesInServer(server))
for (const table of RollTableOrder) {
response.push(`${RollTableNames[table]} - ${RollTableEmoji[table]} ${RollTableEmbedTitles[table]}`)
for (const value of tables[table]) {
response.push(` * ${value}`)
}
response.push('')
}
return new Response(response.join('\n'), {status: 200})
} catch (e) {
console.error("Failed to respond to list endpoint", e)
return new Response(`Could not list responses: ${e}`, {
status: 500,
})
}
} else {
return new Response(`Invalid path ${url.pathname}`, {status: 404})
}
},
};

@ -1,43 +0,0 @@
export enum RollTable {
Setting = 0,
Theme = 1,
Start = 2,
Challenge = 3,
Twist = 4,
Focus = 5,
Word = 6,
}
export enum ValueAccess {
Global = 0,
Server = 1,
CreatorDM = 2,
}
export const RollTableOrder =
[RollTable.Setting, RollTable.Theme, RollTable.Start, RollTable.Challenge, RollTable.Twist, RollTable.Focus, RollTable.Word] as const satisfies RollTable[]
export const RollTableOrdinals =
{
[RollTable.Setting]: 0,
[RollTable.Theme]: 1,
[RollTable.Start]: 2,
[RollTable.Challenge]: 3,
[RollTable.Twist]: 4,
[RollTable.Focus]: 5,
[RollTable.Word]: 6,
} as const satisfies {[key in RollTable]: number} & {[key in Extract<keyof typeof RollTableOrder, number> as typeof RollTableOrder[key]]: key}
export type RollableTables = {readonly [key in RollTable]: readonly string[]}
export function isTable(val: number): val is RollTable {
return RollTableOrdinals.hasOwnProperty(val)
}
export function rollOn(table: RollTable, tables: RollableTables): string {
const values = tables[table]
if (values.length === 0) {
throw Error(`no possible options for table ${table}`)
}
return values[Math.floor(values.length * Math.random())]
}

@ -0,0 +1,380 @@
import {
type FinalGeneratedContents,
type FinalGeneratedState,
type GeneratedContents,
type GeneratedState,
type InProgressGeneratedContents,
type InProgressGeneratedState,
RolledValues, rollOn, rollResultToString,
RollSelections,
type RollTable,
type RollTableAuthor,
RollTableDatabase,
type RollTableDetailsNoResults,
type RollTableResultFull,
} from '../../common/rolltable';
import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes';
import { DatabaseQueries } from './queries';
function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['getResultMappingsForDiscordSet']>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> & {
status: 'updated' | 'existing'
}) | undefined {
if (!result) {
return result;
}
return {
full: true,
mappingId: result.mappingId,
textId: result.resultId,
text: result.resultText,
table: {
full: 'details',
id: result.tableId,
identifier: result.tableIdentifier,
name: result.tableName,
title: result.tableTitle,
emoji: result.tableEmoji,
header: result.tableHeader,
ordinal: result.tableOrdinal
},
author: (result.authorId === null || result.authorName === null || result.authorRelation === null) ? null : {
id: result.authorId,
name: result.authorName,
url: result.authorUrl,
relation: result.authorRelation
},
set: {
id: result.setId,
name: result.setName,
description: result.setDescription,
global: !!(result.setGlobal)
},
updated: new Date(result.updated),
status: result.status
};
}
export class Database {
private readonly db: TypedDBWrapper;
private readonly queries: PreparedQueries<typeof DatabaseQueries>;
constructor(db: D1Database) {
this.db = new TypedDBWrapper(db);
this.queries = this.db.prepareAll(DatabaseQueries);
}
async autocompleteTable(tableSoFar: string) {
return this.db.run(this.queries.autocompleteTable({
tableIdentifierSubstring: tableSoFar
}));
}
async autocompleteText(setSnowflake: string, tableIdentifier: string, partialText: string, includeGlobal: boolean) {
return this.db.run(this.queries.autocompleteTextForDiscordSet({
setSnowflake: setSnowflake,
tableIdentifierSubstring: tableIdentifier,
pattern: partialText,
includeGlobal
}));
}
async addResponseFromDiscord(timestamp: number, table: string | number, text: string, userId: string, username: string, setId: string) {
const [, , , , results] = await this.db.batch(
this.queries.addResultForAddMapping({ tableIdentifier: table, text }),
this.queries.addDiscordAuthorForAddMapping({ userSnowflake: userId, username }),
this.queries.addDiscordSetForAddMapping({ setSnowflake: setId, userSnowflake: userId }),
this.queries.addDiscordResultMapping({
timestamp,
tableIdentifier: table,
resultText: text,
userSnowflake: userId,
setSnowflake: setId
}),
this.queries.getResultMappingsForDiscordSet({
timestamp,
tableIdentifier: table,
text,
setSnowflake: setId,
includeGlobal: false
})
);
const result = processOperationResult(results[0]);
if (!result) {
throw Error('failed adding the new response');
}
return {
...result,
status: result.status === 'updated' ? 'added' : 'existed'
};
}
async editResponseFromDiscord(timestamp: number, table: number | string, oldText: string, newText: string, userId: string, username: string, setId: string): Promise<{
status: 'nonexistent'
} | {
status: 'noneditable',
old: RollTableResultFull<RollTableDetailsNoResults>
} | {
status: 'conflict' | 'updated',
old: RollTableResultFull<RollTableDetailsNoResults>,
new: RollTableResultFull<RollTableDetailsNoResults>,
}> {
const [oldResults, , , , newResults] = await this.db.batch(
this.queries.getResultMappingsForDiscordSet({
timestamp,
tableIdentifier: table,
text: oldText,
setSnowflake: setId,
includeGlobal: true
}),
this.queries.addResultForEditMapping({
tableIdentifier: table,
oldText,
newText,
setSnowflake: setId
}),
this.queries.addDiscordAuthorForEditMapping({
userSnowflake: userId,
username,
tableIdentifier: table,
oldText,
newText,
setSnowflake: setId
}),
this.queries.editMappingForDiscord({
timestamp,
tableIdentifier: table,
oldText,
newText,
userSnowflake: userId,
setSnowflake: setId
}),
this.queries.getResultMappingsForDiscordSet({
timestamp,
tableIdentifier: table,
text: newText,
setSnowflake: setId,
includeGlobal: false
})
);
const oldResult = processOperationResult(oldResults[0]);
if (!oldResult) {
return { status: 'nonexistent' };
}
if (oldResult.set?.global) {
return { status: 'noneditable', old: oldResult };
}
const newResult = processOperationResult(newResults[0]);
if (!newResult) {
throw Error('failed to update response');
}
return {
status: newResult.status === 'updated' ? 'updated' : 'conflict',
old: oldResult,
new: newResult
};
}
async deleteResponseFromDiscord(table: number | string, text: string, setId: string): Promise<{
status: 'nonexistent'
} | {
status: 'noneditable' | 'deleted',
old: RollTableResultFull<RollTableDetailsNoResults>
}> {
const [oldResults, deleted] = await this.db.batch(
this.queries.getResultMappingsForDiscordSet({
timestamp: null,
tableIdentifier: table,
text,
setSnowflake: setId,
includeGlobal: true
}),
this.queries.deleteDiscordResultMapping({
tableIdentifier: table,
text,
setSnowflake: setId
})
);
const oldResult = processOperationResult(oldResults[0]);
if (!oldResult) {
return {
status: 'nonexistent'
};
}
if (!deleted) {
return {
status: 'noneditable',
old: oldResult
};
}
return {
status: 'deleted',
old: oldResult
};
}
async getResponseFromDiscord(table: number | string, text: string, setId: string): Promise<{
status: 'nonexistent'
} | ({
status: 'existent',
} & RollTableResultFull<RollTableDetailsNoResults>)> {
const results = await this.db.run(this.queries.getResultMappingsForDiscordSet({
timestamp: null,
tableIdentifier: table,
text,
setSnowflake: setId,
includeGlobal: true
}));
const result = processOperationResult(results[0]);
if (!result) {
return {
status: 'nonexistent'
};
}
return {
...result,
status: 'existent'
};
}
async getDiscordAuthor(id: string): Promise<RollTableAuthor | null> {
return await this.db.run(this.queries.getDiscordAuthor({
userSnowflake: id
}));
}
async setDiscordAuthor(id: string, username: string, name: string | null, url: string | null): Promise<RollTableAuthor | null> {
const [, result] = await this.db.batch(
this.queries.setDiscordAuthor({
userSnowflake: id,
username: username,
name: name,
url: url
}),
this.queries.getDiscordAuthor({ userSnowflake: id })
);
return result;
}
private async getGeneratorDataForDiscordSet(reroll: true, setSnowflake: string | null, oldResults?: InProgressGeneratedContents | null, finalize?: false): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults?: null): Promise<RollTableDatabase>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: GeneratedContents, finalize: false): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: GeneratedContents, finalize: true): Promise<FinalGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: InProgressGeneratedContents): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: FinalGeneratedContents): Promise<FinalGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: boolean, setSnowflake: string | null, oldResults: GeneratedContents, finalize?: boolean): Promise<GeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: boolean, setSnowflake: string | null, oldResults?: GeneratedContents | null, finalize?: boolean): Promise<RollTableDatabase | (GeneratedState & {
db: RollTableDatabase
})> {
const oldHeaders = oldResults && oldResults.rolled.size > 0 ? Array.from(oldResults.rolled.keys()) : [];
const [tables, oldKeys, oldSelection, { mappings, sets, authors }] =
await this.db.batch(
this.queries.getTables({}),
this.queries.getTableIdsByIdentifierOrHeader({
identifiersOrHeaders: oldHeaders
}),
this.queries.getTableIdsByIdentifierOrHeader({
identifiersOrHeaders: oldResults && !oldResults.final && oldResults.selected.size > 0 ? Array.from(oldResults.selected) : []
}),
this.queries.getFullDatabaseForDiscordSet({
setSnowflake
}));
const db = new RollTableDatabase({
tables: tables.map(v => ({ ...v, full: 'details' })),
authors,
sets,
results: mappings.map(v => ({ ...v, updated: new Date(v.updated) }))
});
if (!oldResults && !reroll) {
return db;
}
const selected = new RollSelections(oldSelection.flatMap(v => {
if (v === null) {
return [];
}
const table = db.tables.get(v);
if (!table) {
return [];
}
return [table];
}));
const rolled = new RolledValues();
const rollKeys = oldResults ? oldKeys : tables.map(t => t.id);
for (let index = 0; index < rollKeys.length; index += 1) {
const tableId = rollKeys[index];
const lookupTable = tableId !== null ? db.tables.get(tableId) : null;
const oldHeader = oldHeaders[index];
const [oldEmoji, oldTitle] = oldHeader ? oldHeader.split(' ', 2) : ['', ''];
const table: RollTable = lookupTable ?? {
full: false,
header: oldHeader,
emoji: oldEmoji,
title: oldTitle,
ordinal: index
};
const text = oldResults?.rolled.get(oldHeader);
if (reroll && table.full && (!text || selected.has(table))) {
const result = rollOn(table)
rolled.add(result);
} else if (text) {
const lookupResult = text && table.full === 'results' ? table.resultsByText.get(text) : null;
const result = lookupResult ?? {
full: false,
text: text,
table
}
rolled.add(result);
}
}
return (finalize ?? oldResults?.final) ? {
final: true,
db,
rolled
} : {
final: false,
db,
rolled,
selected
};
}
async getGeneratorPageForDiscordSet(setSnowflake: string | null, oldResults?: InProgressGeneratedContents | null): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}> {
return this.getGeneratorDataForDiscordSet(true, setSnowflake, oldResults, false);
}
async expandFromDiscordSet(setId: string, contents: FinalGeneratedContents): Promise<FinalGeneratedState>
async expandFromDiscordSet(setId: string, contents: InProgressGeneratedContents): Promise<InProgressGeneratedState>
async expandFromDiscordSet(setId: string, contents: GeneratedContents): Promise<GeneratedState>
async expandFromDiscordSet(setId: string, contents: GeneratedContents): Promise<GeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, contents);
}
async generateFromDiscordSet(setId: string): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(true, setId);
}
async rerollFromDiscordSet(setId: string, existing: InProgressGeneratedContents): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(true, setId, existing);
}
async reopenFromDiscordSet(setId: string, existing: FinalGeneratedContents): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, existing, false);
}
async finalizeFromDiscordSet(setId: string, existing: InProgressGeneratedContents): Promise<FinalGeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, existing, true);
}
}

@ -0,0 +1,614 @@
import { type QueryDefinitions, validatedDefinitions } from './querytypes';
import {
boolean,
discordSnowflake,
jsonArray,
nullable,
string,
substring,
tableIdentifierOrId,
tableIdentifierSubstring,
timestamp,
URL
} from './validators';
import { extract, guaranteedSingleton, jsonParser, nothing, rows, singleton, writeCount } from './transformers';
export const DatabaseQueries = validatedDefinitions({
autocompleteTable: {
query: `WITH matchingIds (id) AS (SELECT DISTINCT rollableTableIdentifiers.tableId AS id
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier LIKE ?1 ESCAPE '\\'
UNION
SELECT DISTINCT rollableTableHeaders.tableId AS id
FROM rollableTableHeaders
WHERE rollableTableHeaders.header LIKE ?1 ESCAPE '\\'
UNION
SELECT DISTINCT rollableTableBadges.id AS id
FROM rollableTableBadges
WHERE rollableTableBadges.badge LIKE ?1 ESCAPE '\\')
SELECT rollableTables.identifier AS identifier,
rollableTables.name AS name,
rollableTables.emoji AS emoji
FROM rollableTables
WHERE ?1 = '%'
OR rollableTables.id IN matchingIds
LIMIT 25;`,
parameters: {
'tableIdentifierSubstring': { validator: tableIdentifierSubstring, index: 1 }
},
output: rows<{ identifier: string, name: string, emoji: string }>()
},
autocompleteTextForDiscordSet: {
query: `WITH matchingTables (id) AS (SELECT DISTINCT rollableTableIdentifiers.tableId AS id
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier LIKE ?2 ESCAPE '\\'
UNION
SELECT DISTINCT rollableTableHeaders.tableId AS id
FROM rollableTableHeaders
WHERE rollableTableHeaders.header LIKE ?2 ESCAPE '\\'
UNION
SELECT DISTINCT rollableTableBadges.id AS id
FROM rollableTableBadges
WHERE rollableTableBadges.badge LIKE ?2 ESCAPE '\\'),
rollableSets (id) AS (SELECT resultSets.id
FROM resultSets
WHERE (?4 AND resultSets.global)
OR resultSets.discordSnowflake = ?1)
SELECT rollableResults.text AS text
FROM rollableResults
WHERE rollableResults.tableId IN matchingTables
AND EXISTS(SELECT resultMappings.resultId
FROM resultMappings
WHERE resultMappings.setId IN rollableSets
AND resultMappings.resultId = rollableResults.id)
AND (?3 = '%' OR rollableResults.text LIKE ?3 ESCAPE '\\')
ORDER BY (rollableResults.text LIKE SUBSTR(1, ?3) ESCAPE '\\') DESC,
LENGTH(rollableResults.text)
LIMIT 25;`,
parameters: {
'setSnowflake': { validator: discordSnowflake, index: 1 },
'tableIdentifierSubstring': { validator: tableIdentifierSubstring, index: 2 },
'pattern': { validator: substring, index: 3 },
'includeGlobal': { validator: boolean, index: 4 }
},
output: rows<{ text: string }>()
},
addResultForAddMapping: {
query: `WITH rollableTable (id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE (rollableTableIdentifiers.identifier = ?1 OR
rollableTableIdentifiers.tableId = ?1))
INSERT
OR
IGNORE
INTO rollableResults (tableId, text)
VALUES ((SELECT rollableTable.id FROM rollableTable), ?2);`,
parameters: {
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 1
},
'text': {
validator: string,
index: 2
}
},
output: nothing()
},
addDiscordAuthorForAddMapping: {
query: `WITH authorshipType (id) AS (SELECT authorshipTypes.id
FROM authorshipTypes
WHERE authorshipTypes.name = 'Discord contributor')
INSERT
INTO authors (name, url, discordSnowflake, discordUsername, authorshipTypeId)
VALUES (NULL, NULL, ?1, ?2, (SELECT authorshipType.id FROM authorshipType))
ON CONFLICT DO UPDATE SET discordUsername = ?2;`,
parameters: {
'userSnowflake': {
validator: discordSnowflake,
index: 1
},
'username': {
validator: string,
index: 2
}
},
output: nothing()
},
addDiscordSetForAddMapping: {
query: `INSERT OR IGNORE INTO resultSets (name, description, discordSnowflake, creatorId, global)
VALUES (NULL, NULL, ?1, (SELECT authors.id FROM authors WHERE authors.discordSnowflake = ?2), FALSE)`,
parameters: {
'setSnowflake': {
validator: discordSnowflake,
index: 1
},
'userSnowflake': {
validator: discordSnowflake,
index: 2
}
},
output: nothing()
},
addDiscordResultMapping: {
query: `WITH rollableTable (id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier = ?2
OR rollableTableIdentifiers.tableId = ?2
LIMIT 1),
rollableResult (id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.text = ?3
AND rollableResults.tableId = (SELECT id FROM rollableTable)
LIMIT 1),
resultSet (id) AS (SELECT resultSets.id
FROM resultSets
WHERE resultSets.discordSnowflake = ?5
AND NOT resultSets.global
LIMIT 1),
author (id) AS (SELECT authors.id
FROM authors
WHERE authors.discordSnowflake = ?4
LIMIT 1)
INSERT
OR
IGNORE
INTO resultMappings (resultId, setId, authorId, created, updated)
VALUES ((SELECT rollableResult.id FROM rollableResult),
(SELECT resultSet.id FROM resultSet),
(SELECT author.id FROM author),
?1,
?1);`,
parameters: {
'timestamp': {
validator: timestamp,
index: 1
},
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 2
},
'resultText': {
validator: string,
index: 3
},
'userSnowflake': {
validator: discordSnowflake,
index: 4
},
'setSnowflake': {
validator: discordSnowflake,
index: 5
}
},
output: nothing()
},
getResultMappingsForDiscordSet: {
query: `WITH rollableTable (id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier = ?2
OR rollableTableIdentifiers.tableId = ?2
LIMIT 1),
visibleSets (id) AS (SELECT resultSets.id
FROM resultSets
WHERE ((?5 AND resultSets.global) OR resultSets.discordSnowflake = ?4))
SELECT resultMappings.id AS mappingId,
rollableResults.id AS resultId,
rollableResults.text AS resultText,
authors.id AS authorId,
COALESCE(authors.name, authorshipTypes.defaultAuthor) AS authorName,
authors.url AS authorUrl,
authorshipTypes.relationPrefix AS authorRelation,
resultSets.id AS setId,
resultSets.name AS setName,
resultSets.description AS setDescription,
resultSets.global AS setGlobal,
rollableTables.id AS tableId,
rollableTables.identifier AS tableIdentifier,
rollableTables.name AS tableName,
rollableTables.title AS tableTitle,
rollableTables.emoji AS tableEmoji,
rollableTables.header AS tableHeader,
rollableTables.ordinal AS tableOrdinal,
resultMappings.updated AS updated,
(CASE WHEN resultMappings.updated = ?1 THEN 'updated' ELSE 'existing' END) AS status
FROM resultMappings
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId
LEFT JOIN authors ON authors.id = resultMappings.authorId
LEFT JOIN authorshipTypes ON authorshipTypes.id = authors.authorshipTypeId
INNER JOIN resultSets ON resultSets.id = resultMappings.setId
INNER JOIN rollableTables ON rollableTables.id = rollableResults.tableId
WHERE rollableResults.tableId = (SELECT id FROM rollableTable)
AND rollableResults.text = ?3
AND resultMappings.setId IN visibleSets
ORDER BY (NOT setGlobal) DESC, (authorId IS NOT NULL) DESC, updated, mappingId;`,
parameters: {
'timestamp': {
validator: nullable(timestamp),
index: 1
},
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 2
},
'text': {
validator: string,
index: 3
},
'setSnowflake': {
validator: discordSnowflake,
index: 4
},
'includeGlobal': {
validator: boolean,
index: 5
}
},
output: rows<{
mappingId: number,
resultId: number,
resultText: string,
authorId: number | null,
authorName: string | null,
authorUrl: string | null,
authorRelation: string | null,
setId: number,
setName: string | null,
setDescription: string | null,
setGlobal: number,
tableId: number,
tableIdentifier: string,
tableName: string,
tableTitle: string,
tableEmoji: string,
tableHeader: string,
tableOrdinal: number,
updated: number,
status: 'updated' | 'existing'
}>()
},
addResultForEditMapping: {
query: `WITH rollableTable(id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE (rollableTableIdentifiers.identifier = ?1 OR
rollableTableIdentifiers.tableId = ?1)
LIMIT 1),
oldResult (id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = (SELECT rollableTable.id FROM rollableTable)
AND rollableResults.text = ?2
LIMIT 1),
targetSet (id) AS (SELECT resultSets.id FROM resultSets WHERE resultSets.discordSnowflake = ?4 LIMIT 1)
INSERT
OR
IGNORE
INTO rollableResults (tableId, text)
SELECT rollableTable.id, ?3
FROM rollableTable
WHERE ?2 != ?3
AND EXISTS (SELECT resultMappings.id
FROM resultMappings
WHERE resultMappings.resultId = (SELECT oldResult.id FROM oldResult)
AND resultMappings.setId = (SELECT targetSet.id FROM targetSet));`,
parameters: {
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 1
},
'oldText': {
validator: string,
index: 2
},
'newText': {
validator: string,
index: 3
},
'setSnowflake': {
validator: discordSnowflake,
index: 4
}
},
output: nothing()
},
addDiscordAuthorForEditMapping: {
query: `WITH authorshipType (id) AS (SELECT authorshipTypes.id
FROM authorshipTypes
WHERE authorshipTypes.name = 'Discord contributor'
LIMIT 1),
rollableTable (id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier = ?3
OR rollableTableIdentifiers.tableId = ?3
LIMIT 1),
oldResult (id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = (SELECT rollableTable.id FROM rollableTable)
AND rollableResults.text = ?4
LIMIT 1),
newResult (id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = (SELECT rollableTable.id FROM rollableTable)
AND rollableResults.text = ?5
LIMIT 1),
targetSet (id) AS (SELECT resultSets.id FROM resultSets WHERE resultSets.discordSnowflake = ?6 LIMIT 1)
INSERT
INTO authors (name, url, discordSnowflake, discordUsername, authorshipTypeId)
SELECT NULL AS name,
NULL AS url,
?1 AS discordSnowflake,
?2 AS discordUsername,
authorshipType.id AS authorshipTypeId
FROM authorshipType
WHERE ?4 != ?5
AND EXISTS (SELECT resultMappings.id
FROM resultMappings
WHERE resultMappings.resultId = (SELECT oldResult.id FROM oldResult)
AND resultMappings.setId = (SELECT targetSet.id FROM targetSet))
AND NOT EXISTS (SELECT resultMappings.id
FROM resultMappings
WHERE resultMappings.resultId = (SELECT newResult.id FROM newResult)
AND resultMappings.setId = (SELECT targetSet.id FROM targetSet))
ON CONFLICT DO UPDATE SET discordUsername = ?2;`,
parameters: {
'userSnowflake': {
validator: discordSnowflake,
index: 1
},
'username': {
validator: string,
index: 2
},
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 3
},
'oldText': {
validator: string,
index: 4
},
'newText': {
validator: string,
index: 5
},
'setSnowflake': {
validator: discordSnowflake,
index: 6
}
},
output: nothing()
},
editMappingForDiscord: {
query: `WITH rollableTable (id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier = ?2
OR rollableTableIdentifiers.tableId = ?2
LIMIT 1),
oldResult (id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = (SELECT rollableTable.id FROM rollableTable)
AND rollableResults.text = ?3
LIMIT 1),
newResult(id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = (SELECT rollableTable.id FROM rollableTable)
AND rollableResults.text = ?4
LIMIT 1),
author(id) AS (SELECT authors.id FROM authors WHERE authors.discordSnowflake = ?5 LIMIT 1),
targetSet(id) AS (SELECT resultSets.id
FROM resultSets
WHERE resultSets.discordSnowflake = ?6
AND NOT resultSets.global
LIMIT 1)
UPDATE OR IGNORE resultMappings
SET resultId = (SELECT id FROM newResult),
authorId = (SELECT id FROM author),
updated = ?1
WHERE ?3 != ?4
AND resultMappings.resultId = (SELECT id FROM oldResult)
AND resultMappings.setId = (SELECT id FROM targetSet);`,
parameters: {
'timestamp': {
validator: timestamp,
index: 1
},
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 2
},
'oldText': {
validator: string,
index: 3
},
'newText': {
validator: string,
index: 4
},
'userSnowflake': {
validator: discordSnowflake,
index: 5
},
'setSnowflake': {
validator: discordSnowflake,
index: 6
}
},
output: nothing()
},
deleteDiscordResultMapping: {
query: `WITH rollableTable (id) AS (SELECT rollableTableIdentifiers.tableId
FROM rollableTableIdentifiers
WHERE rollableTableIdentifiers.identifier = ?1
OR rollableTableIdentifiers.tableId = ?1
LIMIT 1),
oldResult (id) AS (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = (SELECT rollableTable.id FROM rollableTable)
AND rollableResults.text = ?2
LIMIT 1),
targetSet(id) AS (SELECT resultSets.id
FROM resultSets
WHERE resultSets.discordSnowflake = ?3
AND NOT resultSets.global
LIMIT 1)
DELETE
FROM resultMappings
WHERE resultId = (SELECT oldResult.id FROM oldResult)
AND setId = (SELECT targetSet.id FROM targetSet);`,
parameters: {
'tableIdentifier': {
validator: tableIdentifierOrId,
index: 1
},
'text': {
validator: string,
index: 2
},
'setSnowflake': {
validator: discordSnowflake,
index: 3
}
},
output: writeCount()
},
getDiscordAuthor: {
query: `
SELECT authors.id AS id,
COALESCE(authors.name, authorshipTypes.defaultAuthor) AS name,
authors.url AS url,
authorshipTypes.relationPrefix AS relation
FROM authors
INNER JOIN main.authorshipTypes authorshipTypes on authorshipTypes.id = authors.authorshipTypeId
WHERE authors.discordSnowflake = ?1;`,
parameters: {
'userSnowflake': {
validator: discordSnowflake,
index: 1
}
},
output: singleton<{ id: number, name: string, url: string, relation: string }>()
},
setDiscordAuthor: {
query: `
INSERT INTO authors (discordSnowflake, discordUsername, name, url, authorshipTypeId)
VALUES (?1, ?2, ?3, ?4,
(SELECT authorshipTypes.id FROM authorshipTypes WHERE authorshipTypes.name = 'Discord contributor'))
ON CONFLICT DO UPDATE SET discordUsername = ?2,
name = ?3,
url = ?4;`,
parameters: {
'userSnowflake': {
validator: discordSnowflake,
index: 1
},
'username': {
validator: string,
index: 2
},
'name': {
validator: nullable(string),
index: 3
},
'url': {
validator: nullable(URL),
index: 4
}
},
output: nothing()
},
getTableIdsByIdentifierOrHeader: {
query: `SELECT COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) AS id
FROM json_each(?1) selection
LEFT JOIN rollableTableIdentifiers
ON rollableTableIdentifiers.identifier = selection.value
LEFT JOIN rollableTableHeaders ON rollableTableHeaders.header = selection.value;`,
parameters: {
'identifiersOrHeaders': {
validator: jsonArray,
index: 1
}
},
output: rows(extract<number | null>("id"))
},
getTables: {
query: `SELECT id, identifier, name, title, emoji, header, ordinal
FROM rollableTables`,
parameters: {},
output: rows<{
id: number,
identifier: string,
name: string,
title: string,
emoji: string,
header: string,
ordinal: number
}>()
},
getFullDatabaseForDiscordSet: {
query: `WITH visibleSets (id, name, description, global)
AS (SELECT resultSets.id,
resultSets.name,
resultSets.description,
resultSets.global
FROM resultSets
WHERE (resultSets.global OR resultSets.discordSnowflake = ?1)),
visibleResults (mappingId, setId, textId, tableId, text, authorId, updated)
AS (SELECT resultMappings.id AS mappingId,
resultMappings.setId AS setId,
resultMappings.resultId AS textId,
rollableResults.tableId AS tableId,
rollableResults.text AS text,
resultMappings.authorId AS authorId,
resultMappings.updated AS updated
FROM resultMappings
INNER JOIN visibleSets ON resultMappings.setId = visibleSets.id
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId),
visibleAuthors (id, name, url, relation)
AS (SELECT DISTINCT authors.id,
COALESCE(authors.name, authorshipTypes.defaultAuthor),
authors.url,
authorshipTypes.relationPrefix
FROM visibleResults
INNER JOIN authors ON authors.id = visibleResults.authorId
INNER JOIN authorshipTypes ON authorshipTypes.id = authors.authorshipTypeId)
SELECT (SELECT json_group_array(json_object('id', visibleSets.id,
'name', visibleSets.name,
'description', visibleSets.description,
'global', CASE
WHEN visibleSets.global THEN json('true')
ELSE json('false') END))
FROM visibleSets) AS sets,
(SELECT json_group_array(json_object('id', visibleAuthors.id,
'name', visibleAuthors.name,
'url', visibleAuthors.url,
'relation', visibleAuthors.relation))
FROM visibleAuthors) AS authors,
(SELECT json_group_array(json_object('mappingId', visibleResults.mappingId,
'textId', visibleResults.textId,
'text', visibleResults.text,
'tableId', visibleResults.tableId,
'setId', visibleResults.setId,
'authorId', visibleResults.authorId,
'updated', visibleResults.updated))
FROM visibleResults) AS mappings;`,
parameters: {
'setSnowflake': {
validator: nullable(discordSnowflake),
index: 1
}
},
output: guaranteedSingleton(jsonParser<{
sets: { id: number, name: string | null, description: string | null, global: boolean }[],
authors: { id: number, name: string, url: string | null, relation: string }[],
mappings: {
mappingId: number,
textId: number,
text: string,
tableId: number,
setId: number,
authorId: number,
updated: number
}[],
}>(['sets', 'authors', 'mappings']))
}
} as const satisfies QueryDefinitions);

@ -0,0 +1,138 @@
export type QueryDefinition<T extends string> = {
readonly parameters: {
readonly [key in T]: { readonly index: number, readonly validator: (value: undefined) => string | number | null }
}
readonly query: string
readonly output: (result: D1Result<object>) => unknown
}
export type QueryParameters<DefinitionT extends QueryDefinition<any>> = DefinitionT extends QueryDefinition<infer ParametersT> ? {
readonly [key in ParametersT]: Exclude<Parameters<DefinitionT['parameters'][key]['validator']>[0], undefined>
} : never
export type BoundQuery<ResultT> = {
readonly statement: D1PreparedStatement,
readonly transformer: (result: D1Result<object>) => ResultT
}
export type QueryOutput<DefinitionT extends QueryDefinition<any>> = ReturnType<DefinitionT["output"]>
export type PreparedQuery<DefinitionT extends QueryDefinition<any>> = (values: QueryParameters<DefinitionT>) => BoundQuery<QueryOutput<DefinitionT>>
export type QueryDefinitions = { readonly [key: string]: QueryDefinition<any> }
export type PreparedQueries<T extends QueryDefinitions> = { readonly [key in keyof T]: PreparedQuery<T[key]> }
const QUERY_PARAM_HEURISTIC = /\?(\d+)/g;
function parameterIndexes(parameters: QueryDefinition<any>["parameters"]): Set<number> {
let result = new Set<number>();
for (const key of Object.keys(parameters)) {
const value = parameters[key].index;
if (result.has(value)) {
throw Error(`found duplicate index ${value}`)
}
result.add(value)
}
return result;
}
function queryBindingIndexes(query: string): Set<number> {
const result = new Set<number>()
for (const binding of query.matchAll(QUERY_PARAM_HEURISTIC)) {
result.add(parseInt(binding[1]))
}
return result
}
export function validatedDefinition<T extends QueryDefinition<any>>(definition: T): T {
const queryBindings = queryBindingIndexes(definition.query);
const parameters = parameterIndexes(definition.parameters)
const missing = Array.from(queryBindings).filter(v => !parameters.has(v))
const extra = Array.from(parameters).filter(v => !queryBindings.has(v))
if (missing.length + extra.length > 0) {
if (missing.length > 0) {
if (extra.length > 0) {
throw Error(`missing definitions for ${missing.map(v => `?${v}`).join(', ')} and don't need definitions for ${extra.map(v => `?${v}`).join(', ')}`)
} else {
throw Error(`missing definitions for ${missing.map(v => `?${v}`).join(', ')} `)
}
} else {
throw Error(`don't need definitions for ${extra.map(v => `?${v}`).join(', ')}`)
}
}
return definition;
}
export function validatedDefinitions<T extends QueryDefinitions>(definitions: T): T {
for (const key of Object.keys(definitions) as (keyof T & string)[]) {
try {
validatedDefinition(definitions[key]);
} catch (e) {
throw Error(`when validating definition for ${key}: ${e}`)
}
}
return definitions;
}
export function prepareQuery<T extends QueryDefinition<any>>(database: D1Database, definition: T): PreparedQuery<T> {
const preparedStatement = database.prepare(definition.query);
return function(values: QueryParameters<T>) {
const bindings: unknown[] = new Array(Array.from(parameterIndexes(definition.parameters)).reduce((a, b) => Math.max(a, b), 0));
for (const key of Object.keys(definition.parameters)) {
bindings[definition.parameters[key].index - 1] = definition.parameters[key].validator(values[key]);
}
return {
statement: preparedStatement.bind(...bindings),
transformer: definition.output
};
} as PreparedQuery<T>;
}
export function prepareAllQueries<T extends QueryDefinitions>(database: D1Database, q: T): PreparedQueries<T> {
const result: Partial<PreparedQueries<T>> = {};
for (const key of Object.keys(q) as (keyof T & string)[]) {
try {
result[key] = prepareQuery(database, q[key])
} catch (e) {
throw Error(`when preparing ${key}: ${e}`)
}
}
return result as PreparedQueries<T>;
}
export async function runQuery<T>(db: D1Database, query: BoundQuery<T>): Promise<T> {
const startAt = performance.now()
const [results] = await db.batch([query.statement]);
const endAt = performance.now()
console.info(`DB query time: ${endAt - startAt} / Runtime: ${results.meta.duration} / Rows read: ${results.meta.rows_read} / Rows written: ${results.meta.rows_written}`)
return query.transformer(results as D1Result<object>);
}
export async function batchQueries<T extends [...unknown[]]>(db: D1Database, queries: { readonly [K in keyof T]: BoundQuery<T[K]> }): Promise<T> {
const startAt = performance.now()
const results = await db.batch(queries.map(q => q.statement));
const endAt = performance.now()
console.info(`DB transaction time: ${endAt - startAt} / Runtime: ${results.map(r => `${r.meta.duration ?? 0}`).join('+')} = ${results.reduce((t, r) => t + (r.meta.duration ?? 0), 0)} / Rows read: ${results.map(r => `${r.meta.rows_read ?? 0}`).join('+')} = ${results.reduce((t, r) => t + (r.meta.rows_read ?? 0), 0)} / Rows written: ${results.map(r => `${r.meta.rows_written ?? 0}`).join('+')} = ${results.reduce((t, r) => t + (r.meta.rows_written ?? 0), 0)}`)
return results.map((result, index) => queries[index].transformer(result as D1Result<object>)) as T;
}
export class TypedDBWrapper {
private readonly db: D1Database;
constructor(db: D1Database) {
this.db = db;
}
prepare<T extends QueryDefinition<any>>(query: T): PreparedQuery<T> {
return prepareQuery(this.db, query);
}
prepareAll<T extends QueryDefinitions>(queries: T): PreparedQueries<T> {
return prepareAllQueries(this.db, queries);
}
async run<T>(query: BoundQuery<T>): Promise<T> {
return runQuery(this.db, query);
}
async batch<T extends [...unknown[]]>(...queries: { readonly [K in keyof T]: BoundQuery<T[K]>}): Promise<T> {
return batchQueries<T>(this.db, queries);
}
}
// TODO: Use the new run and batch functions to fix the Database class's methods

@ -0,0 +1,66 @@
export function jsonParser<OutputT extends object = object, KeysT extends keyof OutputT = keyof OutputT, InputT extends { readonly [key in KeysT]: string } = { readonly [key in KeysT]: string }>(keys: readonly KeysT[]): (value: InputT) => Pick<OutputT, KeysT> {
const keysCopy = keys.slice()
return (value) => {
const result: Partial<Pick<OutputT, KeysT>> = {}
for (const key of keysCopy) {
result[key] = JSON.parse(value[key])
}
return result as Pick<OutputT, KeysT>
}
}
export function extract<ValueT, KeyT extends string = string, ObjectT extends {[value in KeyT]: ValueT} = {[value in KeyT]: ValueT}>(key: KeyT): (value: ObjectT) => ObjectT[KeyT] {
return (value) => value[key]
}
export function rows<OutputT, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT[] {
if (transformer) {
return (result) => (result.results as InputT[]).map(transformer)
} else {
return (result) => result.results as OutputT[];
}
}
export function guaranteedSingleton<OutputT, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT {
const inner = singleton<OutputT, InputT>(transformer)
return (result) => {
const out = inner(result)
if (out === null) {
throw Error('expected exactly one result but got none')
}
return out
}
}
export function singleton<OutputT, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT | null {
if (transformer) {
return (result) => {
if (result.results.length > 1) {
throw Error(`expected single result but got ${result.results.length}`);
}
const resultRow = result.results[0] as InputT|undefined
return resultRow ? transformer(resultRow) : null;
};
} else {
return (result) => {
if (result.results.length > 1) {
throw Error(`expected single result but got ${result.results.length}`);
}
const resultRow = result.results[0] as OutputT|undefined
return resultRow ?? null;
};
}
}
export function nothing(): (result: D1Result) => void {
return (result) => {
if (result.results.length > 0) {
throw Error(`expected no results but got ${result.results.length}`);
}
};
}
export function writeCount(): (result: D1Result) => number {
return (result) => result.meta.rows_written;
}

@ -0,0 +1,112 @@
import { collapseWhiteSpace } from 'collapse-white-space';
import { isSnowflake, type Snowflake } from 'discord-snowflake';
const VALID_URL_PATTERN = new URLPattern({
protocol: '(http(?:s)?|mailto)'
});
export function typeOf(data: unknown): string {
if (data === null) {
return 'null'
}
if (Array.isArray(data)) {
return 'array'
}
return typeof data
}
export function string(data: string|undefined, trim?: boolean): string {
if (typeof data !== 'string') {
throw Error(`expected string, but was ${typeOf(data)}`)
}
return collapseWhiteSpace(data, {
style: 'js',
trim: trim ?? true,
preserveLineEndings: false
})
}
export function discordSnowflake(data: string|Snowflake|undefined): Snowflake {
const text = string(data)
if (!isSnowflake(text)) {
throw Error(`expected Discord snowflake, but was ${typeOf(data)}`)
}
return text
}
export function substring(data: string|undefined): string {
const text = string(data, false)
if (text.length === 0) {
return '%';
}
return '%' + text.replaceAll('\\', '\\\\')
.replaceAll('_', '\\_')
.replaceAll('%', '\\%') + '%';
}
export function tableIdentifierOrId(data: string|undefined): string
export function tableIdentifierOrId(data: string|number|undefined): string|number
export function tableIdentifierOrId(data: string|number|undefined): string|number {
if (typeof data === 'number') {
return integer(data)
} else {
return string(data).toLowerCase()
}
}
export function tableIdentifierSubstring(data: string|undefined): string {
return substring(tableIdentifierOrId(data))
}
export function URL(data: string|undefined): string {
const url = string(data)
if (!VALID_URL_PATTERN.test(url)) {
throw Error('url must be a valid HTTP, HTTPS, or MAILTO URL');
}
return url
}
export function boolean(data: boolean|undefined): number {
if (typeof data !== 'boolean') {
throw Error(`expected boolean but was ${typeof(data)}`)
}
return data ? 1 : 0
}
export function integer(data: number|undefined): number {
const num = number(data)
if (!Number.isInteger(num)) {
throw Error(`expected integer but was ${data}`)
}
return num
}
export function number(data: number|undefined): number {
if (typeof data !== 'number') {
throw Error(`expected number but was ${typeof(data)}`)
}
return data
}
export function timestamp(data: number|undefined): number {
return integer(data)
}
export function jsonObject(data: object|undefined): string {
if (typeof data !== 'object' || typeOf(data) !== 'object') {
throw Error(`expected object but was ${typeof(data)}`)
}
return JSON.stringify(data)
}
export function jsonArray(data: (readonly unknown[])|undefined): string {
if (!Array.isArray(data)) {
throw Error(`expected object but was ${typeof(data)}`)
}
return JSON.stringify(data)
}
export function nullable<T extends (value: undefined) => string|number>(transformer: T): (value: Parameters<T>[0]|null) => ReturnType<T>|null {
return (value: Parameters<T>[0]|null): ReturnType<T>|null => value === null ? null : transformer(value) as ReturnType<T>
}

@ -0,0 +1,588 @@
import {
type ApplicationCommandOptionLimitedString,
type AutocompleteChoice,
AutocompleteContext,
CommandContext,
CommandOptionType,
ComponentContext,
SlashCommand,
type SlashCreator
} from 'slash-create/web';
import { type Database } from '../db/database';
import { type Snowflake } from 'discord-snowflake';
import {
DELETE_ID,
DONE_ID, FAILURE_COLOR,
generateAuthorForResult, generateEmbedForResult,
generateErrorMessageFor,
generateFieldForResult,
generateFooterForResult,
generateMessageFor,
getEmbedFrom,
loadEmbed, recordError,
REROLL_ID,
SELECT_ID, SUCCESS_COLOR, WARNING_COLOR
} from './embed';
import {
generatedContentsToString, generatedStateToString,
MAX_IDENTIFIER_LENGTH,
MAX_NAME_LENGTH,
MAX_RESULT_LENGTH,
MAX_URL_LENGTH,
type RollTableAuthor
} from '../../common/rolltable';
import markdownEscape from 'markdown-escape';
const tableOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = {
type: CommandOptionType.STRING,
autocomplete: true,
max_length: MAX_IDENTIFIER_LENGTH
};
const resultOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = {
type: CommandOptionType.STRING,
max_length: MAX_RESULT_LENGTH
};
export class AuthorCommand extends SlashCommand {
private readonly db: Database;
constructor(creator: SlashCreator, db: Database, forGuilds?: Snowflake | Snowflake[]) {
super(creator, {
name: 'author',
description: 'Modifies the attribution of responses you contribute to the generator.',
nsfw: false,
guildIDs: forGuilds,
dmPermission: true,
options: [
{
type: CommandOptionType.SUB_COMMAND,
name: 'show',
description: 'Shows the attribution currently associated with your contributed responses.'
},
{
type: CommandOptionType.SUB_COMMAND,
name: 'set',
description: 'Sets your contributed responses to be associated with a name and optional URL.',
options: [
{
name: 'name',
description: 'The name to associate with the responses you create.',
required: true,
type: CommandOptionType.STRING,
max_length: MAX_NAME_LENGTH
},
{
name: 'url',
description: 'The URL to associate with your name on the responses you create.',
type: CommandOptionType.STRING,
max_length: MAX_URL_LENGTH
}
]
},
{
type: CommandOptionType.SUB_COMMAND,
name: 'anonymous',
description: 'Sets your contributed responses to be anonymous again.'
}
]
});
this.db = db;
}
async run(ctx: CommandContext): Promise<void> {
let author: RollTableAuthor | null;
switch (ctx.subcommands[0]) {
case 'show':
try {
author = await this.onShow(ctx);
} catch (error) {
await ctx.send(generateErrorMessageFor({ error, context: 'get your current authorship' }));
return;
}
break;
case 'set':
try {
author = await this.onSet(ctx);
} catch (error) {
await ctx.send(generateErrorMessageFor({ error, context: 'set your authorship' }));
return;
}
break;
case 'anonymous':
try {
author = await this.onAnonymous(ctx);
} catch (error) {
await ctx.send(generateErrorMessageFor({ error, context: 'reset your authorship to anonymous' }));
return;
}
break;
default:
await ctx.send(generateErrorMessageFor({
error: Error('I don\'t know what command you want'),
context: 'manage authorship'
}));
return;
}
if (author) {
await ctx.send({
embeds: [{
title: 'Your responses are credited as...',
description: `${markdownEscape(author.relation)} ${author.url ? '[' : ''}${markdownEscape(author.name)}${author.url ? '](' : ''}${markdownEscape(author.url ?? '')}${author.url ? ')' : ''}`,
color: SUCCESS_COLOR
}],
ephemeral: true
});
} else {
await ctx.send({
embeds: [{
title: 'Your responses are credited as...',
description: 'Hey, wait, _what_ responses? I don\'t know anything about you because you haven\'t done anything yet. Come back here when you\'ve contributed a response with /response add or /response edit or used /author set or /author anonymous to tell me about yourself.',
color: FAILURE_COLOR
}],
ephemeral: true
});
}
}
private async onShow(ctx: CommandContext): Promise<RollTableAuthor | null> {
return await this.db.getDiscordAuthor(ctx.user.id);
}
private async onSet(ctx: CommandContext): Promise<RollTableAuthor | null> {
return await this.db.setDiscordAuthor(ctx.user.id, ctx.user.username, ctx.options['set']['name'], ctx.options['set']['url']);
}
private async onAnonymous(ctx: CommandContext): Promise<RollTableAuthor | null> {
return await this.db.setDiscordAuthor(ctx.user.id, ctx.user.username, null, null);
}
}
export class ResponseCommand extends SlashCommand {
private readonly db: Database;
private readonly baseUrl: string;
constructor(creator: SlashCreator, db: Database, baseUrl: string, forGuilds?: Snowflake | Snowflake[]) {
super(creator, {
name: 'response',
description: 'Modifies the responses available in the generator.',
nsfw: false,
guildIDs: forGuilds,
dmPermission: true,
options: [
{
type: CommandOptionType.SUB_COMMAND,
name: 'list',
description: 'Provides a link to the list of responses that will appear in /generate in the current context.'
},
{
type: CommandOptionType.SUB_COMMAND,
name: 'show',
description: 'Shows details about a response that was previously created.',
options: [
{
...tableOption,
name: 'table',
description: 'The table to show the response from.',
required: true
},
{
...resultOption,
name: 'text',
description: 'The text of the response to show.',
autocomplete: true,
required: true
}
]
},
{
type: CommandOptionType.SUB_COMMAND,
name: 'add',
description: 'Adds a new response to the generator.',
options: [
{
...tableOption,
name: 'table',
description: 'The table to insert the response into.',
required: true
},
{
...resultOption,
name: 'text',
description: 'The text to use as the response.',
required: true
}
]
},
{
type: CommandOptionType.SUB_COMMAND,
name: 'edit',
description: 'Modifies a response that was previously created.',
options: [
{
...tableOption,
name: 'table',
description: 'The table to update the response from.',
required: true
},
{
...resultOption,
name: 'old_text',
description: 'The text of the response to edit.',
autocomplete: true,
required: true
},
{
...resultOption,
name: 'new_text',
description: 'The text to replace the response with.',
required: true
}
]
},
{
type: CommandOptionType.SUB_COMMAND,
name: 'delete',
description: 'Deletes a response that was previously created.',
options: [
{
...tableOption,
name: 'table',
description: 'The table to delete the response from.',
required: true
},
{
...resultOption,
name: 'text',
description: 'The text of the response to delete.',
autocomplete: true,
required: true
}
]
}
]
});
this.baseUrl = baseUrl;
this.db = db;
}
async autocompleteTable(tableName: string): Promise<AutocompleteChoice[]> {
const results = await this.db.autocompleteTable(tableName);
return results.map(({ name, identifier, emoji }) => ({
name: `${emoji} ${name}`,
value: identifier
}));
}
async autocompleteResultText(setSnowflake: string, tableIdentifier: string, partialText: string, includeGlobal: boolean): Promise<AutocompleteChoice[]> {
const results = await this.db.autocompleteText(setSnowflake, tableIdentifier, partialText, includeGlobal);
return results.map(({ text }) => ({
name: text,
value: text
}));
}
async autocomplete(ctx: AutocompleteContext): Promise<void> {
try {
const subcommand = ctx.subcommands[0];
switch (subcommand) {
case 'add':
switch (ctx.focused) {
case 'table':
await ctx.sendResults(await this.autocompleteTable(ctx.options['add']['table']));
return;
}
break;
case 'edit':
switch (ctx.focused) {
case 'table':
await ctx.sendResults(await this.autocompleteTable(ctx.options['edit']['table']));
return;
case 'old_text':
await ctx.sendResults(await this.autocompleteResultText(
ctx.guildID ?? ctx.user.id, ctx.options['edit']['table'], ctx.options['edit']['old_text'], false));
return;
}
break;
case 'delete':
switch (ctx.focused) {
case 'table':
await ctx.sendResults(await this.autocompleteTable(ctx.options['delete']['table']));
return;
case 'text':
await ctx.sendResults(await this.autocompleteResultText(
ctx.guildID ?? ctx.user.id, ctx.options['delete']['table'], ctx.options['delete']['text'], false));
return;
}
break;
case 'show':
switch (ctx.focused) {
case 'table':
await ctx.sendResults(await this.autocompleteTable(ctx.options['show']['table']));
return;
case 'text':
await ctx.sendResults(await this.autocompleteResultText(
ctx.guildID ?? ctx.user.id, ctx.options['show']['table'], ctx.options['show']['text'], true));
return;
}
break;
}
await ctx.sendResults([]);
} catch (e) {
recordError({
context: 'trying to autocomplete response commands',
error: e
})
await ctx.sendResults([]);
}
}
async run(ctx: CommandContext): Promise<void> {
switch (ctx.subcommands[0]) {
case 'list':
try {
await this.onList(ctx);
} catch (error) {
await ctx.send(generateErrorMessageFor({ error, context: 'get the list URL' }));
}
break;
case 'show':
try {
await this.onShow(ctx);
} catch (error) {
await ctx.send(generateErrorMessageFor({ error, context: 'show that response' }));
}
break;
case 'add':
try {
await this.onAdd(ctx);
} catch (error) {
await ctx.send(generateErrorMessageFor({ error, context: 'add that new response' }));
}
break;
case 'edit':
try {
await this.onEdit(ctx);
} catch (error) {
await ctx.send(generateErrorMessageFor({ error, context: 'edit that response' }));
}
break;
case 'delete':
try {
await this.onDelete(ctx);
} catch (error) {
await ctx.send(generateErrorMessageFor({ error, context: 'delete that response' }));
}
break;
default:
await ctx.send(generateErrorMessageFor({
error: Error('I don\'t know what command you want'),
context: 'manage responses'
}));
break;
}
}
private async onList(ctx: CommandContext) {
if (ctx.guildID) {
await ctx.send({
embeds: [{
color: SUCCESS_COLOR,
title: `Response list for this server`,
description: 'Shows all global and server-local responses.',
url: `${this.baseUrl}/responses?server=${ctx.guildID}`
}]
});
} else {
await ctx.send({
embeds: [{
color: FAILURE_COLOR,
title: `Response list for DMs`,
description: 'This is not supported right now, so please just hang tight.'
}]
});
}
}
private async onShow(ctx: CommandContext) {
const setId = ctx.guildID ?? ctx.user.id;
const table = ctx.options['show']['table'];
const text = ctx.options['show']['text'];
const result = await this.db.getResponseFromDiscord(table, text, setId);
switch (result.status) {
case 'nonexistent':
await ctx.send(generateErrorMessageFor({
error: `couldn't find a response with that text`,
context: `show the response with the text ${text} from the ${table} table`
}));
break;
case 'existent':
await ctx.send({
embeds: [generateEmbedForResult('Your requested response', SUCCESS_COLOR, result)]
});
break;
}
}
private async onAdd(ctx: CommandContext): Promise<void> {
const userId = ctx.user.id;
const setId = ctx.guildID ?? userId;
const timestamp = ctx.invokedAt;
const table = ctx.options['add']['table'] as string | number;
const text = ctx.options['add']['text'];
const result =
await this.db.addResponseFromDiscord(timestamp, table, text, userId, ctx.user.username, setId);
await ctx.send({
embeds: [generateEmbedForResult(`${result.status === 'added' ? 'Your new' : 'An existing'} response`, result.status === 'added' ? SUCCESS_COLOR : WARNING_COLOR, result)],
ephemeral: result.status === 'existed'
});
}
private async onEdit(ctx: CommandContext): Promise<void> {
const userId = ctx.user.id;
const setId = ctx.guildID ?? userId;
const timestamp = ctx.invokedAt;
const table = ctx.options['edit']['table'];
const oldText = ctx.options['edit']['old_text'];
const newText = ctx.options['edit']['new_text'];
const result = await this.db.editResponseFromDiscord(timestamp, table, oldText, newText, userId, ctx.user.username, setId);
switch (result.status) {
case 'nonexistent':
await ctx.send(generateErrorMessageFor({
error: `couldn't find a response with that text`,
context: `alter the response with the text ${oldText} from the ${table} table`
}));
break;
case 'noneditable':
await ctx.send({
embeds: [generateEmbedForResult('A non-editable response (unchanged)', FAILURE_COLOR, result.old)],
ephemeral: true
});
break;
case 'conflict':
await ctx.send({
embeds: [generateEmbedForResult('The old response (still existing)', WARNING_COLOR, result.old), generateEmbedForResult('A conflicting response', FAILURE_COLOR, result.new)],
ephemeral: true
});
break;
case 'updated':
await ctx.send({
embeds: [generateEmbedForResult('The old response (now gone)', SUCCESS_COLOR, result.old), generateEmbedForResult('Your updated response', SUCCESS_COLOR, result.new)]
});
break;
}
}
private async onDelete(ctx: CommandContext): Promise<void> {
const setId = ctx.guildID ?? ctx.user.id;
const table = ctx.options['delete']['table'];
const text = ctx.options['delete']['text'];
const result = await this.db.deleteResponseFromDiscord(table, text, setId);
switch (result.status) {
case 'nonexistent':
await ctx.send(generateErrorMessageFor({
error: `couldn't find a response with that text`,
context: `remove the response with the text ${text} from the ${table} table`
}));
break;
case 'noneditable':
await ctx.send({
embeds: [generateEmbedForResult(
`A non-editable response (still existing)`,
FAILURE_COLOR,
result.old)],
ephemeral: true
});
break;
case 'deleted':
await ctx.send({
embeds: [generateEmbedForResult(
`The response you deleted`,
SUCCESS_COLOR,
result.old)]
});
break;
}
}
}
export class GenerateCommand extends SlashCommand {
private readonly db: Database;
constructor(creator: SlashCreator, db: Database, forGuilds?: Snowflake | Snowflake[]) {
super(creator, {
name: 'generate',
description: 'Generates a new scenario to play with and sends it to the current channel.',
nsfw: false,
dmPermission: true,
guildIDs: forGuilds,
throttling: {
duration: 5,
usages: 1
}
});
this.db = db;
if (!forGuilds) {
creator.registerGlobalComponent(DONE_ID, this.onDone.bind(this));
creator.registerGlobalComponent(REROLL_ID, this.onReroll.bind(this));
creator.registerGlobalComponent(SELECT_ID, this.onSelect.bind(this));
creator.registerGlobalComponent(DELETE_ID, this.onDelete.bind(this));
}
}
async run(ctx: CommandContext): Promise<void> {
try {
const state = await this.db.generateFromDiscordSet(ctx.guildID ?? ctx.user.id);
await ctx.send(generateMessageFor(state));
} catch (error) {
await ctx.send(generateErrorMessageFor({error, context: 'generate a scenario'}));
}
}
async onSelect(ctx: ComponentContext): Promise<void> {
try {
const oldEmbed = getEmbedFrom(ctx.message);
const oldContents = loadEmbed(oldEmbed, false);
const newContents = {
...oldContents,
selected: new Set(ctx.values)
};
const final = await this.db.expandFromDiscordSet(ctx.guildID ?? ctx.user.id, newContents);
await ctx.editParent(generateMessageFor(final));
} catch (error) {
await ctx.send(generateErrorMessageFor({error, context: 'change the selected components'}));
}
}
async onDone(ctx: ComponentContext): Promise<void> {
try {
const embed = getEmbedFrom(ctx.message);
const finalContents = loadEmbed(embed, false);
const finalState = await this.db.finalizeFromDiscordSet(ctx.guildID ?? ctx.user.id, finalContents);
await ctx.editParent(generateMessageFor(finalState));
} catch (error) {
await ctx.send(generateErrorMessageFor({error, context: 'finish this scenario'}));
}
}
async onReroll(ctx: ComponentContext): Promise<void> {
try {
const embed = getEmbedFrom(ctx.message);
const oldContents = loadEmbed(embed, false);
const nextState = await this.db.rerollFromDiscordSet(ctx.guildID ?? ctx.user.id, oldContents);
await ctx.editParent(generateMessageFor(nextState));
} catch (error) {
await ctx.send(generateErrorMessageFor({error, context: 'reroll this scenario'}));
}
}
async onDelete(ctx: ComponentContext): Promise<void> {
try {
await ctx.delete(ctx.messageID);
} catch (error) {
await ctx.send(generateErrorMessageFor({error, context: 'delete this scenario'}));
}
}
}

@ -0,0 +1,209 @@
import type { GeneratedContents, RollTableResult } from '../../common/rolltable';
import {
type FinalGeneratedContents,
type GeneratedState,
type InProgressGeneratedContents,
type RollTableResultFull
} from '../../common/rolltable';
import {
ButtonStyle,
type ComponentActionRow,
type ComponentButton,
type ComponentSelectMenu,
type ComponentSelectOption,
ComponentType,
type EmbedAuthorOptions,
EmbedField,
MessageEmbed,
type MessageEmbedOptions,
type MessageOptions
} from 'slash-create/web';
import markdownEscape from 'markdown-escape';
import type { EmbedFooterOptions } from 'slash-create/web';
export const SCENARIO_COLOR = 0x15A3C7;
export const SUCCESS_COLOR = 0x79AC78;
export const WARNING_COLOR = 0xF8ED62;
export const FAILURE_COLOR = 0xA70000;
export const LOCK_SUFFIX = ' \u{1f512}';
export const UNLOCK_SUFFIX = ' \u{1f513}';
export const ROLL_SUFFIX = ' \u{1f3b2}'
const suffixes = [[LOCK_SUFFIX, false], [UNLOCK_SUFFIX, true], [ROLL_SUFFIX, true]] as const
export function generateAuthorForResult(result: RollTableResultFull): EmbedAuthorOptions|undefined {
return result.author ? {
name: `${result.author.relation} ${result.author.name}`,
url: result.author.url ?? undefined
} : undefined;
}
export function generateFooterForResult(result: RollTableResultFull): EmbedFooterOptions {
return {
text: `in ${result.set.name ? 'the' : 'a'} ${result.set.global ? 'global' : 'server-local'} response set${result.set.name ? ' ' + markdownEscape(result.set.name) : ''}`
};
}
export function generateFieldForResult(value: RollTableResult, selected?: boolean): EmbedField {
let name = markdownEscape(`${value.table.header}${typeof selected === 'boolean' ? selected ? ROLL_SUFFIX : LOCK_SUFFIX : ''}`);
return {
name: name,
value: markdownEscape(value.text),
};
}
export function generateEmbedForResult(title: string, color: number, value: RollTableResultFull): MessageEmbedOptions {
return {
title,
color,
author: generateAuthorForResult(value),
fields: [generateFieldForResult(value)],
timestamp: value.updated,
footer: generateFooterForResult(value),
}
}
export function generateEmbedForScenario(color: number, state: GeneratedState): MessageEmbedOptions {
const fields: EmbedField[] = [];
for (const value of state.rolled.values()) {
fields.push(generateFieldForResult(value, state.final || !value.table.full ? undefined : state.selected.has(value.table)));
}
return {
title: 'Your generated scenario',
color,
fields,
timestamp: new Date()
};
}
export function getEmbedFrom({ embeds }: { embeds?: MessageEmbed[] }): MessageEmbed {
const result = embeds && embeds.length >= 1 ? embeds[0] : null;
if (!result) {
throw Error('there were no embeds on the message to read');
}
return result;
}
export function loadEmbed(embed: MessageEmbed, final: false): InProgressGeneratedContents
export function loadEmbed(embed: MessageEmbed, final: true): FinalGeneratedContents
export function loadEmbed(embed: MessageEmbed, final: boolean): GeneratedContents {
const rolled = new Map<string, string>()
const selection = new Set<string>()
if (!embed.fields) {
throw Error('there were no fields on the embed to read');
}
for (const field of embed.fields) {
let suffixInfo: readonly [string, boolean]|null = null
for (const potentialSuffixInfo of suffixes) {
if ((!suffixInfo || (potentialSuffixInfo[0].length > suffixInfo[0].length)) && field.name.endsWith(potentialSuffixInfo[0])) {
suffixInfo = potentialSuffixInfo
}
}
if (suffixInfo) {
const [suffix, selected] = suffixInfo
const name = field.name.substring(0, suffix ? field.name.length - suffix.length : undefined)
rolled.set(name, field.value)
if (selected) {
selection.add(name)
}
} else {
rolled.set(field.name, field.value)
}
}
if (final) {
return {
final,
rolled,
}
} else {
return {
final: false,
rolled,
selected: selection,
}
}
}
export const SELECT_ID = 'selected';
export const REROLL_ID = 'reroll';
export const DONE_ID = 'done';
export const DELETE_ID = 'delete';
export function generateActionsFor(state: GeneratedState): ComponentActionRow[] {
if (state.final) {
return [];
}
const selectOptions: ComponentSelectOption[] = Array.from(state.rolled.keys()).flatMap((v) => (v.full ? [{
default: state.selected.has(v),
value: v.identifier,
label: v.name,
emoji: { name: v.emoji }
}] : []));
if (selectOptions.length === 0) {
return [];
}
const select: ComponentSelectMenu = {
type: ComponentType.STRING_SELECT,
custom_id: SELECT_ID,
disabled: false,
max_values: selectOptions.length,
min_values: 0,
options: selectOptions,
placeholder: 'Components to reroll'
};
const selectRow: ComponentActionRow = { type: ComponentType.ACTION_ROW, components: [select] };
const rerollButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: REROLL_ID,
disabled: state.selected.size === 0,
emoji: { name: '\u{1f3b2}' },
label: (state.selected.size === 0 ? 'Reroll' : state.selected.size === state.rolled.size ? 'Reroll ALL' : 'Reroll Selected'),
style: ButtonStyle.PRIMARY
};
const doneButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: DONE_ID,
disabled: false,
emoji: { name: '\u{1f44d}' },
label: 'Looks good!',
style: ButtonStyle.SUCCESS
};
const deleteButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: DELETE_ID,
disabled: false,
emoji: { name: '\u{1f5d1}\ufe0f' },
label: 'Trash it.',
style: ButtonStyle.DESTRUCTIVE
};
const buttonRow: ComponentActionRow = {
type: ComponentType.ACTION_ROW,
components: [rerollButton, doneButton, deleteButton]
};
return [selectRow, buttonRow];
}
export function generateMessageFor(state: GeneratedState): MessageOptions {
return { embeds: [generateEmbedForScenario(SCENARIO_COLOR, state)], components: generateActionsFor(state), ephemeral: false }
}
export function recordError<T extends {error: unknown, context?: string, extraData?: string}>(input: T): T & {message: string, stack: string} {
const {error, context, extraData} = input
const message = error instanceof Error ? error.message : `${error}`
const stack = (error instanceof Error ? error.stack : null) ?? `${error}`
console.error(`when trying to ${context ?? 'do something (unknown context)'}: ${stack}${extraData ? '\nExtra data: ' + extraData : ''}`)
return {...input, message, stack}
}
export function generateErrorMessageFor(input: {error: unknown, context?: string, title?: string, extraData?: string}): MessageOptions {
const {context, title, message} = recordError(input)
return {
embeds: [{
title: title ?? 'Error',
description: `I wasn't able to ${markdownEscape(context ?? 'do that')}. Thing is, ${markdownEscape(message)}...`,
color: FAILURE_COLOR,
}],
ephemeral: true
};
}

@ -0,0 +1,101 @@
import { Database } from '../db/database';
import { CloudflareWorkerServer, SlashCreator } from 'slash-create/web';
import { isSnowflake, type Snowflake } from 'discord-snowflake';
import { AuthorCommand, GenerateCommand, ResponseCommand } from './commands';
import { type IRequestStrict, Router } from 'itty-router';
import { getQueryArray } from '../request/query';
function getAuthorization(username: string, password: string): string {
return btoa(username + ':' + password);
}
async function getToken(env: Pick<DiscordEnv, 'DISCORD_APP_ID' | 'DISCORD_APP_SECRET'>) {
const tokenRequest = new Request(`https://discord.com/api/v10/oauth2/token`, {
headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${getAuthorization(env.DISCORD_APP_ID, env.DISCORD_APP_SECRET)}`
}),
body: new URLSearchParams({ 'grant_type': 'client_credentials', 'scope': 'applications.commands.update' }),
method: 'POST'
});
const tokenResponse = await fetch(tokenRequest);
if (tokenResponse.status !== 200) {
const text = await tokenResponse.text();
console.error(`Failed getting token`, text);
throw Error(text);
}
const json = await tokenResponse.json() as { access_token: string };
return 'Bearer ' + json.access_token;
}
export interface DiscordEnv {
readonly BASE_URL: string;
readonly DISCORD_APP_ID: string;
readonly DISCORD_APP_SECRET: string;
readonly DISCORD_PUBLIC_KEY: string;
readonly DISCORD_DEV_GUILD_IDS?: string;
}
interface SlashCreatorContext {
cfServer: CloudflareWorkerServer;
slashCreator: SlashCreator;
}
async function getSlashCreator(
{ DISCORD_APP_ID, DISCORD_APP_SECRET, DISCORD_PUBLIC_KEY, DISCORD_DEV_GUILD_IDS, BASE_URL }: DiscordEnv,
db: Database
): Promise<SlashCreatorContext> {
if (DISCORD_APP_ID === "" || DISCORD_APP_SECRET === "" || DISCORD_PUBLIC_KEY === "") {
throw Error("Discord is not configured on this build")
}
const server = new CloudflareWorkerServer();
const creator = new SlashCreator({
allowedMentions: { everyone: false, roles: false, users: false },
applicationID: DISCORD_APP_ID,
componentTimeouts: true,
defaultImageSize: 0,
disableTimeouts: false,
handleCommandsManually: false,
publicKey: DISCORD_PUBLIC_KEY,
unknownCommandResponse: true,
token: await getToken({ DISCORD_APP_ID, DISCORD_APP_SECRET })
});
const withGuilds: Snowflake[] = DISCORD_DEV_GUILD_IDS ? DISCORD_DEV_GUILD_IDS.split(',').flatMap(v => isSnowflake(v) ? [v] : []) : [];
creator.withServer(server);
creator.registerCommand(new GenerateCommand(creator, db));
creator.registerCommand(new AuthorCommand(creator, db));
creator.registerCommand(new ResponseCommand(creator, db, BASE_URL));
creator.registerCommand(new GenerateCommand(creator, db, withGuilds));
creator.registerCommand(new AuthorCommand(creator, db, withGuilds));
creator.registerCommand(new ResponseCommand(creator, db, BASE_URL, withGuilds));
return {
cfServer: server,
slashCreator: creator
};
}
export function discordRouter(base: string) {
const router = Router<IRequestStrict, [env: DiscordEnv, db: Database, ctx: ExecutionContext]>({ base });
router.all('/interactions', async (req, env, db, ctx) =>
(await getSlashCreator(env, db)).cfServer.fetch(req, null, ctx));
router.get('/sync', async (req, env, db, _ctx) => {
let servers = getQueryArray(req.query['server']);
const { slashCreator } = await getSlashCreator(env, db);
if (servers.length === 0) {
await slashCreator.syncCommands({
syncGuilds: true,
deleteCommands: true,
skipGuildErrors: false
});
} else {
for (const id of servers) {
await slashCreator.syncCommandsIn(id, true);
}
}
return new Response('Commands successfully synced!', {
status: 200,
statusText: 'OK'
});
});
return router;
}

@ -0,0 +1,51 @@
import { Database } from './db/database';
import { discordRouter } from './discord/router';
import { createCors, Router, IRequestStrict } from 'itty-router';
import { webRouter } from './web/router';
export interface Env {
readonly BASE_URL: string;
readonly CREDITS_URL: string;
readonly DISCORD_APP_ID: string;
readonly DISCORD_APP_SECRET: string;
readonly DISCORD_PUBLIC_KEY: string;
readonly DISCORD_DEV_GUILD_IDS: string;
readonly DB: D1Database;
}
const { preflight, corsify } = createCors();
const discord = discordRouter('/discord')
const web = webRouter('/')
const router = Router<IRequestStrict, [env: Env, db: Database, ctx: ExecutionContext]>()
.all('*', preflight)
.all('/discord/*', discord.handle.bind(discord))
.all('/*', web.handle.bind(web))
.all('*', (_req, _env, _db, _ctx) => null);
// noinspection JSUnusedGlobalSymbols
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const startTime = performance.now()
return router.handle(req, env, new Database(env.DB), ctx).then((result) => {
if (result instanceof Response) {
return result;
} else if (typeof result === 'string') {
const headers = new Headers()
headers.set("Content-Type", "text/html")
return new Response(result, { status: 200, statusText: 'OK', headers});
} else if (typeof result === 'object' || Array.isArray(result)) {
return Response.json(result, { status: 200, statusText: 'OK' });
} else {
return new Response('Not Found', { status: 404, statusText: 'Not Found' });
}
}).catch((reason) => {
return new Response(`Failed: ${reason}`, { status: 500, statusText: 'Internal Server Error' });
}).then((response) => {
return corsify(response);
}).finally(() => {
const endTime = performance.now()
console.info(`request runtime: ${endTime - startTime}`)
});
}
};

@ -0,0 +1,20 @@
export function takeLast(a: string, b: string): string {
return b
}
export function getQuerySingleton(value: string|string[]|undefined, reducer: (a: string, b: string) => string): string|undefined {
if (typeof value === 'undefined' || typeof value === 'string') {
return value
}
return value.reduce(reducer)
}
export function getQueryArray(value: string|string[]|undefined): string[] {
if (typeof value === 'undefined') {
return []
}
if (typeof value === 'string') {
return [value]
}
return value
}

@ -13,10 +13,10 @@
/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
// "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFactory": "elements.createElement", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */

@ -0,0 +1,17 @@
import type { SourceMap } from '../../../common/bundle';
export enum SourceMapExtension {
CSS = 'css',
JS = 'js',
}
export type SourceMapFilename<NameT extends string, HashT extends string, ExtensionT extends SourceMapExtension> =
`${NameT}.${HashT}.${ExtensionT}.map`
export const SourceMaps = new Map<SourceMapFilename<string, string, SourceMapExtension>, SourceMap>([
])
export function getSourceMapFileName<NameT extends string, HashT extends string, ExtensionT extends SourceMapExtension>(name: NameT, hash: HashT, extension: ExtensionT): SourceMapFilename<NameT, HashT, ExtensionT> {
return `${name}.${hash}.${extension}.map`
}

@ -0,0 +1,94 @@
import { type IRequestStrict, Router } from 'itty-router';
import type { Database } from '../db/database';
import { buildGeneratorPage, buildResponsesPage, wrapPage } from '../../common/template';
import { CSS, JS } from './bundles/client.generated';
import type { HashedBundled } from '../../common/bundle';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps';
import { collapseWhiteSpace } from 'collapse-white-space';
import { getQuerySingleton, takeLast } from '../request/query';
interface WebEnv {
readonly BASE_URL: string,
readonly CREDITS_URL: string,
readonly DISCORD_APP_ID: string
}
export function webRouter(base: string) {
function getSourceMappedJS(name: keyof typeof JS) {
const { bundled, hash }: HashedBundled = JS[name];
return bundled + `\n//# sourceMappingURL=${getSourceMapFileName(name, hash, SourceMapExtension.JS)}`;
}
function getSourceMappedCSS(name: keyof typeof CSS) {
const { bundled, hash }: HashedBundled = CSS[name];
return bundled + `\n/*# sourceMappingURL=${getSourceMapFileName(name, hash, SourceMapExtension.CSS)} */`;
}
async function handleMainPage(req: IRequestStrict, env: WebEnv, db: Database): Promise<string> {
const results = await db.getGeneratorPageForDiscordSet(
getQuerySingleton(req.query['server'], takeLast) ?? null);
const generator = buildGeneratorPage({
creditsUrl: env.CREDITS_URL,
clientId: env.DISCORD_APP_ID,
generatorTargetUrl: env.BASE_URL,
results: results.rolled,
editable: !results.final,
selected: results.selected,
includesResponses: true
})
const responses = buildResponsesPage({
tables: Array.from(results.db.tables.values()),
results: results.rolled,
creditsUrl: env.CREDITS_URL,
includesGenerator: true
})
const wrapped = wrapPage({
title: 'Vore Scenario Generator',
script: getSourceMappedJS('combinedGeneratorResponses'),
styles: getSourceMappedCSS('combinedGeneratorResponses'),
noscriptStyles: getSourceMappedCSS('noscript'),
bodyContent: [generator, responses].join('')
})
return collapseWhiteSpace(wrapped, { style: 'html' });
}
const router = Router<IRequestStrict, [env: WebEnv, db: Database, ctx: ExecutionContext]>({ base })
.get('/responses', async (req, _env, _db, _ctx) => {
const url = new URL(req.url);
url.pathname = base;
url.hash = '#responses';
return Response.redirect(url.toString(), 303);
})
.get('/generator', async (req, _env, _db, _ctx) => {
const url = new URL(req.url);
url.pathname = base;
url.hash = '#generator';
return Response.redirect(url.toString(), 303);
})
.get('/scenario', async (_req, _env, _db, _ctx) => {
// TODO: implement me
return new Response('Not yet supported', { status: 404 });
})
.get('/', handleMainPage)
.post('/', handleMainPage);
for (const key in CSS) {
if (CSS.hasOwnProperty(key)) {
const result = CSS[key as keyof typeof CSS]
if (result.sourceMap) {
router.get(`/${getSourceMapFileName(key, result.hash, SourceMapExtension.CSS)}`, () => result.sourceMap)
}
}
}
for (const key in JS) {
if (JS.hasOwnProperty(key)) {
const result = JS[key as keyof typeof JS]
if (result.sourceMap) {
router.get(`/${getSourceMapFileName(key, result.hash, SourceMapExtension.JS)}`, () => result.sourceMap)
}
}
}
for (const [filename, contents] of SourceMaps) {
router.get(`/${filename}`, () => contents);
}
return router;
}

@ -1,17 +1,104 @@
name = "vore-scenario-generator"
main = "src/index.ts"
main = "src/server/entrypoint.ts"
compatibility_date = "2023-12-18"
keep_vars = true
[vars]
# BASE_URL
# DISCORD_APP_ID
# DISCORD_APP_SECRET
# DISCORD_PUBLIC_KEY
# DISCORD_DEV_GUILD_IDS
### Development ###
workers_dev = true
[build]
command = "tsx src/build/bundle-client-with-source-map.ts"
cwd = "."
watch_dir = ["src/client", "src/common", "src/build"]
[placement]
mode = "smart"
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "dev-ncc-gen"
database_id = "cdd2a712-0aa4-4929-8731-b338fb4f03db"
migrations_table = "d1_migrations"
migrations_dir = "migrations"
[[kv_namespaces]]
binding = "KV"
id = "b25d8b0e63cb45fb8b4c78ec29fa7c02"
[vars]
BASE_URL = "https://vore-scenario-generator.reya-cloudflare.workers.dev"
DISCORD_APP_ID = ""
DISCORD_PUBLIC_KEY = ""
DISCORD_DEV_GUILD_IDS = ""
CREDITS_URL = "https://git.reya.zone/reya/vore-scenario-generator#credits"
### Staging ###
[env.staging]
workers_dev = false
[env.staging.build]
command = "tsx src/build/bundle-client-with-source-map.ts"
cwd = "."
watch_dir = ["src/client", "src/common", "src/build"]
[env.staging.placement]
mode = "smart"
[[env.staging.routes]]
pattern = "staging.scenario-generator.deliciousreya.net"
custom_domain = true
[[env.staging.d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "staging-ncc-gen"
database_id = "e0ce391d-e34c-45d2-9bc0-521970f077c5"
migrations_table = "d1_migrations"
migrations_dir = "migrations"
[[env.staging.kv_namespaces]]
binding = "KV"
id = "6bd3f3b6455e4fcaa78b7120922f8e2d"
[env.staging.vars]
BASE_URL = "https://staging.scenario-generator.deliciousreya.net"
DISCORD_APP_ID = "1194035515255689337"
DISCORD_PUBLIC_KEY = "b7e0a1c6fa4f99102d0caad4b3d878b16c285d638d16dcc78d92a699da495671"
DISCORD_DEV_GUILD_IDS = "771270287483994123"
CREDITS_URL = "https://git.reya.zone/reya/vore-scenario-generator#credits"
### Production ###
[env.production]
workers_dev = false
[env.production.build]
command = "tsx src/build/check-source-map-and-bundle-client.ts"
cwd = "."
watch_dir = ["src/client", "src/common", "src/build"]
[env.production.placement]
mode = "smart"
[[env.production.routes]]
pattern = "scenario-generator.deliciousreya.net"
custom_domain = true
[[env.production.d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "production-ncc-gen"
database_id = "d09d3c74-c75f-4418-8f1b-2fe7f21637e6"
migrations_table = "d1_migrations"
migrations_dir = "migrations"
[[env.production.kv_namespaces]]
binding = "KV"
id = "5c4a6e5a848c4a029f885e12c56d862f"
[env.production.vars]
BASE_URL = "https://scenario-generator.deliciousreya.net"
DISCORD_APP_ID = "1192326191189864458"
DISCORD_PUBLIC_KEY = "1e064fcb320647e9a72b3a97657ba820e1929d6b32ae3429303fedb0faac6551"
DISCORD_DEV_GUILD_IDS = ""
CREDITS_URL = "https://git.reya.zone/reya/vore-scenario-generator#credits"

Loading…
Cancel
Save