Add Dockerfile, party command

main
Mari 7 months ago
parent 2a73d5fa24
commit 25a9be8edf
  1. 8
      .dockerignore
  2. 2
      .eslintrc.cjs
  3. 19
      Dockerfile
  4. 30
      data/theme.svg
  5. 1807
      package-lock.json
  6. 38
      package.json
  7. 151
      src/character.ts
  8. 479
      src/commands/base.ts
  9. 81
      src/commands/experience.ts
  10. 107
      src/commands/harm.ts
  11. 94
      src/commands/heal.ts
  12. 80
      src/commands/luck.ts
  13. 88
      src/commands/party.ts
  14. 41
      src/commands/status.ts
  15. 63
      src/index.ts
  16. 228
      src/renderStatus.ts

@ -0,0 +1,8 @@
/data
.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc

@ -4,7 +4,7 @@ module.exports = {
es6: true, es6: true,
node: true node: true
}, },
extends: ['eslint:recommended', 'prettier/@typescript-eslint', 'plugin:prettier/recommended'], extends: ['eslint:recommended', 'plugin:prettier/recommended'],
globals: { globals: {
NodeJS: true, NodeJS: true,
BigInt: true BigInt: true

@ -0,0 +1,19 @@
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
WORKDIR /usr/src/app
COPY src /usr/src/app/
COPY package*.json /usr/src/app/
RUN npm ci
RUN npm run lint
RUN npm run build
RUN npm ci --production
FROM node:16.17.0-bullseye-slim
ENV NODE_ENV production
COPY --from=install /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node --from=build /usr/src/app/dist /usr/src/app/dist
CMD ["dumb-init", "node", "/usr/src/app/dist/index.js"]

@ -2,37 +2,14 @@
<!-- Created with Inkscape (http://www.inkscape.org/) --> <!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg <svg
width="382.68823mm" width="385mm"
height="138.60893mm" height="139mm"
viewBox="0 0 382.68823 138.60893" viewBox="0 0 385 139"
version="1.1" version="1.1"
id="svg15923" id="svg15923"
sodipodi:docname="motw-status.plain.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"> xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview4170"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.88635031"
inkscape:cx="518.98216"
inkscape:cy="309.13285"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs <defs
id="defs15920"> id="defs15920">
<linearGradient <linearGradient
@ -969,7 +946,6 @@
style="font-variation-settings:normal;opacity:1;fill:#00ff66;fill-opacity:1;stroke:#00ff66;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill;stop-color:#000000;stop-opacity:1" style="font-variation-settings:normal;opacity:1;fill:#00ff66;fill-opacity:1;stroke:#00ff66;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill;stop-color:#000000;stop-opacity:1"
d="m 205.5774,151.89962 v 23.40489 h 15.603 c 9.40135,0.18078 10.6739,-7.30771 10.41637,-12.80347 -0.23189,-4.94863 -0.64382,-10.65845 -10.41637,-10.60142 z" d="m 205.5774,151.89962 v 23.40489 h 15.603 c 9.40135,0.18078 10.6739,-7.30771 10.41637,-12.80347 -0.23189,-4.94863 -0.64382,-10.65845 -10.41637,-10.60142 z"
transform="translate(1.3125e-6)" transform="translate(1.3125e-6)"
inkscape:original-d="m 205.5774,151.89962 v 23.40489 h 15.603 c 9.40135,0.18078 10.6739,-7.30771 10.41637,-12.80347 -0.23189,-4.94863 -0.64382,-10.65845 -10.41637,-10.60142 z"
clip-path="url(#clipPath4737)" /> clip-path="url(#clipPath4737)" />
</g> </g>
<g <g

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

1807
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -8,33 +8,29 @@
"sync": "slash-up sync", "sync": "slash-up sync",
"sync:dev": "slash-up sync -e development", "sync:dev": "slash-up sync -e development",
"start": "cd dist && node index.js", "start": "cd dist && node index.js",
"build": "npx tsc", "build": "tsc",
"lint": "npx eslint --ext .ts ./src", "lint": "eslint --ext .ts ./src",
"lint:fix": "npx eslint --ext .ts ./src --fix" "lint:fix": "eslint --ext .ts ./src --fix"
}, },
"dependencies": { "dependencies": {
"@resvg/resvg-js": "^2.6.0", "@resvg/resvg-js": "^2.6.2",
"@types/jsdom": "^21.1.6",
"cat-loggr": "^1.1.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fastify": "^3.9.2", "fastify": "^4.26.2",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"slash-create": "^5.2.0", "slash-create": "^6.1.3",
"sharp": "^0.33.3", "sharp": "^0.33.3",
"yaml": "^2.4.0" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.11", "@types/node": "^16.18.96",
"@types/node": "^14.14.37", "@types/jsdom": "^21.1.6",
"@types/svgdom": "^0.1.2", "@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/eslint-plugin": "^4.19.0", "@typescript-eslint/parser": "^7.8.0",
"@typescript-eslint/parser": "^4.19.0", "eslint": "^8.57.0",
"eslint": "^7.15.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "^7.0.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^3.3.0", "prettier": "^3.2.5",
"prettier": "^2.2.1", "slash-up": "^1.4.2",
"slash-up": "^1.0.11", "typescript": "^5.4.5"
"ts-node": "^9.1.1",
"typescript": "^4.2.3"
} }
} }

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

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

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

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

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

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

@ -0,0 +1,88 @@
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type CharacterStatusOptions,
type GameCharacterData,
type LoadedCharacterData
} from './base.js';
import { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create';
import {type GameParty, saveParty} from '../character.js';
export class PartyCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator, opts: CharacterStatusOptions) {
super(creator, {
...opts,
name: 'party',
description: 'Gets or sets the current party list.',
type: ApplicationCommandType.CHAT_INPUT,
guildIDs: process.env.DEVELOPMENT_GUILD_ID,
options: [
{
...CharacterOptionTemplate,
name: 'characters',
description: 'The name of the character(s) to become the current party. If not set, shows the party instead.',
required: false
},
{
type: CommandOptionType.BOOLEAN,
name: 'replace',
description:
'If true, the party is being entirely replaced, so show the character list instead of the delta.',
required: false
}
]
});
}
async process(
ctx: CommandContext,
{ characters, party }: { characters: Map<string, readonly GameCharacterData[]>; party: GameParty }
): Promise<
| readonly [string, LoadedCharacterData[]]
| readonly [string, LoadedCharacterData]
| readonly LoadedCharacterData[]
| LoadedCharacterData
| string
> {
const replace: boolean | undefined = ctx.options.replace;
const oldPartyMemberNames = party.activeParty;
const newPartyMemberData = characters.get('party');
if (!newPartyMemberData || newPartyMemberData.length === 0) {
return [
`The current active party is:\n **${oldPartyMemberNames.join('**\n **')}**`,
(await this.loadCharacters(oldPartyMemberNames)).flatMap((c) => (c.success ? [c] : []))
];
}
const newPartyMemberNames = newPartyMemberData.map(c => c.name)
await saveParty(this.dataDir, {
...party,
activeParty: newPartyMemberNames,
})
const oldPartySet = new Set(oldPartyMemberNames);
const newPartySet = new Set(newPartyMemberNames);
const leftSet = new Set<string>();
for (const member of oldPartySet) {
if (!newPartySet.has(member)) {
leftSet.add(member);
}
}
const joinedSet = new Set<string>();
for (const member of newPartySet) {
if (!oldPartySet.has(member)) {
joinedSet.add(member);
}
}
const deltas = [
...Array.from(joinedSet).map((c) => `**${c}** joined the party.`),
...Array.from(leftSet).map((c) => `**${c}** left the party.`)
];
if (replace || deltas.length === 0 || (replace === false && deltas.length > newPartyMemberNames.length)) {
return [
`The current active party is:\n **${newPartyMemberData.map((c) => c.name).join('**\n **')}**`,
newPartyMemberData.flatMap((c) => (c.success ? [c] : []))
];
} else {
return [deltas.join('\n'), newPartyMemberData.flatMap((c) => (c.success ? [c] : []))];
}
}
}

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

@ -1,13 +1,13 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { SlashCreator, FastifyServer } from 'slash-create'; import { SlashCreator, FastifyServer } from 'slash-create';
import path from 'path'; import path from 'path';
import {fastify} from "fastify"; import { fastify } from 'fastify';
import {HealCharacterCommand} from "./commands/heal.js"; import { HealCharacterCommand } from './commands/heal.js';
import {HarmCharacterCommand} from "./commands/harm.js"; import { HarmCharacterCommand } from './commands/harm.js';
import {ExperienceCharacterCommand} from "./commands/experience.js"; import { ExperienceCharacterCommand } from './commands/experience.js';
import {LuckCharacterCommand} from "./commands/luck.js"; import { LuckCharacterCommand } from './commands/luck.js';
import {AbstractCharacterStatusCommand} from "./commands/base.js"; import { CharacterStatusCommand } from './commands/status.js';
import {CharacterStatusCommand} from "./commands/status.js"; import { PartyCommand } from './commands/party.js';
let dotenvPath = path.join(process.cwd(), '.env'); let dotenvPath = path.join(process.cwd(), '.env');
if (path.parse(process.cwd()).name === 'dist') dotenvPath = path.join(process.cwd(), '..', '.env'); if (path.parse(process.cwd()).name === 'dist') dotenvPath = path.join(process.cwd(), '..', '.env');
@ -16,11 +16,11 @@ dotenv.config({ path: dotenvPath });
const creator = new SlashCreator({ const creator = new SlashCreator({
applicationID: process.env.DISCORD_APP_ID!, applicationID: process.env.DISCORD_APP_ID!,
publicKey: process.env.DISCORD_PUBLIC_KEY, publicKey: process.env.DISCORD_PUBLIC_KEY!,
token: process.env.DISCORD_BOT_TOKEN, token: process.env.DISCORD_BOT_TOKEN!,
serverPort: parseInt(process.env.PORT ?? "0", 10) || 8020, serverPort: parseInt(process.env.PORT ?? '0', 10) || 8020,
serverHost: '0.0.0.0', serverHost: process.env.HOST ?? '0.0.0.0',
endpointPath: "/interactions", endpointPath: '/interactions'
}); });
creator.on('debug', (message) => console.log(message)); creator.on('debug', (message) => console.log(message));
@ -34,18 +34,33 @@ creator.on('commandRegister', (command) => console.info(`Registered command ${co
creator.on('commandError', (command, error) => console.error(`Command ${command.commandName}:`, error)); creator.on('commandError', (command, error) => console.error(`Command ${command.commandName}:`, error));
const server = fastify({ const server = fastify({
logger: true, 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)
}); });
const options = { dataDir: process.env.DATA_DIR ?? '../data' };
creator
.withServer(new FastifyServer(server))
.registerCommands([
new HealCharacterCommand(creator, options),
new HarmCharacterCommand(creator, options),
new ExperienceCharacterCommand(creator, options),
new LuckCharacterCommand(creator, options),
new CharacterStatusCommand(creator, options),
new PartyCommand(creator, options)
]);
creator
.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: unknown) => {
console.error(err);
});
console.info(`Starting server at "localhost:${creator.options.serverPort}/interactions"`); console.info(`Starting server at "localhost:${creator.options.serverPort}/interactions"`);

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

Loading…
Cancel
Save