Steppies project start

main
Mari 1 year ago
commit 05c4bae65d
  1. 9
      .editorconfig
  2. 6
      .env.example
  3. 46
      .eslintrc.js
  4. 8
      .gitignore
  5. 52
      .idea/codeStyles/Project.xml
  6. 5
      .idea/codeStyles/codeStyleConfig.xml
  7. 7
      .idea/prettier.xml
  8. 116
      .idea/workspace.xml
  9. 8
      .prettierrc
  10. 8
      .vscode/extensions.json
  11. 51
      README.md
  12. 6
      jest.config.js
  13. 13516
      package-lock.json
  14. 41
      package.json
  15. 13
      patches/slash-create+5.5.2.patch
  16. 19
      slash-up.config.js
  17. 25
      src/commands/hello.ts
  18. 1
      src/commands/index.ts
  19. 19
      src/index.ts
  20. 73
      src/shim/creator.ts
  21. 4
      src/shim/index.ts
  22. 63
      src/shim/servers/cfworker.ts
  23. 42
      src/shim/util/multipartData.ts
  24. 61
      src/shim/util/requestHandler.ts
  25. 40
      src/shim/util/verify.ts
  26. 62
      src/state/gamestate.spec.ts
  27. 339
      src/state/gamestate.ts
  28. 5
      src/types.d.ts
  29. 37
      steppies.md
  30. 24
      tsconfig.json
  31. 32
      webpack.config.js
  32. 10
      wrangler.example.toml

@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

@ -0,0 +1,6 @@
DISCORD_APP_ID=
DISCORD_PUBLIC_KEY=
DISCORD_BOT_TOKEN=
# You can fill this in for development.env
DEVELOPMENT_GUILD_ID=

@ -0,0 +1,46 @@
module.exports = {
env: {
commonjs: true,
es6: true,
browser: true
},
extends: ['eslint:recommended', 'plugin:prettier/recommended'],
globals: {
DISCORD_APP_ID: true,
DISCORD_PUBLIC_KEY: true,
DISCORD_BOT_TOKEN: true
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 6,
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
rules: {
'prettier/prettier': 'warn',
'no-cond-assign': [2, 'except-parens'],
'no-unused-vars': 0,
'@typescript-eslint/no-unused-vars': 1,
'no-empty': [
'error',
{
allowEmptyCatch: true
}
],
'prefer-const': [
'warn',
{
destructuring: 'all'
}
],
'spaced-comment': 'warn'
},
overrides: [
{
files: ['slash-up.config.js', 'webpack.config.js'],
env: {
node: true
}
}
]
};

8
.gitignore vendored

@ -0,0 +1,8 @@
/coverage
/dist
/node_modules
wrangler.toml
/.vscode/settings.json
*.log
*.env
.dev.vars

@ -0,0 +1,52 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
<option name="HTML_QUOTE_STYLE" value="Single" />
<option name="HTML_ENFORCE_QUOTES" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="120" />
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="120" />
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myRunOnSave" value="true" />
<option name="myRunOnReformat" value="true" />
</component>
</project>

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="84c57704-c34b-42d3-90dc-bc3746b4a30c" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="TypeScript File" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="MarkdownSettingsMigration">
<option name="stateVersion" value="1" />
</component>
<component name="ProjectId" id="2IHAWQfFWS3VH0VkyejFrSj157w" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"WebServerToolWindowFactoryState": "false",
"last_opened_file_path": "/home/reya/WebstormProjects/steppies/node_modules/ts-jest",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.standard": "true",
"node.js.detected.package.stylelint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.standard": "",
"node.js.selected.package.stylelint": "",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs.jest.jest_package": "/home/reya/WebstormProjects/steppies/node_modules/jest",
"nodejs_interpreter_path": "node",
"nodejs_package_manager_path": "npm",
"prettierjs.PrettierConfiguration.Package": "/home/reya/WebstormProjects/steppies/node_modules/prettier",
"settings.editor.selected.configurable": "configurable.group.appearance",
"ts.external.directory.path": "/opt/WebStorm/plugins/JavaScriptLanguage/jsLanguageServicesImpl/external",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager" selected="Jest.All Tests">
<configuration name="All Tests" type="JavaScriptTestRunnerJest" nameIsGenerated="true">
<node-interpreter value="project" />
<node-options value="" />
<jest-package value="$PROJECT_DIR$/node_modules/jest" />
<working-dir value="$PROJECT_DIR$" />
<envs />
<scope-kind value="ALL" />
<method v="2" />
</configuration>
<configuration name="Debug Application" type="JavascriptDebugType" uri="http://localhost:3000">
<method v="2" />
</configuration>
<configuration name="npm start" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="start" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
<list>
<item itemvalue="JavaScript Debug.Debug Application" />
<item itemvalue="Jest.All Tests" />
<item itemvalue="npm.npm start" />
</list>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="84c57704-c34b-42d3-90dc-bc3746b4a30c" name="Changes" comment="" />
<created>1669828165304</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1669828165304</updated>
<workItem from="1669828166439" duration="20904000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/steppies$All_Tests.info" NAME="All Tests Coverage Results" MODIFIED="1669853649183" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="JestJavaScriptTestRunnerCoverage" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" />
</component>
</project>

@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"trailingComma": "all",
"printWidth": 120
}

@ -0,0 +1,8 @@
{
"recommendations": [
"deepscan.vscode-deepscan",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig"
]
}

@ -0,0 +1,51 @@
# /create with Cloudflare Workers
A [slash-create](https://npm.im/slash-create) template, using [Cloudflare Workers](https://workers.cloudflare.com).
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/Snazzah/slash-create-worker)
## Getting Started
### Cloning the repo
You can either use degit to locally clone this repo without git, or [create a new repo from this template](https://github.com/Snazzah/slash-create-worker/generate) and clone that.
```sh
npx degit Snazzah/slash-create-worker
```
After that, make sure to install dependencies using npm or yarn:
```sh
npm install
# yarn
```
### Installing and setting up Wrangler
> Make sure to [sign up for a Cloudflare Workers account](https://dash.cloudflare.com/sign-up/workers) in a browser before continuing.
Install wrangler with npm or yarn:
```sh
npm install -D wrangler@latest
# yarn global add wrangler@latest
```
Read more about [installing wrangler](https://developers.cloudflare.com/workers/cli-wrangler/install-update).
Afterwards, run `wrangler login` to login to your Cloudflare account with OAuth:
```sh
wrangler login
```
Copy `wrangler.example.toml` into `wrangler.toml`. Make sure to fill in your account ID in the config and update the name of the worker. You can find your account ID [here](https://dash.cloudflare.com/?to=/:account/workers) towards the right side.
### Filling in secrets
You can enter in environment secrets with `wrangler secret put`, here are the keys that are required to run this:
```sh
npx wrangler secret put DISCORD_APP_ID
npx wrangler secret put DISCORD_PUBLIC_KEY
npx wrangler secret put DISCORD_BOT_TOKEN
```
### Development
To run this locally, copy `.env.example` to `.dev.vars` and fill in the variables, then you can run `npm run dev` (or `yarn dev`) to start a local dev environment and use something like ngrok to tunnel it to a URL.
To sync commands in the development environment, copy `.env.example` to `development.env` and fill in the variables, then run `npm run sync:dev` (or `yarn sync:dev`).
> Note: When you create a command, make sure to include it in the array of commands in `./src/commands/index.ts`.
### Production
To sync to production, copy `.env.example` to `.env` and fill in the variables, then run `npm run sync`. To publish code to a worker, run `npm run deploy`.

@ -0,0 +1,6 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverageFrom: ['src/**/*.{ts,js}', '!src/index.ts', '!src/types.d.ts', '!src/shim/**/*.{ts,js}'],
}

13516
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,41 @@
{
"name": "temptress-bot",
"description": "Bot for horny thoughts",
"author": "Reya C.",
"main": "dist/worker.js",
"private": true,
"scripts": {
"build": "webpack",
"dev": "wrangler dev -l",
"deploy": "wrangler publish",
"sync": "slash-up sync",
"sync:dev": "slash-up sync -e development",
"lint": "eslint --ext .ts .",
"lint:fix": "eslint --ext .ts . --fix",
"test": "jest",
"postinstall": "patch-package"
},
"devDependencies": {
"@cloudflare/workers-types": "^2.0.0",
"@jest/globals": "^29.3.1",
"@typescript-eslint/eslint-plugin": "^4.31.2",
"@typescript-eslint/parser": "^4.31.2",
"dotenv": "^10.0.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"prettier": "^2.0.5",
"rimraf": "^3.0.2",
"slash-create": "^5.5.2",
"slash-up": "^1.1.2",
"ts-jest": "^29.0.3",
"ts-loader": "^7.0.5",
"typescript": "^4.4.3",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.0",
"wrangler": "^2.0.5"
},
"dependencies": {
"patch-package": "^6.4.7"
}
}

@ -0,0 +1,13 @@
diff --git a/node_modules/slash-create/lib/structures/interfaces/messageInteraction.d.ts b/node_modules/slash-create/lib/structures/interfaces/messageInteraction.d.ts
index 9ecfd7f..70ec168 100644
--- a/node_modules/slash-create/lib/structures/interfaces/messageInteraction.d.ts
+++ b/node_modules/slash-create/lib/structures/interfaces/messageInteraction.d.ts
@@ -153,7 +153,7 @@ export interface EditMessageOptions {
/** A file within {@link EditMessageOptions}. */
export interface MessageFile {
/** The attachment to send. */
- file: Buffer;
+ file: Blob | Uint8Array | File;
/** The name of the file. */
name: string;
}

@ -0,0 +1,19 @@
// This is the slash-up config file.
// Make sure to fill in "token" and "applicationId" before using.
// You can also use environment variables from the ".env" file if any.
module.exports = {
// The Token of the Discord bot
token: process.env.DISCORD_BOT_TOKEN,
// The Application ID of the Discord bot
applicationId: process.env.DISCORD_APP_ID,
// This is where the path to command files are, .ts files are supported!
commandPath: './src/commands',
// You can use different environments with --env (-e)
env: {
development: {
// The "globalToGuild" option makes global commands sync to the specified guild instead.
globalToGuild: process.env.DEVELOPMENT_GUILD_ID
}
}
};

@ -0,0 +1,25 @@
import { SlashCommand, CommandOptionType, SlashCreator, CommandContext } from 'slash-create';
export default class BotCommand extends SlashCommand {
constructor(creator: SlashCreator) {
super(creator, {
name: 'hello',
description: 'Says hello to you.',
options: [
{
type: CommandOptionType.STRING,
name: 'food',
description: 'What food do you like?'
}
]
});
creator.registerGlobalComponent("hi", (interact) => {
})
}
async run(ctx: CommandContext) {
return ctx.options.food ? `You like ${ctx.options.food}? Nice!` : `Hello, ${ctx.user.username}!`;
}
}

@ -0,0 +1 @@
export const commands = [require('./hello')];

@ -0,0 +1,19 @@
import { commands } from './commands'
import { SlashCreator, CFWorkerServer } from './shim'
export const creator = new SlashCreator({
applicationID: DISCORD_APP_ID,
publicKey: DISCORD_PUBLIC_KEY,
token: DISCORD_BOT_TOKEN,
})
creator.withServer(new CFWorkerServer()).registerCommands(commands)
creator.on('warn', (message) => console.warn(message))
creator.on('error', (error) => console.error(error.stack || error.toString()))
creator.on('commandRun', (command, _, ctx) =>
console.info(`${ctx.user.username}#${ctx.user.discriminator} (${ctx.user.id}) ran command ${command.commandName}`),
)
creator.on('commandError', (command, error) =>
console.error(`Command ${command.commandName} errored:`, error.stack || error.toString()),
)

@ -0,0 +1,73 @@
import {
SlashCreator as Creator,
RespondFunction,
Server,
SlashCreatorOptions,
TransformedRequest
} from 'slash-create';
import { FetchRequestHandler } from './util/requestHandler';
import { verify } from './util/verify';
// @ts-expect-error doesn't like _onRequest
export class SlashCreator extends Creator {
/** The request handler for the creator */
readonly requestHandler: FetchRequestHandler;
/** @param opts The options for the creator */
constructor(opts: SlashCreatorOptions) {
// eslint-disable-next-line constructor-super
super(opts);
// @ts-ignore
this.requestHandler = new FetchRequestHandler(this);
}
/**
* Attaches a server to the creator.
* @param server The server to use
*/
withServer(server: Server) {
if (this.server) throw new Error('A server was already set in this creator.');
this.server = server;
if (this.server.isWebserver) {
if (!this.options.publicKey) throw new Error('A public key is required to be set when using a webserver.');
// @ts-ignore
this.server.createEndpoint(this.options.endpointPath as string, this._onRequest.bind(this));
// @ts-ignore
} else this.server.handleInteraction((interaction) => this._onInteraction(interaction, null, false));
return this;
}
// Overwriting the verification method to use Web Crypto API and use waitUntil for the promise
private async _onRequest(treq: TransformedRequest, respond: RespondFunction, wait: (f: any) => void) {
this.emit('debug', 'Got request');
// Verify request
const signature = treq.headers['x-signature-ed25519'] as string;
const timestamp = treq.headers['x-signature-timestamp'] as string;
// Check if both signature and timestamp exists, and the timestamp isn't past due.
if (
!signature ||
!timestamp ||
parseInt(timestamp) < (Date.now() - (this.options.maxSignatureTimestamp as number)) / 1000
)
return respond({
status: 401,
body: 'Invalid signature'
});
if (!(await verify(treq))) {
this.emit('debug', 'A request failed to be verified');
this.emit('unverifiedRequest', treq);
return respond({
status: 401,
body: 'Invalid signature'
});
}
// @ts-expect-error
wait(this._onInteraction(treq.body, respond, true).catch(() => {}));
}
}

@ -0,0 +1,4 @@
export * from './creator';
export * from './servers/cfworker';
export * from './util/requestHandler';
export * from './util/verify';

@ -0,0 +1,63 @@
import { RespondFunction, Server, TransformedRequest } from 'slash-create';
import { MultipartData } from '../util/multipartData';
export type ServerRequestHandler = (treq: TransformedRequest, respond: RespondFunction, wait: (f: any) => void) => void;
/**
* A server for Cloudflare Workers.
* @see https://developers.cloudflare.com/workers/
*/
export class CFWorkerServer extends Server {
constructor() {
super({ alreadyListening: true });
this.isWebserver = true;
}
/** @private */
createEndpoint(path: string, handler: ServerRequestHandler) {
addEventListener('fetch', (event) => {
if (event.request.method !== 'POST')
return event.respondWith(new Response('Server only supports POST requests.', { status: 405 }));
return event.respondWith(
// eslint-disable-next-line no-async-promise-executor
new Promise(async (resolve) => {
const body = await event.request.text();
handler(
{
headers: Object.fromEntries(event.request.headers.entries()),
body: body ? JSON.parse(body) : body,
request: event.request,
response: null
},
async (response) => {
if (response.files) {
const data = new MultipartData();
for (const file of response.files) await data.attach(file.name, file.file, file.name);
await data.attach('payload_json', JSON.stringify(response.body));
resolve(
new Response(data.finish(), {
status: response.status || 200,
headers: {
...((response.headers || {}) as Record<string, string>),
'content-type': 'multipart/form-data; boundary=' + data.boundary
}
})
);
} else
resolve(
new Response(JSON.stringify(response.body), {
status: response.status || 200,
headers: {
...((response.headers || {}) as Record<string, string>),
'content-type': 'application/json'
}
})
);
},
event.waitUntil.bind(event)
);
})
);
});
}
}

@ -0,0 +1,42 @@
export class MultipartData {
boundary = '----------------SlashCreate';
bufs: Uint8Array[] = [];
async attach(fieldName: string, data: any, filename?: string) {
if (data === undefined) return;
let str = '\r\n--' + this.boundary + '\r\nContent-Disposition: form-data; name="' + fieldName + '"';
if (filename) str += '; filename="' + filename + '"';
if (data instanceof Blob || data instanceof File) {
str += `\r\nContent-Type: ${data.type}`;
data = new Uint8Array(await data.arrayBuffer());
} else if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
str += '\r\nContent-Type: application/octet-stream';
data = new Uint8Array(data);
} else if (typeof data === 'object') {
str += '\r\nContent-Type: application/json';
data = encode(JSON.stringify(data));
} else {
data = encode('' + data);
}
this.bufs.push(encode(str + '\r\n\r\n'));
this.bufs.push(data);
}
finish() {
this.bufs.push(encode('\r\n--' + this.boundary + '--'));
let index = 0;
const result = new Uint8Array(this.bufs.reduce((a, b) => a + b.byteLength, 0));
for (const buf of this.bufs) {
result.set(new Uint8Array(buf), index);
index += buf.byteLength;
}
return result;
}
}
function encode(text: string) {
return new TextEncoder().encode(text);
}

@ -0,0 +1,61 @@
import { RequestHandler } from 'slash-create';
import { MultipartData } from './multipartData';
/**
* The request handler for REST requests.
* @private
*/
export class FetchRequestHandler extends RequestHandler {
/**
* Make an API request
* @param method Uppercase HTTP method
* @param url URL of the endpoint
* @param auth Whether to add the Authorization header and token or not
* @param body Request payload
* @param file The file(s) to send
*/
async request(method: string, url: string, auth = true, body?: any, file?: any): Promise<any> {
// @ts-ignore
const creator = this._creator;
const headers: Record<string, string> = {
'User-Agent': this.userAgent,
'Accept-Encoding': 'gzip,deflate',
'X-RateLimit-Precision': 'millisecond'
};
let data: any = body;
if (auth) {
if (!creator.options.token) throw new Error('No token was set in the SlashCreator.');
headers.Authorization = creator.options.token;
}
if (file) {
if (Array.isArray(file) || file.file) {
data = new MultipartData();
headers['Content-Type'] = 'multipart/form-data; boundary=' + data.boundary;
if (Array.isArray(file)) for (const f of file) await (data as MultipartData).attach(f.name, f.file, f.name);
else await (data as MultipartData).attach(file.name, file.file, file.name);
if (body) await (data as MultipartData).attach('payload_json', JSON.stringify(body));
data = data.finish();
} else throw new Error('Invalid file object');
} else if (body) {
if (method !== 'GET' && method !== 'DELETE') {
data = JSON.stringify(body);
headers['Content-Type'] = 'application/json';
}
}
const res = await fetch('https://discord.com' + this.baseURL + url, { method, body: data, headers });
if (!res.ok) {
const data = await res.text();
throw new Error(`${method} got ${res.status} - ${data}`);
}
return await res.json();
}
toString() {
return '[RequestHandler]';
}
}

@ -0,0 +1,40 @@
// from https://gist.github.com/devsnek/77275f6e3f810a9545440931ed314dc1
import { TransformedRequest } from 'slash-create';
function hex2bin(hex: string) {
const buf = new Uint8Array(Math.ceil(hex.length / 2));
for (var i = 0; i < buf.length; i++) {
buf[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return buf;
}
const encoder = new TextEncoder();
let publicKey: CryptoKey;
async function getPublicKey() {
if (publicKey) return publicKey;
// @ts-expect-error Node.js needs to know this is a public key
publicKey = await crypto.subtle.importKey(
'raw',
hex2bin(DISCORD_PUBLIC_KEY),
{ name: 'NODE-ED25519', namedCurve: 'NODE-ED25519', public: true },
true,
['verify']
);
return publicKey;
}
export async function verify(treq: TransformedRequest) {
const signature = hex2bin(treq.headers['x-signature-ed25519'] as string);
const timestamp = treq.headers['x-signature-timestamp'] as string;
const unknown = JSON.stringify(treq.body);
return await crypto.subtle.verify(
'NODE-ED25519',
await getPublicKey(),
signature,
encoder.encode(timestamp + unknown)
);
}

@ -0,0 +1,62 @@
import { describe, expect, test } from '@jest/globals'
import { DieState, isHeldState, isSelectedState, setDeselected, setSelected, toggleSelected } from './gamestate'
describe('isHeldState', () => {
test.each<[DieState, boolean]>([
[DieState.ROLLED, false],
[DieState.SELECTED, false],
[DieState.HELD, true],
[DieState.HELD_SELECTED, true],
[DieState.SCORED, false],
])('(%p) returns %p', (state, value): void => {
expect(isHeldState(state)).toEqual(value)
})
})
describe('isSelectedState', () => {
test.each<[DieState, boolean]>([
[DieState.ROLLED, false],
[DieState.SELECTED, true],
[DieState.HELD, false],
[DieState.HELD_SELECTED, true],
[DieState.SCORED, false],
])('(%p) returns %p', (state, value): void => {
expect(isSelectedState(state)).toEqual(value)
})
})
describe('setSelected', () => {
test.each<[DieState, DieState]>([
[DieState.ROLLED, DieState.SELECTED],
[DieState.SELECTED, DieState.SELECTED],
[DieState.HELD, DieState.HELD_SELECTED],
[DieState.HELD_SELECTED, DieState.HELD_SELECTED],
[DieState.SCORED, DieState.SCORED],
])('(%p) returns %p', (state, value): void => {
expect(setSelected(state)).toEqual(value)
})
})
describe('setDeselected', () => {
test.each<[DieState, DieState]>([
[DieState.ROLLED, DieState.ROLLED],
[DieState.SELECTED, DieState.ROLLED],
[DieState.HELD, DieState.HELD],
[DieState.HELD_SELECTED, DieState.HELD],
[DieState.SCORED, DieState.SCORED],
])('(%p) returns %p', (state, value): void => {
expect(setDeselected(state)).toEqual(value)
})
})
describe('toggleSelected', () => {
test.each<[DieState, DieState]>([
[DieState.ROLLED, DieState.SELECTED],
[DieState.SELECTED, DieState.ROLLED],
[DieState.HELD, DieState.HELD_SELECTED],
[DieState.HELD_SELECTED, DieState.HELD],
[DieState.SCORED, DieState.SCORED],
])('(%p) returns %p', (state, value): void => {
expect(toggleSelected(state)).toEqual(value)
})
})

@ -0,0 +1,339 @@
export enum DieFace {
FAIL = 'F',
STOP = 'S',
SCORE_0 = '0',
SCORE_1 = '1',
SCORE_2 = '2',
SCORE_3 = '3',
SCORE_4 = '4',
SCORE_5 = '5',
WILD = '*',
HEART = '<3',
QUESTION = '?',
}
export enum DieState {
ROLLED = 'rolled',
SELECTED = 'selected',
HELD = 'held',
HELD_SELECTED = 'held_selected',
SCORED = 'scored',
}
export function isHeldState(d: DieState): d is DieState.HELD | DieState.HELD_SELECTED {
return d === DieState.HELD || d === DieState.HELD_SELECTED
}
export function isSelectedState(d: DieState): d is DieState.SELECTED | DieState.HELD_SELECTED {
return d === DieState.SELECTED || d === DieState.HELD_SELECTED
}
export function setSelected(d: DieState): DieState.SELECTED | DieState.HELD_SELECTED | DieState.SCORED {
switch (d) {
case DieState.ROLLED:
return DieState.SELECTED
case DieState.HELD:
return DieState.HELD_SELECTED
case DieState.SELECTED:
case DieState.HELD_SELECTED:
case DieState.SCORED:
return d
}
}
export function setDeselected(d: DieState): DieState.ROLLED | DieState.HELD | DieState.SCORED {
switch (d) {
case DieState.SELECTED:
return DieState.ROLLED
case DieState.HELD_SELECTED:
return DieState.HELD
case DieState.ROLLED:
case DieState.HELD:
case DieState.SCORED:
return d
}
}
export function toggleSelected(d: DieState): DieState {
switch (d) {
case DieState.ROLLED:
return DieState.SELECTED
case DieState.SELECTED:
return DieState.ROLLED
case DieState.HELD:
return DieState.HELD_SELECTED
case DieState.HELD_SELECTED:
return DieState.HELD
case DieState.SCORED:
return DieState.SCORED
}
}
export interface DieResult {
readonly type: DieType
readonly face: DieFace
readonly state: DieState
}
export interface DieType {
// The name of this die type
readonly name: string
// The faces of this die type; faces may be repeated to increase their odds
readonly faces: readonly DieFace[]
}
export interface AIWeights {
// How much this AI likes to choose actions that increase the damage bonus
// damageBonusWeight is static and does not change
readonly damageBonusWeight: number
// How much this AI likes to choose actions that increase the requirement of stop dice in a turn
// stopWeight is multiplied by the number of stop dice both players can gain
readonly stopWeight: number
// How much this AI likes to choose actions that decrease the limit of fail dice in a turn
// failWeight is multiplied by the number of fail dice both players can lose
readonly failWeight: number
// How much this AI likes to choose actions that are finishers for the opponent.
// finisherWeight is multiplied by the percentage of max damage the opponent has taken
readonly finisherWeight: number
// How much this AI dislikes choosing actions that are finishers for itself.
// selfFinisherWeight is multiplied by the percentage of max damage the AI has taken
// If this would reduce the weight of an action to 0 or less, that action has a weight of 0 and will only be
// selected randomly if all actions have a weight of 0.
readonly selfFinisherWeight: number
// Approximate lead that the AI would prefer to have before ending its turn when it has the option to keep pressing
readonly desiredLead: number
// Approximate damage behind the previous turn total that the AI is willing to take to play it safe,
// assuming doing so wouldn't be fatal (not enough damage to kill or not a finisher)
// When there's one fail die left, the AI will prioritize stopping as long as the damage it will be taking is within
// a randomly selected 50-150% of this value
readonly allowedDamage: number
// Integer from 0-100:
// 100 means AI keeps rolling until it has one fail die left before trying to stop
// 0 means AI always stops as soon as it has at least the desired lead
// Anything in between is the percentage chance of continuing, which is rolled once for each fail die remaining
// besides the last. If the chance fails once, then the AI uses that turn as if it were trying to stop.
// This roll is only performed when the AI already has the lead, is ahead by a value within an acceptable distance
// of the desired lead, and the AI has at least two additional fail dice remaining before failing.
readonly recklessnessPercent: number
// Integer from 0-100:
// 0 means AI never holds stops until it's ready to end its turn
// 100 means AI always holds stops it sees
// Anything in between is the percentage chance of locking a new stop die that appears, rolled once per stop die
// needed to end the turn per die that comes up stop. If the chance succeeds once, then the AI holds that stop die.
// This roll is only performed when the AI is not already stopping its turn.
readonly holdStopsPercent: number
}
export interface PlayerStartingState extends Pick<PlayerState, 'damageMax' | 'stopCount' | 'failCount'> {
// Percentage of damage that this player starts off dealing
readonly damageBonusBase: number
// Percentage of damage this player's damage increases by with each damage bonus earned
readonly damageBonusIncrement: number
// Maximum number of damage bonuses that this player can have, or 0 if there's no upper limit
readonly maxDamageBonuses: number
// Minimum number of dice required to stop
readonly minStopCount: number
// Minimum number of dice required to fail
readonly minFailCount: number
// Maximum number of dice required to stop
readonly maxStopCount: number
// Maximum number of dice required to fail
readonly maxFailCount: number
// Base amount of damage that must be dealt to recover, or 0 if recovery is forbidden
// Defaults to 1000
readonly recoverBase?: number
// Amount by which the amount needed to recover increases each time recovery is earned
// Defaults to 1000
readonly recoverIncrement?: number
// Percentage of current damage taken that is removed when recovery is earned
// Defaults to 50
readonly recoverPercent?: number
// Instructions for if this player is an AI player. If not specified, this player cannot be played by the AI.
readonly aiData?: AIWeights
}
export interface Difficulty {
// Name of the difficulty in the select menu
readonly name: string
// Short description of the difficulty in the select menu
readonly shortDescription?: string
// Description of the difficulty when selected
readonly description?: string
// Starting values for the top
readonly topStats: PlayerStartingState
// Starting values for the bottom
readonly bottomStats: PlayerStartingState
}
export interface PlayerText {
// Possible random names for this player
readonly names?: readonly string[]
// Text given at the start of the game from this side
readonly startText?: TriggeredText
// Text given at the end of the game from this side
readonly endText?: TriggeredText
// The name of the damage value for this player
readonly damage: string
// The name of the player's damage limit, defaulting to "Max " + damage
readonly maxDamage?: string
// The name of this player's stop count requirement
readonly stopCount: string
// The name of this player's fail count limit
readonly failCount: string
// The name of this player's incoming damage bonus
readonly damageBonus: string
}
export interface GameTheme {
readonly name: string
readonly shortDescription: string
readonly description: string
readonly actions: readonly GameAction[]
readonly difficulties: readonly Difficulty[]
readonly topText: PlayerText
readonly bottomText: PlayerText
}
export interface TriggeredText {
// The dialogues that can be triggered for this action.
// Dialogue can be disabled for characters run by human players.
readonly dialogue?: readonly string[]
// The descriptions that can be triggered for this action.
// Descriptions are always displayed, regardless of player.
readonly description?: readonly string[]
}
export interface ActionText {
// Name of the action in the select list for this side
readonly name: string
// Description of the action in the select list for this side
readonly shortDescription: string
// Description of the action when selected for this side
readonly description: string
// Text given when this side selects this action.
readonly selectAction?: TriggeredText
// Text given when the other side selects this action.
readonly opponentSelectsAction?: TriggeredText
// Text given when this side finishes a turn in this action without failing.
readonly passTurn?: TriggeredText
// Text given when this side receives a turn from the opponent in this action.
readonly opponentPassesTurn?: TriggeredText
// Text given when this side rerolls the dice for this action.
readonly reroll?: TriggeredText
// Text given when the opposing side rerolls the dice for this action.
readonly opponentRerolls?: TriggeredText
// Text given the first time each turn this side is one fail die away from failing
readonly aboutToFailTurn?: TriggeredText
// Text given the first time each turn the opposing side is one fail die away from failing
readonly opponentAboutToFailTurn?: TriggeredText
// Text given when this side fails the turn by accumulating fail dice
readonly failTurn?: TriggeredText
// Text given when the opposing side fails the turn by accumulating fail dice
readonly opponentFailsTurn?: TriggeredText
// Text given when this side fails the turn by stopping without enough points
readonly abandonTurn?: TriggeredText
// Text given when the opposing side fails the turn by stopping without enough points
readonly opponentAbandonsTurn?: TriggeredText
// Text given when this side is defeated by being pushed over their max damage with this action
readonly defeated?: TriggeredText
// Text given when this side defeats the opponent by pushing them over their max damage with this action
readonly opponentDefeated?: TriggeredText
}
export interface GameAction {
// The text from the top's perspective, and for the top's actions
readonly topText: ActionText
// The text from the bottom's perspective, and for the bottom's actions
readonly bottomText: ActionText
// The dice that are rolled for this action
readonly dice: readonly DieType[]
// True if the loser of this action increases the amount of damage they take
readonly givesDamageBonus: boolean
// True if the loser of this action has to get an additional stop die to end their turn
readonly givesStopCount: boolean
// True if the loser of this action has one fewer buffer for fail dice
readonly givesFailCount: boolean
// Whether this action can end the game in a loss for the top
readonly canFinishTop: boolean
// Whether this action can end the game in a loss for the bottom
readonly canFinishBottom: boolean
}
export interface PlayerState {
// This player's name
readonly name: string
// Amount of damage taken so far this battle
readonly damage: number
// Max damage that can be taken, 0 for endless mode
readonly damageMax: number
// The total damage taken across this battle
readonly damageTotal: number
// Number of stop dice required to end a turn with stops
readonly stopCount: number
// Number of fail dice required to fail a turn by accumulating too many fail dice
readonly failCount: number
// Incoming damage for this side is multiplied by 100% + 10% * damageBonuses
readonly damageBonuses: number
// Amount that Total Damage must reach to reduce this player's damage by 50% of its current value
// 0 means no recovery is allowed for this player
readonly nextRecoverAt: number
// Number of times damage was recovered so far this game (adds 1000 + 100 * timesRecovered to nextRecoverAt)
readonly timesRecovered: number
}
export interface GameState {
// The version of the serialization format this state was saved with.
readonly version: number
// The state of the top player
readonly top: PlayerState
// The state of the bottom player
readonly bottom: PlayerState
// The theme that defines this game for the players.
readonly theme: GameTheme
// The difficulty selected for this game.
readonly difficulty: Difficulty
// Null if the top is a computer.
readonly topHumanId: string | null
// Null if the bottom is a computer.
readonly bottomHumanId: string | null
// If true, the top is choosing the action this round.
readonly isTopRound: boolean
// The action chosen for this round, or null if the current player is choosing an action
readonly action: GameAction | null
// If true, the top is rolling this turn.
readonly isTopTurn: boolean
// The total for the previous turn, or 0 if the current player is taking the first turn
// If the current player fails when the turn total is 0, they take the penalty and pass the initiative without taking damage.
// This can still end the game if the current action is capable of ending the game for this player.
readonly lastTurnTotal: number
// The dice available for the current turn, or null if the current player has not rolled yet this turn.
readonly lastRoll: readonly DieResult[] | null
// The total value of the selected dice, or 0 if the selected dice cannot be scored.
readonly selectedDiceValue: number
// Whether the selected dice are sufficient to end the turn.
readonly selectedDiceEndTurn: boolean
// The number of dice that have come up failures so far in the current turn
readonly countedFails: number
// The total of the dice that have been scored so far this turn
readonly currentTurnTotal: number
}

5
src/types.d.ts vendored

@ -0,0 +1,5 @@
// secrets: wrangler secret put <name>
declare const DISCORD_APP_ID: string
declare const DISCORD_PUBLIC_KEY: string
declare const DISCORD_BOT_TOKEN: string
declare const DEVELOPMENT_GUILD_ID: string

@ -0,0 +1,37 @@
Activity pairs:
Sniff foot / Press foot to nose
Lick sole / Put sole against mouth
Suck toes / Put toes in mouth
Prostrate / Debase
Squirm / Humiliate
Plead / Force to Beg
Masturbate with toes / Toe footjob
Fuck soles / Sole footjob
Grind against heel / Heel footjob
In addition to having different dice (different sets of faces, thus different odds, plus different numbers of dice, ranging from 5-8) for each activity, the activity groups have different penalties:
Sniff/Lick/Suck = builds intoxication/trance but cannot end the game (taking too much damage leaves the affected party over the limit but isn't a finisher)
Prostrate/Squirm/Plead = builds submissiveness/vulnerability but cannot end the game (taking too much damage leaves the affected party over the limit but isn't a finisher)
Masturbate/Fuck/Grind = builds arousal/thrill penalty multiplier for failed round; if a player is over 100% Arousal/Thrill after losing a round in one of these, that player loses the game
Intoxication/Trance = number of Stop dice that must be stored up at once to end turn successfully (counts up from 1 to 5)
Submissiveness/Vulnerability = number of Fail dice that need to appear during a turn to end turn in failure (counts down from 5 to 1)
Arousal/Thrill Penalty Multiplier = amount of damage dealt by a failed round (counts up from 100% in 10% increments)
Each round, turns go back and forth with each turn needing to end with a higher score than the previous turn in that round; first person to end their turn in failure or be unable to succeed enough to beat their opponent's previous turn score loses and takes the punishment for that category as well as an amount of arousal/thrill damage equal to the difference between their opponent's previous turn score and their current turn score (or 0, if they lost by accumulating Fail dice)
Players alternate each round, with the current player for a round choosing an activity and being the first to take their turn for that activity
Stop dice can be banked, which reduces the number of dice available but makes stopping safer (especially after building up Intoxication/Trance)
Fail dice are counted each time they appear, and do not reduce the number of dice available
Scoring dice are worth their face value each time they appear, and do not reduce the number of dice available
Combo dice can be banked and are only valuable if the full combo shows up, which scores the big points for the combo and frees the banked dice to be rolled again
Intoxication/Trance and Submissiveness/Vulnerability decrease by 1 (minimum 1 each) after each round when at least (current value + 2) rounds have passed since their last increase
Arousal/Thrill Penalty Multiplier does not decay
In endless mode, Thrill instead functions as a scoring mechanism, with Arousal decreasing by 50% of its current value (max 50%) every 1000 Thrill points + 100 Thrill points per Arousal decrease this game
Arousal damage taken is also tracked, with total Arousal damage taken added to Thrill damage dealt for the final score

@ -0,0 +1,24 @@
{
"compilerOptions": {
"outDir": "./dist",
"module": "commonjs",
"target": "es2020",
"lib": ["es2020", "webworker"],
"alwaysStrict": true,
"strict": true,
"preserveConstEnums": true,
"moduleResolution": "node",
"sourceMap": true,
"esModuleInterop": true,
"types": ["@cloudflare/workers-types"],
"resolveJsonModule": true
},
"include": [
"./src/*.ts",
"./test/*.ts",
"./src/**/*.ts",
"./test/**/*.ts",
"./node_modules/@cloudflare/workers-types/index.d.ts"
],
"exclude": ["node_modules/", "dist/"]
}

@ -0,0 +1,32 @@
const path = require('path');
module.exports = {
output: {
filename: 'worker.js',
path: path.join(__dirname, 'dist')
},
mode: 'production',
resolve: {
extensions: ['.ts', '.tsx', '.js'],
plugins: [],
fallback: {
zlib: false,
https: false,
fs: false,
fastify: false,
express: false,
path: false
}
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
transpileOnly: true
}
}
]
}
};

@ -0,0 +1,10 @@
name = "slash-create-worker"
account_id = ""
main = "dist/worker.js"
compatibility_date = "2021-12-20"
[dev]
port = 8020
[build]
command = "npm run build"
Loading…
Cancel
Save