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

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`
: ""}.`
}
}