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