Base version of motw tracker

main
Mari 9 months ago
commit 2a73d5fa24
  1. 9
      .editorconfig
  2. 45
      .eslintrc.js
  3. 18
      .gitignore
  4. 8
      .idea/.gitignore
  5. 8
      .idea/modules.xml
  6. 13
      .idea/motw-tracker.iml
  7. 6
      .idea/vcs.xml
  8. 8
      .prettierrc
  9. 29
      README.md
  10. 21
      app.json
  11. 1652
      data/theme.svg
  12. 4367
      package-lock.json
  13. 40
      package.json
  14. 9
      pm2.json
  15. 19
      slash-up.config.js
  16. 125
      src/character.ts
  17. 360
      src/commands/base.ts
  18. 65
      src/commands/experience.ts
  19. 88
      src/commands/harm.ts
  20. 77
      src/commands/heal.ts
  21. 66
      src/commands/luck.ts
  22. 36
      src/commands/status.ts
  23. 51
      src/index.ts
  24. 185
      src/renderStatus.ts
  25. 22
      tsconfig.json

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

@ -0,0 +1,45 @@
module.exports = {
env: {
commonjs: true,
es6: true,
node: true
},
extends: ['eslint:recommended', 'prettier/@typescript-eslint', 'plugin:prettier/recommended'],
globals: {
NodeJS: true,
BigInt: 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'],
env: {
node: true
}
}
]
};

18
.gitignore vendored

@ -0,0 +1,18 @@
# Packages
node_modules/
yarn.lock
package_lock.json
# Log files
logs/
*.log
# Miscellaneous
.tmp/
.vscode/**/*
!.vscode/extensions.json
.env
dist/
data/characters
data/images
data/party.yaml

8
.idea/.gitignore vendored

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/motw-tracker.iml" filepath="$PROJECT_DIR$/.idea/motw-tracker.iml" />
</modules>
</component>
</project>

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/dist" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

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

@ -0,0 +1,29 @@
# slash-create-template in TypeScript
This templates helps you in creating slash commands in TypeScript from a webserver.
| [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/Snazzah/slash-create-template/tree/typescript) | [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/GL2qbv?referralCode=snazzah) |
|:-:|:-:|
## Installation
```sh
npx slash-up init typescript slash-commands
cd slash-commands
# edit variables in the ".env" file!
# Create and edit commands in the `commands` folder
npx slash-up sync
yarn build
yarn start
```
### From Railway/Heroku
For Railway and Heroku users, you must sync commands locally to push any command changes to Discord. You can do this by using `slash-up sync` within your Git repository.
Heroku users will have their commands synced when they initially deploy to Heroku.
### Using PM2
```sh
npm i -g pm2
# Follow the installation process above
pm2 start pm2.json
pm2 dump # recommended
```

@ -0,0 +1,21 @@
{
"name": "/create (TypeScript)",
"description": "Deploy a slash-create server for Discord interactions.",
"repository": "https://github.com/Snazzah/slash-create-template/tree/typescript",
"logo": "https://slash-create.js.org/static/logo-nomargin.png",
"scripts": {
"postdeploy": "npx slash-up sync"
},
"env": {
"DISCORD_APP_ID": {
"description": "The application ID of the Discord app"
},
"DISCORD_PUBLIC_KEY": {
"description": "The public key of the Discord app"
},
"DISCORD_BOT_TOKEN": {
"description": "The bot token of the Discord app"
}
},
"keywords": ["node", "fastify", "discord", "interactions"]
}

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 96 KiB

4367
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,40 @@
{
"name": "slash-create-template",
"version": "1.0.0",
"description": "A template for slash-create",
"main": "dist/index.js",
"type": "module",
"scripts": {
"sync": "slash-up sync",
"sync:dev": "slash-up sync -e development",
"start": "cd dist && node index.js",
"build": "npx tsc",
"lint": "npx eslint --ext .ts ./src",
"lint:fix": "npx eslint --ext .ts ./src --fix"
},
"dependencies": {
"@resvg/resvg-js": "^2.6.0",
"@types/jsdom": "^21.1.6",
"cat-loggr": "^1.1.0",
"dotenv": "^16.4.5",
"fastify": "^3.9.2",
"jsdom": "^24.0.0",
"slash-create": "^5.2.0",
"sharp": "^0.33.3",
"yaml": "^2.4.0"
},
"devDependencies": {
"@types/express": "^4.17.11",
"@types/node": "^14.14.37",
"@types/svgdom": "^0.1.2",
"@typescript-eslint/eslint-plugin": "^4.19.0",
"@typescript-eslint/parser": "^4.19.0",
"eslint": "^7.15.0",
"eslint-config-prettier": "^7.0.0",
"eslint-plugin-prettier": "^3.3.0",
"prettier": "^2.2.1",
"slash-up": "^1.0.11",
"ts-node": "^9.1.1",
"typescript": "^4.2.3"
}
}

@ -0,0 +1,9 @@
{
"apps": [
{
"name": "slash-commands",
"script": "node",
"args": "dist/index.js"
}
]
}

@ -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,125 @@
import {parse as parseYaml, stringify as stringifyYaml} from "yaml"
import {readFile, writeFile, readdir} from 'fs/promises'
import {join} from 'path'
export interface GameCharacter {
health: number
armor?: number
unstable?: boolean
luck: number
luckSpecial?: string
experience: number
Charm?: number
Cool?: number
Sharp?: number
Tough?: number
Weird?: number
color: number
defaultFacePath: string
additionalFaces?: GameCharacterFace[]
activeFaceSets?: string[]
moves?: GameCharacterMove[]
improvementsTaken?: string[]
improvementsAvailable?: string[]
improvementsAdvanced?: string[]
}
export enum GameAttribute {
Charm = "Charm",
Cool = "Cool",
Sharp = "Sharp",
Tough = "Tough",
Weird = "Weird",
}
export const GameAttributes = [GameAttribute.Charm, GameAttribute.Cool, GameAttribute.Sharp, GameAttribute.Tough, GameAttribute.Weird] as const
export interface GameCharacterMoveBase {
type?: string
name: string
summary?: string
description?: string
}
export interface GameCharacterPassiveMove extends GameCharacterMoveBase {
type?: "passive"
}
export interface GameCharacterRollableMove extends GameCharacterMoveBase {
type: "rollable"
attribute?: GameAttribute
bonus?: number
advanced?: boolean
onAdvanced?: string
onSuccess?: string
onMixed?: string
onMiss?: string
}
export type GameCharacterMove = GameCharacterPassiveMove|GameCharacterRollableMove
export interface FaceConditionBase {
type: string
negated?: boolean
}
export interface FaceConditionStability extends FaceConditionBase {
type: "stable"|"unstable"|"dead"
}
export interface FaceConditionHealth extends FaceConditionBase {
type: "hpEq"|"hpGt"|"hpLt"|"hpGtEq"|"hpLtEq"
threshold: number
}
export interface FaceConditionHealthDelta extends FaceConditionBase {
type: "beingHealed"|"beingDamaged"|"healthSteady"
}
export interface FaceConditionSet extends FaceConditionBase {
type: "faceSetActive"
set: string
}
export type FaceCondition = FaceConditionStability|FaceConditionHealth|FaceConditionHealthDelta|FaceConditionSet
export interface GameCharacterFace {
path: string
conditions: FaceCondition[]
}
export const FaceSetIdentifier = /^[a-z0-9_]+$/
export async function listCharacters(dataDir: string): Promise<string[]> {
const list = await readdir(join(dataDir, "characters"))
return list.filter(s => s.endsWith(".yaml")).map(s => s.substring(0, s.length - 5))
}
export async function loadCharacter(dataDir: string, name: string): Promise<GameCharacter> {
const contents = await readFile(join(dataDir, "characters", name + ".yaml"), {encoding: "utf-8"})
return parseYaml(contents)
}
export async function saveCharacter(dataDir: string, name: string, character: GameCharacter): Promise<void> {
const contents = stringifyYaml(character)
return writeFile(join(dataDir, "characters", name + ".yaml"), contents)
}
export interface GameParty {
defaultCharacters: Record<string, string>
activeParty: string[]
keeper: string
}
export interface ReadonlyGameParty {
readonly defaultCharacters: Readonly<Record<string, string>>
readonly activeParty: readonly string[]
readonly keeper: string
}
export async function loadParty(dataDir: string): Promise<GameParty> {
const contents = await readFile(join(dataDir, "party.yaml"), {encoding: "utf-8"})
return parseYaml(contents)
}

@ -0,0 +1,360 @@
import {
type ApplicationCommandOption,
type ApplicationCommandOptionAutocompletable, ApplicationCommandType,
type AutocompleteContext, type CommandContext,
CommandOptionType, type Message, type MessageFile,
SlashCommand,
type SlashCommandOptions,
type SlashCreator
} from "slash-create";
import {
type GameCharacter,
type GameParty,
listCharacters,
loadCharacter,
loadParty,
type ReadonlyGameParty, saveCharacter
} from "../character.js";
import {type GameStatus, type GameStatusWithPortrait, renderStatus} from "../renderStatus.js";
import {readFile} from "fs/promises";
import {join} from "path";
import {default as Sharp} from "sharp";
const dataDir = "../data"
export const CharacterOptionTemplate = {
name: "character",
description: "The character(s) to operate on.",
required: false,
type: CommandOptionType.STRING,
autocomplete: true,
isCharacterOption: true,
} as const satisfies ApplicationCommandOptionAutocompletable & {isCharacterOption: true}
export interface CharacterDataBase {
readonly success: boolean
readonly name: string
}
export interface LoadedCharacterData extends CharacterDataBase {
readonly success: true
readonly originalData: Readonly<GameCharacter>
newData?: GameCharacter
}
export interface ErrorCharacterData extends CharacterDataBase {
readonly success: false
readonly error: unknown
}
export interface PartyData {
readonly originalData: ReadonlyGameParty
readonly newData?: GameParty
}
export type GameCharacterData = LoadedCharacterData|ErrorCharacterData
const nameDelimiter = /\s*,(?:\s*,)*\s*/g
const ellipses = "..."
export function ellipsizeAt(s: string, length: number): string {
if (s.length <= length) {
return s
}
return ellipses + s.substring(Math.max(s.length - (length - ellipses.length), 0))
}
const NORMAL_STATUS_HEIGHT = 524;
const NORMAL_STATUS_WIDTH = 1446;
const enableStackedForTwoCharacters = false;
export abstract class AbstractCharacterStatusCommand extends SlashCommand {
readonly characterOptions: (ApplicationCommandOption & {isCharacterOption: true})[]
constructor(creator: SlashCreator, opts: SlashCommandOptions) {
super(creator, opts);
this.characterOptions =
(opts.options?.filter(
s =>
"isCharacterOption" in s &&
(s.isCharacterOption === true) &&
s.type === CommandOptionType.STRING)
?? []) as (ApplicationCommandOption & {isCharacterOption: true})[]
}
async autocomplete(ctx: AutocompleteContext): Promise<boolean> {
const option =
this.characterOptions.find(o => o.name === ctx.focused)
if (!option) {
return ctx.sendResults([])
}
const party = await loadParty("../data")
const defaultCharacter = party.defaultCharacters[ctx.user.id] || null
const activeParty = new Set(Array.isArray(party.activeParty) ? party.activeParty : [])
const completedNames = (ctx.options[option.name] as string).trimStart().split(nameDelimiter)
const completingName = completedNames.pop()!
const completingLowercase = completingName.toLowerCase()
const characters = (await listCharacters("../data"))
.filter(s =>
!completedNames.includes(s) && s.toLowerCase().includes(completingLowercase))
.sort((A, B) => {
if (activeParty.has(A)) {
if (activeParty.has(B)) {
// fall through
} else {
return -1
}
} else if (activeParty.has(B)) {
return 1
} else {
// fall through
}
if (A === defaultCharacter) {
return -1
} else if (B === defaultCharacter) {
return 1
} else {
// fall through
}
const a = A.toLowerCase()
const b = B.toLowerCase()
if (a.startsWith(completingLowercase)) {
if (b.startsWith(completingLowercase)) {
// fall through
} else {
return -1
}
} else if (b.startsWith(completingLowercase)) {
return 1
} else {
// fall through
}
if (b.length === a.length) {
return a.localeCompare(b)
} else {
return b.length - a.length
}
}).slice(0, 20)
const unselectedParty = new Set(activeParty)
completedNames.forEach(entry => entry.includes("*") || unselectedParty.delete(entry))
const partyName = unselectedParty.size > 0 ? `* (Active Party: ${Array.from(unselectedParty).join(", ")})` : "* (Active Party)"
const selectionPrefixName = completedNames.length > 0 ? completedNames.join(", ") + ", " : ""
const selectionPrefixValue = completedNames.length > 0 ? completedNames.join(",") + "," : ""
return ctx.sendResults([
...characters.map(s => ({
name: ellipsizeAt(selectionPrefixName + s, 100),
value: ellipsizeAt(selectionPrefixValue + s, 100),
})),
...(partyName.includes(completingName) ? [{name: ellipsizeAt(selectionPrefixName + partyName, 100), value: ellipsizeAt(selectionPrefixValue + "*", 100)}] : []),
])
}
async characterNames(options: Record<string, unknown>): Promise<[option: string, names: Set<string>][]> {
const [party, allCharacters] =
await Promise.all([loadParty("../data"), listCharacters("../data")])
const activeParty = new Set(Array.isArray(party.activeParty) ? party.activeParty : [])
return this.characterOptions.map(
o =>
options[o.name] && typeof options[o.name] === 'string'
? [o.name,
new Set((options[o.name] as string).trim().split(nameDelimiter)
.flatMap(item => {
if (item.includes("*")) {
return Array.from(activeParty)
} else if (allCharacters.includes(item)) {
return [item]
} else {
const found = allCharacters.find(v => item.toLowerCase() === v.toLowerCase())
if (found) {
return [found]
} else {
return [item]
}
}
}))]
: [o.name, new Set()])
}
abstract process(ctx: CommandContext, characters: Map<string, readonly GameCharacterData[]>): Promise<
readonly [string, LoadedCharacterData[]]
|readonly [string, LoadedCharacterData]
|readonly LoadedCharacterData[]
|LoadedCharacterData
|string>
async run(ctx: CommandContext): Promise<boolean|Message> {
await ctx.defer()
let svg = readFile("../data/theme.svg", {encoding: "utf-8"})
const characterNames = await this.characterNames(ctx.options)
const neededCharacters: Set<string> = new Set()
for (const [_, names] of characterNames) {
for (const name of names) {
neededCharacters.add(name)
}
}
const loadedCharacters = await Promise.all(Array.from(neededCharacters).map(name =>
loadCharacter(dataDir, name)
.then<LoadedCharacterData>(c => ({name, success: true, originalData: c}))
.catch<ErrorCharacterData>(e => ({name, success: false, error: e}))))
const characterMap = new Map<string, GameCharacterData>()
for (const character of loadedCharacters) {
characterMap.set(character.name, character)
}
const optionMap = new Map<string, GameCharacterData[]>()
for (const [option, names] of characterNames) {
optionMap.set(option, Array.from(names).map(x => characterMap.get(x)!))
}
const result = await this.process(ctx, optionMap)
let message: string|null = null, statuses: LoadedCharacterData[] = []
if (Array.isArray(result)) {
if (result.length === 2 && typeof result[0] === "string") {
message = result[0]
if (Array.isArray(result[1])) {
statuses = result[1]
} else {
statuses = [result[1]]
}
} else {
statuses = result
}
} else if (typeof result === "string") {
message = result
} else {
statuses = [result as LoadedCharacterData]
}
const deltas =
await Promise.all(Array.from(new Set(statuses)).filter(s => loadedCharacters.includes(s)).slice(0, 10).map(s => characterDataDelta(s)))
const images = await Promise.all(deltas.map(async s => renderStatus(await svg, s)))
const sharps = images.map(b => Sharp(b))
const metadatas = await Promise.all(sharps.map(s => s.metadata()))
let resultImage: Buffer|null = null
if (images.length === 0) {
resultImage = null
} else if (images.length === 1) {
resultImage = images[0]
} else if (enableStackedForTwoCharacters && images.length === 2) {
const totalHeight = metadatas.reduce((x, y) => x + (y.height ?? NORMAL_STATUS_HEIGHT), 0)
const maxWidth = metadatas.reduce((x, y) => Math.max(x, y.width ?? NORMAL_STATUS_WIDTH), 0)
const result = Sharp({
create: {
background: "#00000000",
channels: 4,
height: totalHeight,
width: maxWidth,
}
})
result.composite([{
input: images[0],
left: 0,
top: 0,
}, {
input: images[1],
left: 0,
top: metadatas[0].height ?? NORMAL_STATUS_HEIGHT,
}])
resultImage = await result.png().toBuffer()
} else {
let maxWidth: [number, number] = [metadatas[0].width ?? NORMAL_STATUS_WIDTH, 0],
totalHeight: [number, number] = [metadatas[0].height ?? NORMAL_STATUS_HEIGHT, 0],
lastHeight: [number, number] = [metadatas[0].height ?? NORMAL_STATUS_HEIGHT, 0]
const topCoordinates: number[] = new Array(metadatas.length)
topCoordinates[0] = 0
for (let i = 1; i < metadatas.length; i += 1) {
const polarity = i % 2
const reversePolarity = 1 - polarity
const width = metadatas[i].width ?? NORMAL_STATUS_WIDTH
const height = metadatas[i].height ?? NORMAL_STATUS_HEIGHT
maxWidth[polarity] = Math.max(maxWidth[polarity], width)
lastHeight[polarity] = height
const top =
Math.max(totalHeight[polarity], totalHeight[reversePolarity] - (lastHeight[reversePolarity] / 2))
topCoordinates[i] = top
totalHeight[polarity] = top + height
}
const result = Sharp({
create: {
background: "#00000000",
channels: 4,
height: Math.max(totalHeight[0], totalHeight[1]),
width: maxWidth[0] + maxWidth[1],
}
})
result.composite(images.map((buf, i) => {
return {
input: buf,
top: topCoordinates[i],
left: i % 2 === 0
? maxWidth[0] - (metadatas[i].width ?? NORMAL_STATUS_WIDTH)
: maxWidth[0],
}
}));
resultImage = await result.png().toBuffer()
}
const pendingSaves: Promise<void>[] = []
for (const character of loadedCharacters) {
if (!character.success) {
continue
}
if (!character.newData) {
continue
}
pendingSaves.push(saveCharacter("../data", character.name, character.newData))
}
await Promise.all(pendingSaves)
return ctx.send({
content: message ?? undefined,
attachments: resultImage ? [{
id: 0,
name: "status.png",
description: ellipsizeAt(deltas.map(k => k.description).join(" "), 1024),
}] : [],
file: resultImage ? [{
name: "status.png",
file: resultImage,
}] : [],
})
}
}
export async function characterDataDelta(c: LoadedCharacterData): Promise<GameStatusWithPortrait & {description: string}> {
const oldData = c.originalData
const newData = c.newData ?? oldData
const face = readFile(join("../data/images/", c.name, newData.defaultFacePath))
const result = {
experienceDelta: (newData.experience ?? 0) - (oldData.experience ?? 0),
experience: newData.experience ?? 0,
healthDelta: (newData.health ?? 8) - (oldData.health ?? 8),
health: newData.health ?? 8,
luckDelta: (newData.luck ?? 7) - (oldData.luck ?? 7),
luck: newData.luck ?? 7,
unstable: newData.unstable ?? false,
portrait: await face,
}
return {
...result,
description: `Status of ${c.name}: ${
result.health} HP${result.unstable ? ", unstable" : ""}${
result.healthDelta > 0
? ` after healing ${result.healthDelta} Harm`
: result.healthDelta < 0
? ` after taking ${-result.healthDelta} Harm` : ""}; ${
result.experience} EXP${result.experience >= 5 ? ", ready to level up" : ""}${
result.experienceDelta > 0
? ` after gaining ${result.experienceDelta} EXP`
: result.experienceDelta < 0
? ` after losing ${-result.experienceDelta}`
: ""}; ${
result.luck} Luck${
result.luckDelta > 0
? ` after gaining ${result.luckDelta} Luck`
: result.luckDelta < 0
? ` after spending ${-result.luckDelta} Luck`
: ""}.`
}
}

@ -0,0 +1,65 @@
import {
ApplicationCommandType,
AutocompleteContext, type CommandContext,
CommandOptionType, Message,
SlashCommand,
type SlashCreator
} from "slash-create";
import {type GameCharacter, listCharacters, loadCharacter, saveCharacter} from "../character.js";
import {renderStatus} from "../renderStatus.js";
import {readFile} from "fs/promises";
import {join} from "path";
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
export class ExperienceCharacterCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
super(creator, {
name: "experience",
description: "Modifies the EXP total of the given character(s).",
type: ApplicationCommandType.CHAT_INPUT,
guildIDs: process.env.DEVELOPMENT_GUILD_ID,
options: [
{
...CharacterOptionTemplate,
name: "character",
description: "The name of the character(s) to grant EXP to or remove EXP from.",
required: true,
},
{
type: CommandOptionType.INTEGER,
name: "delta",
description: "The amount of EXP to apply to the character(s) (default +1).",
max_value: 25,
min_value: -25,
required: false,
},
]
});
}
async process(ctx: CommandContext, characters: Map<string, readonly GameCharacterData[]>): Promise<readonly [string, LoadedCharacterData[]] | readonly [string, LoadedCharacterData] | readonly LoadedCharacterData[] | LoadedCharacterData | string> {
const delta = ctx.options["delta"] ?? 1
const description: string[] = []
const result: LoadedCharacterData[] = []
for (const character of characters.get("character")!) {
if (!character.success) {
description.push(`**${character.name}** ${delta >= 0 ? "gained" : "lost"} ${Math.abs(delta)} EXP${delta >= 0 ? "!" : "."}`)
continue
}
character.newData = {
...character.newData ?? character.originalData
}
const oldLevels = Math.floor(character.newData.experience / 5)
character.newData.experience = Math.max(0, (character.newData.experience ?? 0) + delta)
const levels = Math.floor(character.newData.experience / 5)
result.push(character)
description.push(`**${character.name}** ${delta >= 0 ? "gained" : "lost"} ${Math.abs(delta)} EXP${delta >= 0 ? "!" : "."}${levels > oldLevels ? " ***Level up!***" : ""}`)
}
return [description.join("\n"), result]
}
}

@ -0,0 +1,88 @@
import {
ApplicationCommandType,
AutocompleteContext,
type CommandContext,
CommandOptionType,
Message,
SlashCommand,
type SlashCreator
} from "slash-create";
import {type GameCharacter, listCharacters, loadCharacter, saveCharacter} from "../character.js";
import {renderStatus} from "../renderStatus.js";
import {readFile} from "fs/promises";
import {join} from "path";
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
export class HarmCharacterCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
super(creator, {
name: "harm",
description: "Harms the given character(s).",
type: ApplicationCommandType.CHAT_INPUT,
guildIDs: process.env.DEVELOPMENT_GUILD_ID,
options: [
{
...CharacterOptionTemplate,
description: "The name of the character(s) to harm.",
required: true,
},
{
type: CommandOptionType.INTEGER,
name: "damage",
description: "The amount of harm to deal to the character(s).",
max_value: 99,
min_value: 0,
required: true,
},
{
type: CommandOptionType.BOOLEAN,
name: "piercing",
description: "If set, ignores any armor the character(s) may have.",
},
{
type: CommandOptionType.BOOLEAN,
name: "destabilize",
description: "True to force unstable, False to never set unstable. Default based on remaining health.",
required: false,
}
]
});
}
async process(ctx: CommandContext, characters: Map<string, readonly GameCharacterData[]>): Promise<readonly [string, LoadedCharacterData[]] | readonly [string, LoadedCharacterData] | readonly LoadedCharacterData[] | LoadedCharacterData | string> {
const baseDamage: number = ctx.options["damage"]
const piercing: boolean = ctx.options["piercing"] ?? false
const makeUnstable: boolean|null = ctx.options["destabilize"] ?? null
const description: string[] = []
const result: LoadedCharacterData[] = []
for (const character of characters.get("character")!) {
if (!character.success) {
description.push(`**${character.name}** took ${baseDamage} Harm${piercing ? " ignore-armour" : ""}${baseDamage > 0 ? "!" : "."}${makeUnstable ? " ***Unstable!***" : ""}`)
continue
}
character.newData = {
...(character.newData ?? character.originalData)
}
const effectiveDamage = Math.max(0, baseDamage - (piercing ? 0 : (character.newData.armor ?? 0)))
const blocked = Math.max(0, baseDamage - effectiveDamage)
const wasUnstable = character.newData.unstable ?? false
const wasAlive = (character.newData.health ?? 8) > 0
character.newData.health = Math.max(0, (character.newData.health ?? 8) - effectiveDamage)
if ((makeUnstable === null && character.newData.health <= 4) || makeUnstable) {
character.newData.unstable = true
}
const isUnstable = character.newData.unstable ?? false
const isAlive = character.newData.health > 0
description.push(`**${character.name}** took ${effectiveDamage} Harm${
piercing ? " ignore-armour" : blocked > 0 ? ` (${blocked} blocked)` : ""}${effectiveDamage > 0 ? "!" : "."}${
wasAlive && !isAlive ? " ***Defeated...***" : !wasUnstable && isUnstable ? " ***Unstable!***" : ""}`)
result.push(character)
}
return [description.join("\n"), result]
}
}

@ -0,0 +1,77 @@
import {
ApplicationCommandType,
AutocompleteContext, type CommandContext,
CommandOptionType, Message,
SlashCommand,
type SlashCreator
} from "slash-create";
import {type GameCharacter, listCharacters, loadCharacter, saveCharacter} from "../character.js";
import {renderStatus} from "../renderStatus.js";
import {readFile} from "fs/promises";
import {join} from "path";
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
export class HealCharacterCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
super(creator, {
name: "heal",
description: "Heals the given character(s).",
type: ApplicationCommandType.CHAT_INPUT,
guildIDs: process.env.DEVELOPMENT_GUILD_ID,
options: [
{
...CharacterOptionTemplate,
name: "character",
description: "The name of the character(s) to heal.",
required: true,
},
{
type: CommandOptionType.INTEGER,
name: "healing",
description: "The amount of healing to apply to the character(s).",
max_value: 99,
min_value: 0,
required: true,
},
{
type: CommandOptionType.BOOLEAN,
name: "stabilize",
description: "If true, repairs the unstable status of the character(s).",
},
]
});
}
async process(ctx: CommandContext, characters: Map<string, readonly GameCharacterData[]>): Promise<readonly [string, LoadedCharacterData[]] | readonly [string, LoadedCharacterData] | readonly LoadedCharacterData[] | LoadedCharacterData | string> {
const healing: number = ctx.options["healing"]
const stabilize: boolean = ctx.options["stabilize"] ?? false
const description: string[] = []
const result: LoadedCharacterData[] = []
for (const character of characters.get("character")!) {
if (!character.success) {
description.push(`**${character.name}** healed ${healing} Harm${healing > 0 ? "!" : "."}${stabilize ? " ***Stabilized!***" : ""}`)
continue
}
character.newData = {
...(character.newData ?? character.originalData)
}
const wasUnstable = character.newData.unstable ?? false
const wasAlive = (character.newData.health ?? 8) > 0
character.newData.health = Math.min((character.newData.health ?? 8) + healing, 8)
if (stabilize) {
character.newData.unstable = false
}
const isUnstable = character.newData.unstable ?? false
const isAlive = character.newData.health > 0
description.push(`**${character.name}** healed ${healing} Harm${healing > 0 ? "!" : "."}${
!wasAlive && isAlive ? " ***Revived!***" : wasUnstable && !isUnstable ? " ***Stabilized!***" : ""}`)
result.push(character)
}
return [description.join("\n"), result]
}
}

@ -0,0 +1,66 @@
import {
ApplicationCommandType,
AutocompleteContext, type CommandContext,
CommandOptionType, Message,
SlashCommand,
type SlashCreator
} from "slash-create";
import {type GameCharacter, listCharacters, loadCharacter, saveCharacter} from "../character.js";
import {renderStatus} from "../renderStatus.js";
import {readFile} from "fs/promises";
import {join} from "path";
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
export class LuckCharacterCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
super(creator, {
name: "luck",
description: "Modifies the luck of the given character(s).",
type: ApplicationCommandType.CHAT_INPUT,
guildIDs: process.env.DEVELOPMENT_GUILD_ID,
options: [
{
...CharacterOptionTemplate,
name: "character",
description: "The name of the character(s) to grant luck to or remove luck from.",
required: true,
},
{
type: CommandOptionType.INTEGER,
name: "delta",
description: "The amount of luck to apply to the character(s) (default -1).",
max_value: 7,
min_value: -7,
required: false,
},
]
});
}
async process(ctx: CommandContext, characters: Map<string, readonly GameCharacterData[]>): Promise<readonly [string, LoadedCharacterData[]] | readonly [string, LoadedCharacterData] | readonly LoadedCharacterData[] | LoadedCharacterData | string> {
const delta: number = ctx.options["delta"]
const description: string[] = []
const result: LoadedCharacterData[] = []
for (const character of characters.get("character")!) {
if (!character.success) {
description.push(`**${character.name}** ${delta > 0 ? "recovered" : "spent"} ${Math.abs(delta)} Luck${delta !== 0 ? "!" : "."}`)
continue
}
character.newData = {
...(character.newData ?? character.originalData)
}
const wasDoomed = (character.newData.luck ?? 7) === 0
character.newData.luck = Math.max(0, Math.min((character.newData.luck ?? 7) + delta, 7))
const isDoomed = character.newData.luck === 0
description.push(`**${character.name}** ${delta > 0 ? "recovered" : "spent"} ${Math.abs(delta)} Luck${delta !== 0 ? "!" : "."}${
!wasDoomed && isDoomed ? " ***Doomed...***" : wasDoomed && !isDoomed ? " ***Fate averted!***" : ""}`)
result.push(character)
}
return [description.join("\n"), result]
}
}

@ -0,0 +1,36 @@
import {
ApplicationCommandType,
type CommandContext,
CommandOptionType,
SlashCommand,
type SlashCreator
} from "slash-create";
import {
CharacterOptionTemplate,
AbstractCharacterStatusCommand,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
export class CharacterStatusCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
super(creator, {
name: "status",
description: "Gets the status of the given character(s).",
type: ApplicationCommandType.CHAT_INPUT,
guildIDs: process.env.DEVELOPMENT_GUILD_ID,
options: [
{
...CharacterOptionTemplate,
name: "character",
description: "The name of the character(s) to get the status of.",
required: true,
},
]
});
}
async process(ctx: CommandContext, characters: Map<string, readonly GameCharacterData[]>): Promise<readonly [string, LoadedCharacterData[]] | readonly [string, LoadedCharacterData] | readonly LoadedCharacterData[] | LoadedCharacterData | string> {
return characters.get("character")!.flatMap((x) => x.success ? [x] : []);
}
}

@ -0,0 +1,51 @@
import dotenv from 'dotenv';
import { SlashCreator, FastifyServer } from 'slash-create';
import path from 'path';
import {fastify} from "fastify";
import {HealCharacterCommand} from "./commands/heal.js";
import {HarmCharacterCommand} from "./commands/harm.js";
import {ExperienceCharacterCommand} from "./commands/experience.js";
import {LuckCharacterCommand} from "./commands/luck.js";
import {AbstractCharacterStatusCommand} from "./commands/base.js";
import {CharacterStatusCommand} from "./commands/status.js";
let dotenvPath = path.join(process.cwd(), '.env');
if (path.parse(process.cwd()).name === 'dist') dotenvPath = path.join(process.cwd(), '..', '.env');
dotenv.config({ path: dotenvPath });
const creator = new SlashCreator({
applicationID: process.env.DISCORD_APP_ID!,
publicKey: process.env.DISCORD_PUBLIC_KEY,
token: process.env.DISCORD_BOT_TOKEN,
serverPort: parseInt(process.env.PORT ?? "0", 10) || 8020,
serverHost: '0.0.0.0',
endpointPath: "/interactions",
});
creator.on('debug', (message) => console.log(message));
creator.on('warn', (message) => console.warn(message));
creator.on('error', (error) => console.error(error));
creator.on('synced', () => console.info('Commands synced!'));
creator.on('commandRun', (command, _, ctx) =>
console.info(`${ctx.user.username}#${ctx.user.discriminator} (${ctx.user.id}) ran command ${command.commandName}`)
);
creator.on('commandRegister', (command) => console.info(`Registered command ${command.commandName}`));
creator.on('commandError', (command, error) => console.error(`Command ${command.commandName}:`, error));
const server = fastify({
logger: true,
})
creator.withServer(new FastifyServer(server)).registerCommands([HealCharacterCommand, HarmCharacterCommand, ExperienceCharacterCommand, LuckCharacterCommand, CharacterStatusCommand]).startServer().then(() => {
console.info("startServer completed")
if (process.env.DEVELOPMENT_GUILD_ID) {
creator.syncCommandsIn(process.env.DEVELOPMENT_GUILD_ID).then(() => {
console.info("synchronized commands")
})
}
}).catch((err) => {
console.error(err)
});
console.info(`Starting server at "localhost:${creator.options.serverPort}/interactions"`);

@ -0,0 +1,185 @@
import {Resvg} from "@resvg/resvg-js";
import {JSDOM} from "jsdom";
export interface GameStatus {
health: number
unstable: boolean
healthDelta: number
experience: number
experienceDelta: number
luck: number
luckDelta: number
}
export interface GameStatusWithPortrait extends GameStatus {
portrait: Buffer
}
function removeElement(dom: Element): void {
dom.remove()
}
function healthBelow(health: number): (status: GameStatus) => boolean {
return (s) => s.health < health
}
function luckBelow(luck: number): (status: GameStatus) => boolean {
return (s) => s.luck < luck
}
function experienceBelow(exp: number): (status: GameStatus) => boolean {
return (s) => s.experience < exp
}
function healthNotEqual(health: number): (status: GameStatus) => boolean {
return (s) => s.health !== health
}
function experienceDeltaNotIncludes(index: number): (status: GameStatus) => boolean {
return (s) => {
if (s.experienceDelta <= 0) {
return true
}
const effectiveMax = Math.min(5, s.experience)
return !(index <= effectiveMax && index > effectiveMax - s.experienceDelta)
}
}
function luckDeltaNotIncludes(index: number): (status: GameStatus) => boolean {
return (s) => {
if (s.luckDelta === 0) {
return true
} else if (s.luckDelta > 0) {
return !((index <= s.luck) && (index > s.luck - s.luckDelta))
} else {
return !((index > s.luck) && (index <= s.luck - s.luckDelta))
}
}
}
function healthDamageNotIncludes(index: number): (status: GameStatus) => boolean {
return (s) => {
if (s.healthDelta >= 0) {
return true
}
return !((index > s.health) && (index <= s.health - s.healthDelta))
}
}
function healthRecoveredNotIncludes(index: number): (status: GameStatus) => boolean {
return (s) => {
if (s.healthDelta <= 0) {
return true
}
return !((index <= s.health) && (index > s.health - s.healthDelta))
}
}
function notStableAlive(): (status: GameStatus) => boolean {
return (status) => status.unstable || status.health <= 0
}
function notUnstableAlive(): (status: GameStatus) => boolean {
return (status) => !status.unstable || status.health <= 0
}
function notDead(): (status: GameStatus) => boolean {
return (status) => status.health > 0
}
function dead(): (status: GameStatus) => boolean {
return (status) => status.health <= 0
}
const mappings: [filter: (status: GameStatus) => boolean, selector: string, hit: (dom: Element) => void][] = [
[healthBelow(8), "#health8", removeElement],
[healthBelow(7), "#health7", removeElement],
[healthBelow(6), "#health6", removeElement],
[healthBelow(5), "#health5", removeElement],
[healthBelow(4), "#health4", removeElement],
[healthBelow(3), "#health3", removeElement],
[healthBelow(2), "#health2", removeElement],
[healthBelow(1), "#health1", removeElement],
[luckBelow(7), "#luck7", removeElement],
[luckBelow(6), "#luck6", removeElement],
[luckBelow(5), "#luck5", removeElement],
[luckBelow(4), "#luck4", removeElement],
[luckBelow(3), "#luck3", removeElement],
[luckBelow(2), "#luck2", removeElement],
[luckBelow(1), "#luck1", removeElement],
[experienceBelow(5), "#experienceLevelUpReady", removeElement],
[experienceBelow(5), "#experience5", removeElement],
[experienceBelow(4), "#experience4", removeElement],
[experienceBelow(3), "#experience3", removeElement],
[experienceBelow(2), "#experience2", removeElement],
[experienceBelow(1), "#experience1", removeElement],
[healthDamageNotIncludes(8), "#healthDamage8", removeElement],
[healthDamageNotIncludes(7), "#healthDamage7", removeElement],
[healthDamageNotIncludes(6), "#healthDamage6", removeElement],
[healthDamageNotIncludes(5), "#healthDamage5", removeElement],
[healthDamageNotIncludes(4), "#healthDamage4", removeElement],
[healthDamageNotIncludes(3), "#healthDamage3", removeElement],
[healthDamageNotIncludes(2), "#healthDamage2", removeElement],
[healthDamageNotIncludes(1), "#healthDamage1", removeElement],
[healthRecoveredNotIncludes(8), "#healthRecovery8", removeElement],
[healthRecoveredNotIncludes(7), "#healthRecovery7", removeElement],
[healthRecoveredNotIncludes(6), "#healthRecovery6", removeElement],
[healthRecoveredNotIncludes(5), "#healthRecovery5", removeElement],
[healthRecoveredNotIncludes(4), "#healthRecovery4", removeElement],
[healthRecoveredNotIncludes(3), "#healthRecovery3", removeElement],
[healthRecoveredNotIncludes(2), "#healthRecovery2", removeElement],
[healthRecoveredNotIncludes(1), "#healthRecovery1", removeElement],
[healthNotEqual(8), "#healthCounter8", removeElement],
[healthNotEqual(7), "#healthCounter7", removeElement],
[healthNotEqual(6), "#healthCounter6", removeElement],
[healthNotEqual(5), "#healthCounter5", removeElement],
[healthNotEqual(4), "#healthCounter4", removeElement],
[healthNotEqual(3), "#healthCounter3", removeElement],
[healthNotEqual(2), "#healthCounter2", removeElement],
[healthNotEqual(1), "#healthCounter1", removeElement],
[healthNotEqual(0), "#healthCounter0", removeElement],
[luckDeltaNotIncludes(7), "#luckShine7", removeElement],
[luckDeltaNotIncludes(6), "#luckShine6", removeElement],
[luckDeltaNotIncludes(5), "#luckShine5", removeElement],
[luckDeltaNotIncludes(4), "#luckShine4", removeElement],
[luckDeltaNotIncludes(3), "#luckShine3", removeElement],
[luckDeltaNotIncludes(2), "#luckShine2", removeElement],
[luckDeltaNotIncludes(1), "#luckShine1", removeElement],
[experienceDeltaNotIncludes(5), "#experienceUp5", removeElement],
[experienceDeltaNotIncludes(4), "#experienceUp4", removeElement],
[experienceDeltaNotIncludes(3), "#experienceUp3", removeElement],
[experienceDeltaNotIncludes(2), "#experienceUp2", removeElement],
[experienceDeltaNotIncludes(1), "#experienceUp1", removeElement],
[notStableAlive(), "#healthIcon", removeElement],
[notUnstableAlive(), "#healthLowIcon", removeElement],
[notDead(), "#healthEmptyIcon", removeElement],
[notDead(), "#characterDyingFace", removeElement],
[dead(), "#characterFaceImage", removeElement]
]
export async function renderStatus(svgTemplate: string, status: GameStatusWithPortrait): Promise<Buffer> {
const dom = new JSDOM(svgTemplate, {
contentType: "image/svg+xml",
pretendToBeVisual: false,
includeNodeLocations: false,
url: "https://localhost/status.xml"
})
for (const [filter, selector, action] of mappings) {
if (filter(status)) {
for (const el of dom.window.document.querySelectorAll(selector)) {
action(el)
}
}
}
const resvg = new Resvg(dom.window.document.documentElement.outerHTML, {})
resvg.resolveImage("https://invalid.invalid/face.png", status.portrait)
return resvg.render().asPng()
}

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es2022",
"module": "Node16",
"moduleDetection": "auto",
"moduleResolution": "Node16",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"typeRoots": ["./node_modules/@types", "types"],
},
"include": [
"./src/**/*"
],
"exclude": [
"node_modules",
"dist",
"testing",
]
}
Loading…
Cancel
Save