You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

416 lines
14 KiB

import {
type ApplicationCommandOption,
type ApplicationCommandOptionAutocompletable,
type AutocompleteContext,
type CommandContext,
CommandOptionType,
type Message,
SlashCommand,
type SlashCommandOptions,
type SlashCreator
} from 'slash-create';
import {
type GameCharacter,
type GameParty,
listCharacters,
loadCharacter,
loadParty,
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';
export const CharacterOptionTemplate = {
name: 'character',
description: 'The character(s) to operate on.',
required: false,
type: CommandOptionType.STRING,
autocomplete: true,
isCharacterOption: true
} as const satisfies ApplicationCommandOptionAutocompletable & { isCharacterOption: true };
export interface CharacterDataBase {
readonly success: boolean;
readonly name: string;
}
export interface LoadedCharacterData extends CharacterDataBase {
readonly success: true;
readonly originalData: Readonly<GameCharacter>;
newData?: GameCharacter;
}
export interface ErrorCharacterData extends CharacterDataBase {
readonly success: false;
readonly error: unknown;
}
export type GameCharacterData = LoadedCharacterData | ErrorCharacterData;
export interface CharacterStatusOptions {
dataDir: string;
}
const nameDelimiter = /\s*,(?:\s*,)*\s*/g;
const ellipses = '...';
export function trimAt(s: string, length: number): string {
if (s.length <= length) {
return s;
}
return ellipses + s.substring(Math.max(s.length - (length - ellipses.length), 0));
}
const NORMAL_STATUS_HEIGHT = 524;
const NORMAL_STATUS_WIDTH = 1446;
const enableStackedForTwoCharacters = false;
export abstract class AbstractCharacterStatusCommand extends SlashCommand {
readonly characterOptions: (ApplicationCommandOption & { isCharacterOption: true })[];
readonly dataDir: string;
protected constructor(creator: SlashCreator, opts: SlashCommandOptions & CharacterStatusOptions) {
super(creator, opts);
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);
if (!option) {
return ctx.sendResults([]);
}
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;
}
} else if (activeParty.has(B)) {
return 1;
} else {
// fall through
}
if (A === defaultCharacter) {
return -1;
} else if (B === defaultCharacter) {
return 1;
} else {
// fall through
}
const a = A.toLowerCase();
const b = B.toLowerCase();
if (a.startsWith(completingLowercase)) {
if (b.startsWith(completingLowercase)) {
// fall through
} else {
return -1;
}
} else if (b.startsWith(completingLowercase)) {
return 1;
} else {
// fall through
}
if (b.length === a.length) {
return a.localeCompare(b);
} else {
return b.length - a.length;
}
})
.slice(0, 20);
const unselectedParty = new Set(activeParty);
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>,
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];
} else {
const found = allCharacters.find((v) => item.toLowerCase() === v.toLowerCase());
if (found) {
return [found];
} else {
return [item];
}
}
})
)
]
: [o.name, new Set()]
);
}
abstract process(
ctx: CommandContext,
data: { characters: Map<string, readonly GameCharacterData[]>; party: GameParty }
): Promise<
| readonly [string, LoadedCharacterData[]]
| readonly [string, LoadedCharacterData]
| readonly LoadedCharacterData[]
| LoadedCharacterData
| string
>;
async loadCharacters(names: Iterable<string>): Promise<GameCharacterData[]> {
return Promise.all(
Array.from(names).map((name) =>
loadCharacter(this.dataDir, name)
.then<LoadedCharacterData>((c) => ({ name, success: true, originalData: c }))
.catch<ErrorCharacterData>((e) => {
console.error(`While loading ${name}: `, e)
return { name, success: false, error: e }
})
)
);
}
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);
}
}
const loadedCharacters = await this.loadCharacters(neededCharacters);
const characterMap = new Map<string, GameCharacterData>();
for (const character of loadedCharacters) {
characterMap.set(character.name, character);
}
const optionMap = new Map<string, GameCharacterData[]>();
for (const [option, names] of characterNames) {
optionMap.set(
option,
Array.from(names).map((x) => characterMap.get(x)!)
);
}
const result = await this.process(ctx, { 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 (Array.isArray(result[1])) {
statuses = result[1];
} else {
statuses = [result[1]];
}
} else {
statuses = result;
}
} else if (typeof result === 'string') {
message = result;
} else {
statuses = [result as LoadedCharacterData];
}
const deltas = await Promise.all(
Array.from(new Set(statuses))
.slice(0, 10)
.map((s) => characterDataDelta(s, this.dataDir))
);
const fontDir = join(this.dataDir, 'fonts')
const images = await Promise.all(deltas.map(async (s) => renderStatus(await svg, s, fontDir)));
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;
} else if (images.length === 1) {
resultImage = images[0];
} else if (enableStackedForTwoCharacters && images.length === 2) {
const totalHeight = metadatas.reduce((x, y) => x + (y.height ?? NORMAL_STATUS_HEIGHT), 0);
const maxWidth = metadatas.reduce((x, y) => Math.max(x, y.width ?? NORMAL_STATUS_WIDTH), 0);
const result = Sharp({
create: {
background: '#00000000',
channels: 4,
height: totalHeight,
width: maxWidth
}
});
result.composite([
{
input: images[0],
left: 0,
top: 0
},
{
input: images[1],
left: 0,
top: metadatas[0].height ?? NORMAL_STATUS_HEIGHT
}
]);
resultImage = await result.png().toBuffer();
} else {
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;
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] - Math.ceil(lastHeight[reversePolarity] / 2));
topCoordinates[i] = top;
totalHeight[polarity] = top + height;
}
console.log(`Left height: ${totalHeight[0]} / Right height: ${totalHeight[1]}`)
console.log(`Left width: ${maxWidth[0]} / Right width: ${maxWidth[1]}`)
const result = Sharp({
create: {
background: '#00000000',
channels: 4,
height: Math.max(totalHeight[0], totalHeight[1]),
width: maxWidth[0] + maxWidth[1],
}
});
result.composite(
images.map((buf, i) => {
return {
input: buf,
top: topCoordinates[i],
left: i % 2 === 0 ? maxWidth[0] - (metadatas[i].width ?? NORMAL_STATUS_WIDTH) : maxWidth[0]
};
})
);
resultImage = await result.png().toBuffer();
}
const pendingSaves: Promise<void>[] = [];
for (const character of loadedCharacters) {
if (!character.success) {
continue;
}
if (!character.newData) {
continue;
}
pendingSaves.push(saveCharacter(this.dataDir, character.name, character.newData));
}
await Promise.all(pendingSaves);
return ctx.send({
content: message ?? (resultImage ? undefined : "Uh... OK? There's kind of nothing to say..."),
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,
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),
health: newData.health ?? 8,
luckDelta: (newData.luck ?? 7) - (oldData.luck ?? 7),
luck: newData.luck ?? 7,
unstable: newData.unstable ?? false,
portrait: await face
};
return {
...result,
description: `Status of ${c.name}: ${result.health} HP${result.unstable ? ', unstable' : ''}${
result.healthDelta > 0
? ` after healing ${result.healthDelta} Harm`
: result.healthDelta < 0
? ` after taking ${-result.healthDelta} Harm`
: ''
}; ${result.experience} EXP${result.experience >= 5 ? ', ready to level up' : ''}${
result.experienceDelta > 0
? ` after gaining ${result.experienceDelta} EXP`
: result.experienceDelta < 0
? ` after losing ${-result.experienceDelta}`
: ''
}; ${result.luck} Luck${
result.luckDelta > 0
? ` after gaining ${result.luckDelta} Luck`
: result.luckDelta < 0
? ` after spending ${-result.luckDelta} Luck`
: ''
}.`
};
}