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