parent
df17f6f5c3
commit
ed7d67e746
@ -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,65 @@ |
|||||||
|
<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="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="TRUE" /> |
||||||
|
<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 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,214 @@ |
|||||||
|
-- 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 rollableTableHeaderUpdate |
||||||
|
AFTER UPDATE OF badge |
||||||
|
ON rollableTables |
||||||
|
FOR EACH ROW |
||||||
|
WHEN NOT EXISTS (SELECT id |
||||||
|
FROM rollableTableBadges |
||||||
|
WHERE badge = NEW.badge |
||||||
|
AND tableId = NEW.badge) |
||||||
|
BEGIN |
||||||
|
INSERT INTO rollableTableHeaders (header, tableId) VALUES (NEW.badge, NEW.id); |
||||||
|
END; |
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS rollableTableHeaderUpdate |
||||||
|
AFTER UPDATE OF header |
||||||
|
ON rollableTables |
||||||
|
FOR EACH ROW |
||||||
|
WHEN 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.js'; |
||||||
|
|
||||||
|
async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) { |
||||||
|
const bundle = await getBundle(inPath) |
||||||
|
await writeBundle(bundle, outPath) |
||||||
|
} |
||||||
|
|
||||||
|
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,201 @@ |
|||||||
|
import { |
||||||
|
createPrinter, |
||||||
|
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 } from '../common/bundle.js'; |
||||||
|
import type { RawSourceMap } from 'source-map'; |
||||||
|
import { rollup, type RollupCache } from 'rollup'; |
||||||
|
import babel from '@rollup/plugin-babel'; |
||||||
|
import typescript from '@rollup/plugin-typescript'; |
||||||
|
import terser from '@rollup/plugin-terser'; |
||||||
|
|
||||||
|
function* assignProperties(pairs: Iterable<[string, HashedBundled]>): Generator<PropertyAssignment> { |
||||||
|
for (const [identifier, { bundled, hash }] 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) |
||||||
|
), |
||||||
|
], true)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function declareObjectLiteral(identifier: string, pairs: Iterable<[string, HashedBundled]>): VariableDeclaration { |
||||||
|
return factory.createVariableDeclaration( |
||||||
|
factory.createIdentifier(identifier), |
||||||
|
undefined, |
||||||
|
undefined, |
||||||
|
factory.createSatisfiesExpression( |
||||||
|
factory.createAsExpression( |
||||||
|
factory.createObjectLiteralExpression(Array.from(assignProperties(pairs)), true), |
||||||
|
factory.createTypeReferenceNode(factory.createIdentifier('const'))), |
||||||
|
factory.createTypeReferenceNode( |
||||||
|
factory.createIdentifier("Record"), |
||||||
|
[ |
||||||
|
factory.createTypeReferenceNode(factory.createIdentifier("string")), |
||||||
|
factory.createTypeReferenceNode(factory.createIdentifier("HashedBundled"))]))); |
||||||
|
} |
||||||
|
|
||||||
|
function exportObjectLiteral(identifier: string, pairs: Iterable<[string, HashedBundled]>): VariableStatement { |
||||||
|
return factory.createVariableStatement( |
||||||
|
[factory.createToken(SyntaxKind.ExportKeyword)], |
||||||
|
factory.createVariableDeclarationList([declareObjectLiteral(identifier, pairs)], 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, |
||||||
|
sourceMapFileInline: 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()) as RawSourceMap }; |
||||||
|
} |
||||||
|
|
||||||
|
async function processTypescript(atPath: string, inDir: string, cache?: RollupCache): Promise<{cache: RollupCache, bundle: SourceMappedBundled}> { |
||||||
|
const build = await rollup({ |
||||||
|
cache: cache ?? true, |
||||||
|
input: atPath, |
||||||
|
plugins: [ |
||||||
|
typescript({ |
||||||
|
noEmitOnError: true, |
||||||
|
noForceEmit: true, |
||||||
|
emitDeclarationOnly: false, |
||||||
|
noEmit: true, |
||||||
|
include: [join(inDir, '**', '*.ts')], |
||||||
|
typescript: typescriptModule, |
||||||
|
tsconfig: join(inDir, 'tsconfig.json') |
||||||
|
}), |
||||||
|
babel({ |
||||||
|
babelHelpers: 'bundled', |
||||||
|
include: [join(inDir, '**', '*.ts'), join(inDir, '**', '*.js')], |
||||||
|
extensions: ['js', 'ts'], |
||||||
|
presets: ['@babel/preset-typescript'] |
||||||
|
}), |
||||||
|
terser({}) |
||||||
|
] |
||||||
|
}) |
||||||
|
const {output: [chunk]} = await build.generate({ |
||||||
|
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/client.generated.ts')) |
||||||
|
|
||||||
|
export async function writeBundle({ css, js }: {css: Map<string, HashedBundled>, js: Map<string, HashedBundled>}, outFile: string): 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("HashedBundled")) |
||||||
|
]) |
||||||
|
), |
||||||
|
factory.createStringLiteral("../../common/bundle.js")), |
||||||
|
exportObjectLiteral('CSS', css), |
||||||
|
exportObjectLiteral('JS', js) |
||||||
|
], 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.js'; |
||||||
|
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from '../server/web/sourcemaps.js'; |
||||||
|
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/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/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) |
||||||
|
} |
||||||
|
|
||||||
|
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,63 @@ |
|||||||
|
.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, .attributed:focus-within .attributionBubble { |
||||||
|
opacity: 100%; |
||||||
|
transform: none; |
||||||
|
user-select: text; |
||||||
|
} |
||||||
|
|
||||||
|
.attributed:hover .attributionBubble *, .attributed:focus-within .attributionBubble * { |
||||||
|
user-select: text; |
||||||
|
} |
@ -0,0 +1,118 @@ |
|||||||
|
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; |
||||||
|
cursor: pointer; |
||||||
|
text-align: left; |
||||||
|
word-wrap: normal; |
||||||
|
width: 100%; |
||||||
|
white-space: normal; |
||||||
|
user-select: text; |
||||||
|
} |
||||||
|
|
||||||
|
footer { |
||||||
|
text-align: center; |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
@import "generator-entrypoint"; |
||||||
|
@import "responses-entrypoint"; |
||||||
|
|
||||||
|
#generator:not(:target) { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
#generator:target ~ #responses { |
||||||
|
display: none; |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
function updateHash(): void { |
||||||
|
if (location.hash === "" || location.hash === "#" || !location.hash) { |
||||||
|
location.replace("#generator") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
window.addEventListener("hashchange", updateHash) |
||||||
|
updateHash() |
@ -0,0 +1,78 @@ |
|||||||
|
@import "basic-look"; |
||||||
|
@import "attribution"; |
||||||
|
|
||||||
|
#generator { |
||||||
|
position: absolute; |
||||||
|
top: 0; |
||||||
|
min-height: 100dvh; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
margin: 0; |
||||||
|
padding: 2rem; |
||||||
|
display: flex; |
||||||
|
flex-flow: column nowrap; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
#generatorHead { |
||||||
|
margin-top: 0; |
||||||
|
user-select: text; |
||||||
|
} |
||||||
|
|
||||||
|
#generatedScenario { |
||||||
|
} |
||||||
|
|
||||||
|
.generatedHead { |
||||||
|
user-select: text; |
||||||
|
} |
||||||
|
|
||||||
|
.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-left: 0; |
||||||
|
margin-top: 0; |
||||||
|
appearance: none; |
||||||
|
font: inherit; |
||||||
|
outline: 0; |
||||||
|
border: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.generatedSelect { |
||||||
|
flex: 0 0 auto; |
||||||
|
appearance: none; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 1.5rem; |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
#generator .buttons { |
||||||
|
margin-left: -0.3rem; |
||||||
|
} |
||||||
|
|
||||||
|
.generatedSelect::after { |
||||||
|
content: '🔒' |
||||||
|
} |
||||||
|
|
||||||
|
.generatedSelect:checked::after { |
||||||
|
content: '🎲'; |
||||||
|
} |
||||||
|
|
||||||
|
#copyButtons::before { |
||||||
|
content: "Copy as:"; |
||||||
|
margin: 0.2rem 0 0 0.3rem |
||||||
|
} |
@ -0,0 +1,4 @@ |
|||||||
|
.requiresJs { |
||||||
|
display: none !important; |
||||||
|
flex: 0 1 0 !important; |
||||||
|
} |
@ -0,0 +1,102 @@ |
|||||||
|
@import "basic-look"; |
||||||
|
@import "attribution"; |
||||||
|
|
||||||
|
#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 nav { |
||||||
|
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; |
||||||
|
} |
||||||
|
|
||||||
|
#responsesHeader nav .buttons { |
||||||
|
flex-flow: row wrap; |
||||||
|
padding-top: 0.2rem; |
||||||
|
padding-right: 0.2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.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,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": "ES5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, |
||||||
|
"lib": ["dom", "dom.iterable", "ESNext"] |
||||||
|
/* 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,15 @@ |
|||||||
|
import type { RawSourceMap } from 'source-map'; |
||||||
|
|
||||||
|
export interface Bundled { |
||||||
|
readonly bundled: string, |
||||||
|
} |
||||||
|
|
||||||
|
export interface HashedBundled extends Bundled { |
||||||
|
readonly hash: string, |
||||||
|
} |
||||||
|
|
||||||
|
export interface SourceMappedBundled extends Bundled { |
||||||
|
readonly sourceMap: RawSourceMap, |
||||||
|
} |
||||||
|
|
||||||
|
export interface SourceMappedHashedBundled extends SourceMappedBundled, HashedBundled {} |
@ -0,0 +1,452 @@ |
|||||||
|
export interface RollTableLimited { |
||||||
|
readonly full: false, |
||||||
|
readonly emoji: string, |
||||||
|
readonly title: string, |
||||||
|
readonly header: string, |
||||||
|
readonly ordinal: number, |
||||||
|
readonly results?: null, |
||||||
|
} |
||||||
|
|
||||||
|
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 | RollTableDetailsInput |
||||||
|
|
||||||
|
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.results.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 |
||||||
|
|
||||||
|
function setToString(v: RollTableResultSet): string { |
||||||
|
return `${v.global ? 'global' : 'local'} ${v.name ?? 'set'}` |
||||||
|
} |
||||||
|
|
||||||
|
function authorToString(v: RollTableAuthor): string { |
||||||
|
return `${v.relation} ${v.name} (${v.id})` |
||||||
|
} |
||||||
|
|
||||||
|
function resultToString(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 results: 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 interface RollTableDetailsInputNoResults extends RollTableDetailsBase { |
||||||
|
readonly results?: null |
||||||
|
} |
||||||
|
|
||||||
|
export type RollTableDetailsInput = RollTableDetailsInputResults | RollTableDetailsInputNoResults |
||||||
|
export type RollTableDetailsOrInput = RollTableDetails | RollTableDetailsInput |
||||||
|
|
||||||
|
export interface RollTableDetailsNoResults extends RollTableDetailsBase { |
||||||
|
readonly full: 'details' |
||||||
|
readonly results?: null; |
||||||
|
} |
||||||
|
|
||||||
|
export interface RollTableDetailsAndResults extends RollTableDetailsBase { |
||||||
|
readonly full: 'results' |
||||||
|
readonly results: ReadonlyMap<number, RollTableResultFull<this>>; |
||||||
|
} |
||||||
|
|
||||||
|
interface RollTableDetailsAndResultsInternal extends RollTableDetailsBase { |
||||||
|
readonly full: 'results' |
||||||
|
readonly results: Map<number, 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); |
||||||
|
} |
||||||
|
|
||||||
|
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 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<RollTableDetailsInput> | 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; |
||||||
|
} |
||||||
|
|
||||||
|
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: RollTableDetailsInput): RollTableDetailsAndResults { |
||||||
|
return this.addTableInternal(table); |
||||||
|
} |
||||||
|
|
||||||
|
private addTableInternal(table: RollTableDetailsInput): RollTableDetailsAndResultsInternal { |
||||||
|
const existingTable = this.tablesById.get(table.id); |
||||||
|
if (existingTable) { |
||||||
|
if (table.results) { |
||||||
|
for (const result of table.results) { |
||||||
|
this.addResult(result); |
||||||
|
} |
||||||
|
} |
||||||
|
return existingTable; |
||||||
|
} |
||||||
|
const internalTable: RollTableDetailsAndResultsInternal = { |
||||||
|
...table, |
||||||
|
full: 'results', |
||||||
|
results: new Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>>() |
||||||
|
}; |
||||||
|
if (table.results) { |
||||||
|
for (const result of table.results) { |
||||||
|
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)) { |
||||||
|
const internalTable = |
||||||
|
this.tablesById.get(result.table.id) ?? this.addTableInternal({... result.table, results: null}); |
||||||
|
const internalAuthor = |
||||||
|
result.full && result.author ? (this.authorsById.get(result.author.id) ?? this.addAuthor(result.author)) : null; |
||||||
|
const internalSet = this.setsById.get(result.set.id) ?? this.addSet(result.set); |
||||||
|
const out: RollTableResultFull<RollTableDetailsAndResultsInternal> = { |
||||||
|
...result, |
||||||
|
table: internalTable, |
||||||
|
author: internalAuthor, |
||||||
|
set: internalSet |
||||||
|
}; |
||||||
|
internalTable.results.set(out.textId, out); |
||||||
|
this.mappingsByTextId.set(out.textId, out); |
||||||
|
this.mappingsByMappingId.set(out.mappingId, out); |
||||||
|
return out; |
||||||
|
} 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 out: RollTableResultFull<RollTableDetailsAndResultsInternal> = { |
||||||
|
full: true, |
||||||
|
textId: result.textId, |
||||||
|
mappingId: result.mappingId, |
||||||
|
text: result.text, |
||||||
|
table: internalTable, |
||||||
|
author: internalAuthor, |
||||||
|
set: internalSet, |
||||||
|
updated: result.updated |
||||||
|
}; |
||||||
|
internalTable.results.set(out.textId, out); |
||||||
|
this.mappingsByTextId.set(out.textId, out); |
||||||
|
this.mappingsByMappingId.set(out.mappingId, out); |
||||||
|
return out; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function rollOn(table: RollTableDetailsAndResults): RollTableResult<RollTableDetailsAndResults> { |
||||||
|
const results = Array.from(table.results.values()); |
||||||
|
if (results.length === 0) { |
||||||
|
throw Error(`no results for table ${table.identifier}`); |
||||||
|
} |
||||||
|
return results[Math.floor(results.length * Math.random())]; |
||||||
|
} |
||||||
|
|
||||||
|
function rollOnAll(tables: Iterable<RollTableDetailsAndResults>): RolledValues<RollTableDetailsAndResults> { |
||||||
|
const result = new RolledValues<RollTableDetailsAndResults>(); |
||||||
|
for (const table of tables) { |
||||||
|
result.set(table, rollOn(table)); |
||||||
|
} |
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
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 function generatedStateToString(contents: GeneratedState): string { |
||||||
|
if (contents.final) { |
||||||
|
return `Final state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${resultToString(value)}`).join(" ::: ")}` |
||||||
|
} else { |
||||||
|
return `Current state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${resultToString(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(); |
||||||
|
} |
||||||
|
} |
@ -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,473 @@ |
|||||||
|
import { |
||||||
|
type FinalGeneratedContents, |
||||||
|
type FinalGeneratedState, |
||||||
|
type GeneratedContents, |
||||||
|
type GeneratedState, |
||||||
|
type InProgressGeneratedContents, |
||||||
|
type InProgressGeneratedState, RolledValues, RollSelections, |
||||||
|
type RollTable, |
||||||
|
type RollTableAuthor, RollTableDatabase, |
||||||
|
type RollTableDetailsNoResults, |
||||||
|
type RollTableResult, |
||||||
|
type RollTableResultFull |
||||||
|
} from '../../common/rolltable.js'; |
||||||
|
import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes.js'; |
||||||
|
import { |
||||||
|
DatabaseQueries, |
||||||
|
} from './queries.js'; |
||||||
|
import { recordError } from '../discord/embed.js'; |
||||||
|
|
||||||
|
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 |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function processGeneratedRow(result: QueryOutput<typeof DatabaseQueries['generateFromDiscord']>[number]): RollTableResult & { selected: boolean } { |
||||||
|
if (result.tableId === null) { |
||||||
|
return { |
||||||
|
full: false, |
||||||
|
table: { |
||||||
|
full: false, |
||||||
|
emoji: result.tableEmoji, |
||||||
|
title: result.tableTitle, |
||||||
|
header: result.tableHeader, |
||||||
|
ordinal: result.tableOrdinal |
||||||
|
}, |
||||||
|
text: result.resultText, |
||||||
|
selected: false |
||||||
|
}; |
||||||
|
} else if (result.mappingId === null) { |
||||||
|
return { |
||||||
|
full: false, |
||||||
|
table: { |
||||||
|
full: 'details', |
||||||
|
emoji: result.tableEmoji, |
||||||
|
header: result.tableHeader, |
||||||
|
id: result.tableId, |
||||||
|
identifier: result.tableIdentifier, |
||||||
|
name: result.tableName, |
||||||
|
ordinal: result.tableOrdinal, |
||||||
|
title: result.tableTitle |
||||||
|
}, |
||||||
|
text: result.resultText, |
||||||
|
selected: result.selected |
||||||
|
}; |
||||||
|
} else { |
||||||
|
return { |
||||||
|
full: true, |
||||||
|
table: { |
||||||
|
full: 'details', |
||||||
|
emoji: result.tableEmoji, |
||||||
|
header: result.tableHeader, |
||||||
|
id: result.tableId, |
||||||
|
identifier: result.tableIdentifier, |
||||||
|
name: result.tableName, |
||||||
|
ordinal: result.tableOrdinal, |
||||||
|
title: result.tableTitle |
||||||
|
}, |
||||||
|
author: result.authorId && result.authorName && result.authorRelation ? { |
||||||
|
id: result.authorId, |
||||||
|
name: result.authorName, |
||||||
|
url: result.authorUrl, |
||||||
|
relation: result.authorRelation |
||||||
|
} : null, |
||||||
|
set: { |
||||||
|
id: result.setId, |
||||||
|
name: result.setName, |
||||||
|
description: result.setDescription, |
||||||
|
global: !!(result.setGlobal) |
||||||
|
}, |
||||||
|
mappingId: result.mappingId, |
||||||
|
textId: result.resultId, |
||||||
|
text: result.resultText, |
||||||
|
updated: new Date(result.updated), |
||||||
|
selected: result.selected |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function processGeneration(results: QueryOutput<typeof DatabaseQueries['generateFromDiscord']>, final: true): FinalGeneratedState |
||||||
|
function processGeneration(results: QueryOutput<typeof DatabaseQueries['generateFromDiscord']>, final: false): InProgressGeneratedState |
||||||
|
function processGeneration(results: QueryOutput<typeof DatabaseQueries['generateFromDiscord']>, final: boolean): GeneratedState |
||||||
|
function processGeneration(results: QueryOutput<typeof DatabaseQueries['generateFromDiscord']>, final: boolean): GeneratedState { |
||||||
|
const rolled = new Map<RollTable, RollTableResult>(); |
||||||
|
const selected = new Set<RollTable>(); |
||||||
|
for (const rawResult of results) { |
||||||
|
const processed = processGeneratedRow(rawResult); |
||||||
|
rolled.set(processed.table, processed); |
||||||
|
if (!final && processed.selected) { |
||||||
|
selected.add(processed.table); |
||||||
|
} |
||||||
|
} |
||||||
|
return final ? { |
||||||
|
final, |
||||||
|
rolled |
||||||
|
} : { |
||||||
|
final, |
||||||
|
rolled, |
||||||
|
selected |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
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' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
private async runGenerateFromDiscord(reroll: true, setId: string|null, contents?: InProgressGeneratedContents | null, finalize?: false): Promise<InProgressGeneratedState> |
||||||
|
private async runGenerateFromDiscord(reroll: false, setId: string|null, contents: GeneratedContents, finalize: false): Promise<InProgressGeneratedState> |
||||||
|
private async runGenerateFromDiscord(reroll: false, setId: string|null, contents: GeneratedContents, finalize: true): Promise<FinalGeneratedState> |
||||||
|
private async runGenerateFromDiscord(reroll: false, setId: string|null, contents: InProgressGeneratedContents): Promise<InProgressGeneratedState> |
||||||
|
private async runGenerateFromDiscord(reroll: false, setId: string|null, contents: FinalGeneratedContents): Promise<FinalGeneratedState> |
||||||
|
private async runGenerateFromDiscord(reroll: false, setId: string|null, contents: GeneratedContents, finalize?: boolean): Promise<GeneratedState> |
||||||
|
private async runGenerateFromDiscord(reroll: boolean, setId: string|null, contents?: GeneratedContents | null, finalize?: boolean): Promise<GeneratedState> { |
||||||
|
const results = await this.db.run(this.queries.generateFromDiscord({ |
||||||
|
reroll, |
||||||
|
setSnowflake: setId, |
||||||
|
original: contents ? Array.from(contents.rolled) : null, |
||||||
|
selection: contents && !contents.final ? Array.from(contents.selected) : null |
||||||
|
})) |
||||||
|
return processGeneration(results, finalize ?? contents?.final ?? 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.runGenerateFromDiscord(false, setId, contents); |
||||||
|
} |
||||||
|
|
||||||
|
async generateFromDiscordSet(setId: string): Promise<InProgressGeneratedState> { |
||||||
|
return this.runGenerateFromDiscord(true, setId); |
||||||
|
} |
||||||
|
|
||||||
|
async rerollFromDiscordSet(setId: string, existing: InProgressGeneratedContents): Promise<InProgressGeneratedState> { |
||||||
|
return this.runGenerateFromDiscord(true, setId, existing); |
||||||
|
} |
||||||
|
|
||||||
|
async reopenFromDiscordSet(setId: string, existing: FinalGeneratedContents): Promise<InProgressGeneratedState> { |
||||||
|
return this.runGenerateFromDiscord(false, setId, existing, false); |
||||||
|
} |
||||||
|
|
||||||
|
async finalizeFromDiscordSet(setId: string, existing: InProgressGeneratedContents): Promise<FinalGeneratedState> { |
||||||
|
return this.runGenerateFromDiscord(false, setId, existing, true); |
||||||
|
} |
||||||
|
|
||||||
|
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 getWebPageDataForDiscordSet(reroll: true, setSnowflake: string|null, oldResults?: InProgressGeneratedContents | null, finalize?: false): Promise<InProgressGeneratedState & {db: RollTableDatabase}> |
||||||
|
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults?: null): Promise<RollTableDatabase> |
||||||
|
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: GeneratedContents, finalize: false): Promise<InProgressGeneratedState & {db: RollTableDatabase}> |
||||||
|
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: GeneratedContents, finalize: true): Promise<FinalGeneratedState & {db: RollTableDatabase}> |
||||||
|
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: InProgressGeneratedContents): Promise<InProgressGeneratedState & {db: RollTableDatabase}> |
||||||
|
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: FinalGeneratedContents): Promise<FinalGeneratedState & {db: RollTableDatabase}> |
||||||
|
private async getWebPageDataForDiscordSet(reroll: boolean, setSnowflake: string|null, oldResults?: GeneratedContents|null, finalize?: boolean): Promise<RollTableDatabase | (GeneratedState & {db: RollTableDatabase})> |
||||||
|
private async getWebPageDataForDiscordSet(reroll: boolean, setSnowflake: string|null, oldResults?: GeneratedContents|null, finalize?: boolean): Promise<RollTableDatabase | (GeneratedState & {db: RollTableDatabase})> { |
||||||
|
const { tables, mappings, results, sets, authors } = |
||||||
|
await this.db.run(this.queries.getFullDatabaseForDiscordSet({ |
||||||
|
reroll, |
||||||
|
setSnowflake, |
||||||
|
original: oldResults ? Array.from(oldResults.rolled) : null, |
||||||
|
selection: oldResults && !oldResults.final ? Array.from(oldResults.selected) : null, |
||||||
|
})) |
||||||
|
const db = new RollTableDatabase({ |
||||||
|
tables, authors, sets, results: mappings.map(v => ({...v, updated: new Date(v.updated)}))}) |
||||||
|
if (!results) { |
||||||
|
return db |
||||||
|
} |
||||||
|
const rolled = new RolledValues() |
||||||
|
for (const result of results) { |
||||||
|
switch (result.type) { |
||||||
|
case 'mapping': |
||||||
|
const mapping = db.mappings.get(result.mappingId) |
||||||
|
if (mapping) { |
||||||
|
rolled.add(mapping) |
||||||
|
} else { |
||||||
|
recordError({ |
||||||
|
error: Error(`no mapping with ID ${result.mappingId}`), |
||||||
|
context: 'getting web page data for discord set', |
||||||
|
}) |
||||||
|
} |
||||||
|
break |
||||||
|
case 'unknownText': |
||||||
|
const table = db.tables.get(result.tableId) |
||||||
|
if (table) { |
||||||
|
rolled.add({ |
||||||
|
full: false, |
||||||
|
text: result.text, |
||||||
|
table |
||||||
|
}) |
||||||
|
} else { |
||||||
|
recordError({ |
||||||
|
error: Error(`no table with ID ${result.tableId}`), |
||||||
|
context: `assembling unknown text for discord set` |
||||||
|
}) |
||||||
|
} |
||||||
|
break |
||||||
|
case 'unknownTable': |
||||||
|
rolled.add({ |
||||||
|
full: false, |
||||||
|
table: { |
||||||
|
full: false, |
||||||
|
ordinal: result.ordinal, |
||||||
|
header: result.header, |
||||||
|
emoji: result.emoji, |
||||||
|
title: result.title |
||||||
|
}, |
||||||
|
text: result.text |
||||||
|
}) |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
if (finalize === true || (finalize === null && oldResults?.final)) { |
||||||
|
return { |
||||||
|
final: true, |
||||||
|
db, |
||||||
|
rolled |
||||||
|
} |
||||||
|
} |
||||||
|
const selected = new RollSelections() |
||||||
|
for (const table of tables) { |
||||||
|
if (table.selected) { |
||||||
|
selected.add(db.tables.get(table.id)!) |
||||||
|
} |
||||||
|
} |
||||||
|
return { |
||||||
|
final: false, |
||||||
|
db, |
||||||
|
rolled, |
||||||
|
selected |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async getGeneratorPageForDiscordSet(setSnowflake: string|null, oldResults?: InProgressGeneratedContents|null): Promise<InProgressGeneratedState & {db: RollTableDatabase}> { |
||||||
|
return this.getWebPageDataForDiscordSet(true, setSnowflake, oldResults, false) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,878 @@ |
|||||||
|
import { type QueryDefinitions, validatedDefinitions } from './querytypes.js'; |
||||||
|
import { |
||||||
|
boolean, |
||||||
|
discordSnowflake, |
||||||
|
jsonArray, |
||||||
|
nullable, |
||||||
|
string, |
||||||
|
substring, |
||||||
|
tableIdentifierOrId, |
||||||
|
tableIdentifierSubstring, |
||||||
|
timestamp, |
||||||
|
URL |
||||||
|
} from './validators.js'; |
||||||
|
import { guaranteedSingleton, jsonParser, nothing, rows, singleton, writeCount } from './transformers.js'; |
||||||
|
|
||||||
|
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() |
||||||
|
}, |
||||||
|
generateFromDiscord: { |
||||||
|
query: `WITH originalResults (tableId, header, ordinal, text) AS (SELECT rollableTables.id AS tableId,
|
||||||
|
rollableTables.header AS header, |
||||||
|
rollableTables.ordinal AS ordinal, |
||||||
|
NULL AS text |
||||||
|
FROM rollableTables |
||||||
|
WHERE ?3 IS NULL |
||||||
|
UNION ALL |
||||||
|
SELECT rollableTableHeaders.tableId AS id, |
||||||
|
original.value ->> '$[0]' AS header, |
||||||
|
original.key AS ordinal, |
||||||
|
original.value ->> '$[1]' AS text |
||||||
|
FROM json_each(COALESCE(?3, '[]')) original |
||||||
|
LEFT JOIN rollableTableHeaders |
||||||
|
ON rollableTableHeaders.header = (original.value ->> '$[0]') |
||||||
|
ORDER BY ordinal), |
||||||
|
selection (tableId) |
||||||
|
AS (SELECT COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) AS tableId |
||||||
|
FROM json_each(COALESCE(?4, '[]')) selection |
||||||
|
LEFT JOIN rollableTableIdentifiers |
||||||
|
ON rollableTableIdentifiers.identifier = selection.value |
||||||
|
LEFT JOIN rollableTableHeaders |
||||||
|
ON rollableTableHeaders.header = selection.value |
||||||
|
WHERE COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) IS NOT NULL), |
||||||
|
visibleSets (id, global) AS (SELECT resultSets.id, resultSets.global |
||||||
|
FROM resultSets |
||||||
|
WHERE (resultSets.global OR resultSets.discordSnowflake = ?2)), |
||||||
|
usedResults (id) AS (SELECT DISTINCT resultMappings.resultId |
||||||
|
FROM resultMappings |
||||||
|
WHERE resultMappings.setId IN (SELECT id FROM visibleSets)), |
||||||
|
usedTables (id) AS (SELECT DISTINCT rollableResults.tableId |
||||||
|
FROM usedResults |
||||||
|
INNER JOIN rollableResults ON rollableResults.id = usedResults.id), |
||||||
|
usedMappings (id) AS (SELECT (SELECT resultMappings.id |
||||||
|
FROM resultMappings |
||||||
|
WHERE resultMappings.resultId = usedResults.id |
||||||
|
AND resultMappings.setId IN (SELECT id FROM visibleSets) |
||||||
|
ORDER BY (NOT visibleSets.global) DESC, |
||||||
|
(resultMappings.authorId IS NOT NULL) DESC, |
||||||
|
updated |
||||||
|
LIMIT 1) |
||||||
|
FROM usedResults), |
||||||
|
usedAuthors (id) AS (SELECT DISTINCT resultMappings.authorId |
||||||
|
FROM usedMappings |
||||||
|
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id), |
||||||
|
usedSets (id) AS (SELECT DISTINCT resultMappings.setId |
||||||
|
FROM usedMappings |
||||||
|
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id), |
||||||
|
results (resultId, tableId, header, ordinal, originalText) AS |
||||||
|
(SELECT (SELECT rollableResults.id |
||||||
|
FROM rollableResults |
||||||
|
WHERE rollableResults.tableId = originalResult.tableId |
||||||
|
AND rollableResults.id IN usedResults |
||||||
|
AND ((?1 AND (originalResult.text IS NULL OR ?4 IS NULL OR |
||||||
|
originalResult.tableId IN selection)) OR |
||||||
|
rollableResults.text = originalResult.text) |
||||||
|
ORDER BY RANDOM() |
||||||
|
LIMIT 1) AS resultId, |
||||||
|
originalResult.tableId AS tableId, |
||||||
|
originalResult.header AS header, |
||||||
|
originalResult.ordinal AS ordinal, |
||||||
|
originalResult.text AS originalText |
||||||
|
FROM originalResults AS originalResult) |
||||||
|
SELECT resultMappings.id AS mappingId, |
||||||
|
rollableResults.id AS resultId, |
||||||
|
COALESCE(rollableResults.text, results.originalText, '') 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, |
||||||
|
COALESCE( |
||||||
|
rollableTables.title, |
||||||
|
SUBSTR(results.header, INSTR(results.header, ' ') + 1)) AS tableTitle, |
||||||
|
COALESCE( |
||||||
|
rollableTables.emoji, |
||||||
|
SUBSTR(results.header, 1, INSTR(results.header, ' ') - 1)) AS tableEmoji, |
||||||
|
results.header AS tableHeader, |
||||||
|
results.ordinal AS tableOrdinal, |
||||||
|
resultMappings.updated AS updated, |
||||||
|
results.tableId IN selection AS selected |
||||||
|
FROM results |
||||||
|
LEFT JOIN rollableResults ON rollableResults.id = results.resultId |
||||||
|
LEFT JOIN rollableTables ON rollableTables.id = results.tableId |
||||||
|
LEFT JOIN resultMappings ON resultMappings.id = (SELECT resultMappings.id |
||||||
|
FROM resultMappings |
||||||
|
INNER JOIN visibleSets ON visibleSets.id = resultMappings.setId |
||||||
|
WHERE resultMappings.resultId = results.resultId |
||||||
|
ORDER BY (NOT visibleSets.global) DESC, |
||||||
|
(resultMappings.authorId IS NOT NULL) DESC, |
||||||
|
updated |
||||||
|
LIMIT 1) |
||||||
|
LEFT JOIN authors ON authors.id = resultMappings.authorId |
||||||
|
LEFT JOIN authorshipTypes ON authorshipTypes.id = authors.authorshipTypeId |
||||||
|
LEFT JOIN resultSets ON resultSets.id = resultMappings.setId;`,
|
||||||
|
parameters: { |
||||||
|
'reroll': { |
||||||
|
validator: boolean, |
||||||
|
index: 1 |
||||||
|
}, |
||||||
|
'setSnowflake': { |
||||||
|
validator: nullable(discordSnowflake), |
||||||
|
index: 2 |
||||||
|
}, |
||||||
|
'original': { |
||||||
|
validator: nullable(jsonArray), |
||||||
|
index: 3 |
||||||
|
}, |
||||||
|
'selection': { |
||||||
|
validator: nullable(jsonArray), |
||||||
|
index: 4 |
||||||
|
} |
||||||
|
}, |
||||||
|
output: rows<{ |
||||||
|
mappingId: null, |
||||||
|
resultId: null, |
||||||
|
resultText: string, |
||||||
|
authorId: null, |
||||||
|
authorName: null, |
||||||
|
authorUrl: null, |
||||||
|
authorRelation: null, |
||||||
|
setId: null, |
||||||
|
setName: null, |
||||||
|
setDescription: null, |
||||||
|
setGlobal: null, |
||||||
|
tableId: null, |
||||||
|
tableIdentifier: null, |
||||||
|
tableName: null, |
||||||
|
tableTitle: string, |
||||||
|
tableEmoji: string, |
||||||
|
tableHeader: string, |
||||||
|
tableOrdinal: number, |
||||||
|
updated: null, |
||||||
|
selected: false; |
||||||
|
} | { |
||||||
|
mappingId: null, |
||||||
|
resultId: null, |
||||||
|
resultText: string, |
||||||
|
authorId: null, |
||||||
|
authorName: null, |
||||||
|
authorUrl: null, |
||||||
|
authorRelation: null, |
||||||
|
setId: null, |
||||||
|
setName: null, |
||||||
|
setDescription: null, |
||||||
|
setGlobal: null, |
||||||
|
tableId: number, |
||||||
|
tableIdentifier: string, |
||||||
|
tableName: string, |
||||||
|
tableTitle: string, |
||||||
|
tableEmoji: string, |
||||||
|
tableHeader: string, |
||||||
|
tableOrdinal: number, |
||||||
|
updated: null, |
||||||
|
selected: boolean, |
||||||
|
} | { |
||||||
|
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, |
||||||
|
selected: boolean, |
||||||
|
}>() |
||||||
|
}, |
||||||
|
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() |
||||||
|
}, |
||||||
|
getFullDatabaseForDiscordSet: { |
||||||
|
query: `WITH originalResults (tableId, header, ordinal, text) AS (SELECT rollableTables.id AS tableId,
|
||||||
|
rollableTables.header AS header, |
||||||
|
rollableTables.ordinal AS ordinal, |
||||||
|
NULL AS text |
||||||
|
FROM rollableTables |
||||||
|
WHERE ?3 IS NULL |
||||||
|
UNION ALL |
||||||
|
SELECT rollableTableHeaders.tableId AS id, |
||||||
|
original.value ->> '$[0]' AS header, |
||||||
|
original.key AS ordinal, |
||||||
|
original.value ->> '$[1]' AS text |
||||||
|
FROM json_each(COALESCE(?3, '[]')) original |
||||||
|
LEFT JOIN rollableTableHeaders |
||||||
|
ON rollableTableHeaders.header = (original.value ->> '$[0]') |
||||||
|
ORDER BY ordinal), |
||||||
|
selection (tableId) |
||||||
|
AS (SELECT COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) AS tableId |
||||||
|
FROM json_each(COALESCE(?4, '[]')) selection |
||||||
|
LEFT JOIN rollableTableIdentifiers |
||||||
|
ON rollableTableIdentifiers.identifier = selection.value |
||||||
|
LEFT JOIN rollableTableHeaders |
||||||
|
ON rollableTableHeaders.header = selection.value |
||||||
|
WHERE COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) IS NOT NULL), |
||||||
|
visibleSets (id, global) AS (SELECT resultSets.id, resultSets.global |
||||||
|
FROM resultSets |
||||||
|
WHERE (resultSets.global OR resultSets.discordSnowflake = ?2)), |
||||||
|
usedResults (id) AS (SELECT DISTINCT resultMappings.resultId |
||||||
|
FROM resultMappings |
||||||
|
WHERE resultMappings.setId IN (SELECT id FROM visibleSets)), |
||||||
|
usedTables (id) AS (SELECT DISTINCT rollableResults.tableId |
||||||
|
FROM usedResults |
||||||
|
INNER JOIN rollableResults ON rollableResults.id = usedResults.id), |
||||||
|
usedMappings (id) AS (SELECT (SELECT resultMappings.id |
||||||
|
FROM resultMappings |
||||||
|
INNER JOIN visibleSets ON resultMappings.setId = visibleSets.id |
||||||
|
WHERE resultMappings.resultId = usedResults.id |
||||||
|
ORDER BY (NOT visibleSets.global) DESC, |
||||||
|
(resultMappings.authorId IS NOT NULL) DESC, |
||||||
|
updated |
||||||
|
LIMIT 1) |
||||||
|
FROM usedResults), |
||||||
|
usedAuthors (id) AS (SELECT DISTINCT resultMappings.authorId |
||||||
|
FROM usedMappings |
||||||
|
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id), |
||||||
|
usedSets (id) AS (SELECT DISTINCT resultMappings.setId |
||||||
|
FROM usedMappings |
||||||
|
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id), |
||||||
|
generationResults (resultObj) AS |
||||||
|
(SELECT COALESCE((SELECT json_object( |
||||||
|
'type', 'mapping', |
||||||
|
'mappingId', resultMappings.id) |
||||||
|
FROM rollableResults |
||||||
|
INNER JOIN resultMappings |
||||||
|
ON resultMappings.resultId = rollableResults.id |
||||||
|
INNER JOIN usedMappings ON usedMappings.id = resultMappings.id |
||||||
|
WHERE rollableResults.tableId = originalResult.tableId |
||||||
|
AND rollableResults.id IN usedResults |
||||||
|
AND ((?1 AND (originalResult.text IS NULL OR ?4 IS NULL OR |
||||||
|
originalResult.tableId IN selection)) OR |
||||||
|
rollableResults.text = originalResult.text) |
||||||
|
ORDER BY RANDOM() |
||||||
|
LIMIT 1), |
||||||
|
CASE |
||||||
|
WHEN originalResult.tableId IN usedTables THEN json_object( |
||||||
|
'type', 'unknownText', |
||||||
|
'tableId', originalResult.tableId, |
||||||
|
'text', originalResult.text) |
||||||
|
ELSE json_object( |
||||||
|
'type', 'unknownTable', |
||||||
|
'header', originalResult.header, |
||||||
|
'title', |
||||||
|
SUBSTR(originalResult.header, INSTR(originalResult.header, ' ') + 1), |
||||||
|
'emoji', |
||||||
|
SUBSTR(originalResult.header, 1, INSTR(originalResult.header, ' ') - 1), |
||||||
|
'ordinal', originalResult.ordinal, |
||||||
|
'text', originalResult.text) END) |
||||||
|
FROM originalResults AS originalResult |
||||||
|
WHERE ?1 |
||||||
|
OR (?3 IS NOT NULL)) |
||||||
|
SELECT (SELECT json_group_array(json(tableObj)) |
||||||
|
FROM (SELECT json_object('id', rollableTables.id, |
||||||
|
'identifier', rollableTables.identifier, |
||||||
|
'name', rollableTables.name, |
||||||
|
'title', rollableTables.title, |
||||||
|
'emoji', rollableTables.emoji, |
||||||
|
'header', rollableTables.header, |
||||||
|
'ordinal', rollableTables.ordinal, |
||||||
|
'selected', CASE |
||||||
|
WHEN rollableTables.id IN selection THEN json('true') |
||||||
|
ELSE json('false') END) AS tableObj |
||||||
|
FROM usedTables |
||||||
|
INNER JOIN rollableTables ON rollableTables.id = usedTables.id)) AS tables, |
||||||
|
(SELECT json_group_array(json(setObj)) |
||||||
|
FROM (SELECT json_object('id', resultSets.id, |
||||||
|
'name', resultSets.name, |
||||||
|
'description', resultSets.description, |
||||||
|
'global', CASE |
||||||
|
WHEN resultSets.global THEN json('true') |
||||||
|
ELSE json('false') END) AS setObj |
||||||
|
FROM usedSets |
||||||
|
INNER JOIN resultSets ON resultSets.id = usedSets.id)) AS sets, |
||||||
|
(SELECT json_group_array(json(authorObj)) |
||||||
|
FROM (SELECT json_object('id', authors.id, |
||||||
|
'name', COALESCE(authors.name, authorshipTypes.defaultAuthor), |
||||||
|
'url', authors.url, |
||||||
|
'relation', authorshipTypes.relationPrefix) AS authorObj |
||||||
|
FROM usedAuthors |
||||||
|
INNER JOIN authors ON authors.id = usedAuthors.id |
||||||
|
INNER JOIN authorshipTypes ON authorshipTypes.id = authors.authorshipTypeId)) AS authors, |
||||||
|
(SELECT json_group_array(json(mappingObj)) |
||||||
|
FROM (SELECT json_object('mappingId', resultMappings.id, |
||||||
|
'textId', resultMappings.resultId, |
||||||
|
'text', rollableResults.text, |
||||||
|
'tableId', rollableResults.tableId, |
||||||
|
'setId', resultMappings.setId, |
||||||
|
'authorId', resultMappings.authorId, |
||||||
|
'updated', resultMappings.updated) AS mappingObj |
||||||
|
FROM usedMappings |
||||||
|
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id |
||||||
|
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId)) AS mappings, |
||||||
|
CASE |
||||||
|
WHEN EXISTS (SELECT resultObj FROM generationResults) |
||||||
|
THEN (SELECT json_group_array(json(resultObj)) FROM generationResults) |
||||||
|
ELSE json('null') END AS results;`,
|
||||||
|
parameters: { |
||||||
|
'reroll': { |
||||||
|
validator: boolean, |
||||||
|
index: 1 |
||||||
|
}, |
||||||
|
'setSnowflake': { |
||||||
|
validator: nullable(discordSnowflake), |
||||||
|
index: 2 |
||||||
|
}, |
||||||
|
'original': { |
||||||
|
validator: nullable(jsonArray), |
||||||
|
index: 3 |
||||||
|
}, |
||||||
|
'selection': { |
||||||
|
validator: nullable(jsonArray), |
||||||
|
index: 4 |
||||||
|
} |
||||||
|
}, |
||||||
|
output: guaranteedSingleton(jsonParser<{ |
||||||
|
tables: { |
||||||
|
id: number, |
||||||
|
identifier: string, |
||||||
|
name: string, |
||||||
|
title: string, |
||||||
|
emoji: string, |
||||||
|
header: string, |
||||||
|
ordinal: number, |
||||||
|
selected: boolean |
||||||
|
}[] |
||||||
|
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 |
||||||
|
}[], |
||||||
|
results: (({ type: 'mapping', mappingId: number } | { type: 'unknownText', tableId: number, text: string } | { |
||||||
|
type: 'unknownTable', |
||||||
|
header: string, |
||||||
|
title: string, |
||||||
|
emoji: string, |
||||||
|
ordinal: number, |
||||||
|
text: string |
||||||
|
})[]) | null |
||||||
|
}>(['tables', 'sets', 'authors', 'mappings', 'results'])) |
||||||
|
} |
||||||
|
} as const satisfies QueryDefinitions); |
||||||
|
|
@ -0,0 +1,132 @@ |
|||||||
|
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 [results] = await db.batch([query.statement]); |
||||||
|
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 results = await db.batch(queries.map(q => q.statement)); |
||||||
|
return results.map((result, index) => queries[index].transformer(result as D1Result<object>)) as unknown 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,62 @@ |
|||||||
|
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 rows<OutputT extends object = object, 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 extends object = object, 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 extends object = object, 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.js'; |
||||||
|
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.js'; |
||||||
|
import { |
||||||
|
generatedContentsToString, generatedStateToString, |
||||||
|
MAX_IDENTIFIER_LENGTH, |
||||||
|
MAX_NAME_LENGTH, |
||||||
|
MAX_RESULT_LENGTH, |
||||||
|
MAX_URL_LENGTH, |
||||||
|
type RollTableAuthor |
||||||
|
} from '../../common/rolltable.js'; |
||||||
|
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,208 @@ |
|||||||
|
import type { GeneratedContents, RollTableResult } from '../../common/rolltable.js'; |
||||||
|
import { |
||||||
|
type FinalGeneratedContents, |
||||||
|
type GeneratedState, |
||||||
|
type InProgressGeneratedContents, |
||||||
|
type RollTableResultFull |
||||||
|
} from '../../common/rolltable.js'; |
||||||
|
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.js'; |
||||||
|
|
||||||
|
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 { |
||||||
|
return { |
||||||
|
name: markdownEscape(`${value.table.header}${typeof selected === 'boolean' ? selected ? ROLL_SUFFIX : LOCK_SUFFIX : ''}`), |
||||||
|
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.js'; |
||||||
|
import { CloudflareWorkerServer, SlashCreator } from 'slash-create/web'; |
||||||
|
import { isSnowflake, type Snowflake } from 'discord-snowflake'; |
||||||
|
import { AuthorCommand, GenerateCommand, ResponseCommand } from './commands.js'; |
||||||
|
import { type IRequestStrict, Router } from 'itty-router'; |
||||||
|
import { getQueryArray } from '../request/query.js'; |
||||||
|
|
||||||
|
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,47 @@ |
|||||||
|
import { Database } from './db/database.js'; |
||||||
|
import { discordRouter } from './discord/router.js'; |
||||||
|
import { createCors, Router, IRequestStrict } from 'itty-router'; |
||||||
|
import { webRouter } from './web/router.js'; |
||||||
|
|
||||||
|
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> { |
||||||
|
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); |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
@ -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|null { |
||||||
|
if (typeof value === 'undefined' || typeof value === 'string') { |
||||||
|
return value ?? null |
||||||
|
} |
||||||
|
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,84 @@ |
|||||||
|
import { type IRequestStrict, Router } from 'itty-router'; |
||||||
|
import type { Database } from '../db/database.js'; |
||||||
|
import { buildGeneratorPage, buildResponsesPage, wrapPage } from './template.js'; |
||||||
|
import { CSS, JS } from './client.generated.js'; |
||||||
|
import type { HashedBundled } from '../../common/bundle.js'; |
||||||
|
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './sourcemaps.js'; |
||||||
|
import { collapseWhiteSpace } from 'collapse-white-space'; |
||||||
|
import { getQuerySingleton, takeLast } from '../request/query.js'; |
||||||
|
|
||||||
|
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 startAt = performance.now() |
||||||
|
const results = await db.getGeneratorPageForDiscordSet(getQuerySingleton(req.query['server'], takeLast)); |
||||||
|
const resultsAt = performance.now() |
||||||
|
const generator = buildGeneratorPage({ |
||||||
|
creditsUrl: env.CREDITS_URL, |
||||||
|
clientId: env.DISCORD_APP_ID, |
||||||
|
baseUrl: env.BASE_URL, |
||||||
|
results: results.rolled, |
||||||
|
selected: results.selected, |
||||||
|
includesResponses: true |
||||||
|
}) |
||||||
|
const generatorAt = performance.now() |
||||||
|
const responses = buildResponsesPage({ |
||||||
|
tables: Array.from(results.db.tables.values()), |
||||||
|
results: results.rolled, |
||||||
|
creditsUrl: env.CREDITS_URL, |
||||||
|
includesGenerator: true |
||||||
|
}) |
||||||
|
const responsesAt = performance.now() |
||||||
|
const wrapped = wrapPage({ |
||||||
|
title: 'Vore Scenario Generator', |
||||||
|
script: getSourceMappedJS('combinedGeneratorResponses'), |
||||||
|
styles: getSourceMappedCSS('combinedGeneratorResponses'), |
||||||
|
noscriptStyles: getSourceMappedCSS('noscript'), |
||||||
|
bodyContent: [generator, responses].join('') |
||||||
|
}) |
||||||
|
const wrappedAt = performance.now() |
||||||
|
const trimmed = collapseWhiteSpace(wrapped, {style: 'html'}) |
||||||
|
const trimmedAt = performance.now() |
||||||
|
console.log(`database: ${resultsAt - startAt}, generator: ${generatorAt - resultsAt}, responses: ${responsesAt - generatorAt}, wrapped: ${wrappedAt - responsesAt}, trimmed: ${trimmedAt - wrappedAt}`) |
||||||
|
return trimmed; |
||||||
|
} |
||||||
|
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 [filename, contents] of SourceMaps) { |
||||||
|
router.get(`/${filename}`, () => contents); |
||||||
|
} |
||||||
|
return router; |
||||||
|
} |
@ -0,0 +1,18 @@ |
|||||||
|
import type { RawSourceMap } from 'source-map'; |
||||||
|
import type { HashedBundled } from '../../common/bundle.js'; |
||||||
|
|
||||||
|
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>, RawSourceMap>([ |
||||||
|
|
||||||
|
]) |
||||||
|
|
||||||
|
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` |
||||||
|
} |
@ -1,17 +1,104 @@ |
|||||||
name = "vore-scenario-generator" |
name = "vore-scenario-generator" |
||||||
main = "src/index.ts" |
main = "src/server/entrypoint.ts" |
||||||
compatibility_date = "2023-12-18" |
compatibility_date = "2023-12-18" |
||||||
|
keep_vars = true |
||||||
|
|
||||||
[vars] |
### Development ### |
||||||
# BASE_URL |
|
||||||
# DISCORD_APP_ID |
workers_dev = true |
||||||
# DISCORD_APP_SECRET |
|
||||||
# DISCORD_PUBLIC_KEY |
[build] |
||||||
# DISCORD_DEV_GUILD_IDS |
command = "tsx src/build/bundle-client.ts" |
||||||
|
cwd = "." |
||||||
|
watch_dir = "src/client" |
||||||
|
|
||||||
|
[placement] |
||||||
|
mode = "smart" |
||||||
|
|
||||||
[[d1_databases]] |
[[d1_databases]] |
||||||
binding = "DB" # i.e. available in your Worker on env.DB |
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.ts" |
||||||
|
cwd = "." |
||||||
|
watch_dir = "src/client" |
||||||
|
|
||||||
|
[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" |
||||||
|
|
||||||
|
[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_name = "production-ncc-gen" |
||||||
database_id = "d09d3c74-c75f-4418-8f1b-2fe7f21637e6" |
database_id = "d09d3c74-c75f-4418-8f1b-2fe7f21637e6" |
||||||
migrations_table = "d1_migrations" |
migrations_table = "d1_migrations" |
||||||
migrations_dir = "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