Files
amayo/src/commands/messages/game/_helpers.ts
shni 67643595f3 feat: add TypeScript type checking task to VSCode configuration
feat: implement area metadata blocks in game commands for enhanced area details

fix: refactor game commands to utilize new area metadata blocks and improve code consistency

feat: enhance Appwrite API integration with additional collections and storage support

refactor: update componentsV2 to support image blocks in display rendering
2025-10-07 22:17:03 -05:00

663 lines
18 KiB
TypeScript

import { prisma } from "../../../core/database/prisma";
import { textBlock, dividerBlock } from "../../../core/lib/componentsV2";
import type { GameArea } from "@prisma/client";
import type { ItemProps } from "../../../game/economy/types";
import type {
Message,
TextBasedChannel,
MessageComponentInteraction,
StringSelectMenuInteraction,
ButtonInteraction,
ModalSubmitInteraction,
} from "discord.js";
import { MessageFlags } from "discord.js";
import {
ButtonStyle,
ComponentType,
TextInputStyle,
} from "discord-api-types/v10";
export function parseItemProps(json: unknown): ItemProps {
if (!json || typeof json !== "object") return {};
return json as ItemProps;
}
export async function resolveArea(guildId: string, areaKey: string) {
const area = await prisma.gameArea.findFirst({
where: { key: areaKey, OR: [{ guildId }, { guildId: null }] },
orderBy: [{ guildId: "desc" }],
});
return area;
}
export interface ResolvedAreaInfo {
area: GameArea | null;
source: "guild" | "global" | "none";
}
export async function resolveGuildAreaWithFallback(
guildId: string,
areaKey: string
): Promise<ResolvedAreaInfo> {
const guildArea = await prisma.gameArea.findFirst({
where: { key: areaKey, guildId },
});
if (guildArea) {
return { area: guildArea, source: "guild" };
}
const globalArea = await prisma.gameArea.findFirst({
where: { key: areaKey, guildId: null },
});
if (globalArea) {
return { area: globalArea, source: "global" };
}
return { area: null, source: "none" };
}
export async function resolveAreaByType(
guildId: string,
type: string
): Promise<ResolvedAreaInfo> {
const guildArea = await prisma.gameArea.findFirst({
where: { type, guildId },
orderBy: [{ createdAt: "asc" }],
});
if (guildArea) {
return { area: guildArea, source: "guild" };
}
const globalArea = await prisma.gameArea.findFirst({
where: { type, guildId: null },
orderBy: [{ createdAt: "asc" }],
});
if (globalArea) {
return { area: globalArea, source: "global" };
}
return { area: null, source: "none" };
}
export async function getDefaultLevel(
userId: string,
guildId: string,
areaId: string
): Promise<number> {
const prog = await prisma.playerProgress.findUnique({
where: { userId_guildId_areaId: { userId, guildId, areaId } },
});
return Math.max(1, prog?.highestLevel ?? 1);
}
export async function findBestToolKey(
userId: string,
guildId: string,
toolType: string
): Promise<string | null> {
const inv = await prisma.inventoryEntry.findMany({
where: { userId, guildId, quantity: { gt: 0 } },
include: { item: true },
});
let best: { key: string; tier: number } | null = null;
for (const e of inv) {
const it = e.item;
const props = parseItemProps(it.props);
const t = props.tool;
if (!t || t.type !== toolType) continue;
const tier = Math.max(0, t.tier ?? 0);
if (!best || tier > best.tier) best = { key: it.key, tier };
}
return best?.key ?? null;
}
export interface ParsedGameArgs {
levelArg: number | null;
providedTool: string | null;
areaOverride: string | null;
}
const AREA_OVERRIDE_PREFIX = "area:";
export function parseGameArgs(args: string[]): ParsedGameArgs {
const tokens = args.filter(
(arg): arg is string => typeof arg === "string" && arg.trim().length > 0
);
let levelArg: number | null = null;
let providedTool: string | null = null;
let areaOverride: string | null = null;
for (const token of tokens) {
if (token.startsWith(AREA_OVERRIDE_PREFIX)) {
const override = token.slice(AREA_OVERRIDE_PREFIX.length).trim();
if (override) areaOverride = override;
continue;
}
if (levelArg === null && /^\d+$/.test(token)) {
levelArg = parseInt(token, 10);
continue;
}
if (!providedTool) {
providedTool = token;
}
}
return { levelArg, providedTool, areaOverride };
}
const DEFAULT_ITEM_ICON = "📦";
export function resolveItemIcon(
icon?: string | null,
fallback = DEFAULT_ITEM_ICON
) {
const trimmed = icon?.trim();
return trimmed && trimmed.length > 0 ? trimmed : fallback;
}
export function formatItemLabel(
item: { key: string; name?: string | null; icon?: string | null },
options: { fallbackIcon?: string; bold?: boolean } = {}
): string {
const fallbackIcon = options.fallbackIcon ?? DEFAULT_ITEM_ICON;
const icon = resolveItemIcon(item.icon, fallbackIcon);
const label = (item.name ?? "").trim() || item.key;
const content = `${icon ? `${icon} ` : ""}${label}`.trim();
return options.bold ? `**${content}**` : content;
}
export type ItemBasicInfo = {
key: string;
name: string | null;
icon: string | null;
};
export async function fetchItemBasics(
guildId: string,
keys: string[]
): Promise<Map<string, ItemBasicInfo>> {
const uniqueKeys = Array.from(
new Set(keys.filter((key): key is string => Boolean(key && key.trim())))
);
if (uniqueKeys.length === 0) return new Map();
const rows = await prisma.economyItem.findMany({
where: {
key: { in: uniqueKeys },
OR: [{ guildId }, { guildId: null }],
},
orderBy: [{ key: "asc" }, { guildId: "desc" }],
select: { key: true, name: true, icon: true, guildId: true },
});
const result = new Map<string, ItemBasicInfo>();
for (const row of rows) {
const current = result.get(row.key);
if (!current || row.guildId === guildId) {
result.set(row.key, { key: row.key, name: row.name, icon: row.icon });
}
}
for (const key of uniqueKeys) {
if (!result.has(key)) {
result.set(key, { key, name: null, icon: null });
}
}
return result;
}
export type AreaMetadata =
| {
previewImage?: string;
image?: string;
referenceImage?: string;
description?: string;
[k: string]: any;
}
| null
| undefined;
export function buildAreaMetadataBlocks(
area: Pick<GameArea, "metadata" | "key" | "name">
) {
const blocks: any[] = [];
const meta = (area.metadata as AreaMetadata) || undefined;
if (!meta) return blocks;
const img = meta.previewImage || meta.image || meta.referenceImage;
const desc =
typeof meta.description === "string" && meta.description.trim().length > 0
? meta.description.trim()
: null;
if (desc) {
blocks.push(textBlock(`**🗺️ Detalles del área**\n${desc}`));
}
if (img && typeof img === "string") {
// Mostrar también como texto para compatibilidad, y dejar que el renderer agregue imagen si soporta
blocks.push(dividerBlock({ divider: false, spacing: 1 }));
blocks.push(textBlock(`**🖼️ Mapa/Imagen:** ${img}`));
// Si el renderer soporta bloque de imagen, los consumidores podrán usarlo
// @ts-ignore: el builder acepta bloques extendidos
blocks.push({ kind: "image", url: img });
}
return blocks;
}
export interface KeyPickerOption {
value: string;
label: string;
description?: string;
keywords?: string[];
}
export interface KeyPickerConfig<T> {
entries: T[];
getOption: (entry: T) => KeyPickerOption;
title: string;
customIdPrefix: string;
emptyText: string;
placeholder?: string;
filterHint?: string;
accentColor?: number;
userId?: string;
}
export interface KeyPickerResult<T> {
entry: T | null;
panelMessage: Message | null;
reason: "selected" | "empty" | "cancelled" | "timeout";
}
export async function promptKeySelection<T>(
message: Message,
config: KeyPickerConfig<T>
): Promise<KeyPickerResult<T>> {
const channel = message.channel as TextBasedChannel & { send: Function };
const userId = config.userId ?? message.author?.id ?? message.member?.user.id;
const baseOptions = config.entries.map((entry) => {
const option = config.getOption(entry);
const searchText = [
option.label,
option.description,
option.value,
...(option.keywords ?? []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return { entry, option, searchText };
});
if (baseOptions.length === 0) {
const emptyPanel = {
type: 17,
accent_color: 0xffa500,
components: [
{
type: 10,
content: config.emptyText,
},
],
};
await (channel.send as any)({
content: null,
flags: 32768,
reply: { messageReference: message.id },
components: [emptyPanel],
});
return { entry: null, panelMessage: null, reason: "empty" };
}
let filter = "";
let page = 0;
const pageSize = 25;
const accentColor = config.accentColor ?? 0x5865f2;
const placeholder = config.placeholder ?? "Selecciona una opción…";
const buildComponents = () => {
const normalizedFilter = filter.trim().toLowerCase();
const filtered = normalizedFilter
? baseOptions.filter((item) => item.searchText.includes(normalizedFilter))
: baseOptions;
const totalFiltered = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize));
const safePage = Math.min(Math.max(0, page), totalPages - 1);
if (safePage !== page) page = safePage;
const start = safePage * pageSize;
const slice = filtered.slice(start, start + pageSize);
const pageLabel = `Página ${
totalFiltered === 0 ? 0 : safePage + 1
}/${totalPages}`;
const statsLine = `Total: **${baseOptions.length}** • Coincidencias: **${totalFiltered}**\n${pageLabel}`;
const filterLine = filter ? `\nFiltro activo: \`${filter}\`` : "";
const hintLine = config.filterHint ? `\n${config.filterHint}` : "";
const display = {
type: 17,
accent_color: accentColor,
components: [
{ type: 10, content: `# ${config.title}` },
{ type: 14, divider: true },
{
type: 10,
content: `${statsLine}${filterLine}${hintLine}`,
},
{ type: 14, divider: true },
{
type: 10,
content:
totalFiltered === 0
? "No hay resultados para el filtro actual. Ajusta el filtro o limpia la búsqueda."
: "Selecciona una opción del menú desplegable para continuar.",
},
],
};
let options = slice.map(({ option }) => ({
label: option.label.slice(0, 100),
value: option.value,
description: option.description?.slice(0, 100),
}));
const selectDisabled = options.length === 0;
if (selectDisabled) {
options = [
{
label: "Sin resultados",
value: `${config.customIdPrefix}_empty`,
description: "Ajusta el filtro para ver opciones.",
},
];
}
const selectRow = {
type: 1,
components: [
{
type: 3,
custom_id: `${config.customIdPrefix}_select`,
placeholder,
options,
disabled: selectDisabled,
},
],
};
const navRow = {
type: 1,
components: [
{
type: 2,
style: ButtonStyle.Secondary,
label: "◀️",
custom_id: `${config.customIdPrefix}_prev`,
disabled: safePage <= 0 || totalFiltered === 0,
},
{
type: 2,
style: ButtonStyle.Secondary,
label: "▶️",
custom_id: `${config.customIdPrefix}_next`,
disabled: safePage >= totalPages - 1 || totalFiltered === 0,
},
{
type: 2,
style: ButtonStyle.Primary,
label: "🔎 Filtro",
custom_id: `${config.customIdPrefix}_filter`,
},
{
type: 2,
style: ButtonStyle.Secondary,
label: "Limpiar",
custom_id: `${config.customIdPrefix}_clear`,
disabled: filter.length === 0,
},
{
type: 2,
style: ButtonStyle.Danger,
label: "Cancelar",
custom_id: `${config.customIdPrefix}_cancel`,
},
],
};
return [display, selectRow, navRow];
};
const panelMessage: Message = await (channel.send as any)({
content: null,
flags: 32768,
reply: { messageReference: message.id },
components: buildComponents(),
});
let resolved = false;
const result = await new Promise<KeyPickerResult<T>>((resolve) => {
const finish = (
entry: T | null,
reason: "selected" | "cancelled" | "timeout"
) => {
if (resolved) return;
resolved = true;
resolve({ entry, panelMessage, reason });
};
const collector = panelMessage.createMessageComponentCollector({
time: 5 * 60_000,
filter: (i: MessageComponentInteraction) =>
i.user.id === userId && i.customId.startsWith(config.customIdPrefix),
});
collector.on(
"collect",
async (interaction: MessageComponentInteraction) => {
try {
if (
interaction.customId === `${config.customIdPrefix}_select` &&
interaction.isStringSelectMenu()
) {
const select = interaction as StringSelectMenuInteraction;
const value = select.values?.[0];
const selected = baseOptions.find(
(opt) => opt.option.value === value
);
if (!selected) {
await select.reply({
content: "❌ Opción no válida.",
flags: MessageFlags.Ephemeral,
});
return;
}
try {
await select.update({
components: [
{
type: 17,
accent_color: accentColor,
components: [
{
type: 10,
content: `⏳ Cargando **${selected.option.label}**…`,
},
],
},
],
});
} catch {
if (!select.deferred && !select.replied) {
try {
await select.deferUpdate();
} catch {}
}
}
finish(selected.entry, "selected");
collector.stop("selected");
return;
}
if (
interaction.customId === `${config.customIdPrefix}_prev` &&
interaction.isButton()
) {
if (page > 0) page -= 1;
await interaction.update({ components: buildComponents() });
return;
}
if (
interaction.customId === `${config.customIdPrefix}_next` &&
interaction.isButton()
) {
page += 1;
await interaction.update({ components: buildComponents() });
return;
}
if (
interaction.customId === `${config.customIdPrefix}_clear` &&
interaction.isButton()
) {
filter = "";
page = 0;
await interaction.update({ components: buildComponents() });
return;
}
if (
interaction.customId === `${config.customIdPrefix}_cancel` &&
interaction.isButton()
) {
try {
await interaction.update({
components: [
{
type: 17,
accent_color: 0xff0000,
components: [
{ type: 10, content: "❌ Selección cancelada." },
],
},
],
});
} catch {
if (!interaction.deferred && !interaction.replied) {
try {
await interaction.deferUpdate();
} catch {}
}
}
finish(null, "cancelled");
collector.stop("cancelled");
return;
}
if (
interaction.customId === `${config.customIdPrefix}_filter` &&
interaction.isButton()
) {
const modal = {
title: "Filtrar lista",
customId: `${config.customIdPrefix}_filter_modal`,
components: [
{
type: ComponentType.Label,
label: "Texto a buscar",
component: {
type: ComponentType.TextInput,
customId: "query",
style: TextInputStyle.Short,
required: false,
value: filter,
placeholder: "Nombre, key, categoría…",
},
},
],
} as const;
await (interaction as ButtonInteraction).showModal(modal);
let submitted: ModalSubmitInteraction | undefined;
try {
submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (sub) =>
sub.user.id === userId &&
sub.customId === `${config.customIdPrefix}_filter_modal`,
});
} catch {
return;
}
try {
const value =
submitted.components.getTextInputValue("query")?.trim() ?? "";
filter = value;
page = 0;
await submitted.deferUpdate();
await panelMessage.edit({ components: buildComponents() });
} catch {
// ignore errors updating filter
}
return;
}
} catch (err) {
if (!interaction.deferred && !interaction.replied) {
await interaction.reply({
content: "❌ Error procesando la selección.",
flags: MessageFlags.Ephemeral,
});
}
}
}
);
collector.on("end", async (_collected, reason) => {
if (resolved) return;
resolved = true;
if (reason !== "selected" && reason !== "cancelled") {
const expiredPanel = {
type: 17,
accent_color: 0xffa500,
components: [{ type: 10, content: "⏰ Selección expirada." }],
};
try {
await panelMessage.edit({ components: [expiredPanel] });
} catch {}
}
let mappedReason: "selected" | "cancelled" | "timeout";
if (reason === "selected") mappedReason = "selected";
else if (reason === "cancelled") mappedReason = "cancelled";
else mappedReason = "timeout";
resolve({ entry: null, panelMessage, reason: mappedReason });
});
});
return result;
}
export function sendDisplayReply(
message: Message,
display: any,
extraComponents: any[] = []
) {
const channel = message.channel as TextBasedChannel & { send: Function };
return (channel.send as any)({
flags: 32768,
message_reference: { message_id: message.id },
components: [display, ...extraComponents],
});
}