Add Dockerfile, party command

main
Mari 8 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,
node: true
},
extends: ['eslint:recommended', 'prettier/@typescript-eslint', 'plugin:prettier/recommended'],
extends: ['eslint:recommended', 'plugin:prettier/recommended'],
globals: {
NodeJS: 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/) -->
<svg
width="382.68823mm"
height="138.60893mm"
viewBox="0 0 382.68823 138.60893"
width="385mm"
height="139mm"
viewBox="0 0 385 139"
version="1.1"
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="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
id="defs15920">
<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"
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)"
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)" />
</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: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"
"build": "tsc",
"lint": "eslint --ext .ts ./src",
"lint:fix": "eslint --ext .ts ./src --fix"
},
"dependencies": {
"@resvg/resvg-js": "^2.6.0",
"@types/jsdom": "^21.1.6",
"cat-loggr": "^1.1.0",
"@resvg/resvg-js": "^2.6.2",
"dotenv": "^16.4.5",
"fastify": "^3.9.2",
"fastify": "^4.26.2",
"jsdom": "^24.0.0",
"slash-create": "^5.2.0",
"slash-create": "^6.1.3",
"sharp": "^0.33.3",
"yaml": "^2.4.0"
"yaml": "^2.4.2"
},
"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"
"@types/node": "^16.18.96",
"@types/jsdom": "^21.1.6",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.5",
"slash-up": "^1.4.2",
"typescript": "^5.4.5"
}
}

@ -1,125 +1,136 @@
import {parse as parseYaml, stringify as stringifyYaml} from "yaml"
import {readFile, writeFile, readdir} from 'fs/promises'
import {join} from 'path'
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[]
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",
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 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
type?: string;
name: string;
summary?: string;
description?: string;
}
export interface GameCharacterPassiveMove extends GameCharacterMoveBase {
type?: "passive"
type?: 'passive';
}
export interface GameCharacterRollableMove extends GameCharacterMoveBase {
type: "rollable"
attribute?: GameAttribute
bonus?: number
advanced?: boolean
onAdvanced?: string
onSuccess?: string
onMixed?: string
onMiss?: string
type: 'rollable';
attribute?: GameAttribute;
bonus?: number;
advanced?: boolean;
onAdvanced?: string;
onSuccess?: string;
onMixed?: string;
onMiss?: string;
}
export type GameCharacterMove = GameCharacterPassiveMove|GameCharacterRollableMove
export type GameCharacterMove = GameCharacterPassiveMove | GameCharacterRollableMove;
export interface FaceConditionBase {
type: string
negated?: boolean
type: string;
negated?: boolean;
}
export interface FaceConditionStability extends FaceConditionBase {
type: "stable"|"unstable"|"dead"
type: 'stable' | 'unstable' | 'dead';
}
export interface FaceConditionHealth extends FaceConditionBase {
type: "hpEq"|"hpGt"|"hpLt"|"hpGtEq"|"hpLtEq"
threshold: number
type: 'hpEq' | 'hpGt' | 'hpLt' | 'hpGtEq' | 'hpLtEq';
threshold: number;
}
export interface FaceConditionHealthDelta extends FaceConditionBase {
type: "beingHealed"|"beingDamaged"|"healthSteady"
type: 'beingHealed' | 'beingDamaged' | 'healthSteady';
}
export interface FaceConditionSet extends FaceConditionBase {
type: "faceSetActive"
set: string
type: 'faceSetActive';
set: string;
}
export type FaceCondition = FaceConditionStability|FaceConditionHealth|FaceConditionHealthDelta|FaceConditionSet
export type FaceCondition = FaceConditionStability | FaceConditionHealth | FaceConditionHealthDelta | FaceConditionSet;
export interface GameCharacterFace {
path: string
conditions: FaceCondition[]
path: string;
conditions: FaceCondition[];
}
export const FaceSetIdentifier = /^[a-z0-9_]+$/
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))
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)
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)
const contents = stringifyYaml(character);
return writeFile(join(dataDir, 'characters', name + '.yaml'), contents, { encoding: 'utf-8' });
}
export interface GameParty {
defaultCharacters: Record<string, string>
activeParty: string[]
keeper: string
defaultCharacters: Record<string, string>;
activeParty: string[];
keeper: string;
}
export interface ReadonlyGameParty {
readonly defaultCharacters: Readonly<Record<string, string>>
readonly activeParty: readonly string[]
readonly keeper: string
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)
const contents = await readFile(join(dataDir, 'party.yaml'), { encoding: 'utf-8' });
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 {
type ApplicationCommandOption,
type ApplicationCommandOptionAutocompletable, ApplicationCommandType,
type AutocompleteContext, type CommandContext,
CommandOptionType, type Message, type MessageFile,
type ApplicationCommandOptionAutocompletable,
type AutocompleteContext,
type CommandContext,
CommandOptionType,
type Message,
SlashCommand,
type SlashCommandOptions,
type SlashCreator
} from "slash-create";
} 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";
saveCharacter
} from '../character.js';
import { type GameStatusWithPortrait, renderStatus } from '../renderStatus.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { default as Sharp } from 'sharp';
const dataDir = "../data"
const dataDir = '../data';
export const CharacterOptionTemplate = {
name: "character",
description: "The character(s) to operate on.",
name: 'character',
description: 'The character(s) to operate on.',
required: false,
type: CommandOptionType.STRING,
autocomplete: true,
isCharacterOption: true,
} as const satisfies ApplicationCommandOptionAutocompletable & {isCharacterOption: true}
isCharacterOption: true
} as const satisfies ApplicationCommandOptionAutocompletable & { isCharacterOption: true };
export interface CharacterDataBase {
readonly success: boolean
readonly name: string
readonly success: boolean;
readonly name: string;
}
export interface LoadedCharacterData extends CharacterDataBase {
readonly success: true
readonly originalData: Readonly<GameCharacter>
newData?: GameCharacter
readonly success: true;
readonly originalData: Readonly<GameCharacter>;
newData?: GameCharacter;
}
export interface ErrorCharacterData extends CharacterDataBase {
readonly success: false
readonly error: unknown
readonly success: false;
readonly error: unknown;
}
export interface PartyData {
readonly originalData: ReadonlyGameParty
readonly newData?: GameParty
}
export type GameCharacterData = LoadedCharacterData | ErrorCharacterData;
export type GameCharacterData = LoadedCharacterData|ErrorCharacterData
export interface CharacterStatusOptions {
dataDir: string;
}
const nameDelimiter = /\s*,(?:\s*,)*\s*/g
const nameDelimiter = /\s*,(?:\s*,)*\s*/g;
const ellipses = "..."
export function ellipsizeAt(s: string, length: number): string {
const ellipses = '...';
export function trimAt(s: string, length: number): string {
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;
@ -71,262 +72,313 @@ const NORMAL_STATUS_WIDTH = 1446;
const enableStackedForTwoCharacters = false;
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);
this.characterOptions =
(opts.options?.filter(
s =>
"isCharacterOption" in s &&
(s.isCharacterOption === true) &&
s.type === CommandOptionType.STRING)
?? []) as (ApplicationCommandOption & {isCharacterOption: true})[]
this.dataDir = opts.dataDir;
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)
const option = this.characterOptions.find((o) => o.name === ctx.focused);
if (!option) {
return ctx.sendResults([])
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))
const party = await loadParty(this.dataDir);
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(this.dataDir))
.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
return -1;
}
} else if (activeParty.has(B)) {
return 1
return 1;
} else {
// fall through
}
if (A === defaultCharacter) {
return -1
return -1;
} else if (B === defaultCharacter) {
return 1
return 1;
} else {
// fall through
}
const a = A.toLowerCase()
const b = B.toLowerCase()
const a = A.toLowerCase();
const b = B.toLowerCase();
if (a.startsWith(completingLowercase)) {
if (b.startsWith(completingLowercase)) {
// fall through
} else {
return -1
return -1;
}
} else if (b.startsWith(completingLowercase)) {
return 1
return 1;
} else {
// fall through
}
if (b.length === a.length) {
return a.localeCompare(b)
return a.localeCompare(b);
} else {
return b.length - a.length
return b.length - a.length;
}
}).slice(0, 20)
})
.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)}] : []),
])
const unselectedParty = new Set(activeParty);
const selectedElements = new Set<string>();
completedNames.forEach((entry) => {
const trimmed = entry.trim();
if (trimmed === '*') {
for (const partyMember of activeParty) {
selectedElements.add(partyMember);
}
unselectedParty.clear();
} else {
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>][]> {
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)
async characterNames(
options: Record<string, unknown>,
partyPromise: Promise<GameParty>
): Promise<[option: string, names: Set<string>][]> {
const [party, allCharacters] = await Promise.all([partyPromise, listCharacters(this.dataDir)]);
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 === '*') {
return Array.from(activeParty);
} else if (allCharacters.includes(item)) {
return [item]
return [item];
} else {
const found = allCharacters.find(v => item.toLowerCase() === v.toLowerCase())
const found = allCharacters.find((v) => item.toLowerCase() === v.toLowerCase());
if (found) {
return [found]
return [found];
} else {
return [item]
return [item];
}
}
}))]
: [o.name, new Set()])
})
)
]
: [o.name, new Set()]
);
}
abstract process(ctx: CommandContext, characters: Map<string, readonly GameCharacterData[]>): Promise<
readonly [string, LoadedCharacterData[]]
|readonly [string, LoadedCharacterData]
|readonly LoadedCharacterData[]
|LoadedCharacterData
|string>
abstract process(
ctx: CommandContext,
data: { characters: Map<string, readonly GameCharacterData[]>; party: GameParty }
): 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"})
async loadCharacters(names: Iterable<string>): Promise<GameCharacterData[]> {
return Promise.all(
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)
const neededCharacters: Set<string> = new Set()
for (const [_, names] of characterNames) {
async run(ctx: CommandContext): Promise<boolean | Message> {
await ctx.defer();
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) {
neededCharacters.add(name)
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>()
const loadedCharacters = await this.loadCharacters(neededCharacters);
const characterMap = new Map<string, GameCharacterData>();
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) {
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)
let message: string|null = null, statuses: LoadedCharacterData[] = []
const result = await this.process(ctx, { party: await party, characters: optionMap });
let message: string | null = null,
statuses: LoadedCharacterData[] = [];
if (Array.isArray(result)) {
if (result.length === 2 && typeof result[0] === "string") {
message = result[0]
if (result.length === 2 && typeof result[0] === 'string') {
message = result[0];
if (Array.isArray(result[1])) {
statuses = result[1]
statuses = result[1];
} else {
statuses = [result[1]]
statuses = [result[1]];
}
} else {
statuses = result
statuses = result;
}
} else if (typeof result === "string") {
message = result
} else if (typeof result === 'string') {
message = result;
} else {
statuses = [result as LoadedCharacterData]
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
const deltas = await Promise.all(
Array.from(new Set(statuses))
.filter((s) => loadedCharacters.includes(s))
.slice(0, 10)
.map((s) => characterDataDelta(s, this.dataDir))
);
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) {
resultImage = null
resultImage = null;
} else if (images.length === 1) {
resultImage = images[0]
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 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",
background: '#00000000',
channels: 4,
height: totalHeight,
width: maxWidth,
width: maxWidth
}
})
result.composite([{
input: images[0],
left: 0,
top: 0,
}, {
input: images[1],
left: 0,
top: metadatas[0].height ?? NORMAL_STATUS_HEIGHT,
}])
});
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()
resultImage = await result.png().toBuffer();
} 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],
lastHeight: [number, number] = [metadatas[0].height ?? NORMAL_STATUS_HEIGHT, 0]
const topCoordinates: number[] = new Array(metadatas.length)
topCoordinates[0] = 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 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",
background: '#00000000',
channels: 4,
height: Math.max(totalHeight[0], totalHeight[1]),
width: maxWidth[0] + maxWidth[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],
}
}));
});
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()
resultImage = await result.png().toBuffer();
}
const pendingSaves: Promise<void>[] = []
const pendingSaves: Promise<void>[] = [];
for (const character of loadedCharacters) {
if (!character.success) {
continue
continue;
}
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({
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,
}] : [],
})
attachments: resultImage
? [
{
id: 0,
name: 'status.png',
description: trimAt(deltas.map((k) => k.description).join(' '), 1024)
}
]
: [],
files: 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 = {
export async function characterDataDelta(
c: LoadedCharacterData,
dataDir: string
): Promise<GameStatusWithPortrait & { description: string }> {
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),
experience: newData.experience ?? 0,
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),
luck: newData.luck ?? 7,
unstable: newData.unstable ?? false,
portrait: await face,
}
portrait: await face
};
return {
...result,
description: `Status of ${c.name}: ${
result.health} HP${result.unstable ? ", unstable" : ""}${
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`
: ""}.`
}
? ` 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`
: ''
}.`
};
}

@ -1,65 +1,72 @@
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 { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create';
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type CharacterStatusOptions,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
} from './base.js';
export class ExperienceCharacterCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
constructor(creator: SlashCreator, opts: CharacterStatusOptions) {
super(creator, {
name: "experience",
description: "Modifies the EXP total of the given character(s).",
...opts,
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,
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).",
name: 'delta',
description: 'The amount of EXP to apply to the character(s) (default +1).',
max_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> {
const delta = ctx.options["delta"] ?? 1
const description: string[] = []
const result: LoadedCharacterData[] = []
for (const character of characters.get("character")!) {
async process(
ctx: CommandContext,
{ characters }: { 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
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!***" : ""}`)
...(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]
return [description.join('\n'), result];
}
}

@ -1,88 +1,95 @@
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 { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create';
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type CharacterStatusOptions,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
} from './base.js';
export class HarmCharacterCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
constructor(creator: SlashCreator, opts: CharacterStatusOptions) {
super(creator, {
name: "harm",
description: "Harms the given character(s).",
...opts,
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,
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).",
name: 'damage',
description: 'The amount of harm to deal to the character(s).',
max_value: 99,
min_value: 0,
required: true,
required: true
},
{
type: CommandOptionType.BOOLEAN,
name: "piercing",
description: "If set, ignores any armor the character(s) may have.",
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,
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")!) {
async process(
ctx: CommandContext,
{ characters }: { 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
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)
};
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
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)
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]
return [description.join('\n'), result];
}
}

@ -1,77 +1,85 @@
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 { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create';
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type CharacterStatusOptions,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
} from './base.js';
export class HealCharacterCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
constructor(creator: SlashCreator, opts: CharacterStatusOptions) {
super(creator, {
name: "heal",
description: "Heals the given character(s).",
...opts,
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,
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).",
name: 'healing',
description: 'The amount of healing to apply to the character(s).',
max_value: 99,
min_value: 0,
required: true,
required: true
},
{
type: CommandOptionType.BOOLEAN,
name: "stabilize",
description: "If true, repairs the unstable status of the character(s).",
},
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")!) {
async process(
ctx: CommandContext,
{ characters }: { 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
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)
};
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
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)
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]
return [description.join('\n'), result];
}
}

@ -1,66 +1,72 @@
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 { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create';
import {
AbstractCharacterStatusCommand,
CharacterOptionTemplate,
type CharacterStatusOptions,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
} from './base.js';
export class LuckCharacterCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
constructor(creator: SlashCreator, opts: CharacterStatusOptions) {
super(creator, {
name: "luck",
description: "Modifies the luck of the given character(s).",
...opts,
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,
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).",
name: 'delta',
description: 'The amount of luck to apply to the character(s) (default -1).',
max_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> {
const delta: number = ctx.options["delta"]
const description: string[] = []
const result: LoadedCharacterData[] = []
for (const character of characters.get("character")!) {
async process(
ctx: CommandContext,
{ characters }: { characters: Map<string, readonly GameCharacterData[]> }
): Promise<
| 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) {
description.push(`**${character.name}** ${delta > 0 ? "recovered" : "spent"} ${Math.abs(delta)} Luck${delta !== 0 ? "!" : "."}`)
continue
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)
};
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]
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 {
ApplicationCommandType,
type CommandContext,
CommandOptionType,
SlashCommand,
type SlashCreator
} from "slash-create";
import { ApplicationCommandType, type CommandContext, type SlashCreator } from 'slash-create';
import {
CharacterOptionTemplate,
AbstractCharacterStatusCommand,
type GameCharacterData,
type LoadedCharacterData
} from "./base.js";
type LoadedCharacterData,
type CharacterStatusOptions
} from './base.js';
export class CharacterStatusCommand extends AbstractCharacterStatusCommand {
constructor(creator: SlashCreator) {
constructor(creator: SlashCreator, opts: CharacterStatusOptions) {
super(creator, {
name: "status",
description: "Gets the status of the given character(s).",
...opts,
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,
},
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] : []);
async process(
_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 { 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";
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 { CharacterStatusCommand } from './commands/status.js';
import { PartyCommand } from './commands/party.js';
let 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({
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",
publicKey: process.env.DISCORD_PUBLIC_KEY!,
token: process.env.DISCORD_BOT_TOKEN!,
serverPort: parseInt(process.env.PORT ?? '0', 10) || 8020,
serverHost: process.env.HOST ?? '0.0.0.0',
endpointPath: '/interactions'
});
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));
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)
logger: true
});
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"`);

@ -1,185 +1,189 @@
import {Resvg} from "@resvg/resvg-js";
import {JSDOM} from "jsdom";
import { Resvg } from '@resvg/resvg-js';
import { JSDOM } from 'jsdom';
export interface Element {
remove(): void;
}
export interface GameStatus {
health: number
unstable: boolean
healthDelta: number
experience: number
experienceDelta: number
luck: number
luckDelta: number
health: number;
unstable: boolean;
healthDelta: number;
experience: number;
experienceDelta: number;
luck: number;
luckDelta: number;
}
export interface GameStatusWithPortrait extends GameStatus {
portrait: Buffer
portrait: Buffer;
}
function removeElement(dom: Element): void {
dom.remove()
dom.remove();
}
function healthBelow(health: number): (status: GameStatus) => boolean {
return (s) => s.health < health
return (s) => s.health < health;
}
function luckBelow(luck: number): (status: GameStatus) => boolean {
return (s) => s.luck < luck
return (s) => s.luck < luck;
}
function experienceBelow(exp: number): (status: GameStatus) => boolean {
return (s) => s.experience < exp
return (s) => s.experience < exp;
}
function healthNotEqual(health: number): (status: GameStatus) => boolean {
return (s) => s.health !== health
return (s) => s.health !== health;
}
function experienceDeltaNotIncludes(index: number): (status: GameStatus) => boolean {
return (s) => {
if (s.experienceDelta <= 0) {
return true
return true;
}
const effectiveMax = Math.min(5, s.experience)
return !(index <= effectiveMax && index > effectiveMax - s.experienceDelta)
}
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
return true;
} else if (s.luckDelta > 0) {
return !((index <= s.luck) && (index > s.luck - s.luckDelta))
return !(index <= s.luck && index > s.luck - s.luckDelta);
} 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 {
return (s) => {
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 {
return (s) => {
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 {
return (status) => status.unstable || status.health <= 0
return (status) => status.unstable || status.health <= 0;
}
function notUnstableAlive(): (status: GameStatus) => boolean {
return (status) => !status.unstable || status.health <= 0
return (status) => !status.unstable || status.health <= 0;
}
function notDead(): (status: GameStatus) => boolean {
return (status) => status.health > 0
return (status) => status.health > 0;
}
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][] = [
[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]
]
[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",
contentType: 'image/svg+xml',
pretendToBeVisual: false,
includeNodeLocations: false,
url: "https://localhost/status.xml"
})
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)
action(el);
}
}
}
const resvg = new Resvg(dom.window.document.documentElement.outerHTML, {})
resvg.resolveImage("https://invalid.invalid/face.png", status.portrait)
return resvg.render().asPng()
const resvg = new Resvg(dom.window.document.documentElement.outerHTML, {});
resvg.resolveImage('https://invalid.invalid/face.png', status.portrait);
return resvg.render().asPng();
}

Loading…
Cancel
Save