parent
16c66b5460
commit
df17f6f5c3
@ -0,0 +1,13 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<project version="4"> |
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true"> |
||||
<data-source source="LOCAL" name="D1 JDBC Data Source" uuid="0d7460e5-a5b0-427c-ba12-108e8ff70b34"> |
||||
<driver-ref>java.sql.Driver</driver-ref> |
||||
<synchronize>true</synchronize> |
||||
<configured-by-url>true</configured-by-url> |
||||
<jdbc-driver>org.isaacmcfadyen.D1Driver</jdbc-driver> |
||||
<jdbc-url>jdbc:d1://d09d3c74-c75f-4418-8f1b-2fe7f21637e6</jdbc-url> |
||||
<working-dir>$ProjectFileDir$</working-dir> |
||||
</data-source> |
||||
</component> |
||||
</project> |
@ -0,0 +1,55 @@ |
||||
# Vore Scenario Generator |
||||
|
||||
![Example scenario, reading: |
||||
The action takes place at a sports event. |
||||
The encounter is themed around voreception. |
||||
The action begins when a threat is made. |
||||
Things are more difficult because someone is not prepared |
||||
Partway through, unexpectedly, the prey nearly escapes |
||||
The vore scene is focused on the aftermath |
||||
The word of the day is Millionaire](example.png) |
||||
|
||||
It does exactly what it says on the tin: it generates random vore scenarios. For you! |
||||
[The list of default responses are here if you want to check it in advance.](https://scenario-generator.deliciousreya.net/responses) |
||||
|
||||
## Usage |
||||
|
||||
### Installation |
||||
|
||||
* Visit [this Discord OAuth link](https://discord.com/api/oauth2/authorize?client_id=1192326191189864458&permissions=0&scope=applications.commands) |
||||
and give permission to use application commands in your server. Share it with the admin of your server if you don't |
||||
have permission yourself! |
||||
* Don't worry - not only does this bot not do anything nefarious with your messages, but it actually can't read them at |
||||
all. It only reads the commands you give it and its own messages! If you're concerned, though, please go through the |
||||
code. Kink stuff is very personal and private! Use caution when running random horny software! |
||||
|
||||
### Generation |
||||
|
||||
* Run the `/generate` command to generate a random scenario. |
||||
* Don't like what you got? Pick the components you don't like from the select box, |
||||
then click "Reroll Selected" to generate fresh ones. |
||||
* Think the scenario is beyond saving? Click "Trash it." to delete the message. |
||||
* Satisfied with the results? Click "Looks good!" to remove the reroll commands. |
||||
|
||||
### Customization |
||||
|
||||
Note that changes to custom responses will be sent to your current channel and will be visible to the admin (that's me) |
||||
and everyone in the server, as well as anyone who knows or can guess your server ID. Assume your responses are not |
||||
private. |
||||
|
||||
* Run `/response add [table] [text]` to add a new custom response. Use the table listing in the Discord command. |
||||
* Run `/response delete [table] [text]` to remove a custom response. Give the response text exactly! |
||||
* Run `/response edit [table] [old text] [new text]` to modify a custom response. Give the old response text exactly! |
||||
* Run `/response list` to get a link to a list of all responses that will be given in the current server, including both |
||||
default and custom responses. |
||||
|
||||
## 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) and 1 other. |
||||
* Development by [DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), using |
||||
[slash-create](https://slash-create.js.org/#/) and |
||||
[discord-snowflake](https://github.com/ianmitchell/interaction-kit/tree/main/packages/discord-snowflake). |
||||
* Hosted on [CloudFlare Workers](https://developers.cloudflare.com/workers/) |
||||
with [D1](https://developers.cloudflare.com/d1/). |
After Width: | Height: | Size: 53 KiB |
@ -0,0 +1,14 @@ |
||||
-- Migration number: 0000 2024-01-04T08:37:46.641Z |
||||
CREATE TABLE IF NOT EXISTS responses ( |
||||
id INTEGER PRIMARY KEY ASC NOT NULL, |
||||
tableId INTEGER NOT NULL CHECK (tableId IN (0, 1, 2, 3, 4, 5, 6)), |
||||
text TEXT NOT NULL, |
||||
timestamp INTEGER NOT NULL, |
||||
userSnowflake TEXT NOT NULL, |
||||
serverSnowflake TEXT NULL, |
||||
access INTEGER NOT NULL CHECK (access IN (0, 1, 2)) |
||||
) STRICT; |
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS responses_unique_global ON responses (tableId, text) WHERE access = 0; |
||||
CREATE UNIQUE INDEX IF NOT EXISTS responses_unique_by_server ON responses (serverSnowflake, tableId, text) WHERE access = 1; |
||||
CREATE UNIQUE INDEX IF NOT EXISTS responses_unique_by_dm ON responses (userSnowflake, tableId, text) WHERE access = 2; |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,376 @@ |
||||
import { CommandContext, CommandOptionType, ComponentContext, SlashCommand, type SlashCreator } from 'slash-create/web'; |
||||
import { |
||||
calculateUnlockedValues, |
||||
DELETE_ID, |
||||
DONE_ID, |
||||
generateErrorMessageFor, |
||||
generateFieldFor, |
||||
generateMessageFor, |
||||
generateValuesFor, |
||||
getEmbedFrom, |
||||
loadEmbed, |
||||
populateLocksFor, |
||||
REROLL_ID, |
||||
RollTableNames, |
||||
SELECT_ID, |
||||
selectUnlockedFrom |
||||
} from './generated.js'; |
||||
import { type DbAccess, DeleteResult, UpdateResult } from './dbAccess.js'; |
||||
import { isTable, RollTableOrder, ValueAccess } from './rolltable.js'; |
||||
import { getTimestamp, isSnowflake, type Snowflake } from 'discord-snowflake'; |
||||
|
||||
export class ResponseCommand extends SlashCommand { |
||||
private readonly db: DbAccess |
||||
private readonly baseUrl: string; |
||||
|
||||
constructor(creator: SlashCreator, db: DbAccess, baseUrl: string, forGuilds?: Snowflake|Snowflake[]) { |
||||
super(creator, { |
||||
name: "response", |
||||
description: "Modifies the responses available in the generator.", |
||||
nsfw: false, |
||||
guildIDs: forGuilds, |
||||
dmPermission: true, |
||||
options: [ |
||||
{ |
||||
type: CommandOptionType.SUB_COMMAND, |
||||
name: "add", |
||||
description: "Adds a new response to the generator.", |
||||
options: [ |
||||
{ |
||||
type: CommandOptionType.INTEGER, |
||||
name: "table", |
||||
description: "The table to insert the response into.", |
||||
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})), |
||||
required: true, |
||||
}, |
||||
{ |
||||
type: CommandOptionType.STRING, |
||||
name: "text", |
||||
description: "The text to use as the response.", |
||||
required: true, |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
type: CommandOptionType.SUB_COMMAND, |
||||
name: "list", |
||||
description: "Lists responses that will appear in /generate in the current context." |
||||
}, |
||||
{ |
||||
type: CommandOptionType.SUB_COMMAND, |
||||
name: "edit", |
||||
description: "Modifies a response that was previously created.", |
||||
options: [ |
||||
{ |
||||
type: CommandOptionType.INTEGER, |
||||
name: "table", |
||||
description: "The table to update the response from.", |
||||
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})), |
||||
required: true, |
||||
}, |
||||
{ |
||||
type: CommandOptionType.STRING, |
||||
name: "old_text", |
||||
description: "The text of the response to edit.", |
||||
required: true, |
||||
}, |
||||
{ |
||||
type: CommandOptionType.STRING, |
||||
name: "new_text", |
||||
description: "The text to replace the response with.", |
||||
required: true, |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
type: CommandOptionType.SUB_COMMAND, |
||||
name: "delete", |
||||
description: "Deletes a response that was previously created.", |
||||
options: [ |
||||
{ |
||||
type: CommandOptionType.INTEGER, |
||||
name: "table", |
||||
description: "The table to delete the response from.", |
||||
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})), |
||||
required: true, |
||||
}, |
||||
{ |
||||
type: CommandOptionType.STRING, |
||||
name: "text", |
||||
description: "The text of the response to delete.", |
||||
required: true, |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
}); |
||||
this.baseUrl = baseUrl |
||||
this.db = db |
||||
} |
||||
|
||||
async run(ctx: CommandContext): Promise<void> { |
||||
switch (ctx.subcommands[0]) { |
||||
case "add": |
||||
try { |
||||
await this.onAdd(ctx) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "add a new response")) |
||||
} |
||||
break |
||||
case "list": |
||||
try { |
||||
await this.onList(ctx) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "get the list URL")) |
||||
} |
||||
break |
||||
case "edit": |
||||
try { |
||||
await this.onEdit(ctx) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "edit a response")) |
||||
} |
||||
break |
||||
case "delete": |
||||
try { |
||||
await this.onDelete(ctx) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "delete a response")) |
||||
} |
||||
break |
||||
default: |
||||
await ctx.send(generateErrorMessageFor(Error("I don't know what command you want"), "manage responses")) |
||||
break |
||||
} |
||||
} |
||||
|
||||
private async onAdd(ctx: CommandContext): Promise<void> { |
||||
const guildId = ctx.guildID ?? null |
||||
const userId = ctx.user.id |
||||
const id = ctx.interactionID |
||||
if (!isSnowflake(id)) { |
||||
throw Error("the snowflake wasn't a snowflake") |
||||
} |
||||
const timestamp = getTimestamp(id) |
||||
const table = ctx.options['add']['table'] |
||||
if (!isTable(table)) { |
||||
throw Error(`there's no table number ${table}`) |
||||
} |
||||
const text = ctx.options['add']['text'] |
||||
const { timestamp: insertedTimestamp, access, inserted } = await this.db.putResponse(timestamp, table, text, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server) |
||||
|
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: `${inserted ? 'Your new' : 'An existing'}${access === ValueAccess.Global ? " global" : ""} response`, |
||||
fields: [generateFieldFor(table, text)], |
||||
timestamp: new Date(insertedTimestamp), |
||||
}], |
||||
ephemeral: !inserted, |
||||
}) |
||||
} |
||||
|
||||
private async onList(ctx: CommandContext) { |
||||
if (ctx.guildID) { |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: `Response list for this server`, |
||||
description: "Shows all global and server-local responses.", |
||||
url: `${this.baseUrl}/responses?server=${ctx.guildID}`, |
||||
}] |
||||
}) |
||||
} else { |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: `Response list for DMs`, |
||||
description: "It's not supported right now, so please just hang tight." |
||||
}] |
||||
}) |
||||
} |
||||
} |
||||
|
||||
private async onEdit(ctx: CommandContext): Promise<void> { |
||||
const guildId = ctx.guildID ?? null |
||||
const userId = ctx.user.id |
||||
const id = ctx.interactionID |
||||
if (!isSnowflake(id)) { |
||||
throw Error("the snowflake wasn't a snowflake") |
||||
} |
||||
const timestamp = getTimestamp(id) |
||||
const table = ctx.options['edit']['table'] |
||||
if (!isTable(table)) { |
||||
throw Error(`there's no table number ${table}`) |
||||
} |
||||
const oldText = ctx.options['edit']['old_text'] |
||||
const newText = ctx.options['edit']['new_text'] |
||||
const result = await this.db.updateResponse(timestamp, table, oldText, newText, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server) |
||||
switch (result.result) { |
||||
case UpdateResult.Updated: |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: `Your updated response`, |
||||
fields: [generateFieldFor(table, oldText), generateFieldFor(table, newText)], |
||||
timestamp: new Date(timestamp).toISOString() |
||||
}], |
||||
}) |
||||
break |
||||
case UpdateResult.NewConflict: |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: `An existing${result.access === ValueAccess.Global ? " global" : ""} response`, |
||||
fields: [generateFieldFor(table, newText)], |
||||
timestamp: new Date(result.timestamp).toISOString(), |
||||
}], |
||||
ephemeral: true, |
||||
}) |
||||
break |
||||
case UpdateResult.NoOldText: |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: `A nonexistent response`, |
||||
fields: [generateFieldFor(table, oldText)], |
||||
}], |
||||
ephemeral: true, |
||||
}) |
||||
break |
||||
case UpdateResult.OldGlobal: |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: `An uneditable global response`, |
||||
fields: [generateFieldFor(table, oldText)], |
||||
}], |
||||
ephemeral: true, |
||||
}) |
||||
break |
||||
} |
||||
} |
||||
|
||||
private async onDelete(ctx: CommandContext): Promise<void> { |
||||
const guildId = ctx.guildID ?? null |
||||
const userId = ctx.user.id |
||||
const id = ctx.interactionID |
||||
if (!isSnowflake(id)) { |
||||
throw Error("the snowflake wasn't a snowflake") |
||||
} |
||||
const timestamp = getTimestamp(id) |
||||
const table = ctx.options['delete']['table'] |
||||
if (!isTable(table)) { |
||||
throw Error(`there's no table number ${table}`) |
||||
} |
||||
const text = ctx.options['delete']['text'] |
||||
const result = await this.db.deleteResponse(table, text, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server) |
||||
switch (result.result) { |
||||
case DeleteResult.Deleted: |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: 'Your deleted response', |
||||
fields: [generateFieldFor(table, text)], |
||||
timestamp: new Date(result.timestamp).toISOString(), |
||||
}] |
||||
}) |
||||
break |
||||
case DeleteResult.OldGlobal: |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: 'An undeletable global response', |
||||
fields: [generateFieldFor(table, text)], |
||||
timestamp: new Date(result.timestamp).toISOString(), |
||||
}], |
||||
ephemeral: true |
||||
}) |
||||
break |
||||
case DeleteResult.NoOldText: |
||||
await ctx.send({ |
||||
embeds: [{ |
||||
title: 'A nonexistent response', |
||||
fields: [generateFieldFor(table, text)], |
||||
}], |
||||
ephemeral: true |
||||
}) |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
export class GenerateCommand extends SlashCommand { |
||||
private readonly db: DbAccess |
||||
|
||||
constructor(creator: SlashCreator, db: DbAccess, forGuilds?: Snowflake|Snowflake[]) { |
||||
super(creator, { |
||||
name: "generate", |
||||
description: "Generates a new scenario to play with and sends it to the current channel.", |
||||
nsfw: false, |
||||
dmPermission: true, |
||||
guildIDs: forGuilds, |
||||
throttling: { |
||||
duration: 5, |
||||
usages: 1, |
||||
} |
||||
}); |
||||
this.db = db |
||||
if (!forGuilds) { |
||||
creator.registerGlobalComponent(DONE_ID, this.onDone.bind(this)) |
||||
creator.registerGlobalComponent(REROLL_ID, this.onReroll.bind(this)) |
||||
creator.registerGlobalComponent(SELECT_ID, this.onSelect.bind(this)) |
||||
creator.registerGlobalComponent(DELETE_ID, this.onDelete.bind(this)) |
||||
} |
||||
} |
||||
|
||||
async run(ctx: CommandContext): Promise<void> { |
||||
try { |
||||
const tables = calculateUnlockedValues() |
||||
const responses = await (ctx.guildID |
||||
? this.db.getResponsesInServer(ctx.guildID) |
||||
: this.db.getResponsesInDMWith(ctx.user.id)) |
||||
const values = generateValuesFor(tables, responses) |
||||
const locks = populateLocksFor(values) |
||||
await ctx.send(generateMessageFor(values, locks)) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "generate a scenario for you")) |
||||
} |
||||
} |
||||
|
||||
async onSelect(ctx: ComponentContext): Promise<void> { |
||||
try { |
||||
const oldEmbed = getEmbedFrom(ctx.message) |
||||
const {values, locked: oldLocks} = loadEmbed(oldEmbed) |
||||
const newLocks = selectUnlockedFrom(ctx.values, oldLocks) |
||||
await ctx.editParent(generateMessageFor(values, newLocks)) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "change the selected components")) |
||||
} |
||||
} |
||||
|
||||
async onDone(ctx: ComponentContext): Promise<void> { |
||||
try { |
||||
const oldEmbed = getEmbedFrom(ctx.message) |
||||
const { values } = loadEmbed(oldEmbed) |
||||
await ctx.editParent(generateMessageFor(values, undefined)) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "finish this scenario")) |
||||
} |
||||
} |
||||
|
||||
async onReroll(ctx: ComponentContext): Promise<void> { |
||||
try { |
||||
const oldEmbed = getEmbedFrom(ctx.message) |
||||
const { values: oldValues, locked: locks } = loadEmbed(oldEmbed) |
||||
const selected = calculateUnlockedValues(oldValues, locks) |
||||
const responses = await (ctx.guildID |
||||
? this.db.getResponsesInServer(ctx.guildID) |
||||
: this.db.getResponsesInDMWith(ctx.user.id)) |
||||
const newValues = generateValuesFor(selected, responses, oldValues) |
||||
await ctx.editParent(generateMessageFor(newValues, locks)) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "reroll this scenario")) |
||||
throw e |
||||
} |
||||
} |
||||
|
||||
async onDelete(ctx: ComponentContext): Promise<void> { |
||||
try { |
||||
await ctx.delete(ctx.messageID) |
||||
} catch (e) { |
||||
await ctx.send(generateErrorMessageFor(e, "delete this scenario")) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,180 @@ |
||||
import { type RollableTables, RollTable, RollTableOrder, ValueAccess } from './rolltable.js'; |
||||
|
||||
interface DbResponse { |
||||
tableId: RollTable, |
||||
text: string, |
||||
} |
||||
|
||||
export enum UpdateResult { |
||||
Updated = 0, |
||||
NewConflict = 1, |
||||
OldGlobal = 2, |
||||
NoOldText = 3, |
||||
} |
||||
|
||||
export enum DeleteResult { |
||||
Deleted = 0, |
||||
OldGlobal = 2, |
||||
NoOldText = 3, |
||||
} |
||||
|
||||
function buildRollableTables(responses: DbResponse[]): RollableTables { |
||||
const out: {[key in RollTable]?: string[]} = {} |
||||
for (const table of RollTableOrder) { |
||||
out[table] = [] |
||||
} |
||||
for (const { tableId, text } of responses) { |
||||
out[tableId]?.push(text) |
||||
} |
||||
return out as RollableTables |
||||
} |
||||
|
||||
export class DbAccess { |
||||
private readonly getResponsesInServerQuery: D1PreparedStatement |
||||
private readonly getResponsesInDMQuery: D1PreparedStatement; |
||||
private readonly putResponseQuery: D1PreparedStatement; |
||||
private readonly checkResponseAlreadyExistsQuery: D1PreparedStatement; |
||||
private readonly getResponsesGlobal: D1PreparedStatement; |
||||
private readonly updateResponseQuery: D1PreparedStatement; |
||||
private readonly deleteResponseQuery: D1PreparedStatement; |
||||
|
||||
constructor(db: D1Database) { |
||||
this.getResponsesGlobal = db.prepare( |
||||
`SELECT DISTINCT tableId, text FROM responses
|
||||
WHERE access = ${ValueAccess.Global}`)
|
||||
this.getResponsesInServerQuery = db.prepare( |
||||
`SELECT DISTINCT tableId, text FROM responses
|
||||
WHERE access = ${ValueAccess.Global} |
||||
OR (access = ${ValueAccess.Server} AND serverSnowflake = ?);`)
|
||||
this.getResponsesInDMQuery = db.prepare( |
||||
`SELECT DISTINCT tableId, text FROM responses
|
||||
WHERE access = ${ValueAccess.Global} |
||||
OR (access = ${ValueAccess.CreatorDM} AND userSnowflake = ?);`)
|
||||
this.putResponseQuery = db.prepare( |
||||
`INSERT OR IGNORE INTO responses (id, tableId, text, timestamp, userSnowflake, serverSnowflake, access) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING timestamp, access;`) |
||||
this.updateResponseQuery = db.prepare( |
||||
`UPDATE responses SET text = ?3, timestamp = ?4, userSnowflake = ?5, serverSnowflake = ?6
|
||||
WHERE tableId = ?1 AND text = ?2 |
||||
AND ((?7 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?5) |
||||
OR (?7 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?6)) |
||||
RETURNING timestamp, access;`)
|
||||
this.deleteResponseQuery = db.prepare( |
||||
`DELETE FROM responses
|
||||
WHERE tableId = ?1 AND text = ?2 |
||||
AND ((?5 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?3) |
||||
OR (?5 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?4)) |
||||
RETURNING timestamp, access;`)
|
||||
this.checkResponseAlreadyExistsQuery = db.prepare( |
||||
`SELECT timestamp, access FROM responses
|
||||
WHERE tableId = ?1 AND text = ?2 |
||||
AND (access = ${ValueAccess.Global} |
||||
OR (?3 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?4) |
||||
OR (?3 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?4));`)
|
||||
} |
||||
|
||||
async getGlobalResponses(): Promise<RollableTables> { |
||||
const {results} = await this.getResponsesGlobal.all<DbResponse>() |
||||
return buildRollableTables(results) |
||||
} |
||||
|
||||
async getResponsesInServer(inServerSnowflake: string): Promise<RollableTables> { |
||||
const statement = this.getResponsesInServerQuery.bind(inServerSnowflake) |
||||
const {results} = await statement.all<DbResponse>() |
||||
return buildRollableTables(results) |
||||
} |
||||
|
||||
async getResponsesInDMWith(withUserSnowflake: string): Promise<RollableTables> { |
||||
const statement = this.getResponsesInDMQuery.bind(withUserSnowflake) |
||||
const {results} = await statement.all<DbResponse>() |
||||
return buildRollableTables(results) |
||||
} |
||||
|
||||
async putResponse(requestTimestamp: number, table: RollTable, text: string, fromUserSnowflake: string, inServerSnowflake: string|null, access?: ValueAccess): Promise<{ |
||||
timestamp: number, |
||||
access: ValueAccess, |
||||
inserted: boolean |
||||
}> { |
||||
const effectiveAccess = access ?? (inServerSnowflake ? ValueAccess.Server : ValueAccess.CreatorDM) |
||||
const relevantSnowflake = access === ValueAccess.Server ? inServerSnowflake : access === ValueAccess.CreatorDM ? fromUserSnowflake : null |
||||
const existingResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, text, effectiveAccess, relevantSnowflake) |
||||
const existingResponse = await existingResponseStatement.first<{timestamp: number, access: ValueAccess}>() |
||||
if (existingResponse) { |
||||
return { |
||||
timestamp: existingResponse.timestamp, |
||||
access: existingResponse.access, |
||||
inserted: false, |
||||
} |
||||
} |
||||
const statement = this.putResponseQuery.bind( |
||||
requestTimestamp, table, text, requestTimestamp, fromUserSnowflake, inServerSnowflake, effectiveAccess) |
||||
const result = await statement.first<{timestamp: number, access: ValueAccess}>() |
||||
if (!result) { |
||||
throw Error("no response from insert") |
||||
} |
||||
return { |
||||
timestamp: result.timestamp, |
||||
access: result.access, |
||||
inserted: true |
||||
} |
||||
} |
||||
|
||||
async updateResponse(timestamp: number, table: RollTable, oldText: string, newText: string, userId: string, guildId: string | null, access?: ValueAccess): Promise<{result: UpdateResult.NoOldText|UpdateResult.Updated} | {result: UpdateResult.NewConflict, timestamp: number, access: ValueAccess} | {result: UpdateResult.OldGlobal, timestamp: number, access: ValueAccess.Global}> { |
||||
const effectiveAccess = access ?? (guildId ? ValueAccess.Server : ValueAccess.CreatorDM) |
||||
const relevantSnowflake = access === ValueAccess.Server ? guildId : access === ValueAccess.CreatorDM ? userId : null |
||||
const existingOldResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, oldText, effectiveAccess, relevantSnowflake) |
||||
const existingOldResponse = await existingOldResponseStatement.first<{timestamp: number, access: ValueAccess}>() |
||||
if (!existingOldResponse) { |
||||
return { |
||||
result: UpdateResult.NoOldText |
||||
} |
||||
} else if (existingOldResponse.access === ValueAccess.Global) { |
||||
return { |
||||
timestamp: existingOldResponse.timestamp, |
||||
access: existingOldResponse.access, |
||||
result: UpdateResult.OldGlobal, |
||||
} |
||||
} |
||||
const existingNewResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, newText, effectiveAccess, relevantSnowflake) |
||||
const existingNewResponse = await existingNewResponseStatement.first<{timestamp: number, access: ValueAccess}>() |
||||
if (existingNewResponse) { |
||||
return { |
||||
result: UpdateResult.NewConflict, |
||||
timestamp: existingNewResponse.timestamp, |
||||
access: existingNewResponse.access, |
||||
} |
||||
} |
||||
const statement = this.updateResponseQuery.bind( |
||||
table, oldText, newText, timestamp, userId, guildId, effectiveAccess) |
||||
await statement.run() |
||||
return {result: UpdateResult.Updated} |
||||
} |
||||
|
||||
async deleteResponse(table: RollTable, text: string, userId: string, guildId: string | null, access?: ValueAccess): Promise< |
||||
{result: DeleteResult.Deleted, timestamp: number, access: ValueAccess} | |
||||
{result: DeleteResult.NoOldText} | |
||||
{result: DeleteResult.OldGlobal, timestamp: number, access: ValueAccess.Global} |
||||
> { |
||||
const effectiveAccess = access ?? (guildId ? ValueAccess.Server : ValueAccess.CreatorDM) |
||||
const relevantSnowflake = access === ValueAccess.Server ? guildId : access === ValueAccess.CreatorDM ? userId : null |
||||
const existingOldResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, text, effectiveAccess, relevantSnowflake) |
||||
const existingOldResponse = await existingOldResponseStatement.first<{timestamp: number, access: ValueAccess}>() |
||||
if (!existingOldResponse) { |
||||
return { |
||||
result: DeleteResult.NoOldText |
||||
} |
||||
} else if (existingOldResponse.access === ValueAccess.Global) { |
||||
return { |
||||
timestamp: existingOldResponse.timestamp, |
||||
access: existingOldResponse.access, |
||||
result: DeleteResult.OldGlobal, |
||||
} |
||||
} |
||||
const statement = this.deleteResponseQuery.bind( |
||||
table, text, userId, guildId, effectiveAccess) |
||||
const deleted = await statement.first<{timestamp: number, access: ValueAccess}>() |
||||
if (!deleted) { |
||||
throw Error("no response from delete") |
||||
} |
||||
return {result: DeleteResult.Deleted, timestamp: deleted.timestamp, access: deleted.access} |
||||
} |
||||
} |
@ -0,0 +1,233 @@ |
||||
import { type RollableTables, rollOn, RollTable, RollTableOrder } from './rolltable.js'; |
||||
import { |
||||
ButtonStyle, |
||||
type ComponentActionRow, |
||||
type ComponentButton, |
||||
type ComponentSelectMenu, |
||||
type ComponentSelectOption, |
||||
ComponentType, |
||||
EmbedField, |
||||
MessageEmbed, type MessageEmbedOptions, type MessageOptions |
||||
} from 'slash-create/web'; |
||||
|
||||
export type ComponentValues = { [key in RollTable]?: string } |
||||
export type ComponentLocks = { [Key in RollTable]?: boolean } |
||||
|
||||
export interface GeneratedMessage { |
||||
values: ComponentValues |
||||
locked?: ComponentLocks |
||||
} |
||||
|
||||
export const RollTableEmoji = { |
||||
[RollTable.Setting]: '\u{1f3d9}\ufe0f', |
||||
[RollTable.Theme]: '\u{1f4d4}', |
||||
[RollTable.Start]: '\u25b6\ufe0f', |
||||
[RollTable.Challenge]: '\u{1f613}', |
||||
[RollTable.Twist]: '\u{1f500}', |
||||
[RollTable.Focus]: '\u{1f444}', |
||||
[RollTable.Word]: '\u{2728}' |
||||
} as const satisfies {readonly [key in RollTable]: string} |
||||
|
||||
export const RollTableEmbedTitles = { |
||||
[RollTable.Setting]: 'The action takes place...', |
||||
[RollTable.Theme]: 'The encounter is themed around...', |
||||
[RollTable.Start]: 'The action begins when...', |
||||
[RollTable.Challenge]: 'Things are more difficult because...', |
||||
[RollTable.Twist]: 'Partway through, unexpectedly...', |
||||
[RollTable.Focus]: 'The vore scene is focused on...', |
||||
[RollTable.Word]: 'The word of the day is...' |
||||
} as const satisfies {readonly [key in RollTable]: string} |
||||
|
||||
export const RollTableNames = { |
||||
[RollTable.Setting]: 'Setting', |
||||
[RollTable.Theme]: 'Theme', |
||||
[RollTable.Start]: 'Inciting Incident', |
||||
[RollTable.Challenge]: 'Challenge', |
||||
[RollTable.Twist]: 'Twist', |
||||
[RollTable.Focus]: 'Vore Scene Focus', |
||||
[RollTable.Word]: 'Word of the Day' |
||||
} as const satisfies {readonly [key in RollTable]: string} |
||||
|
||||
|
||||
export const RollTableEmbedsReversed = { |
||||
"\u{1f3d9}\ufe0f The action takes place...": RollTable.Setting, |
||||
"\u{1f4d4} The encounter is themed around...": RollTable.Theme, |
||||
"\u25b6\ufe0f The action begins when...": RollTable.Start, |
||||
"\u{1f613} Things are more difficult because...": RollTable.Challenge, |
||||
"\u{1f500} Partway through, unexpectedly...": RollTable.Twist, |
||||
"\u{1f444} The vore scene is focused on...": RollTable.Focus, |
||||
"\u{2728} The word of the day is...": RollTable.Word, |
||||
} as const satisfies {readonly [key in RollTable as `${typeof RollTableEmoji[key]} ${typeof RollTableEmbedTitles[key]}`]: key} & {[other: string]: RollTable} |
||||
|
||||
export function calculateUnlockedValues(original?: ComponentValues|undefined, locks?: ComponentLocks|undefined): RollTable[] { |
||||
if (!original && !locks) { |
||||
return RollTableOrder |
||||
} |
||||
const existingItems = original ? RollTableOrder.filter(v => typeof original[v] !== "undefined") : RollTableOrder |
||||
return locks ? existingItems.filter(v => locks[v] !== true) : existingItems |
||||
} |
||||
|
||||
export function generateValuesFor(selected: readonly RollTable[], tables: RollableTables, original: ComponentValues = {}): ComponentValues { |
||||
const result: ComponentValues = Object.assign({}, original) |
||||
for (const table of selected) { |
||||
result[table] = rollOn(table, tables) |
||||
} |
||||
return result |
||||
} |
||||
|
||||
export const LOCK_SUFFIX = " \u{1f512}" |
||||
export const UNLOCK_SUFFIX = " \u{1f513}" |
||||
|
||||
export function generateFieldFor(field: RollTable, value: string, lock: boolean|null = null) { |
||||
return { |
||||
name: RollTableEmoji[field] + " " + RollTableEmbedTitles[field] + (lock !== null ? (lock ? LOCK_SUFFIX : UNLOCK_SUFFIX) : ""), |
||||
value, |
||||
} |
||||
} |
||||
|
||||
export function generateEmbedFor(values: ComponentValues, locks: ComponentLocks|undefined): MessageEmbedOptions { |
||||
const fields: EmbedField[] = [] |
||||
const usableLocks = locks ?? {} |
||||
for (const field of RollTableOrder) { |
||||
const value = values[field] |
||||
if (value) { |
||||
fields.push(generateFieldFor(field, value, usableLocks.hasOwnProperty(field) ? usableLocks[field] : null)) |
||||
} |
||||
} |
||||
return { |
||||
title: 'Your generated scenario', |
||||
fields, |
||||
timestamp: new Date().toISOString() |
||||
} |
||||
} |
||||
|
||||
export function getEmbedFrom({embeds}: {embeds?: MessageEmbed[]|undefined}): MessageEmbed { |
||||
const result = embeds && embeds.length >= 1 ? embeds[0] : null |
||||
if (!result) { |
||||
throw Error("there were no embeds on the message to read") |
||||
} |
||||
return result |
||||
} |
||||
export function loadEmbed(embed: MessageEmbed): GeneratedMessage { |
||||
const result: {values: ComponentValues, locked: ComponentLocks} = { |
||||
values: {}, |
||||
locked: {}, |
||||
} |
||||
if (!embed.fields || embed.fields.length === 0) { |
||||
throw Error("there were no fields on the embed to read") |
||||
} |
||||
for (const field of embed.fields!) { |
||||
let locked: boolean|undefined, |
||||
name = field.name |
||||
if (name.endsWith(LOCK_SUFFIX)) { |
||||
locked = true |
||||
name = name.substring(0, name.length - LOCK_SUFFIX.length) |
||||
} else if (name.endsWith(UNLOCK_SUFFIX)) { |
||||
locked = false |
||||
name = name.substring(0, name.length - UNLOCK_SUFFIX.length) |
||||
} else { |
||||
throw Error(`there was no lock or unlock suffix on ${name}`) |
||||
} |
||||
const value = field.value |
||||
if (RollTableEmbedsReversed.hasOwnProperty(name)) { |
||||
const table = RollTableEmbedsReversed[name as keyof typeof RollTableEmbedsReversed] |
||||
if (typeof locked !== "undefined") { |
||||
result.locked[table] = locked |
||||
} |
||||
result.values[table] = value |
||||
} else { |
||||
throw Error(`I don't know a field named ${name}`) |
||||
} |
||||
} |
||||
return result |
||||
} |
||||
|
||||
export function populateLocksFor(values: ComponentValues, original?: ComponentLocks|undefined): ComponentLocks { |
||||
const result = Object.assign({}, original) |
||||
for (const table of RollTableOrder) { |
||||
if (typeof values[table] !== "undefined") { |
||||
result[table] = result[table] ?? true |
||||
} |
||||
} |
||||
return result |
||||
} |
||||
|
||||
export function selectUnlockedFrom(values: string[], oldLocks?: ComponentLocks | undefined): ComponentLocks { |
||||
const result = Object.assign({}, oldLocks ?? {}) |
||||
for (const table of RollTableOrder) { |
||||
if (result.hasOwnProperty(table)) { |
||||
result[table] = !values.includes(`${table}`) |
||||
} |
||||
} |
||||
return result |
||||
} |
||||
|
||||
export const SELECT_ID = "selected" |
||||
export const REROLL_ID = "reroll" |
||||
export const DONE_ID = "done" |
||||
export const DELETE_ID = "delete" |
||||
|
||||
export function generateActionsFor(values: ComponentValues, locks: ComponentLocks|undefined): ComponentActionRow[] { |
||||
if (!locks) { |
||||
return [] |
||||
} |
||||
const items = RollTableOrder.filter((v) => values.hasOwnProperty(v)) |
||||
const lockedItems = items.filter((v) => locks[v] === true) |
||||
const selectOptions: ComponentSelectOption[] = items.map((v) => ({ |
||||
default: !(locks[v] ?? false), |
||||
value: `${v}`, |
||||
label: RollTableNames[v], |
||||
emoji: {name: RollTableEmoji[v]} |
||||
})) |
||||
if (selectOptions.length === 0) { |
||||
return [] |
||||
} |
||||
const select: ComponentSelectMenu = { |
||||
type: ComponentType.STRING_SELECT, |
||||
custom_id: SELECT_ID, |
||||
disabled: false, |
||||
max_values: selectOptions.length, |
||||
min_values: 0, |
||||
options: selectOptions, |
||||
placeholder: 'Components to reroll' |
||||
} |
||||
const selectRow: ComponentActionRow = { type: ComponentType.ACTION_ROW, components: [ select ] } |
||||
const rerollButton: ComponentButton = { |
||||
type: ComponentType.BUTTON, |
||||
custom_id: REROLL_ID, |
||||
disabled: lockedItems.length === items.length, |
||||
emoji: {name: '\u{1f3b2}'}, |
||||
label: (lockedItems.length === 0 ? "Reroll ALL" : "Reroll Selected"), |
||||
style: ButtonStyle.PRIMARY |
||||
} |
||||
const doneButton: ComponentButton = { |
||||
type: ComponentType.BUTTON, |
||||
custom_id: DONE_ID, |
||||
disabled: false, |
||||
emoji: { name: '\u{1f44d}' }, |
||||
label: 'Looks good!', |
||||
style: ButtonStyle.SUCCESS, |
||||
} |
||||
const deleteButton: ComponentButton = { |
||||
type: ComponentType.BUTTON, |
||||
custom_id: DELETE_ID, |
||||
disabled: false, |
||||
emoji: { name: '\u{1f5d1}\ufe0f' }, |
||||
label: 'Trash it.', |
||||
style: ButtonStyle.DESTRUCTIVE, |
||||
} |
||||
const buttonRow: ComponentActionRow = { type: ComponentType.ACTION_ROW, components: [rerollButton, doneButton, deleteButton] } |
||||
return [selectRow, buttonRow] |
||||
} |
||||
|
||||
export function generateMessageFor(values: ComponentValues, locks: ComponentLocks|undefined): MessageOptions { |
||||
return { embeds: [generateEmbedFor(values, locks)], components: generateActionsFor(values, locks), ephemeral: false } |
||||
} |
||||
|
||||
export function generateErrorMessageFor(e: unknown, context?: string): MessageOptions { |
||||
console.error(`Error when trying to ${context ?? "do something (unknown context)"}`, e) |
||||
return { |
||||
content: `I wasn't able to ${context ?? "do that"}. Thing is, ${e}...`, |
||||
ephemeral: true, |
||||
} |
||||
} |
@ -0,0 +1,43 @@ |
||||
export enum RollTable { |
||||
Setting = 0, |
||||
Theme = 1, |
||||
Start = 2, |
||||
Challenge = 3, |
||||
Twist = 4, |
||||
Focus = 5, |
||||
Word = 6, |
||||
} |
||||
|
||||
export enum ValueAccess { |
||||
Global = 0, |
||||
Server = 1, |
||||
CreatorDM = 2, |
||||
} |
||||
|
||||
export const RollTableOrder = |
||||
[RollTable.Setting, RollTable.Theme, RollTable.Start, RollTable.Challenge, RollTable.Twist, RollTable.Focus, RollTable.Word] as const satisfies RollTable[] |
||||
|
||||
export const RollTableOrdinals = |
||||
{ |
||||
[RollTable.Setting]: 0, |
||||
[RollTable.Theme]: 1, |
||||
[RollTable.Start]: 2, |
||||
[RollTable.Challenge]: 3, |
||||
[RollTable.Twist]: 4, |
||||
[RollTable.Focus]: 5, |
||||
[RollTable.Word]: 6, |
||||
} as const satisfies {[key in RollTable]: number} & {[key in Extract<keyof typeof RollTableOrder, number> as typeof RollTableOrder[key]]: key} |
||||
|
||||
export type RollableTables = {readonly [key in RollTable]: readonly string[]} |
||||
|
||||
export function isTable(val: number): val is RollTable { |
||||
return RollTableOrdinals.hasOwnProperty(val) |
||||
} |
||||
|
||||
export function rollOn(table: RollTable, tables: RollableTables): string { |
||||
const values = tables[table] |
||||
if (values.length === 0) { |
||||
throw Error(`no possible options for table ${table}`) |
||||
} |
||||
return values[Math.floor(values.length * Math.random())] |
||||
} |
@ -1,51 +1,17 @@ |
||||
name = "ncc-gen" |
||||
name = "vore-scenario-generator" |
||||
main = "src/index.ts" |
||||
compatibility_date = "2023-12-18" |
||||
|
||||
# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) |
||||
# Note: Use secrets to store sensitive data. |
||||
# Docs: https://developers.cloudflare.com/workers/platform/environment-variables |
||||
# [vars] |
||||
# MY_VARIABLE = "production_value" |
||||
|
||||
# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. |
||||
# Docs: https://developers.cloudflare.com/workers/runtime-apis/kv |
||||
# [[kv_namespaces]] |
||||
# binding = "MY_KV_NAMESPACE" |
||||
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" |
||||
|
||||
# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. |
||||
# Docs: https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ |
||||
# [[r2_buckets]] |
||||
# binding = "MY_BUCKET" |
||||
# bucket_name = "my-bucket" |
||||
|
||||
# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. |
||||
# Docs: https://developers.cloudflare.com/queues/get-started |
||||
# [[queues.producers]] |
||||
# binding = "MY_QUEUE" |
||||
# queue = "my-queue" |
||||
|
||||
# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. |
||||
# Docs: https://developers.cloudflare.com/queues/get-started |
||||
# [[queues.consumers]] |
||||
# queue = "my-queue" |
||||
|
||||
# Bind another Worker service. Use this binding to call another Worker without network overhead. |
||||
# Docs: https://developers.cloudflare.com/workers/platform/services |
||||
# [[services]] |
||||
# binding = "MY_SERVICE" |
||||
# service = "my-service" |
||||
|
||||
# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. |
||||
# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. |
||||
# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects |
||||
# [[durable_objects.bindings]] |
||||
# name = "MY_DURABLE_OBJECT" |
||||
# class_name = "MyDurableObject" |
||||
|
||||
# Durable Object migrations. |
||||
# Docs: https://developers.cloudflare.com/workers/learning/using-durable-objects#configure-durable-object-classes-with-migrations |
||||
# [[migrations]] |
||||
# tag = "v1" |
||||
# new_classes = ["MyDurableObject"] |
||||
[vars] |
||||
# BASE_URL |
||||
# DISCORD_APP_ID |
||||
# DISCORD_APP_SECRET |
||||
# DISCORD_PUBLIC_KEY |
||||
# DISCORD_DEV_GUILD_IDS |
||||
|
||||
[[d1_databases]] |
||||
binding = "DB" # i.e. available in your Worker on env.DB |
||||
database_name = "production-ncc-gen" |
||||
database_id = "d09d3c74-c75f-4418-8f1b-2fe7f21637e6" |
||||
migrations_table = "d1_migrations" |
||||
migrations_dir = "migrations" |
||||
|
Loading…
Reference in new issue