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.
417 lines
14 KiB
417 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 {
|
|
selectedElements.add(trimmed)
|
|
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 && activeParty.size > 1 && (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`
|
|
: ''
|
|
}.`
|
|
};
|
|
}
|
|
|