almost done with the web interface...

main
Mari 4 months ago
parent ed7d67e746
commit 4256d25c7d
  1. 2
      .gitignore
  2. 3
      .idea/codeStyles/Project.xml
  3. 2
      LICENSE
  4. 26
      README.md
  5. 25
      migrations/0002_load_rollable_tables_from_the_database_too.sql
  6. 2245
      package-lock.json
  7. 10
      package.json
  8. 4
      src/build/bundle-client-with-source-map.ts
  9. 67
      src/build/bundler.ts
  10. 10
      src/build/check-source-map-and-bundle-client.ts
  11. 28
      src/client/attribution.less
  12. 35
      src/client/basic-look.less
  13. 2
      src/client/combined-generator-responses-entrypoint.ts
  14. 26
      src/client/generator-entrypoint.less
  15. 244
      src/client/generator-entrypoint.ts
  16. 2
      src/client/noscript-entrypoint.less
  17. 7
      src/client/onload.ts
  18. 46
      src/client/popup.less
  19. 19
      src/client/popup.ts
  20. 15
      src/client/pulse.less
  21. 6
      src/client/pulse.ts
  22. 11
      src/client/responses-entrypoint.less
  23. 16
      src/client/responses-entrypoint.ts
  24. 244
      src/client/scraper.ts
  25. 6
      src/client/tsconfig.json
  26. 6
      src/common/bbcode.ts
  27. 17
      src/common/bundle.ts
  28. 169
      src/common/rolltable.ts
  29. 94
      src/common/template.ts
  30. 11
      src/common/tsconfig.json
  31. 349
      src/server/db/database.ts
  32. 422
      src/server/db/queries.ts
  33. 10
      src/server/db/querytypes.ts
  34. 10
      src/server/db/transformers.ts
  35. 6
      src/server/discord/commands.ts
  36. 9
      src/server/discord/embed.ts
  37. 6
      src/server/discord/router.ts
  38. 10
      src/server/entrypoint.ts
  39. 4
      src/server/request/query.ts
  40. 0
      src/server/tsconfig.json
  41. 5
      src/server/web/bundles/sourcemaps.ts
  42. 44
      src/server/web/router.ts
  43. 10
      wrangler.toml

2
.gitignore vendored

@ -173,4 +173,4 @@ dist
# generated bundles from src/client # generated bundles from src/client
src/server/web/client.generated.ts src/server/web/bundles/client.generated.ts

@ -9,6 +9,7 @@
<option name="USE_DOUBLE_QUOTES" value="false" /> <option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" /> <option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" /> <option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="FALSE" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" /> <option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" /> <option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings> </JSCodeStyleSettings>
@ -18,7 +19,7 @@
<option name="USE_DOUBLE_QUOTES" value="false" /> <option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" /> <option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" /> <option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="TRUE" /> <option name="USE_EXPLICIT_JS_EXTENSION" value="FALSE" />
<option name="USE_IMPORT_TYPE" value="ALWAYS" /> <option name="USE_IMPORT_TYPE" value="ALWAYS" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" /> <option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" /> <option name="SPACES_WITHIN_IMPORTS" value="true" />

@ -1,4 +1,4 @@
Copyright 2024 DeliciousReya Copyright (c) 2024 DeliciousReya
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 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:

@ -64,11 +64,27 @@ responses are not private.
## Credits ## Credits
* Icon source: [obsid1an on DeviantArt](https://www.deviantart.com/obsid1an/art/Slot-Machine-Game-Icon-341475642) * Icon source: [obsid1an on DeviantArt](https://www.deviantart.com/obsid1an/art/Slot-Machine-Game-Icon-341475642)
* Writing for default responses by [Ssublissive](https://aryion.com/g4/gallery/Ssublissive), with additional writing by * Writing for default response sets by [Ssublissive](https://aryion.com/g4/gallery/Ssublissive), with additional writing
[DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), [Seina](https://aryion.com/g4/user/RediQ), by [DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), [Seina](https://aryion.com/g4/user/RediQ),
and [sushisama](https://arsenicteacups.carrd.co/). and [sushisama](https://arsenicteacups.carrd.co/).
* Development by [DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), using * UX testing by a :dolphin: friend.
[slash-create](https://slash-create.js.org/#/), [discord-snowflake](https://github.com/ianmitchell/interaction-kit/tree/main/packages/discord-snowflake), [itty-router](https://github.com/kwhitley/itty-router), * Development by [DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya) in [TypeScript](https://www.typescriptlang.org/), using
and [typed-html](https://github.com/nicojs/typed-html). * [slash-create](https://slash-create.js.org/#/) and [discord-snowflake](https://github.com/ianmitchell/interaction-kit/tree/main/packages/discord-snowflake#readme) for Discord management,
* [itty-router](https://github.com/kwhitley/itty-router#readme), [escape-html](https://github.com/component/escape-html#readme),
[markdown-escape](https://github.com/kemitchell/markdown-escape.js#readme), [slug](https://github.com/Trott/slug#readme), and [collapse-white-space](https://github.com/wooorm/collapse-white-space#readme) for response generation,
* [less](https://lesscss.org) with [clean-css](https://github.com/clean-css/clean-css) and [babel](https://babeljs.io/) with [terser](https://terser.org/) for pretty and small client code,
* [rollup](https://rollupjs.org/) with [plugin-commonjs](https://github.com/rollup/plugins/tree/master/packages/commonjs/#readme)/[plugin-node-resolve](https://github.com/rollup/plugins/tree/master/packages/node-resolve/#readme)/[plugin-ts](https://github.com/wessberg/rollup-plugin-ts#readme)/[plugin-terser](https://github.com/rollup/plugins/tree/master/packages/terser#readme) for complete client bundles,
* and [camelcase](https://github.com/sindresorhus/camelcase#readme) and [fast-deep-equal](https://github.com/epoberezkin/fast-deep-equal#readme) for keeping the client bundles and their source maps up to date.
* Hosted on [CloudFlare Workers](https://developers.cloudflare.com/workers/) * Hosted on [CloudFlare Workers](https://developers.cloudflare.com/workers/)
with [D1](https://developers.cloudflare.com/d1/). with [D1](https://developers.cloudflare.com/d1/).
## License (MIT)
Copyright (c) 2024 DeliciousReya and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -146,26 +146,27 @@ BEGIN
UPDATE responses SET tableId = NEW.id WHERE tableId = OLD.id; UPDATE responses SET tableId = NEW.id WHERE tableId = OLD.id;
END; END;
CREATE TRIGGER IF NOT EXISTS rollableTableHeaderUpdate CREATE TRIGGER IF NOT EXISTS rollableTableBadgeUpdate
AFTER UPDATE OF badge AFTER UPDATE
ON rollableTables ON rollableTables
FOR EACH ROW FOR EACH ROW
WHEN NOT EXISTS (SELECT id WHEN OLD.badge != NEW.badge AND NOT EXISTS (SELECT id
FROM rollableTableBadges FROM rollableTableBadges
WHERE badge = NEW.badge WHERE badge = NEW.badge
AND tableId = NEW.badge) AND tableId = NEW.id)
BEGIN BEGIN
INSERT INTO rollableTableHeaders (header, tableId) VALUES (NEW.badge, NEW.id); INSERT INTO rollableTableBadges (badge, tableId)
VALUES (NEW.badge, NEW.id);
END; END;
CREATE TRIGGER IF NOT EXISTS rollableTableHeaderUpdate CREATE TRIGGER IF NOT EXISTS rollableTableHeaderUpdate
AFTER UPDATE OF header AFTER UPDATE
ON rollableTables ON rollableTables
FOR EACH ROW FOR EACH ROW
WHEN NOT EXISTS (SELECT id WHEN OLD.header != NEW.header AND NOT EXISTS (SELECT id
FROM rollableTableHeaders FROM rollableTableHeaders
WHERE header = NEW.header WHERE header = NEW.header
AND tableId = NEW.id) AND tableId = NEW.id)
BEGIN BEGIN
INSERT INTO rollableTableHeaders (header, tableId) VALUES (NEW.header, NEW.id); INSERT INTO rollableTableHeaders (header, tableId) VALUES (NEW.header, NEW.id);
END; END;

2245
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -4,16 +4,22 @@
"private": true, "private": true,
"scripts": { "scripts": {
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"stage": "wrangler deploy --env staging",
"dev": "wrangler dev", "dev": "wrangler dev",
"start": "wrangler dev", "start": "wrangler dev",
"generate": "tsx src/build/bundle-client.ts" "generate": "tsx src/build/bundle-client.ts"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.7",
"@babel/plugin-transform-runtime": "^7.23.7",
"@babel/preset-env": "^7.23.8",
"@babel/preset-typescript": "^7.23.3", "@babel/preset-typescript": "^7.23.3",
"@babel/runtime": "^7.23.8",
"@cloudflare/workers-types": "^4.20231218.0", "@cloudflare/workers-types": "^4.20231218.0",
"@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"@types/clean-css": "^4.2.11", "@types/clean-css": "^4.2.11",
"@types/escape-html": "^1.0.4", "@types/escape-html": "^1.0.4",
"@types/less": "^3.0.6", "@types/less": "^3.0.6",
@ -24,7 +30,7 @@
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"less": "^4.2.0", "less": "^4.2.0",
"rollup": "^4.9.5", "rollup": "^4.9.5",
"source-map": "^0.7.4", "rollup-plugin-ts": "^3.4.5",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"wrangler": "^3.0.0" "wrangler": "^3.0.0"

@ -1,8 +1,8 @@
import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler.js'; import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler';
async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) { async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) {
const bundle = await getBundle(inPath) const bundle = await getBundle(inPath)
await writeBundle(bundle, outPath) await writeBundle(bundle, outPath, true)
} }
main(...process.argv.slice(2)).then(() => { main(...process.argv.slice(2)).then(() => {

@ -1,5 +1,6 @@
import { import {
createPrinter, createPrinter,
parseJsonText,
factory, factory,
NewLineKind, NewLineKind,
NodeFlags, NodeFlags,
@ -15,15 +16,21 @@ import {createHash} from 'node:crypto';
import camelcase from 'camelcase'; import camelcase from 'camelcase';
import { render as renderLess } from 'less'; import { render as renderLess } from 'less';
import CleanCSS from 'clean-css'; import CleanCSS from 'clean-css';
import type { HashedBundled, SourceMappedHashedBundled, SourceMappedBundled, Bundled } from '../common/bundle.js'; import type {
import type { RawSourceMap } from 'source-map'; HashedBundled,
SourceMappedHashedBundled,
SourceMappedBundled,
Bundled,
MaybeSourceMappedHashedBundled, SourceMap
} from '../common/bundle';
import { rollup, type RollupCache } from 'rollup'; import { rollup, type RollupCache } from 'rollup';
import babel from '@rollup/plugin-babel'; import typescript from 'rollup-plugin-ts';
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser'; import terser from '@rollup/plugin-terser';
import nodeResolve from '@rollup/plugin-node-resolve';
import commonJs from '@rollup/plugin-commonjs';
function* assignProperties(pairs: Iterable<[string, HashedBundled]>): Generator<PropertyAssignment> { function* assignProperties(pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): Generator<PropertyAssignment> {
for (const [identifier, { bundled, hash }] of pairs) { for (const [identifier, { bundled, hash, sourceMap }] of pairs) {
yield factory.createPropertyAssignment( yield factory.createPropertyAssignment(
factory.createIdentifier(identifier), factory.createIdentifier(identifier),
factory.createObjectLiteralExpression([ factory.createObjectLiteralExpression([
@ -35,30 +42,34 @@ function* assignProperties(pairs: Iterable<[string, HashedBundled]>): Generator<
factory.createIdentifier("hash"), factory.createIdentifier("hash"),
factory.createStringLiteral(hash) factory.createStringLiteral(hash)
), ),
...(includeSourceMap && sourceMap ? [factory.createPropertyAssignment(
factory.createIdentifier("sourceMap"),
parseJsonText(hash + ".map", JSON.stringify(sourceMap)).statements[0].expression,
)] : [])
], true)); ], true));
} }
} }
function declareObjectLiteral(identifier: string, pairs: Iterable<[string, HashedBundled]>): VariableDeclaration { function declareObjectLiteral(identifier: string, pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): VariableDeclaration {
return factory.createVariableDeclaration( return factory.createVariableDeclaration(
factory.createIdentifier(identifier), factory.createIdentifier(identifier),
undefined, undefined,
undefined, undefined,
factory.createSatisfiesExpression( factory.createSatisfiesExpression(
factory.createAsExpression( factory.createAsExpression(
factory.createObjectLiteralExpression(Array.from(assignProperties(pairs)), true), factory.createObjectLiteralExpression(Array.from(assignProperties(pairs, includeSourceMap)), true),
factory.createTypeReferenceNode(factory.createIdentifier('const'))), factory.createTypeReferenceNode(factory.createIdentifier('const'))),
factory.createTypeReferenceNode( factory.createTypeReferenceNode(
factory.createIdentifier("Record"), factory.createIdentifier("Record"),
[ [
factory.createTypeReferenceNode(factory.createIdentifier("string")), factory.createTypeReferenceNode(factory.createIdentifier("string")),
factory.createTypeReferenceNode(factory.createIdentifier("HashedBundled"))]))); factory.createTypeReferenceNode(factory.createIdentifier("MaybeSourceMappedHashedBundled"))])));
} }
function exportObjectLiteral(identifier: string, pairs: Iterable<[string, HashedBundled]>): VariableStatement { function exportObjectLiteral(identifier: string, pairs: Iterable<[string, MaybeSourceMappedHashedBundled]>, includeSourceMap: boolean): VariableStatement {
return factory.createVariableStatement( return factory.createVariableStatement(
[factory.createToken(SyntaxKind.ExportKeyword)], [factory.createToken(SyntaxKind.ExportKeyword)],
factory.createVariableDeclarationList([declareObjectLiteral(identifier, pairs)], NodeFlags.Const) factory.createVariableDeclarationList([declareObjectLiteral(identifier, pairs, includeSourceMap)], NodeFlags.Const)
); );
} }
@ -72,7 +83,6 @@ async function processLess(atPath: string): Promise<SourceMappedBundled> {
strictImports: true, strictImports: true,
sourceMap: { sourceMap: {
outputSourceFiles: true, outputSourceFiles: true,
sourceMapFileInline: true
} }
}); });
const { styles, sourceMap } = await new CleanCSS({ const { styles, sourceMap } = await new CleanCSS({
@ -93,8 +103,7 @@ async function processLess(atPath: string): Promise<SourceMappedBundled> {
sourceMap: lessMap sourceMap: lessMap
} }
}) })
return { bundled: styles, sourceMap: {...JSON.parse(sourceMap!.toString()), file: fileBase + ".css"} as SourceMap };
return { bundled: styles, sourceMap: JSON.parse(sourceMap!.toString()) as RawSourceMap };
} }
async function processTypescript(atPath: string, inDir: string, cache?: RollupCache): Promise<{cache: RollupCache, bundle: SourceMappedBundled}> { async function processTypescript(atPath: string, inDir: string, cache?: RollupCache): Promise<{cache: RollupCache, bundle: SourceMappedBundled}> {
@ -102,25 +111,20 @@ async function processTypescript(atPath: string, inDir: string, cache?: RollupCa
cache: cache ?? true, cache: cache ?? true,
input: atPath, input: atPath,
plugins: [ plugins: [
nodeResolve({
}),
commonJs({
}),
typescript({ typescript({
noEmitOnError: true, transpiler: "babel",
noForceEmit: true,
emitDeclarationOnly: false,
noEmit: true,
include: [join(inDir, '**', '*.ts')],
typescript: typescriptModule, typescript: typescriptModule,
tsconfig: join(inDir, 'tsconfig.json') tsconfig: join(inDir, 'tsconfig.json')
}), }),
babel({
babelHelpers: 'bundled',
include: [join(inDir, '**', '*.ts'), join(inDir, '**', '*.js')],
extensions: ['js', 'ts'],
presets: ['@babel/preset-typescript']
}),
terser({}) terser({})
] ]
}) })
const {output: [chunk]} = await build.generate({ const {output: [chunk]} = await build.generate({
name: camelcase(basename(atPath.substring(0, atPath.length - TS_SUFFIX.length))),
sourcemap: 'hidden', sourcemap: 'hidden',
sourcemapFile: join(inDir, 'sourcemap.map'), sourcemapFile: join(inDir, 'sourcemap.map'),
format: 'iife', format: 'iife',
@ -169,9 +173,12 @@ export async function getBundle(inDir: string): Promise<{ css: Map<string, Sourc
} }
export const DEFAULT_IN_PATH = normalize(join(__dirname, '../../src/client/')) 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 const DEFAULT_OUT_PATH = normalize(join(__dirname, '../../src/server/web/bundles/client.generated.ts'))
export async function writeBundle({ css, js }: {css: Map<string, HashedBundled>, js: Map<string, HashedBundled>}, outFile: string): Promise<void> { export async function writeBundle({ css, js }: {css: Map<string, SourceMappedHashedBundled>, js: Map<string, SourceMappedHashedBundled>}, outFile: string, includeSourceMap: true): Promise<void>
export async function writeBundle({ css, js }: {css: Map<string, HashedBundled>, js: Map<string, HashedBundled>}, outFile: string, includeSourceMap: false): Promise<void>
export async function writeBundle({ css, js }: {css: Map<string, MaybeSourceMappedHashedBundled>, js: Map<string, MaybeSourceMappedHashedBundled>}, outFile: string, includeSourceMap: boolean): Promise<void>
export async function writeBundle({ css, js }: {css: Map<string, MaybeSourceMappedHashedBundled>, js: Map<string, MaybeSourceMappedHashedBundled>}, outFile: string, includeSourceMap: boolean): Promise<void> {
const printer = createPrinter({ const printer = createPrinter({
newLine: NewLineKind.LineFeed, newLine: NewLineKind.LineFeed,
omitTrailingSemicolon: true omitTrailingSemicolon: true
@ -186,12 +193,12 @@ export async function writeBundle({ css, js }: {css: Map<string, HashedBundled>,
factory.createImportSpecifier( factory.createImportSpecifier(
true, true,
undefined, undefined,
factory.createIdentifier("HashedBundled")) factory.createIdentifier( "MaybeSourceMappedHashedBundled")),
]) ])
), ),
factory.createStringLiteral("../../common/bundle.js")), factory.createStringLiteral("../../common/bundle.js")),
exportObjectLiteral('CSS', css), exportObjectLiteral('CSS', css, includeSourceMap),
exportObjectLiteral('JS', js) exportObjectLiteral('JS', js, includeSourceMap)
], factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None)), { ], factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None)), {
encoding: 'utf-8', encoding: 'utf-8',
mode: 0o644 mode: 0o644

@ -1,5 +1,5 @@
import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler.js'; import { DEFAULT_IN_PATH, DEFAULT_OUT_PATH, getBundle, writeBundle } from './bundler';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from '../server/web/sourcemaps.js'; import { getSourceMapFileName, SourceMapExtension, SourceMaps } from '../server/web/bundles/sourcemaps';
import deepEqual from 'fast-deep-equal'; import deepEqual from 'fast-deep-equal';
async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) { async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_OUT_PATH) {
@ -9,15 +9,15 @@ async function main(inPath: string = DEFAULT_IN_PATH, outPath: string = DEFAULT_
const filename = getSourceMapFileName(name, hash, SourceMapExtension.CSS) const filename = getSourceMapFileName(name, hash, SourceMapExtension.CSS)
const existingMap = SourceMaps.get(filename) const existingMap = SourceMaps.get(filename)
if (!existingMap) { 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`) errors.push(`source map for ${filename} is missing; add this line to server/web/bundles/sourcemaps.ts:\n\t\t${JSON.stringify([filename, sourceMap])},\n\n`)
} else if (!deepEqual(sourceMap, existingMap)) { } 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`) errors.push(`source map for ${filename} is incorrect; replace this line in server/web/bundles/sourcemaps.ts:\n\t\t${JSON.stringify([filename, existingMap])},\n\nwith this line:\n\t\t${JSON.stringify([filename, sourceMap])},\n\n`)
} }
} }
if (errors.length > 0) { if (errors.length > 0) {
throw Error(errors.join('\n')) throw Error(errors.join('\n'))
} }
await writeBundle(bundle, outPath) await writeBundle(bundle, outPath, false)
} }
main(...process.argv.slice(2)).then(() => { main(...process.argv.slice(2)).then(() => {

@ -52,6 +52,14 @@
user-select: text; user-select: text;
} }
.attributed:hover .attributionBubble {
transition-delay: 1.0s;
}
.attributed:focus-within .attributionBubble {
transition-delay: 0s;
}
.attributed:hover .attributionBubble, .attributed:focus-within .attributionBubble { .attributed:hover .attributionBubble, .attributed:focus-within .attributionBubble {
opacity: 100%; opacity: 100%;
transform: none; transform: none;
@ -61,3 +69,23 @@
.attributed:hover .attributionBubble *, .attributed:focus-within .attributionBubble * { .attributed:hover .attributionBubble *, .attributed:focus-within .attributionBubble * {
user-select: text; user-select: text;
} }
.attributionBubble a {
transition: color 300ms ease;
}
.attributionBubble a:link {
color: aquamarine;
}
.attributionBubble a:visited {
color: mediumaquamarine;
}
.attributionBubble a:focus, .attributionBubble a:hover {
color: lightcyan;
}
.attributionBubble a:active {
color: aqua;
}

@ -104,15 +104,48 @@ footer {
font-family: inherit; font-family: inherit;
outline: 0; outline: 0;
border: 0; border: 0;
padding: 0; padding: 0.2rem 0.5rem;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
word-wrap: normal; word-wrap: normal;
width: 100%; width: 100%;
box-sizing: border-box;
white-space: normal; white-space: normal;
user-select: text; user-select: text;
transition: background-color 0.2s ease;
border-radius: 0.3rem;
}
.resultText:hover {
background-color: #BFBFBF60;
}
.resultText:active, .resultText:focus {
background-color: #9F9FFF90;
} }
footer { footer {
text-align: center; text-align: center;
} }
@keyframes popup {
from {
transform: scale(0);
opacity: 0;
}
10% {
transform: none;
opacity: 100%;
}
75% {
transform: none;
opacity: 100%;
}
to {
transform: scale(0);
opacity: 0;
}
}

@ -1,3 +1,5 @@
import './generator-entrypoint'
function updateHash(): void { function updateHash(): void {
if (location.hash === "" || location.hash === "#" || !location.hash) { if (location.hash === "" || location.hash === "#" || !location.hash) {
location.replace("#generator") location.replace("#generator")

@ -1,5 +1,7 @@
@import "basic-look"; @import "basic-look";
@import "attribution"; @import "attribution";
@import "popup";
@import "pulse";
#generator { #generator {
position: absolute; position: absolute;
@ -10,6 +12,7 @@
margin: 0; margin: 0;
padding: 2rem; padding: 2rem;
display: flex; display: flex;
box-sizing: border-box;
flex-flow: column nowrap; flex-flow: column nowrap;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -25,6 +28,9 @@
.generatedHead { .generatedHead {
user-select: text; user-select: text;
margin: 0.5rem 0 0 0;
display: flex;
flex-flow: row nowrap;
} }
.generatedHead .generatedLabel span { .generatedHead .generatedLabel span {
@ -44,8 +50,8 @@
} }
.generated { .generated {
margin-left: 0; margin: 0;
margin-top: 0; padding: 0;
appearance: none; appearance: none;
font: inherit; font: inherit;
outline: 0; outline: 0;
@ -58,12 +64,28 @@
cursor: pointer; cursor: pointer;
font-size: 1.5rem; font-size: 1.5rem;
margin: 0; margin: 0;
transition: filter 0.3s ease, transform 0.3s ease;
width: 2rem;
height: 2rem;
text-align: center;
line-height: 2rem;
border-radius: 1rem;
} }
#generator .buttons { #generator .buttons {
margin-left: -0.3rem; margin-left: -0.3rem;
} }
.generatedHead:hover .generatedSelect, .generatedHead .generatedSelect:focus {
filter: brightness(120%) saturate(80%);
transform: scale(120%);
}
.generatedHead .generatedSelect:active {
filter: brightness(80%) saturate(110%);
transform: scale(80%);
}
.generatedSelect::after { .generatedSelect::after {
content: '🔒' content: '🔒'
} }

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

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

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

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

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

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

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

@ -1,5 +1,6 @@
@import "basic-look"; @import "basic-look";
@import "attribution"; @import "attribution";
@import "popup";
#responsesHeader { #responsesHeader {
position: sticky; position: sticky;
@ -18,7 +19,7 @@
z-index: 2; z-index: 2;
} }
#responsesHeader nav { #responsesHeader .buttons {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
padding-top: 0.2rem; padding-top: 0.2rem;
@ -29,10 +30,8 @@
overflow-x: visible; overflow-x: visible;
} }
#responsesHeader nav .buttons { #returnToGenerator {
flex-flow: row wrap; flex-basis: 50%;
padding-top: 0.2rem;
padding-right: 0.2rem;
} }
.responseNavEmoji { .responseNavEmoji {
@ -45,7 +44,7 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
.responseLists { #responseLists {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
padding: 0.1rem; padding: 0.1rem;

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

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

@ -11,8 +11,8 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */ /* Language and Environment */
"target": "ES5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, "target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["dom", "dom.iterable", "ESNext"] "lib": ["dom", "dom.iterable", "ES2015"]
/* Specify a set of bundled library declaration files that describe the target runtime environment. */, /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "react" /* Specify what JSX code is generated. */, // "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
@ -70,7 +70,7 @@
/* Interop Constraints */ /* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, "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. */, "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. */, "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. */ // "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. */, "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,

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

@ -1,5 +1,3 @@
import type { RawSourceMap } from 'source-map';
export interface Bundled { export interface Bundled {
readonly bundled: string, readonly bundled: string,
} }
@ -8,8 +6,21 @@ export interface HashedBundled extends Bundled {
readonly hash: string, readonly hash: string,
} }
export interface SourceMap {
readonly version: number
readonly file?: string
readonly sourceRoot?: string
readonly sources: readonly string[]
readonly sourcesContent?: readonly (string|null)[]
readonly names: readonly string[]
readonly mappings: string
readonly x_google_ignoreList?: readonly number[]
}
export interface SourceMappedBundled extends Bundled { export interface SourceMappedBundled extends Bundled {
readonly sourceMap: RawSourceMap, readonly sourceMap: SourceMap,
} }
export interface SourceMappedHashedBundled extends SourceMappedBundled, HashedBundled {} export interface SourceMappedHashedBundled extends SourceMappedBundled, HashedBundled {}
export interface MaybeSourceMappedHashedBundled extends HashedBundled, Partial<Omit<SourceMappedBundled, keyof HashedBundled>> {}

@ -1,10 +1,12 @@
import markdownEscape from 'markdown-escape';
import { bbcodeEscape } from './bbcode';
export interface RollTableLimited { export interface RollTableLimited {
readonly full: false, readonly full: false,
readonly emoji: string, readonly emoji: string,
readonly title: string, readonly title: string,
readonly header: string, readonly header: string,
readonly ordinal: number, readonly ordinal: number,
readonly results?: null,
} }
export interface RollTableDetailsBase { export interface RollTableDetailsBase {
@ -18,11 +20,11 @@ export interface RollTableDetailsBase {
} }
export type RollTable = RollTableLimited | RollTableDetails export type RollTable = RollTableLimited | RollTableDetails
export type RollTableOrInput = RollTable | RollTableDetailsInput export type RollTableOrInput = RollTable | RollTableDetailsInputResults
export function rollTableToString(v: RollTable) { export function rollTableToString(v: RollTable) {
if (v.full) { 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]` : '' }` return `${v.header} (${v.id}/${v.identifier}/${v.name}/${v.emoji}/${v.title}/#${v.ordinal})${v.full === 'results' ? ` [${v.resultsById.size} results]` : '' }`
} else { } else {
return `${v.header} (???#${v.ordinal})` return `${v.header} (???#${v.ordinal})`
} }
@ -76,15 +78,15 @@ export interface RollTableResultFull<T extends RollTableOrInput = RollTableDetai
export type RollTableResult<T extends RollTableOrInput = RollTable> = RollTableResultLimited<T> | RollTableResultFull<T> export type RollTableResult<T extends RollTableOrInput = RollTable> = RollTableResultLimited<T> | RollTableResultFull<T>
export type RollTableResultOrLookup<T extends RollTableOrInput = RollTable> = RollTableResultFull<T>|RollTableResultLookup export type RollTableResultOrLookup<T extends RollTableOrInput = RollTable> = RollTableResultFull<T>|RollTableResultLookup
function setToString(v: RollTableResultSet): string { export function setToString(v: RollTableResultSet): string {
return `${v.global ? 'global' : 'local'} ${v.name ?? 'set'}` return `${v.global ? 'global' : 'local'} ${v.name ?? 'set'}`
} }
function authorToString(v: RollTableAuthor): string { export function authorToString(v: RollTableAuthor): string {
return `${v.relation} ${v.name} (${v.id})` return `${v.relation} ${v.name} (${v.id})`
} }
function resultToString(v: RollTableResult) { export function rollResultToString(v: RollTableResult) {
if (v.full) { if (v.full) {
return `${v.text} (${v.mappingId}: ${v.textId}/${rollTableToStringShort(v.table)}/${setToString(v.set)}/${v.author ? authorToString(v.author) : 'no author'})` return `${v.text} (${v.mappingId}: ${v.textId}/${rollTableToStringShort(v.table)}/${setToString(v.set)}/${v.author ? authorToString(v.author) : 'no author'})`
} else { } else {
@ -103,33 +105,30 @@ export interface RollTableResultLookup {
} }
export interface RollTableDetailsInputResults extends RollTableDetailsBase { export interface RollTableDetailsInputResults extends RollTableDetailsBase {
readonly results: Iterable<RollTableResultOrLookup<RollTableDetailsInputResults>|readonly [number, RollTableResultOrLookup<RollTableDetailsInputResults>]>; readonly full: 'input'
readonly resultsById: Iterable<RollTableResultOrLookup<RollTableDetailsInputResults>|readonly [number, RollTableResultOrLookup<RollTableDetailsInputResults>]>;
} }
function isResultArray(v: unknown): v is readonly [unknown, RollTableResultOrLookup<RollTableDetailsOrInput>] { function isResultArray(v: unknown): v is readonly [unknown, RollTableResultOrLookup<RollTableDetailsOrInput>] {
return Array.isArray(v) && isRollTableResult(v[1]) return Array.isArray(v) && isRollTableResult(v[1])
} }
export interface RollTableDetailsInputNoResults extends RollTableDetailsBase { export type RollTableDetailsOrInput = RollTableDetails | RollTableDetailsInputResults
readonly results?: null
}
export type RollTableDetailsInput = RollTableDetailsInputResults | RollTableDetailsInputNoResults
export type RollTableDetailsOrInput = RollTableDetails | RollTableDetailsInput
export interface RollTableDetailsNoResults extends RollTableDetailsBase { export interface RollTableDetailsNoResults extends RollTableDetailsBase {
readonly full: 'details' readonly full: 'details'
readonly results?: null;
} }
export interface RollTableDetailsAndResults extends RollTableDetailsBase { export interface RollTableDetailsAndResults extends RollTableDetailsBase {
readonly full: 'results' readonly full: 'results'
readonly results: ReadonlyMap<number, RollTableResultFull<this>>; readonly resultsById: ReadonlyMap<number, RollTableResultFull<this>>
readonly resultsByText: ReadonlyMap<string, RollTableResultFull<this>>
} }
interface RollTableDetailsAndResultsInternal extends RollTableDetailsBase { interface RollTableDetailsAndResultsInternal extends RollTableDetailsBase {
readonly full: 'results' readonly full: 'results'
readonly results: Map<number, RollTableResultFull<this>>; readonly resultsById: Map<number, RollTableResultFull<this>>
readonly resultsByText: Map<string, RollTableResultFull<this>>
} }
export type RollTableDetails = RollTableDetailsNoResults|RollTableDetailsAndResults export type RollTableDetails = RollTableDetailsNoResults|RollTableDetailsAndResults
@ -141,11 +140,46 @@ function compareRollTables(a: RollTableOrInput, b: RollTableOrInput): number {
(a.header > b.header ? 1 : a.header < b.header ? -1 : 0); (a.header > b.header ? 1 : a.header < b.header ? -1 : 0);
} }
// <0: a is a better fit
// >0: b is a better fit
// =0: they're the same
function compareRollTableResults(a: RollTableResult|null|undefined, b: RollTableResult|null|undefined): number {
const preferA = -1
const preferB = 1
const equalPreference = 0
if (a && a.full) {
if (b && b.full) {
if (a.set.global === b.set.global) {
return a.updated.getDate() < b.updated.getDate() ? preferA : preferB
} else {
return !a.set.global ? preferA : preferB
}
} else {
return preferA
}
} else {
if (b && b.full) {
return preferB
} else {
return equalPreference
}
}
}
function isRollTableResult(result: unknown): result is RollTableResult<RollTableDetailsOrInput> { function isRollTableResult(result: unknown): result is RollTableResult<RollTableDetailsOrInput> {
return (typeof result === "object" && result !== null && 'table' in result return (typeof result === "object" && result !== null && 'table' in result
&& !('tableId' in result && typeof result.tableId !== 'undefined') && 'full' in result); && !('tableId' in result && typeof result.tableId !== 'undefined') && 'full' in result);
} }
export function getResultFrom(table: RollTable, originalResult: RollTableResult): RollTableResult {
const dbResult = table.full === "results" ? table.resultsByText.get(originalResult.text) : null
return dbResult ?? {
full: false,
table,
text: originalResult.text
}
}
export class RollTableMap<T extends RollTableOrInput> extends Map<T extends RollTable ? number : (number|string), T> { 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]> { [Symbol.iterator](): IterableIterator<[T extends RollTable ? number : (number|string), T]> {
return this.entries(); return this.entries();
@ -191,7 +225,7 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
constructor({ tables = [], results = [], authors = [], sets = [] }: { constructor({ tables = [], results = [], authors = [], sets = [] }: {
tables?: Iterable<RollTableDetailsOrInput>, tables?: Iterable<RollTableDetailsOrInput>,
results?: Iterable<RollTableResultFull<RollTableDetailsInput> | RollTableResultLookup>, results?: Iterable<RollTableResultFull<RollTableDetailsOrInput> | RollTableResultLookup>,
authors?: Iterable<RollTableAuthor>, authors?: Iterable<RollTableAuthor>,
sets?: Iterable<RollTableResultSet> sets?: Iterable<RollTableResultSet>
} = {}) { } = {}) {
@ -217,6 +251,14 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
return this.tablesById; return this.tablesById;
} }
getTableMatching(table: RollTableOrInput): RollTableDetailsAndResults|undefined {
if (table.full) {
return this.tables.get(table.id)
} else {
return Array.from(this.tables.values()).find(t => (t.header === table.header))
}
}
get sets(): ReadonlyMap<number, RollTableResultSet> { get sets(): ReadonlyMap<number, RollTableResultSet> {
return this.setsById; return this.setsById;
} }
@ -233,15 +275,15 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
return this.mappingsByTextId; return this.mappingsByTextId;
} }
addTable(table: RollTableDetailsInput): RollTableDetailsAndResults { addTable(table: RollTableDetailsOrInput): RollTableDetailsAndResults {
return this.addTableInternal(table); return this.addTableInternal(table);
} }
private addTableInternal(table: RollTableDetailsInput): RollTableDetailsAndResultsInternal { private addTableInternal(table: RollTableDetailsOrInput): RollTableDetailsAndResultsInternal {
const existingTable = this.tablesById.get(table.id); const existingTable = this.tablesById.get(table.id);
if (existingTable) { if (existingTable) {
if (table.results) { if (table.full === 'input' || table.full === 'results') {
for (const result of table.results) { for (const result of table.resultsById) {
this.addResult(result); this.addResult(result);
} }
} }
@ -250,10 +292,11 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
const internalTable: RollTableDetailsAndResultsInternal = { const internalTable: RollTableDetailsAndResultsInternal = {
...table, ...table,
full: 'results', full: 'results',
results: new Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>>() resultsById: new Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>>(),
resultsByText: new Map<string, RollTableResultFull<RollTableDetailsAndResultsInternal>>(),
}; };
if (table.results) { if (table.full === 'input' || table.full === 'results') {
for (const result of table.results) { for (const result of table.resultsById) {
this.addResult(result); this.addResult(result);
} }
} }
@ -288,21 +331,24 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>]; const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>];
return this.addResult(innerResult); return this.addResult(innerResult);
} else if (isRollTableResult(result)) { } else if (isRollTableResult(result)) {
const internalTable = if (!this.tables.has(result.table.id)) {
this.tablesById.get(result.table.id) ?? this.addTableInternal({... result.table, results: null}); this.addTableInternal({... result.table, full: 'details'})
const internalAuthor = }
result.full && result.author ? (this.authorsById.get(result.author.id) ?? this.addAuthor(result.author)) : null; if (result.author && !this.authors.has(result.author.id)) {
const internalSet = this.setsById.get(result.set.id) ?? this.addSet(result.set); this.addAuthor(result.author)
const out: RollTableResultFull<RollTableDetailsAndResultsInternal> = { }
...result, if (!this.sets.has(result.set.id)) {
table: internalTable, this.addSet(result.set)
author: internalAuthor, }
set: internalSet return this.addResult({
}; tableId: result.table.id,
internalTable.results.set(out.textId, out); authorId: result.author?.id ?? null,
this.mappingsByTextId.set(out.textId, out); setId: result.set.id,
this.mappingsByMappingId.set(out.mappingId, out); textId: result.textId,
return out; text: result.text,
mappingId: result.mappingId,
updated: result.updated
})
} else { } else {
const internalTable = this.tablesById.get(result.tableId); const internalTable = this.tablesById.get(result.tableId);
const internalAuthor = typeof result.authorId === 'number' ? this.authorsById.get(result.authorId) : null; const internalAuthor = typeof result.authorId === 'number' ? this.authorsById.get(result.authorId) : null;
@ -315,6 +361,8 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
} else if (typeof internalSet === 'undefined') { } else if (typeof internalSet === 'undefined') {
throw Error(`no known set with ID ${result.setId}`); throw Error(`no known set with ID ${result.setId}`);
} }
const oldText = internalTable.resultsByText.get(result.text)
const oldId = internalTable.resultsById.get(result.textId)
const out: RollTableResultFull<RollTableDetailsAndResultsInternal> = { const out: RollTableResultFull<RollTableDetailsAndResultsInternal> = {
full: true, full: true,
textId: result.textId, textId: result.textId,
@ -325,7 +373,12 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
set: internalSet, set: internalSet,
updated: result.updated updated: result.updated
}; };
internalTable.results.set(out.textId, out); if (compareRollTableResults(oldText, out) > 0) {
internalTable.resultsByText.set(out.text, out);
}
if (compareRollTableResults(oldId, out) > 0) {
internalTable.resultsById.set(out.textId, out);
}
this.mappingsByTextId.set(out.textId, out); this.mappingsByTextId.set(out.textId, out);
this.mappingsByMappingId.set(out.mappingId, out); this.mappingsByMappingId.set(out.mappingId, out);
return out; return out;
@ -333,15 +386,15 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
} }
} }
function rollOn(table: RollTableDetailsAndResults): RollTableResult<RollTableDetailsAndResults> { export function rollOn(table: RollTableDetailsAndResults): RollTableResult<RollTableDetailsAndResults> {
const results = Array.from(table.results.values()); const results = Array.from(table.resultsById.values());
if (results.length === 0) { if (results.length === 0) {
throw Error(`no results for table ${table.identifier}`); throw Error(`no results for table ${table.identifier}`);
} }
return results[Math.floor(results.length * Math.random())]; return results[Math.floor(results.length * Math.random())];
} }
function rollOnAll(tables: Iterable<RollTableDetailsAndResults>): RolledValues<RollTableDetailsAndResults> { export function rollOnAll(tables: Iterable<RollTableDetailsAndResults>): RolledValues<RollTableDetailsAndResults> {
const result = new RolledValues<RollTableDetailsAndResults>(); const result = new RolledValues<RollTableDetailsAndResults>();
for (const table of tables) { for (const table of tables) {
result.set(table, rollOn(table)); result.set(table, rollOn(table));
@ -349,7 +402,7 @@ function rollOnAll(tables: Iterable<RollTableDetailsAndResults>): RolledValues<R
return result; return result;
} }
function rerollOn<T extends RollTable>(tables: Iterable<RollTableDetailsAndResults>, original: Iterable<[T, RollTableResult<T>]>): RolledValues<T|RollTableDetailsAndResults> { export function rerollOn<T extends RollTable>(tables: Iterable<RollTableDetailsAndResults>, original: Iterable<[T, RollTableResult<T>]>): RolledValues<T|RollTableDetailsAndResults> {
const result = new RolledValues<T|RollTableDetailsAndResults>(); const result = new RolledValues<T|RollTableDetailsAndResults>();
const tableSet = new Set<RollTable>(tables); const tableSet = new Set<RollTable>(tables);
for (const [table, originalValue] of original) { for (const [table, originalValue] of original) {
@ -374,11 +427,35 @@ export interface InProgressGeneratedState<T extends RollTableOrInput = RollTable
readonly selected: ReadonlySet<T> readonly selected: ReadonlySet<T>
} }
export enum ExportFormat {
Markdown = "md",
BBCode = "bb",
TextEmoji = "emoji",
TextOnly = "text",
}
export function exportResult(result: RollTableResult, format: ExportFormat): string {
switch (format) {
case ExportFormat.Markdown:
return `**${markdownEscape(result.table.header)}**\n${markdownEscape(result.text)}`
case ExportFormat.BBCode:
return `[b]${bbcodeEscape(result.table.title)}[/b]\n${bbcodeEscape(result.text)}`
case ExportFormat.TextEmoji:
return `${result.table.header}\n${result.text}`
case ExportFormat.TextOnly:
return `${result.table.title}\n${result.text}`
}
}
export function exportScenario(contents: RollTableResult[], format: ExportFormat): string {
return contents.map(r => exportResult(r, format)).join("\n\n")
}
export function generatedStateToString(contents: GeneratedState): string { export function generatedStateToString(contents: GeneratedState): string {
if (contents.final) { if (contents.final) {
return `Final state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${resultToString(value)}`).join(" ::: ")}` return `Final state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}`
} else { } 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(", ")}` return `Current state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}. Selection: ${Array.from(contents.selected).map(v => `${rollTableToStringShort(v)}`).join(", ")}`
} }
} }

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

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

@ -4,20 +4,19 @@ import {
type GeneratedContents, type GeneratedContents,
type GeneratedState, type GeneratedState,
type InProgressGeneratedContents, type InProgressGeneratedContents,
type InProgressGeneratedState, RolledValues, RollSelections, type InProgressGeneratedState,
RolledValues, rollOn, rollResultToString,
RollSelections,
type RollTable, type RollTable,
type RollTableAuthor, RollTableDatabase, type RollTableAuthor,
RollTableDatabase,
type RollTableDetailsNoResults, type RollTableDetailsNoResults,
type RollTableResult, type RollTableResultFull,
type RollTableResultFull } from '../../common/rolltable';
} from '../../common/rolltable.js'; import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes';
import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes.js'; import { DatabaseQueries } from './queries';
import {
DatabaseQueries,
} from './queries.js';
import { recordError } from '../discord/embed.js';
function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)["getResultMappingsForDiscordSet"]>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> & { function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['getResultMappingsForDiscordSet']>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> & {
status: 'updated' | 'existing' status: 'updated' | 'existing'
}) | undefined { }) | undefined {
if (!result) { if (!result) {
@ -55,93 +54,6 @@ function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)["ge
}; };
} }
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 { export class Database {
private readonly db: TypedDBWrapper; private readonly db: TypedDBWrapper;
private readonly queries: PreparedQueries<typeof DatabaseQueries>; private readonly queries: PreparedQueries<typeof DatabaseQueries>;
@ -162,7 +74,7 @@ export class Database {
setSnowflake: setSnowflake, setSnowflake: setSnowflake,
tableIdentifierSubstring: tableIdentifier, tableIdentifierSubstring: tableIdentifier,
pattern: partialText, pattern: partialText,
includeGlobal, includeGlobal
})); }));
} }
@ -310,8 +222,8 @@ export class Database {
tableIdentifier: table, tableIdentifier: table,
text, text,
setSnowflake: setId, setSnowflake: setId,
includeGlobal: true, includeGlobal: true
})) }));
const result = processOperationResult(results[0]); const result = processOperationResult(results[0]);
if (!result) { if (!result) {
return { return {
@ -324,150 +236,145 @@ export class Database {
}; };
} }
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> { async getDiscordAuthor(id: string): Promise<RollTableAuthor | null> {
return await this.db.run(this.queries.getDiscordAuthor({ return await this.db.run(this.queries.getDiscordAuthor({
userSnowflake: id, userSnowflake: id
})) }));
} }
async setDiscordAuthor(id: string, username: string, name: string | null, url: string | null): Promise<RollTableAuthor|null> { async setDiscordAuthor(id: string, username: string, name: string | null, url: string | null): Promise<RollTableAuthor | null> {
const [, result] = await this.db.batch( const [, result] = await this.db.batch(
this.queries.setDiscordAuthor({ this.queries.setDiscordAuthor({
userSnowflake: id, userSnowflake: id,
username: username, username: username,
name: name, name: name,
url: url, url: url
}), }),
this.queries.getDiscordAuthor({userSnowflake: id}) this.queries.getDiscordAuthor({ userSnowflake: id })
) );
return result; return result;
} }
private async getWebPageDataForDiscordSet(reroll: true, setSnowflake: string|null, oldResults?: InProgressGeneratedContents | null, finalize?: false): Promise<InProgressGeneratedState & {db: RollTableDatabase}> private async getGeneratorDataForDiscordSet(reroll: true, setSnowflake: string | null, oldResults?: InProgressGeneratedContents | null, finalize?: false): Promise<InProgressGeneratedState & {
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults?: null): Promise<RollTableDatabase> db: 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 getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults?: null): Promise<RollTableDatabase>
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: InProgressGeneratedContents): Promise<InProgressGeneratedState & {db: RollTableDatabase}> private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: GeneratedContents, finalize: false): Promise<InProgressGeneratedState & {
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: FinalGeneratedContents): Promise<FinalGeneratedState & {db: RollTableDatabase}> 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})> { private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: GeneratedContents, finalize: true): Promise<FinalGeneratedState & {
const { tables, mappings, results, sets, authors } = db: RollTableDatabase
await this.db.run(this.queries.getFullDatabaseForDiscordSet({ }>
reroll, private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: InProgressGeneratedContents): Promise<InProgressGeneratedState & {
setSnowflake, db: RollTableDatabase
original: oldResults ? Array.from(oldResults.rolled) : null, }>
selection: oldResults && !oldResults.final ? Array.from(oldResults.selected) : null, private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: FinalGeneratedContents): Promise<FinalGeneratedState & {
})) db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: boolean, setSnowflake: string | null, oldResults: GeneratedContents, finalize?: boolean): Promise<GeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: boolean, setSnowflake: string | null, oldResults?: GeneratedContents | null, finalize?: boolean): Promise<RollTableDatabase | (GeneratedState & {
db: RollTableDatabase
})> {
const oldHeaders = oldResults && oldResults.rolled.size > 0 ? Array.from(oldResults.rolled.keys()) : [];
const [tables, oldKeys, oldSelection, { mappings, sets, authors }] =
await this.db.batch(
this.queries.getTables({}),
this.queries.getTableIdsByIdentifierOrHeader({
identifiersOrHeaders: oldHeaders
}),
this.queries.getTableIdsByIdentifierOrHeader({
identifiersOrHeaders: oldResults && !oldResults.final && oldResults.selected.size > 0 ? Array.from(oldResults.selected) : []
}),
this.queries.getFullDatabaseForDiscordSet({
setSnowflake
}));
const db = new RollTableDatabase({ const db = new RollTableDatabase({
tables, authors, sets, results: mappings.map(v => ({...v, updated: new Date(v.updated)}))}) tables: tables.map(v => ({ ...v, full: 'details' })),
if (!results) { authors,
return db sets,
results: mappings.map(v => ({ ...v, updated: new Date(v.updated) }))
});
if (!oldResults && !reroll) {
return db;
} }
const rolled = new RolledValues() const selected = new RollSelections(oldSelection.flatMap(v => {
for (const result of results) { if (v === null) {
switch (result.type) { return [];
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
} }
} const table = db.tables.get(v);
if (finalize === true || (finalize === null && oldResults?.final)) { if (!table) {
return { return [];
final: true,
db,
rolled
} }
} return [table];
const selected = new RollSelections() }));
for (const table of tables) { const rolled = new RolledValues();
if (table.selected) { const rollKeys = oldResults ? oldKeys : tables.map(t => t.id);
selected.add(db.tables.get(table.id)!) for (let index = 0; index < rollKeys.length; index += 1) {
const tableId = rollKeys[index];
const lookupTable = tableId !== null ? db.tables.get(tableId) : null;
const oldHeader = oldHeaders[index];
const [oldEmoji, oldTitle] = oldHeader ? oldHeader.split(' ', 2) : ['', ''];
const table: RollTable = lookupTable ?? {
full: false,
header: oldHeader,
emoji: oldEmoji,
title: oldTitle,
ordinal: index
};
const text = oldResults?.rolled.get(oldHeader);
if (reroll && table.full && (!text || selected.has(table))) {
const result = rollOn(table)
rolled.add(result);
} else if (text) {
const lookupResult = text && table.full === 'results' ? table.resultsByText.get(text) : null;
const result = lookupResult ?? {
full: false,
text: text,
table
}
rolled.add(result);
} }
} }
return { return (finalize ?? oldResults?.final) ? {
final: true,
db,
rolled
} : {
final: false, final: false,
db, db,
rolled, rolled,
selected selected
} };
}
async getGeneratorPageForDiscordSet(setSnowflake: string | null, oldResults?: InProgressGeneratedContents | null): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}> {
return this.getGeneratorDataForDiscordSet(true, setSnowflake, oldResults, false);
} }
async getGeneratorPageForDiscordSet(setSnowflake: string|null, oldResults?: InProgressGeneratedContents|null): Promise<InProgressGeneratedState & {db: RollTableDatabase}> { async expandFromDiscordSet(setId: string, contents: FinalGeneratedContents): Promise<FinalGeneratedState>
return this.getWebPageDataForDiscordSet(true, setSnowflake, oldResults, false) async expandFromDiscordSet(setId: string, contents: InProgressGeneratedContents): Promise<InProgressGeneratedState>
async expandFromDiscordSet(setId: string, contents: GeneratedContents): Promise<GeneratedState>
async expandFromDiscordSet(setId: string, contents: GeneratedContents): Promise<GeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, contents);
}
async generateFromDiscordSet(setId: string): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(true, setId);
}
async rerollFromDiscordSet(setId: string, existing: InProgressGeneratedContents): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(true, setId, existing);
}
async reopenFromDiscordSet(setId: string, existing: FinalGeneratedContents): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, existing, false);
}
async finalizeFromDiscordSet(setId: string, existing: InProgressGeneratedContents): Promise<FinalGeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, existing, true);
} }
} }

@ -1,4 +1,4 @@
import { type QueryDefinitions, validatedDefinitions } from './querytypes.js'; import { type QueryDefinitions, validatedDefinitions } from './querytypes';
import { import {
boolean, boolean,
discordSnowflake, discordSnowflake,
@ -10,8 +10,8 @@ import {
tableIdentifierSubstring, tableIdentifierSubstring,
timestamp, timestamp,
URL URL
} from './validators.js'; } from './validators';
import { guaranteedSingleton, jsonParser, nothing, rows, singleton, writeCount } from './transformers.js'; import { extract, guaranteedSingleton, jsonParser, nothing, rows, singleton, writeCount } from './transformers';
export const DatabaseQueries = validatedDefinitions({ export const DatabaseQueries = validatedDefinitions({
autocompleteTable: { autocompleteTable: {
@ -470,190 +470,6 @@ export const DatabaseQueries = validatedDefinitions({
}, },
output: writeCount() 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: { getDiscordAuthor: {
query: ` query: `
SELECT authors.id AS id, SELECT authors.id AS id,
@ -699,160 +515,88 @@ export const DatabaseQueries = validatedDefinitions({
}, },
output: nothing() output: nothing()
}, },
getFullDatabaseForDiscordSet: { getTableIdsByIdentifierOrHeader: {
query: `WITH originalResults (tableId, header, ordinal, text) AS (SELECT rollableTables.id AS tableId, query: `SELECT COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) AS id
rollableTables.header AS header, FROM json_each(?1) selection
rollableTables.ordinal AS ordinal, LEFT JOIN rollableTableIdentifiers
NULL AS text ON rollableTableIdentifiers.identifier = selection.value
FROM rollableTables LEFT JOIN rollableTableHeaders ON rollableTableHeaders.header = selection.value;`,
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: { parameters: {
'reroll': { 'identifiersOrHeaders': {
validator: boolean, validator: jsonArray,
index: 1 index: 1
}, }
},
output: rows(extract<number | null>("id"))
},
getTables: {
query: `SELECT id, identifier, name, title, emoji, header, ordinal
FROM rollableTables`,
parameters: {},
output: rows<{
id: number,
identifier: string,
name: string,
title: string,
emoji: string,
header: string,
ordinal: number
}>()
},
getFullDatabaseForDiscordSet: {
query: `WITH visibleSets (id, name, description, global)
AS (SELECT resultSets.id,
resultSets.name,
resultSets.description,
resultSets.global
FROM resultSets
WHERE (resultSets.global OR resultSets.discordSnowflake = ?1)),
visibleResults (mappingId, setId, textId, tableId, text, authorId, updated)
AS (SELECT resultMappings.id AS mappingId,
resultMappings.setId AS setId,
resultMappings.resultId AS textId,
rollableResults.tableId AS tableId,
rollableResults.text AS text,
resultMappings.authorId AS authorId,
resultMappings.updated AS updated
FROM resultMappings
INNER JOIN visibleSets ON resultMappings.setId = visibleSets.id
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId),
visibleAuthors (id, name, url, relation)
AS (SELECT DISTINCT authors.id,
COALESCE(authors.name, authorshipTypes.defaultAuthor),
authors.url,
authorshipTypes.relationPrefix
FROM visibleResults
INNER JOIN authors ON authors.id = visibleResults.authorId
INNER JOIN authorshipTypes ON authorshipTypes.id = authors.authorshipTypeId)
SELECT (SELECT json_group_array(json_object('id', visibleSets.id,
'name', visibleSets.name,
'description', visibleSets.description,
'global', CASE
WHEN visibleSets.global THEN json('true')
ELSE json('false') END))
FROM visibleSets) AS sets,
(SELECT json_group_array(json_object('id', visibleAuthors.id,
'name', visibleAuthors.name,
'url', visibleAuthors.url,
'relation', visibleAuthors.relation))
FROM visibleAuthors) AS authors,
(SELECT json_group_array(json_object('mappingId', visibleResults.mappingId,
'textId', visibleResults.textId,
'text', visibleResults.text,
'tableId', visibleResults.tableId,
'setId', visibleResults.setId,
'authorId', visibleResults.authorId,
'updated', visibleResults.updated))
FROM visibleResults) AS mappings;`,
parameters: {
'setSnowflake': { 'setSnowflake': {
validator: nullable(discordSnowflake), validator: nullable(discordSnowflake),
index: 2 index: 1
},
'original': {
validator: nullable(jsonArray),
index: 3
},
'selection': {
validator: nullable(jsonArray),
index: 4
} }
}, },
output: guaranteedSingleton(jsonParser<{ 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 }[], sets: { id: number, name: string | null, description: string | null, global: boolean }[],
authors: { id: number, name: string, url: string | null, relation: string }[], authors: { id: number, name: string, url: string | null, relation: string }[],
mappings: { mappings: {
@ -864,15 +608,7 @@ export const DatabaseQueries = validatedDefinitions({
authorId: number, authorId: number,
updated: number updated: number
}[], }[],
results: (({ type: 'mapping', mappingId: number } | { type: 'unknownText', tableId: number, text: string } | { }>(['sets', 'authors', 'mappings']))
type: 'unknownTable',
header: string,
title: string,
emoji: string,
ordinal: number,
text: string
})[]) | null
}>(['tables', 'sets', 'authors', 'mappings', 'results']))
} }
} as const satisfies QueryDefinitions); } as const satisfies QueryDefinitions);

@ -96,13 +96,19 @@ export function prepareAllQueries<T extends QueryDefinitions>(database: D1Databa
} }
export async function runQuery<T>(db: D1Database, query: BoundQuery<T>): Promise<T> { export async function runQuery<T>(db: D1Database, query: BoundQuery<T>): Promise<T> {
const startAt = performance.now()
const [results] = await db.batch([query.statement]); const [results] = await db.batch([query.statement]);
const endAt = performance.now()
console.info(`DB query time: ${endAt - startAt} / Runtime: ${results.meta.duration} / Rows read: ${results.meta.rows_read} / Rows written: ${results.meta.rows_written}`)
return query.transformer(results as D1Result<object>); 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> { export async function batchQueries<T extends [...unknown[]]>(db: D1Database, queries: { readonly [K in keyof T]: BoundQuery<T[K]> }): Promise<T> {
const startAt = performance.now()
const results = await db.batch(queries.map(q => q.statement)); const 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; const endAt = performance.now()
console.info(`DB transaction time: ${endAt - startAt} / Runtime: ${results.map(r => `${r.meta.duration ?? 0}`).join('+')} = ${results.reduce((t, r) => t + (r.meta.duration ?? 0), 0)} / Rows read: ${results.map(r => `${r.meta.rows_read ?? 0}`).join('+')} = ${results.reduce((t, r) => t + (r.meta.rows_read ?? 0), 0)} / Rows written: ${results.map(r => `${r.meta.rows_written ?? 0}`).join('+')} = ${results.reduce((t, r) => t + (r.meta.rows_written ?? 0), 0)}`)
return results.map((result, index) => queries[index].transformer(result as D1Result<object>)) as T;
} }
export class TypedDBWrapper { export class TypedDBWrapper {
@ -124,7 +130,7 @@ export class TypedDBWrapper {
return runQuery(this.db, query); return runQuery(this.db, query);
} }
async batch<T extends [...unknown[]]>(...queries: { readonly [K in keyof T]: BoundQuery<T[K]> }): Promise<T> { async batch<T extends [...unknown[]]>(...queries: { readonly [K in keyof T]: BoundQuery<T[K]>}): Promise<T> {
return batchQueries<T>(this.db, queries); return batchQueries<T>(this.db, queries);
} }
} }

@ -9,7 +9,11 @@ export function jsonParser<OutputT extends object = object, KeysT extends keyof
} }
} }
export function rows<OutputT extends object = object, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT[] { export function extract<ValueT, KeyT extends string = string, ObjectT extends {[value in KeyT]: ValueT} = {[value in KeyT]: ValueT}>(key: KeyT): (value: ObjectT) => ObjectT[KeyT] {
return (value) => value[key]
}
export function rows<OutputT, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT[] {
if (transformer) { if (transformer) {
return (result) => (result.results as InputT[]).map(transformer) return (result) => (result.results as InputT[]).map(transformer)
} else { } else {
@ -17,7 +21,7 @@ export function rows<OutputT extends object = object, InputT extends object = ob
} }
} }
export function guaranteedSingleton<OutputT extends object = object, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT { export function guaranteedSingleton<OutputT, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT {
const inner = singleton<OutputT, InputT>(transformer) const inner = singleton<OutputT, InputT>(transformer)
return (result) => { return (result) => {
@ -29,7 +33,7 @@ export function guaranteedSingleton<OutputT extends object = object, InputT exte
} }
} }
export function singleton<OutputT extends object = object, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT | null { export function singleton<OutputT, InputT extends object = object>(transformer?: (value: InputT) => OutputT): (result: D1Result<object>) => OutputT | null {
if (transformer) { if (transformer) {
return (result) => { return (result) => {
if (result.results.length > 1) { if (result.results.length > 1) {

@ -8,7 +8,7 @@ import {
SlashCommand, SlashCommand,
type SlashCreator type SlashCreator
} from 'slash-create/web'; } from 'slash-create/web';
import { type Database } from '../db/database.js'; import { type Database } from '../db/database';
import { type Snowflake } from 'discord-snowflake'; import { type Snowflake } from 'discord-snowflake';
import { import {
DELETE_ID, DELETE_ID,
@ -22,7 +22,7 @@ import {
loadEmbed, recordError, loadEmbed, recordError,
REROLL_ID, REROLL_ID,
SELECT_ID, SUCCESS_COLOR, WARNING_COLOR SELECT_ID, SUCCESS_COLOR, WARNING_COLOR
} from './embed.js'; } from './embed';
import { import {
generatedContentsToString, generatedStateToString, generatedContentsToString, generatedStateToString,
MAX_IDENTIFIER_LENGTH, MAX_IDENTIFIER_LENGTH,
@ -30,7 +30,7 @@ import {
MAX_RESULT_LENGTH, MAX_RESULT_LENGTH,
MAX_URL_LENGTH, MAX_URL_LENGTH,
type RollTableAuthor type RollTableAuthor
} from '../../common/rolltable.js'; } from '../../common/rolltable';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
const tableOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = { const tableOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = {

@ -1,10 +1,10 @@
import type { GeneratedContents, RollTableResult } from '../../common/rolltable.js'; import type { GeneratedContents, RollTableResult } from '../../common/rolltable';
import { import {
type FinalGeneratedContents, type FinalGeneratedContents,
type GeneratedState, type GeneratedState,
type InProgressGeneratedContents, type InProgressGeneratedContents,
type RollTableResultFull type RollTableResultFull
} from '../../common/rolltable.js'; } from '../../common/rolltable';
import { import {
ButtonStyle, ButtonStyle,
type ComponentActionRow, type ComponentActionRow,
@ -19,7 +19,7 @@ import {
type MessageOptions type MessageOptions
} from 'slash-create/web'; } from 'slash-create/web';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import type { EmbedFooterOptions } from 'slash-create/web.js'; import type { EmbedFooterOptions } from 'slash-create/web';
export const SCENARIO_COLOR = 0x15A3C7; export const SCENARIO_COLOR = 0x15A3C7;
export const SUCCESS_COLOR = 0x79AC78; export const SUCCESS_COLOR = 0x79AC78;
@ -46,8 +46,9 @@ export function generateFooterForResult(result: RollTableResultFull): EmbedFoote
} }
export function generateFieldForResult(value: RollTableResult, selected?: boolean): EmbedField { export function generateFieldForResult(value: RollTableResult, selected?: boolean): EmbedField {
let name = markdownEscape(`${value.table.header}${typeof selected === 'boolean' ? selected ? ROLL_SUFFIX : LOCK_SUFFIX : ''}`);
return { return {
name: markdownEscape(`${value.table.header}${typeof selected === 'boolean' ? selected ? ROLL_SUFFIX : LOCK_SUFFIX : ''}`), name: name,
value: markdownEscape(value.text), value: markdownEscape(value.text),
}; };
} }

@ -1,9 +1,9 @@
import { Database } from '../db/database.js'; import { Database } from '../db/database';
import { CloudflareWorkerServer, SlashCreator } from 'slash-create/web'; import { CloudflareWorkerServer, SlashCreator } from 'slash-create/web';
import { isSnowflake, type Snowflake } from 'discord-snowflake'; import { isSnowflake, type Snowflake } from 'discord-snowflake';
import { AuthorCommand, GenerateCommand, ResponseCommand } from './commands.js'; import { AuthorCommand, GenerateCommand, ResponseCommand } from './commands';
import { type IRequestStrict, Router } from 'itty-router'; import { type IRequestStrict, Router } from 'itty-router';
import { getQueryArray } from '../request/query.js'; import { getQueryArray } from '../request/query';
function getAuthorization(username: string, password: string): string { function getAuthorization(username: string, password: string): string {
return btoa(username + ':' + password); return btoa(username + ':' + password);

@ -1,7 +1,7 @@
import { Database } from './db/database.js'; import { Database } from './db/database';
import { discordRouter } from './discord/router.js'; import { discordRouter } from './discord/router';
import { createCors, Router, IRequestStrict } from 'itty-router'; import { createCors, Router, IRequestStrict } from 'itty-router';
import { webRouter } from './web/router.js'; import { webRouter } from './web/router';
export interface Env { export interface Env {
readonly BASE_URL: string; readonly BASE_URL: string;
@ -26,6 +26,7 @@ const router = Router<IRequestStrict, [env: Env, db: Database, ctx: ExecutionCon
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
export default { export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const startTime = performance.now()
return router.handle(req, env, new Database(env.DB), ctx).then((result) => { return router.handle(req, env, new Database(env.DB), ctx).then((result) => {
if (result instanceof Response) { if (result instanceof Response) {
return result; return result;
@ -42,6 +43,9 @@ export default {
return new Response(`Failed: ${reason}`, { status: 500, statusText: 'Internal Server Error' }); return new Response(`Failed: ${reason}`, { status: 500, statusText: 'Internal Server Error' });
}).then((response) => { }).then((response) => {
return corsify(response); return corsify(response);
}).finally(() => {
const endTime = performance.now()
console.info(`request runtime: ${endTime - startTime}`)
}); });
} }
}; };

@ -2,9 +2,9 @@ export function takeLast(a: string, b: string): string {
return b return b
} }
export function getQuerySingleton(value: string|string[]|undefined, reducer: (a: string, b: string) => string): string|null { export function getQuerySingleton(value: string|string[]|undefined, reducer: (a: string, b: string) => string): string|undefined {
if (typeof value === 'undefined' || typeof value === 'string') { if (typeof value === 'undefined' || typeof value === 'string') {
return value ?? null return value
} }
return value.reduce(reducer) return value.reduce(reducer)
} }

@ -1,5 +1,4 @@
import type { RawSourceMap } from 'source-map'; import type { SourceMap } from '../../../common/bundle';
import type { HashedBundled } from '../../common/bundle.js';
export enum SourceMapExtension { export enum SourceMapExtension {
CSS = 'css', CSS = 'css',
@ -9,7 +8,7 @@ export enum SourceMapExtension {
export type SourceMapFilename<NameT extends string, HashT extends string, ExtensionT extends SourceMapExtension> = export type SourceMapFilename<NameT extends string, HashT extends string, ExtensionT extends SourceMapExtension> =
`${NameT}.${HashT}.${ExtensionT}.map` `${NameT}.${HashT}.${ExtensionT}.map`
export const SourceMaps = new Map<SourceMapFilename<string, string, SourceMapExtension>, RawSourceMap>([ export const SourceMaps = new Map<SourceMapFilename<string, string, SourceMapExtension>, SourceMap>([
]) ])

@ -1,11 +1,11 @@
import { type IRequestStrict, Router } from 'itty-router'; import { type IRequestStrict, Router } from 'itty-router';
import type { Database } from '../db/database.js'; import type { Database } from '../db/database';
import { buildGeneratorPage, buildResponsesPage, wrapPage } from './template.js'; import { buildGeneratorPage, buildResponsesPage, wrapPage } from '../../common/template';
import { CSS, JS } from './client.generated.js'; import { CSS, JS } from './bundles/client.generated';
import type { HashedBundled } from '../../common/bundle.js'; import type { HashedBundled } from '../../common/bundle';
import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './sourcemaps.js'; import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps';
import { collapseWhiteSpace } from 'collapse-white-space'; import { collapseWhiteSpace } from 'collapse-white-space';
import { getQuerySingleton, takeLast } from '../request/query.js'; import { getQuerySingleton, takeLast } from '../request/query';
interface WebEnv { interface WebEnv {
readonly BASE_URL: string, readonly BASE_URL: string,
@ -26,25 +26,23 @@ export function webRouter(base: string) {
} }
async function handleMainPage(req: IRequestStrict, env: WebEnv, db: Database): Promise<string> { async function handleMainPage(req: IRequestStrict, env: WebEnv, db: Database): Promise<string> {
const startAt = performance.now() const results = await db.getGeneratorPageForDiscordSet(
const results = await db.getGeneratorPageForDiscordSet(getQuerySingleton(req.query['server'], takeLast)); getQuerySingleton(req.query['server'], takeLast) ?? null);
const resultsAt = performance.now()
const generator = buildGeneratorPage({ const generator = buildGeneratorPage({
creditsUrl: env.CREDITS_URL, creditsUrl: env.CREDITS_URL,
clientId: env.DISCORD_APP_ID, clientId: env.DISCORD_APP_ID,
baseUrl: env.BASE_URL, generatorTargetUrl: env.BASE_URL,
results: results.rolled, results: results.rolled,
editable: !results.final,
selected: results.selected, selected: results.selected,
includesResponses: true includesResponses: true
}) })
const generatorAt = performance.now()
const responses = buildResponsesPage({ const responses = buildResponsesPage({
tables: Array.from(results.db.tables.values()), tables: Array.from(results.db.tables.values()),
results: results.rolled, results: results.rolled,
creditsUrl: env.CREDITS_URL, creditsUrl: env.CREDITS_URL,
includesGenerator: true includesGenerator: true
}) })
const responsesAt = performance.now()
const wrapped = wrapPage({ const wrapped = wrapPage({
title: 'Vore Scenario Generator', title: 'Vore Scenario Generator',
script: getSourceMappedJS('combinedGeneratorResponses'), script: getSourceMappedJS('combinedGeneratorResponses'),
@ -52,11 +50,7 @@ export function webRouter(base: string) {
noscriptStyles: getSourceMappedCSS('noscript'), noscriptStyles: getSourceMappedCSS('noscript'),
bodyContent: [generator, responses].join('') bodyContent: [generator, responses].join('')
}) })
const wrappedAt = performance.now() return collapseWhiteSpace(wrapped, { style: 'html' });
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 }) const router = Router<IRequestStrict, [env: WebEnv, db: Database, ctx: ExecutionContext]>({ base })
.get('/responses', async (req, _env, _db, _ctx) => { .get('/responses', async (req, _env, _db, _ctx) => {
@ -77,6 +71,22 @@ export function webRouter(base: string) {
}) })
.get('/', handleMainPage) .get('/', handleMainPage)
.post('/', handleMainPage); .post('/', handleMainPage);
for (const key in CSS) {
if (CSS.hasOwnProperty(key)) {
const result = CSS[key as keyof typeof CSS]
if (result.sourceMap) {
router.get(`/${getSourceMapFileName(key, result.hash, SourceMapExtension.CSS)}`, () => result.sourceMap)
}
}
}
for (const key in JS) {
if (JS.hasOwnProperty(key)) {
const result = JS[key as keyof typeof JS]
if (result.sourceMap) {
router.get(`/${getSourceMapFileName(key, result.hash, SourceMapExtension.JS)}`, () => result.sourceMap)
}
}
}
for (const [filename, contents] of SourceMaps) { for (const [filename, contents] of SourceMaps) {
router.get(`/${filename}`, () => contents); router.get(`/${filename}`, () => contents);
} }

@ -8,9 +8,9 @@ keep_vars = true
workers_dev = true workers_dev = true
[build] [build]
command = "tsx src/build/bundle-client.ts" command = "tsx src/build/bundle-client-with-source-map.ts"
cwd = "." cwd = "."
watch_dir = "src/client" watch_dir = ["src/client", "src/common", "src/build"]
[placement] [placement]
mode = "smart" mode = "smart"
@ -39,9 +39,9 @@ CREDITS_URL = "https://git.reya.zone/reya/vore-scenario-generator#credits"
workers_dev = false workers_dev = false
[env.staging.build] [env.staging.build]
command = "tsx src/build/bundle-client.ts" command = "tsx src/build/bundle-client-with-source-map.ts"
cwd = "." cwd = "."
watch_dir = "src/client" watch_dir = ["src/client", "src/common", "src/build"]
[env.staging.placement] [env.staging.placement]
mode = "smart" mode = "smart"
@ -76,7 +76,7 @@ workers_dev = false
[env.production.build] [env.production.build]
command = "tsx src/build/check-source-map-and-bundle-client.ts" command = "tsx src/build/check-source-map-and-bundle-client.ts"
cwd = "." cwd = "."
watch_dir = "src/client" watch_dir = ["src/client", "src/common", "src/build"]
[env.production.placement] [env.production.placement]
mode = "smart" mode = "smart"

Loading…
Cancel
Save