commit
2a73d5fa24
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
|
] |
||||||
|
}; |
@ -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 |
@ -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"] |
||||||
|
} |
After Width: | Height: | Size: 96 KiB |
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…
Reference in new issue