Compare commits
2 Commits
df17f6f5c3
...
4256d25c7d
Author | SHA1 | Date |
---|---|---|
Mari | 4256d25c7d | 3 months ago |
Mari | ed7d67e746 | 3 months ago |
@ -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> |
@ -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. |
@ -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; |
File diff suppressed because it is too large
Load Diff
@ -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); |
File diff suppressed because it is too large
Load Diff
@ -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,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 |
||||
} |
@ -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…
Reference in new issue