almost done with the web interface...

main
Mari 3 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
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="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="FALSE" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
@ -18,7 +19,7 @@
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="TRUE" />
<option name="USE_EXPLICIT_JS_EXTENSION" value="FALSE" />
<option name="USE_IMPORT_TYPE" value="ALWAYS" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />

@ -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:

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

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

2245
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -4,16 +4,22 @@
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"stage": "wrangler deploy --env staging",
"dev": "wrangler dev",
"start": "wrangler dev",
"generate": "tsx src/build/bundle-client.ts"
},
"devDependencies": {
"@babel/core": "^7.23.7",
"@babel/plugin-transform-runtime": "^7.23.7",
"@babel/preset-env": "^7.23.8",
"@babel/preset-typescript": "^7.23.3",
"@babel/runtime": "^7.23.8",
"@cloudflare/workers-types": "^4.20231218.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"@types/clean-css": "^4.2.11",
"@types/escape-html": "^1.0.4",
"@types/less": "^3.0.6",
@ -24,7 +30,7 @@
"fast-deep-equal": "^3.1.3",
"less": "^4.2.0",
"rollup": "^4.9.5",
"source-map": "^0.7.4",
"rollup-plugin-ts": "^3.4.5",
"tsx": "^4.7.0",
"typescript": "^5.0.4",
"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) {
const bundle = await getBundle(inPath)
await writeBundle(bundle, outPath)
await writeBundle(bundle, outPath, true)
}
main(...process.argv.slice(2)).then(() => {

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

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

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

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

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

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

@ -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;
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 "attribution";
@import "popup";
#responsesHeader {
position: sticky;
@ -18,7 +19,7 @@
z-index: 2;
}
#responsesHeader nav {
#responsesHeader .buttons {
display: flex;
flex-flow: row wrap;
padding-top: 0.2rem;
@ -29,10 +30,8 @@
overflow-x: visible;
}
#responsesHeader nav .buttons {
flex-flow: row wrap;
padding-top: 0.2rem;
padding-right: 0.2rem;
#returnToGenerator {
flex-basis: 50%;
}
.responseNavEmoji {
@ -45,7 +44,7 @@
font-size: 1.5rem;
}
.responseLists {
#responseLists {
display: flex;
flex-flow: row wrap;
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. */
/* Language and Environment */
"target": "ES5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": ["dom", "dom.iterable", "ESNext"]
"target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["dom", "dom.iterable", "ES2015"]
/* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
@ -70,7 +70,7 @@
/* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
// "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,

@ -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 {
readonly bundled: string,
}
@ -8,8 +6,21 @@ export interface HashedBundled extends Bundled {
readonly hash: string,
}
export interface SourceMap {
readonly version: number
readonly file?: string
readonly sourceRoot?: string
readonly sources: readonly string[]
readonly sourcesContent?: readonly (string|null)[]
readonly names: readonly string[]
readonly mappings: string
readonly x_google_ignoreList?: readonly number[]
}
export interface SourceMappedBundled extends Bundled {
readonly sourceMap: RawSourceMap,
readonly sourceMap: SourceMap,
}
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 {
readonly full: false,
readonly emoji: string,
readonly title: string,
readonly header: string,
readonly ordinal: number,
readonly results?: null,
}
export interface RollTableDetailsBase {
@ -18,11 +20,11 @@ export interface RollTableDetailsBase {
}
export type RollTable = RollTableLimited | RollTableDetails
export type RollTableOrInput = RollTable | RollTableDetailsInput
export type RollTableOrInput = RollTable | RollTableDetailsInputResults
export function rollTableToString(v: RollTable) {
if (v.full) {
return `${v.header} (${v.id}/${v.identifier}/${v.name}/${v.emoji}/${v.title}/#${v.ordinal})${v.full === 'results' ? ` [${v.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 {
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 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'}`
}
function authorToString(v: RollTableAuthor): string {
export function authorToString(v: RollTableAuthor): string {
return `${v.relation} ${v.name} (${v.id})`
}
function resultToString(v: RollTableResult) {
export function rollResultToString(v: RollTableResult) {
if (v.full) {
return `${v.text} (${v.mappingId}: ${v.textId}/${rollTableToStringShort(v.table)}/${setToString(v.set)}/${v.author ? authorToString(v.author) : 'no author'})`
} else {
@ -103,33 +105,30 @@ export interface RollTableResultLookup {
}
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>] {
return Array.isArray(v) && isRollTableResult(v[1])
}
export interface RollTableDetailsInputNoResults extends RollTableDetailsBase {
readonly results?: null
}
export type RollTableDetailsInput = RollTableDetailsInputResults | RollTableDetailsInputNoResults
export type RollTableDetailsOrInput = RollTableDetails | RollTableDetailsInput
export type RollTableDetailsOrInput = RollTableDetails | RollTableDetailsInputResults
export interface RollTableDetailsNoResults extends RollTableDetailsBase {
readonly full: 'details'
readonly results?: null;
}
export interface RollTableDetailsAndResults extends RollTableDetailsBase {
readonly full: 'results'
readonly results: ReadonlyMap<number, RollTableResultFull<this>>;
readonly resultsById: ReadonlyMap<number, RollTableResultFull<this>>
readonly resultsByText: ReadonlyMap<string, RollTableResultFull<this>>
}
interface RollTableDetailsAndResultsInternal extends RollTableDetailsBase {
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
@ -141,11 +140,46 @@ function compareRollTables(a: RollTableOrInput, b: RollTableOrInput): number {
(a.header > b.header ? 1 : a.header < b.header ? -1 : 0);
}
// <0: a is a better fit
// >0: b is a better fit
// =0: they're the same
function compareRollTableResults(a: RollTableResult|null|undefined, b: RollTableResult|null|undefined): number {
const preferA = -1
const preferB = 1
const equalPreference = 0
if (a && a.full) {
if (b && b.full) {
if (a.set.global === b.set.global) {
return a.updated.getDate() < b.updated.getDate() ? preferA : preferB
} else {
return !a.set.global ? preferA : preferB
}
} else {
return preferA
}
} else {
if (b && b.full) {
return preferB
} else {
return equalPreference
}
}
}
function isRollTableResult(result: unknown): result is RollTableResult<RollTableDetailsOrInput> {
return (typeof result === "object" && result !== null && 'table' in result
&& !('tableId' in result && typeof result.tableId !== 'undefined') && 'full' in result);
}
export function getResultFrom(table: RollTable, originalResult: RollTableResult): RollTableResult {
const dbResult = table.full === "results" ? table.resultsByText.get(originalResult.text) : null
return dbResult ?? {
full: false,
table,
text: originalResult.text
}
}
export class RollTableMap<T extends RollTableOrInput> extends Map<T extends RollTable ? number : (number|string), T> {
[Symbol.iterator](): IterableIterator<[T extends RollTable ? number : (number|string), T]> {
return this.entries();
@ -191,7 +225,7 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
constructor({ tables = [], results = [], authors = [], sets = [] }: {
tables?: Iterable<RollTableDetailsOrInput>,
results?: Iterable<RollTableResultFull<RollTableDetailsInput> | RollTableResultLookup>,
results?: Iterable<RollTableResultFull<RollTableDetailsOrInput> | RollTableResultLookup>,
authors?: Iterable<RollTableAuthor>,
sets?: Iterable<RollTableResultSet>
} = {}) {
@ -217,6 +251,14 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
return this.tablesById;
}
getTableMatching(table: RollTableOrInput): RollTableDetailsAndResults|undefined {
if (table.full) {
return this.tables.get(table.id)
} else {
return Array.from(this.tables.values()).find(t => (t.header === table.header))
}
}
get sets(): ReadonlyMap<number, RollTableResultSet> {
return this.setsById;
}
@ -233,15 +275,15 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
return this.mappingsByTextId;
}
addTable(table: RollTableDetailsInput): RollTableDetailsAndResults {
addTable(table: RollTableDetailsOrInput): RollTableDetailsAndResults {
return this.addTableInternal(table);
}
private addTableInternal(table: RollTableDetailsInput): RollTableDetailsAndResultsInternal {
private addTableInternal(table: RollTableDetailsOrInput): RollTableDetailsAndResultsInternal {
const existingTable = this.tablesById.get(table.id);
if (existingTable) {
if (table.results) {
for (const result of table.results) {
if (table.full === 'input' || table.full === 'results') {
for (const result of table.resultsById) {
this.addResult(result);
}
}
@ -250,10 +292,11 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
const internalTable: RollTableDetailsAndResultsInternal = {
...table,
full: 'results',
results: new Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>>()
resultsById: new Map<number, RollTableResultFull<RollTableDetailsAndResultsInternal>>(),
resultsByText: new Map<string, RollTableResultFull<RollTableDetailsAndResultsInternal>>(),
};
if (table.results) {
for (const result of table.results) {
if (table.full === 'input' || table.full === 'results') {
for (const result of table.resultsById) {
this.addResult(result);
}
}
@ -288,21 +331,24 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
const [, innerResult] = result as [number, RollTableResultOrLookup<RollTableDetailsOrInput>];
return this.addResult(innerResult);
} else if (isRollTableResult(result)) {
const internalTable =
this.tablesById.get(result.table.id) ?? this.addTableInternal({... result.table, results: null});
const internalAuthor =
result.full && result.author ? (this.authorsById.get(result.author.id) ?? this.addAuthor(result.author)) : null;
const internalSet = this.setsById.get(result.set.id) ?? this.addSet(result.set);
const out: RollTableResultFull<RollTableDetailsAndResultsInternal> = {
...result,
table: internalTable,
author: internalAuthor,
set: internalSet
};
internalTable.results.set(out.textId, out);
this.mappingsByTextId.set(out.textId, out);
this.mappingsByMappingId.set(out.mappingId, out);
return out;
if (!this.tables.has(result.table.id)) {
this.addTableInternal({... result.table, full: 'details'})
}
if (result.author && !this.authors.has(result.author.id)) {
this.addAuthor(result.author)
}
if (!this.sets.has(result.set.id)) {
this.addSet(result.set)
}
return this.addResult({
tableId: result.table.id,
authorId: result.author?.id ?? null,
setId: result.set.id,
textId: result.textId,
text: result.text,
mappingId: result.mappingId,
updated: result.updated
})
} else {
const internalTable = this.tablesById.get(result.tableId);
const internalAuthor = typeof result.authorId === 'number' ? this.authorsById.get(result.authorId) : null;
@ -315,6 +361,8 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
} else if (typeof internalSet === 'undefined') {
throw Error(`no known set with ID ${result.setId}`);
}
const oldText = internalTable.resultsByText.get(result.text)
const oldId = internalTable.resultsById.get(result.textId)
const out: RollTableResultFull<RollTableDetailsAndResultsInternal> = {
full: true,
textId: result.textId,
@ -325,7 +373,12 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
set: internalSet,
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.mappingsByMappingId.set(out.mappingId, out);
return out;
@ -333,15 +386,15 @@ export class RollTableDatabase implements Iterable<RollTableDetailsAndResults> {
}
}
function rollOn(table: RollTableDetailsAndResults): RollTableResult<RollTableDetailsAndResults> {
const results = Array.from(table.results.values());
export function rollOn(table: RollTableDetailsAndResults): RollTableResult<RollTableDetailsAndResults> {
const results = Array.from(table.resultsById.values());
if (results.length === 0) {
throw Error(`no results for table ${table.identifier}`);
}
return results[Math.floor(results.length * Math.random())];
}
function rollOnAll(tables: Iterable<RollTableDetailsAndResults>): RolledValues<RollTableDetailsAndResults> {
export function rollOnAll(tables: Iterable<RollTableDetailsAndResults>): RolledValues<RollTableDetailsAndResults> {
const result = new RolledValues<RollTableDetailsAndResults>();
for (const table of tables) {
result.set(table, rollOn(table));
@ -349,7 +402,7 @@ function rollOnAll(tables: Iterable<RollTableDetailsAndResults>): RolledValues<R
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 tableSet = new Set<RollTable>(tables);
for (const [table, originalValue] of original) {
@ -374,11 +427,35 @@ export interface InProgressGeneratedState<T extends RollTableOrInput = RollTable
readonly selected: ReadonlySet<T>
}
export enum ExportFormat {
Markdown = "md",
BBCode = "bb",
TextEmoji = "emoji",
TextOnly = "text",
}
export function exportResult(result: RollTableResult, format: ExportFormat): string {
switch (format) {
case ExportFormat.Markdown:
return `**${markdownEscape(result.table.header)}**\n${markdownEscape(result.text)}`
case ExportFormat.BBCode:
return `[b]${bbcodeEscape(result.table.title)}[/b]\n${bbcodeEscape(result.text)}`
case ExportFormat.TextEmoji:
return `${result.table.header}\n${result.text}`
case ExportFormat.TextOnly:
return `${result.table.title}\n${result.text}`
}
}
export function exportScenario(contents: RollTableResult[], format: ExportFormat): string {
return contents.map(r => exportResult(r, format)).join("\n\n")
}
export function generatedStateToString(contents: GeneratedState): string {
if (contents.final) {
return `Final state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${resultToString(value)}`).join(" ::: ")}`
return `Final state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}`
} else {
return `Current state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${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 {
type RollTable, type RollTableAuthor, type RollTableDetails,
type RollTable, type RollTableAuthor, RollTableDatabase, type RollTableDetails,
type RollTableDetailsAndResults,
type RollTableResult, type RollTableResultFull, type RollTableResultSet, rollTableToString
} from '../../common/rolltable.js';
type RollTableResult, type RollTableResultFull, type RollTableResultSet
} from './rolltable';
import escapeHTML from 'escape-html';
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 {
return `
<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>` : ''}
<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>
</footer>`;
}
export function buildAuthor({ author }: { readonly author: RollTableAuthor }): string {
if (author.url) {
return `<div class="author" data-id="${escapeHTML(`${author.id}`)}"><span class="authorRelation">${escapeHTML(author.relation)}</span> <span class="authorName"><a href="${encodeURI(author.url)}" rel="external nofollow" 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 {
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>`;
}
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 (
`<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>
<div class="resultText generated${result.full ? ' attributed' : ''}">${escapeHTML(result.text)}${result.full ? buildResultAttribution({ result }) : ''}</div>
<h2 class="generatedHead"><label class="generatedLabel tableHeader" ${buildTableData(result.table)} ${result.table.full === 'results' ? `for="${selectedIdPrefix}${htmlTableIdentifier(result.table)}"` : ''}><span class="tableEmoji">${escapeHTML(result.table.emoji)}</span> <span class="tableTitle">${escapeHTML(result.table.title)}</span></label>${selected !== null ? `<input class="generatedSelect" id="${selectedIdPrefix}${htmlTableIdentifier(result.table)}" name="${selectedIdPrefix}${htmlTableIdentifier(result.table)}" type="checkbox" ${selected ? 'checked' : ''} />` : ''}</h2>
<div class="generated${result.full ? ' attributed' : ''}"><button type="button" class="resultText" ${buildResultData(result)}>${escapeHTML(result.text)}</button>${result.full ? buildResultAttribution({ result }) : ''}</div>
</li>`)
}
export const submitName = "submit"
export const rerollId = "reroll"
export const rerollAllId = "rerollAll"
export const saveScenarioId = "saveScenario"
export const selectAllId = "selectAll"
export const selectNoneId = "selectNone"
export const copyMDID = "copyMD"
export const copyBBID = "copyBB"
export const copyEmojiTextID = "copyEmojiText"
export const copyTextID = "copyText"
export function buildGeneratorPage(
{ results, baseUrl, clientId, creditsUrl, selected, includesResponses }:
{ readonly results: ReadonlyMap<RollTable, RollTableResult>, readonly baseUrl: string, readonly clientId: string, readonly creditsUrl: string, readonly selected: ReadonlySet<RollTable>, readonly includesResponses: boolean }): string {
{ results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses }:
{ readonly results: ReadonlyMap<RollTable, RollTableResult>, readonly generatorTargetUrl: string, readonly clientId: string, readonly creditsUrl: string, readonly editable: boolean, readonly selected: ReadonlySet<RollTable>, readonly includesResponses: boolean }): string {
return `
<div id="generator" class="page">
<form method="post" 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>
<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="copyButtons" class="buttons requiresJs">
<button class="button" id="copyMD">Markdown</button>
<button class="button" id="copyBB">BBCode</button>
<button class="button" id="copyEmojiText">Text + Emoji</button>
<button class="button" id="copyText">Text Only</button>
<div id="copyButtons" class="buttons requiresJs jsPopupHost">
<button type="button" class="button" id="${copyMDID}">Markdown</button>
<button type="button" class="button" id="${copyBBID}">BBCode</button>
<button type="button" class="button" id="${copyEmojiTextID}">Text + Emoji</button>
<button type="button" class="button" id="${copyTextID}">Text Only</button>
</div>
<div id="rollButtons" class="buttons">
<input type="submit" class="button" id="reroll" name="submit" value="Reroll Selected">
<button class="button requiresJs" id="selectAll">Select All</button>
<button class="button requiresJs" id="selectNone">Select None</button>
</div>
<div id="scenarioButtons" class="buttons">
<a href="${encodeURI(baseUrl)}" class="button" id="rerollAll" draggable="false">New Scenario</a>
<input type="submit" class="button" id="saveScenario" name="submit" formtarget="_blank" value="Get Scenario Link">
${editable ? `<div id="rollButtons" class="buttons jsPopupHost">
<button type="submit" class="button" id="${rerollId}" name="${submitName}" value="${rerollId}">Reroll Selected</button>
<button type="button" class="button requiresJs" id="${selectAllId}">Select All</button>
<button type="button" class="button requiresJs" id="${selectNoneId}">Select None</button>
</div>` : ''}
<div id="scenarioButtons" class="buttons jsPopupHost">
${editable
? `<a href="${encodeURI(generatorTargetUrl)}" class="button" id="${rerollAllId}" draggable="false">New Scenario</a>
<button type="submit" class="button" id="${saveScenarioId}" name="${submitName}" value="${saveScenarioId}">Get Scenario Link</button>`
: `<a href="${encodeURI(generatorTargetUrl)}" class="button" draggable="false">Open in Generator</a>`}
</div>
${clientId !== '' || includesResponses ?
`<div id="generatorLinks" class="buttons">
${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>` : ''}
`<div id="generatorLinks" class="buttons jsPopupHost">
${clientId !== '' ? `<a href="https://discord.com/api/oauth2/authorize?client_id=${encodeURIComponent(clientId)}&permissions=0&scope=applications.commands" class="button" rel="external nofollow noreferrer" draggable="false">Add to Discord</a>` : ''}
${includesResponses ? `<a href="#responses" class="button" id="responsesLink" draggable="false">View Possible Responses</a>` : ''}
</div>` : ''}
</div>
@ -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>`
}
export function buildResultData(result: RollTableResult): string {
return result.full ? `data-mappingid="${result.mappingId}" data-textid="${result.textId}" data-updated="${result.updated.getTime()}"` : ''
}
export function buildTableData(table: RollTable): string {
return `data-ordinal="${table.ordinal}" ${table.full
? `data-id="${table.id}" data-identifier="${escapeHTML(table.identifier)}" data-name="${escapeHTML(table.name)}"`
: ''}`
}
export function buildResponse({result, active}: {readonly result: RollTableResult, readonly active: boolean}) {
return `<li class="response${active ? ' active' : ''}${result.full ? ' attributed' : ''}" data-id="${result.full ? result.mappingId : ''}">
<button class="resultText">${escapeHTML(result.text)}</button>
return `<li class="response${active ? ' active' : ''}${result.full ? ' attributed' : ''} jsPopupHost">
<button type="button" class="resultText" ${buildResultData(result)}>${escapeHTML(result.text)}</button>
${result.full ? buildResultAttribution({result}) : ''}
</li>`
}
export function buildResponseList({table, activeResult}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult}) {
return `<li class="responseType window readable" id="responses-${htmlTableIdentifier(table)}">
<div class="responseTypeHead tableHeader">
<span class="tableEmoji">${escapeHTML(table.emoji)}</span>
<h2>${escapeHTML(table.title)}</h2>
</div>
<h2 class="responseTypeHead tableHeader" ${buildTableData(table)}><span class="tableEmoji">${escapeHTML(table.emoji)}</span> <span class="tableTitle">${escapeHTML(table.title)}</span></h2>
<ul>
${Array.from(table.results.values()).map(result => buildResponse({result, active: result === activeResult})).join('')}
${Array.from(table.resultsById.values()).map(result => buildResponse({result, active: result === activeResult})).join('')}
</ul>
</li>`
}
@ -119,10 +139,10 @@ export function buildResponsesPage(
<h1 id="responsesHead">Possible Responses</h1>
<nav class="buttons" id="responsesHeaderNav">
${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>
</header>
<ul class="responseLists">
<ul id="responseLists">
${Array.from(tables).map(table => buildResponseList({table, activeResult: results?.get(table)})).join('')}
</ul>
${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 GeneratedState,
type InProgressGeneratedContents,
type InProgressGeneratedState, RolledValues, RollSelections,
type InProgressGeneratedState,
RolledValues, rollOn, rollResultToString,
RollSelections,
type RollTable,
type RollTableAuthor, RollTableDatabase,
type RollTableAuthor,
RollTableDatabase,
type RollTableDetailsNoResults,
type RollTableResult,
type RollTableResultFull
} from '../../common/rolltable.js';
import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes.js';
import {
DatabaseQueries,
} from './queries.js';
import { recordError } from '../discord/embed.js';
type RollTableResultFull,
} from '../../common/rolltable';
import { type PreparedQueries, type QueryOutput, TypedDBWrapper } from './querytypes';
import { DatabaseQueries } from './queries';
function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)["getResultMappingsForDiscordSet"]>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> & {
function processOperationResult(result: QueryOutput<(typeof DatabaseQueries)['getResultMappingsForDiscordSet']>[number] | undefined): (RollTableResultFull<RollTableDetailsNoResults> & {
status: 'updated' | 'existing'
}) | undefined {
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 {
private readonly db: TypedDBWrapper;
private readonly queries: PreparedQueries<typeof DatabaseQueries>;
@ -162,7 +74,7 @@ export class Database {
setSnowflake: setSnowflake,
tableIdentifierSubstring: tableIdentifier,
pattern: partialText,
includeGlobal,
includeGlobal
}));
}
@ -310,8 +222,8 @@ export class Database {
tableIdentifier: table,
text,
setSnowflake: setId,
includeGlobal: true,
}))
includeGlobal: true
}));
const result = processOperationResult(results[0]);
if (!result) {
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> {
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(
this.queries.setDiscordAuthor({
userSnowflake: id,
username: username,
name: name,
url: url,
url: url
}),
this.queries.getDiscordAuthor({userSnowflake: id})
)
this.queries.getDiscordAuthor({ userSnowflake: id })
);
return result;
}
private async getWebPageDataForDiscordSet(reroll: true, setSnowflake: string|null, oldResults?: InProgressGeneratedContents | null, finalize?: false): Promise<InProgressGeneratedState & {db: RollTableDatabase}>
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults?: null): Promise<RollTableDatabase>
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: GeneratedContents, finalize: false): Promise<InProgressGeneratedState & {db: RollTableDatabase}>
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: GeneratedContents, finalize: true): Promise<FinalGeneratedState & {db: RollTableDatabase}>
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: InProgressGeneratedContents): Promise<InProgressGeneratedState & {db: RollTableDatabase}>
private async getWebPageDataForDiscordSet(reroll: false, setSnowflake: string|null, oldResults: FinalGeneratedContents): Promise<FinalGeneratedState & {db: RollTableDatabase}>
private async getWebPageDataForDiscordSet(reroll: boolean, setSnowflake: string|null, oldResults?: GeneratedContents|null, finalize?: boolean): Promise<RollTableDatabase | (GeneratedState & {db: RollTableDatabase})>
private async getWebPageDataForDiscordSet(reroll: boolean, setSnowflake: string|null, oldResults?: GeneratedContents|null, finalize?: boolean): Promise<RollTableDatabase | (GeneratedState & {db: RollTableDatabase})> {
const { tables, mappings, results, sets, authors } =
await this.db.run(this.queries.getFullDatabaseForDiscordSet({
reroll,
setSnowflake,
original: oldResults ? Array.from(oldResults.rolled) : null,
selection: oldResults && !oldResults.final ? Array.from(oldResults.selected) : null,
}))
private async getGeneratorDataForDiscordSet(reroll: true, setSnowflake: string | null, oldResults?: InProgressGeneratedContents | null, finalize?: false): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults?: null): Promise<RollTableDatabase>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: GeneratedContents, finalize: false): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: GeneratedContents, finalize: true): Promise<FinalGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: InProgressGeneratedContents): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: false, setSnowflake: string | null, oldResults: FinalGeneratedContents): Promise<FinalGeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: boolean, setSnowflake: string | null, oldResults: GeneratedContents, finalize?: boolean): Promise<GeneratedState & {
db: RollTableDatabase
}>
private async getGeneratorDataForDiscordSet(reroll: boolean, setSnowflake: string | null, oldResults?: GeneratedContents | null, finalize?: boolean): Promise<RollTableDatabase | (GeneratedState & {
db: RollTableDatabase
})> {
const oldHeaders = oldResults && oldResults.rolled.size > 0 ? Array.from(oldResults.rolled.keys()) : [];
const [tables, oldKeys, oldSelection, { mappings, sets, authors }] =
await this.db.batch(
this.queries.getTables({}),
this.queries.getTableIdsByIdentifierOrHeader({
identifiersOrHeaders: oldHeaders
}),
this.queries.getTableIdsByIdentifierOrHeader({
identifiersOrHeaders: oldResults && !oldResults.final && oldResults.selected.size > 0 ? Array.from(oldResults.selected) : []
}),
this.queries.getFullDatabaseForDiscordSet({
setSnowflake
}));
const db = new RollTableDatabase({
tables, authors, sets, results: mappings.map(v => ({...v, updated: new Date(v.updated)}))})
if (!results) {
return db
tables: tables.map(v => ({ ...v, full: 'details' })),
authors,
sets,
results: mappings.map(v => ({ ...v, updated: new Date(v.updated) }))
});
if (!oldResults && !reroll) {
return db;
}
const rolled = new RolledValues()
for (const result of results) {
switch (result.type) {
case 'mapping':
const mapping = db.mappings.get(result.mappingId)
if (mapping) {
rolled.add(mapping)
} else {
recordError({
error: Error(`no mapping with ID ${result.mappingId}`),
context: 'getting web page data for discord set',
})
}
break
case 'unknownText':
const table = db.tables.get(result.tableId)
if (table) {
rolled.add({
full: false,
text: result.text,
table
})
} else {
recordError({
error: Error(`no table with ID ${result.tableId}`),
context: `assembling unknown text for discord set`
})
}
break
case 'unknownTable':
rolled.add({
full: false,
table: {
full: false,
ordinal: result.ordinal,
header: result.header,
emoji: result.emoji,
title: result.title
},
text: result.text
})
break
const selected = new RollSelections(oldSelection.flatMap(v => {
if (v === null) {
return [];
}
}
if (finalize === true || (finalize === null && oldResults?.final)) {
return {
final: true,
db,
rolled
const table = db.tables.get(v);
if (!table) {
return [];
}
}
const selected = new RollSelections()
for (const table of tables) {
if (table.selected) {
selected.add(db.tables.get(table.id)!)
return [table];
}));
const rolled = new RolledValues();
const rollKeys = oldResults ? oldKeys : tables.map(t => t.id);
for (let index = 0; index < rollKeys.length; index += 1) {
const tableId = rollKeys[index];
const lookupTable = tableId !== null ? db.tables.get(tableId) : null;
const oldHeader = oldHeaders[index];
const [oldEmoji, oldTitle] = oldHeader ? oldHeader.split(' ', 2) : ['', ''];
const table: RollTable = lookupTable ?? {
full: false,
header: oldHeader,
emoji: oldEmoji,
title: oldTitle,
ordinal: index
};
const text = oldResults?.rolled.get(oldHeader);
if (reroll && table.full && (!text || selected.has(table))) {
const result = rollOn(table)
rolled.add(result);
} else if (text) {
const lookupResult = text && table.full === 'results' ? table.resultsByText.get(text) : null;
const result = lookupResult ?? {
full: false,
text: text,
table
}
rolled.add(result);
}
}
return {
return (finalize ?? oldResults?.final) ? {
final: true,
db,
rolled
} : {
final: false,
db,
rolled,
selected
}
};
}
async getGeneratorPageForDiscordSet(setSnowflake: string | null, oldResults?: InProgressGeneratedContents | null): Promise<InProgressGeneratedState & {
db: RollTableDatabase
}> {
return this.getGeneratorDataForDiscordSet(true, setSnowflake, oldResults, false);
}
async getGeneratorPageForDiscordSet(setSnowflake: string|null, oldResults?: InProgressGeneratedContents|null): Promise<InProgressGeneratedState & {db: RollTableDatabase}> {
return this.getWebPageDataForDiscordSet(true, setSnowflake, oldResults, false)
async expandFromDiscordSet(setId: string, contents: FinalGeneratedContents): Promise<FinalGeneratedState>
async expandFromDiscordSet(setId: string, contents: InProgressGeneratedContents): Promise<InProgressGeneratedState>
async expandFromDiscordSet(setId: string, contents: GeneratedContents): Promise<GeneratedState>
async expandFromDiscordSet(setId: string, contents: GeneratedContents): Promise<GeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, contents);
}
async generateFromDiscordSet(setId: string): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(true, setId);
}
async rerollFromDiscordSet(setId: string, existing: InProgressGeneratedContents): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(true, setId, existing);
}
async reopenFromDiscordSet(setId: string, existing: FinalGeneratedContents): Promise<InProgressGeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, existing, false);
}
async finalizeFromDiscordSet(setId: string, existing: InProgressGeneratedContents): Promise<FinalGeneratedState> {
return this.getGeneratorDataForDiscordSet(false, setId, existing, true);
}
}

@ -1,4 +1,4 @@
import { type QueryDefinitions, validatedDefinitions } from './querytypes.js';
import { type QueryDefinitions, validatedDefinitions } from './querytypes';
import {
boolean,
discordSnowflake,
@ -10,8 +10,8 @@ import {
tableIdentifierSubstring,
timestamp,
URL
} from './validators.js';
import { guaranteedSingleton, jsonParser, nothing, rows, singleton, writeCount } from './transformers.js';
} from './validators';
import { extract, guaranteedSingleton, jsonParser, nothing, rows, singleton, writeCount } from './transformers';
export const DatabaseQueries = validatedDefinitions({
autocompleteTable: {
@ -470,190 +470,6 @@ export const DatabaseQueries = validatedDefinitions({
},
output: writeCount()
},
generateFromDiscord: {
query: `WITH originalResults (tableId, header, ordinal, text) AS (SELECT rollableTables.id AS tableId,
rollableTables.header AS header,
rollableTables.ordinal AS ordinal,
NULL AS text
FROM rollableTables
WHERE ?3 IS NULL
UNION ALL
SELECT rollableTableHeaders.tableId AS id,
original.value ->> '$[0]' AS header,
original.key AS ordinal,
original.value ->> '$[1]' AS text
FROM json_each(COALESCE(?3, '[]')) original
LEFT JOIN rollableTableHeaders
ON rollableTableHeaders.header = (original.value ->> '$[0]')
ORDER BY ordinal),
selection (tableId)
AS (SELECT COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) AS tableId
FROM json_each(COALESCE(?4, '[]')) selection
LEFT JOIN rollableTableIdentifiers
ON rollableTableIdentifiers.identifier = selection.value
LEFT JOIN rollableTableHeaders
ON rollableTableHeaders.header = selection.value
WHERE COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) IS NOT NULL),
visibleSets (id, global) AS (SELECT resultSets.id, resultSets.global
FROM resultSets
WHERE (resultSets.global OR resultSets.discordSnowflake = ?2)),
usedResults (id) AS (SELECT DISTINCT resultMappings.resultId
FROM resultMappings
WHERE resultMappings.setId IN (SELECT id FROM visibleSets)),
usedTables (id) AS (SELECT DISTINCT rollableResults.tableId
FROM usedResults
INNER JOIN rollableResults ON rollableResults.id = usedResults.id),
usedMappings (id) AS (SELECT (SELECT resultMappings.id
FROM resultMappings
WHERE resultMappings.resultId = usedResults.id
AND resultMappings.setId IN (SELECT id FROM visibleSets)
ORDER BY (NOT visibleSets.global) DESC,
(resultMappings.authorId IS NOT NULL) DESC,
updated
LIMIT 1)
FROM usedResults),
usedAuthors (id) AS (SELECT DISTINCT resultMappings.authorId
FROM usedMappings
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id),
usedSets (id) AS (SELECT DISTINCT resultMappings.setId
FROM usedMappings
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id),
results (resultId, tableId, header, ordinal, originalText) AS
(SELECT (SELECT rollableResults.id
FROM rollableResults
WHERE rollableResults.tableId = originalResult.tableId
AND rollableResults.id IN usedResults
AND ((?1 AND (originalResult.text IS NULL OR ?4 IS NULL OR
originalResult.tableId IN selection)) OR
rollableResults.text = originalResult.text)
ORDER BY RANDOM()
LIMIT 1) AS resultId,
originalResult.tableId AS tableId,
originalResult.header AS header,
originalResult.ordinal AS ordinal,
originalResult.text AS originalText
FROM originalResults AS originalResult)
SELECT resultMappings.id AS mappingId,
rollableResults.id AS resultId,
COALESCE(rollableResults.text, results.originalText, '') AS resultText,
authors.id AS authorId,
COALESCE(authors.name, authorshipTypes.defaultAuthor) AS authorName,
authors.url AS authorUrl,
authorshipTypes.relationPrefix AS authorRelation,
resultSets.id AS setId,
resultSets.name AS setName,
resultSets.description AS setDescription,
resultSets.global AS setGlobal,
rollableTables.id AS tableId,
rollableTables.identifier AS tableIdentifier,
rollableTables.name AS tableName,
COALESCE(
rollableTables.title,
SUBSTR(results.header, INSTR(results.header, ' ') + 1)) AS tableTitle,
COALESCE(
rollableTables.emoji,
SUBSTR(results.header, 1, INSTR(results.header, ' ') - 1)) AS tableEmoji,
results.header AS tableHeader,
results.ordinal AS tableOrdinal,
resultMappings.updated AS updated,
results.tableId IN selection AS selected
FROM results
LEFT JOIN rollableResults ON rollableResults.id = results.resultId
LEFT JOIN rollableTables ON rollableTables.id = results.tableId
LEFT JOIN resultMappings ON resultMappings.id = (SELECT resultMappings.id
FROM resultMappings
INNER JOIN visibleSets ON visibleSets.id = resultMappings.setId
WHERE resultMappings.resultId = results.resultId
ORDER BY (NOT visibleSets.global) DESC,
(resultMappings.authorId IS NOT NULL) DESC,
updated
LIMIT 1)
LEFT JOIN authors ON authors.id = resultMappings.authorId
LEFT JOIN authorshipTypes ON authorshipTypes.id = authors.authorshipTypeId
LEFT JOIN resultSets ON resultSets.id = resultMappings.setId;`,
parameters: {
'reroll': {
validator: boolean,
index: 1
},
'setSnowflake': {
validator: nullable(discordSnowflake),
index: 2
},
'original': {
validator: nullable(jsonArray),
index: 3
},
'selection': {
validator: nullable(jsonArray),
index: 4
}
},
output: rows<{
mappingId: null,
resultId: null,
resultText: string,
authorId: null,
authorName: null,
authorUrl: null,
authorRelation: null,
setId: null,
setName: null,
setDescription: null,
setGlobal: null,
tableId: null,
tableIdentifier: null,
tableName: null,
tableTitle: string,
tableEmoji: string,
tableHeader: string,
tableOrdinal: number,
updated: null,
selected: false;
} | {
mappingId: null,
resultId: null,
resultText: string,
authorId: null,
authorName: null,
authorUrl: null,
authorRelation: null,
setId: null,
setName: null,
setDescription: null,
setGlobal: null,
tableId: number,
tableIdentifier: string,
tableName: string,
tableTitle: string,
tableEmoji: string,
tableHeader: string,
tableOrdinal: number,
updated: null,
selected: boolean,
} | {
mappingId: number,
resultId: number,
resultText: string,
authorId: number | null,
authorName: string | null,
authorUrl: string | null,
authorRelation: string | null,
setId: number,
setName: string | null,
setDescription: string | null,
setGlobal: number,
tableId: number,
tableIdentifier: string,
tableName: string,
tableTitle: string,
tableEmoji: string,
tableHeader: string,
tableOrdinal: number,
updated: number,
selected: boolean,
}>()
},
getDiscordAuthor: {
query: `
SELECT authors.id AS id,
@ -699,160 +515,88 @@ export const DatabaseQueries = validatedDefinitions({
},
output: nothing()
},
getFullDatabaseForDiscordSet: {
query: `WITH originalResults (tableId, header, ordinal, text) AS (SELECT rollableTables.id AS tableId,
rollableTables.header AS header,
rollableTables.ordinal AS ordinal,
NULL AS text
FROM rollableTables
WHERE ?3 IS NULL
UNION ALL
SELECT rollableTableHeaders.tableId AS id,
original.value ->> '$[0]' AS header,
original.key AS ordinal,
original.value ->> '$[1]' AS text
FROM json_each(COALESCE(?3, '[]')) original
LEFT JOIN rollableTableHeaders
ON rollableTableHeaders.header = (original.value ->> '$[0]')
ORDER BY ordinal),
selection (tableId)
AS (SELECT COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) AS tableId
FROM json_each(COALESCE(?4, '[]')) selection
LEFT JOIN rollableTableIdentifiers
ON rollableTableIdentifiers.identifier = selection.value
LEFT JOIN rollableTableHeaders
ON rollableTableHeaders.header = selection.value
WHERE COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) IS NOT NULL),
visibleSets (id, global) AS (SELECT resultSets.id, resultSets.global
FROM resultSets
WHERE (resultSets.global OR resultSets.discordSnowflake = ?2)),
usedResults (id) AS (SELECT DISTINCT resultMappings.resultId
FROM resultMappings
WHERE resultMappings.setId IN (SELECT id FROM visibleSets)),
usedTables (id) AS (SELECT DISTINCT rollableResults.tableId
FROM usedResults
INNER JOIN rollableResults ON rollableResults.id = usedResults.id),
usedMappings (id) AS (SELECT (SELECT resultMappings.id
FROM resultMappings
INNER JOIN visibleSets ON resultMappings.setId = visibleSets.id
WHERE resultMappings.resultId = usedResults.id
ORDER BY (NOT visibleSets.global) DESC,
(resultMappings.authorId IS NOT NULL) DESC,
updated
LIMIT 1)
FROM usedResults),
usedAuthors (id) AS (SELECT DISTINCT resultMappings.authorId
FROM usedMappings
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id),
usedSets (id) AS (SELECT DISTINCT resultMappings.setId
FROM usedMappings
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id),
generationResults (resultObj) AS
(SELECT COALESCE((SELECT json_object(
'type', 'mapping',
'mappingId', resultMappings.id)
FROM rollableResults
INNER JOIN resultMappings
ON resultMappings.resultId = rollableResults.id
INNER JOIN usedMappings ON usedMappings.id = resultMappings.id
WHERE rollableResults.tableId = originalResult.tableId
AND rollableResults.id IN usedResults
AND ((?1 AND (originalResult.text IS NULL OR ?4 IS NULL OR
originalResult.tableId IN selection)) OR
rollableResults.text = originalResult.text)
ORDER BY RANDOM()
LIMIT 1),
CASE
WHEN originalResult.tableId IN usedTables THEN json_object(
'type', 'unknownText',
'tableId', originalResult.tableId,
'text', originalResult.text)
ELSE json_object(
'type', 'unknownTable',
'header', originalResult.header,
'title',
SUBSTR(originalResult.header, INSTR(originalResult.header, ' ') + 1),
'emoji',
SUBSTR(originalResult.header, 1, INSTR(originalResult.header, ' ') - 1),
'ordinal', originalResult.ordinal,
'text', originalResult.text) END)
FROM originalResults AS originalResult
WHERE ?1
OR (?3 IS NOT NULL))
SELECT (SELECT json_group_array(json(tableObj))
FROM (SELECT json_object('id', rollableTables.id,
'identifier', rollableTables.identifier,
'name', rollableTables.name,
'title', rollableTables.title,
'emoji', rollableTables.emoji,
'header', rollableTables.header,
'ordinal', rollableTables.ordinal,
'selected', CASE
WHEN rollableTables.id IN selection THEN json('true')
ELSE json('false') END) AS tableObj
FROM usedTables
INNER JOIN rollableTables ON rollableTables.id = usedTables.id)) AS tables,
(SELECT json_group_array(json(setObj))
FROM (SELECT json_object('id', resultSets.id,
'name', resultSets.name,
'description', resultSets.description,
'global', CASE
WHEN resultSets.global THEN json('true')
ELSE json('false') END) AS setObj
FROM usedSets
INNER JOIN resultSets ON resultSets.id = usedSets.id)) AS sets,
(SELECT json_group_array(json(authorObj))
FROM (SELECT json_object('id', authors.id,
'name', COALESCE(authors.name, authorshipTypes.defaultAuthor),
'url', authors.url,
'relation', authorshipTypes.relationPrefix) AS authorObj
FROM usedAuthors
INNER JOIN authors ON authors.id = usedAuthors.id
INNER JOIN authorshipTypes ON authorshipTypes.id = authors.authorshipTypeId)) AS authors,
(SELECT json_group_array(json(mappingObj))
FROM (SELECT json_object('mappingId', resultMappings.id,
'textId', resultMappings.resultId,
'text', rollableResults.text,
'tableId', rollableResults.tableId,
'setId', resultMappings.setId,
'authorId', resultMappings.authorId,
'updated', resultMappings.updated) AS mappingObj
FROM usedMappings
INNER JOIN resultMappings ON resultMappings.id = usedMappings.id
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId)) AS mappings,
CASE
WHEN EXISTS (SELECT resultObj FROM generationResults)
THEN (SELECT json_group_array(json(resultObj)) FROM generationResults)
ELSE json('null') END AS results;`,
getTableIdsByIdentifierOrHeader: {
query: `SELECT COALESCE(rollableTableIdentifiers.tableId, rollableTableHeaders.tableId) AS id
FROM json_each(?1) selection
LEFT JOIN rollableTableIdentifiers
ON rollableTableIdentifiers.identifier = selection.value
LEFT JOIN rollableTableHeaders ON rollableTableHeaders.header = selection.value;`,
parameters: {
'reroll': {
validator: boolean,
'identifiersOrHeaders': {
validator: jsonArray,
index: 1
},
}
},
output: rows(extract<number | null>("id"))
},
getTables: {
query: `SELECT id, identifier, name, title, emoji, header, ordinal
FROM rollableTables`,
parameters: {},
output: rows<{
id: number,
identifier: string,
name: string,
title: string,
emoji: string,
header: string,
ordinal: number
}>()
},
getFullDatabaseForDiscordSet: {
query: `WITH visibleSets (id, name, description, global)
AS (SELECT resultSets.id,
resultSets.name,
resultSets.description,
resultSets.global
FROM resultSets
WHERE (resultSets.global OR resultSets.discordSnowflake = ?1)),
visibleResults (mappingId, setId, textId, tableId, text, authorId, updated)
AS (SELECT resultMappings.id AS mappingId,
resultMappings.setId AS setId,
resultMappings.resultId AS textId,
rollableResults.tableId AS tableId,
rollableResults.text AS text,
resultMappings.authorId AS authorId,
resultMappings.updated AS updated
FROM resultMappings
INNER JOIN visibleSets ON resultMappings.setId = visibleSets.id
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId),
visibleAuthors (id, name, url, relation)
AS (SELECT DISTINCT authors.id,
COALESCE(authors.name, authorshipTypes.defaultAuthor),
authors.url,
authorshipTypes.relationPrefix
FROM visibleResults
INNER JOIN authors ON authors.id = visibleResults.authorId
INNER JOIN authorshipTypes ON authorshipTypes.id = authors.authorshipTypeId)
SELECT (SELECT json_group_array(json_object('id', visibleSets.id,
'name', visibleSets.name,
'description', visibleSets.description,
'global', CASE
WHEN visibleSets.global THEN json('true')
ELSE json('false') END))
FROM visibleSets) AS sets,
(SELECT json_group_array(json_object('id', visibleAuthors.id,
'name', visibleAuthors.name,
'url', visibleAuthors.url,
'relation', visibleAuthors.relation))
FROM visibleAuthors) AS authors,
(SELECT json_group_array(json_object('mappingId', visibleResults.mappingId,
'textId', visibleResults.textId,
'text', visibleResults.text,
'tableId', visibleResults.tableId,
'setId', visibleResults.setId,
'authorId', visibleResults.authorId,
'updated', visibleResults.updated))
FROM visibleResults) AS mappings;`,
parameters: {
'setSnowflake': {
validator: nullable(discordSnowflake),
index: 2
},
'original': {
validator: nullable(jsonArray),
index: 3
},
'selection': {
validator: nullable(jsonArray),
index: 4
index: 1
}
},
output: guaranteedSingleton(jsonParser<{
tables: {
id: number,
identifier: string,
name: string,
title: string,
emoji: string,
header: string,
ordinal: number,
selected: boolean
}[]
sets: { id: number, name: string | null, description: string | null, global: boolean }[],
authors: { id: number, name: string, url: string | null, relation: string }[],
mappings: {
@ -864,15 +608,7 @@ export const DatabaseQueries = validatedDefinitions({
authorId: number,
updated: number
}[],
results: (({ type: 'mapping', mappingId: number } | { type: 'unknownText', tableId: number, text: string } | {
type: 'unknownTable',
header: string,
title: string,
emoji: string,
ordinal: number,
text: string
})[]) | null
}>(['tables', 'sets', 'authors', 'mappings', 'results']))
}>(['sets', 'authors', 'mappings']))
}
} 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> {
const startAt = performance.now()
const [results] = await db.batch([query.statement]);
const endAt = performance.now()
console.info(`DB query time: ${endAt - startAt} / Runtime: ${results.meta.duration} / Rows read: ${results.meta.rows_read} / Rows written: ${results.meta.rows_written}`)
return query.transformer(results as D1Result<object>);
}
export async function batchQueries<T extends [...unknown[]]>(db: D1Database, queries: { readonly [K in keyof T]: BoundQuery<T[K]> }): Promise<T> {
const startAt = performance.now()
const results = await db.batch(queries.map(q => q.statement));
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 {
@ -124,7 +130,7 @@ export class TypedDBWrapper {
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);
}
}

@ -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) {
return (result) => (result.results as InputT[]).map(transformer)
} 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)
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) {
return (result) => {
if (result.results.length > 1) {

@ -8,7 +8,7 @@ import {
SlashCommand,
type SlashCreator
} from 'slash-create/web';
import { type Database } from '../db/database.js';
import { type Database } from '../db/database';
import { type Snowflake } from 'discord-snowflake';
import {
DELETE_ID,
@ -22,7 +22,7 @@ import {
loadEmbed, recordError,
REROLL_ID,
SELECT_ID, SUCCESS_COLOR, WARNING_COLOR
} from './embed.js';
} from './embed';
import {
generatedContentsToString, generatedStateToString,
MAX_IDENTIFIER_LENGTH,
@ -30,7 +30,7 @@ import {
MAX_RESULT_LENGTH,
MAX_URL_LENGTH,
type RollTableAuthor
} from '../../common/rolltable.js';
} from '../../common/rolltable';
import markdownEscape from 'markdown-escape';
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 {
type FinalGeneratedContents,
type GeneratedState,
type InProgressGeneratedContents,
type RollTableResultFull
} from '../../common/rolltable.js';
} from '../../common/rolltable';
import {
ButtonStyle,
type ComponentActionRow,
@ -19,7 +19,7 @@ import {
type MessageOptions
} from 'slash-create/web';
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 SUCCESS_COLOR = 0x79AC78;
@ -46,8 +46,9 @@ export function generateFooterForResult(result: RollTableResultFull): EmbedFoote
}
export function generateFieldForResult(value: RollTableResult, selected?: boolean): EmbedField {
let name = markdownEscape(`${value.table.header}${typeof selected === 'boolean' ? selected ? ROLL_SUFFIX : LOCK_SUFFIX : ''}`);
return {
name: markdownEscape(`${value.table.header}${typeof selected === 'boolean' ? selected ? ROLL_SUFFIX : LOCK_SUFFIX : ''}`),
name: name,
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 { 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 { getQueryArray } from '../request/query.js';
import { getQueryArray } from '../request/query';
function getAuthorization(username: string, password: string): string {
return btoa(username + ':' + password);

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

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

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

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

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

Loading…
Cancel
Save