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