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
This commit is contained in:
2025-10-07 22:17:03 -05:00
parent 5ea9cfd67c
commit 67643595f3
9 changed files with 572 additions and 215 deletions

View File

@@ -1,38 +1,61 @@
# Custom instructions for GitHub Copilot (discord.js 15.0.0-dev) # 🧠 Custom Instructions for GitHub Copilot
**Project Context: Discord Bot + Game System Integration (discord.js 15.0.0-dev.1759363313-f510b5ffa)**
When generating or modifying code for Discord.js version `15.0.0-dev.1759363313-f510b5ffa`: ---
1. **Primary Source of Truth** ## 1. Primary Source of Truth
- Do not assume the official docs, guide, or GitHub repositories are fully up-to-date. - Always treat the **installed package** in `node_modules/discord.js` as the *definitive source* of API behavior and typings.
- Always prioritize the installed package in `node_modules/discord.js` as the most reliable source. - Do **not** rely solely on public documentation or examples; cross-verify any methods, classes, or type names directly from:
- Always cross-reference with the provided file `example.ts.txt` to understand new or experimental APIs. - The source code and declaration files inside `node_modules/discord.js`
- The projects internal reference file `example.ts.txt`
2. **Secondary Sources** > If discrepancies exist, assume `example.ts.txt` and local types reflect the *intended experimental API* for this build.
- Only use these as references when the information is still valid:
- [Discord.js Guide](https://discordjs.guide)
- [discord.js GitHub](https://github.com/discordjs/discord.js)
- [Discord API Types GitHub](https://github.com/discordjs/discord-api-types)
- [Discord Developer Documentation](https://discord.com/developers/docs/intro)
- Mark clearly if the information is outdated compared to the development version.
3. **Code Analysis** ---
- Inspect the type definitions and source files inside `node_modules/discord.js` before suggesting new methods or classes.
- If `example.ts.txt` shows usage not covered in documentation, assume that is the intended API for this development version.
4. **Validation** ## 2. Secondary Sources
- Always suggest running: Use these **only when confirmed to still be valid** for the current development version:
- [Discord.js Guide](https://discordjs.guide)
- [discord.js GitHub Repository](https://github.com/discordjs/discord.js)
- [Discord API Types GitHub](https://github.com/discordjs/discord-api-types)
- [Discord Developer Documentation](https://discord.com/developers/docs/intro)
> ⚠️ Mark explicitly when a snippet or concept originates from official docs and may be outdated.
---
## 3. Code & Type Analysis Scope
Copilot must **investigate, interpret, and reference** the following project directories for all game logic and command definitions:
- `src/game/**`
- `src/commands/game/**`
- `src/commands/admin/**`
### Tasks:
- Analyze **all game-related classes, interfaces, and types**, including metadata structures (e.g., `GameAreaLevel`, `GameArea`, `ScheduledMobAttack`, and mission types).
- Identify how these interact with command creation and execution flows.
- Detect **missing type declarations**, inconsistent imports, or unreferenced type usages.
- Evaluate whether metadata in `GameAreaLevel` can safely include additional properties (e.g., `image`, `referenceImage`, or similar) for visual mapping of game areas.
- Verify that all related commands and editors properly support or update those fields.
---
## 4. Appwrite Integration Considerations
While analyzing the above directories, also check for:
- Possible migration paths for mission and attack scheduling logic (`ScheduledMobAttack`, mission trackers) into **Appwrite Functions** or **Appwrite Realtime** for better live synchronization and event-driven execution.
- Type definitions or data structures that may need adaptation for Appwrites SDK.
---
## 5. Validation
Before finalizing any generated code or type updates:
- Run TypeScript validation to ensure type correctness:
```bash ```bash
npx tsc --noEmit npx tsc --noEmit
``` ```
to validate typings.
- Remind to check runtime memory and CPU usage with:
```js
console.log(process.memoryUsage());
```
and external profilers if needed.
5. **Communication** ## 6. Communication Protocol
- When suggesting code, state explicitly whether it comes from: When Copilot suggests or modifies code, it must explicitly indicate the origin of the reference:
- `node_modules` (preferred, authoritative) - 🟩 node_modules → Authoritative source (preferred)
- `example.ts.txt` (author-provided experimental reference) - 🟦 example.ts.txt → Experimental / confirmed local reference
- official docs (secondary, possibly outdated) - 🟨 Official docs → Secondary, possibly outdated source

16
.vscode/tasks.json vendored
View File

@@ -12,6 +12,22 @@
"--", "--",
"--noEmit" "--noEmit"
] ]
},
{
"label": "Typecheck: tsc --noEmit",
"type": "shell",
"command": "npm",
"args": [
"run",
"-s",
"tsc",
"--",
"--noEmit"
],
"problemMatcher": [
"$tsc"
],
"group": "build"
} }
] ]
} }

View File

@@ -1,4 +1,5 @@
import { prisma } from "../../../core/database/prisma"; import { prisma } from "../../../core/database/prisma";
import { textBlock, dividerBlock } from "../../../core/lib/componentsV2";
import type { GameArea } from "@prisma/client"; import type { GameArea } from "@prisma/client";
import type { ItemProps } from "../../../game/economy/types"; import type { ItemProps } from "../../../game/economy/types";
import type { import type {
@@ -209,6 +210,44 @@ export async function fetchItemBasics(
return result; 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 { export interface KeyPickerOption {
value: string; value: string;
label: string; label: string;

View File

@@ -1,21 +1,36 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from '../../../core/client'; import type Amayo from "../../../core/client";
import { runMinigame } from '../../../game/minigames/service'; import { runMinigame } from "../../../game/minigames/service";
import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; import {
import { updateStats } from '../../../game/stats/service'; getDefaultLevel,
import { updateQuestProgress } from '../../../game/quests/service'; findBestToolKey,
import { checkAchievements } from '../../../game/achievements/service'; parseGameArgs,
import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; resolveGuildAreaWithFallback,
resolveAreaByType,
sendDisplayReply,
fetchItemBasics,
formatItemLabel,
} from "./_helpers";
import { updateStats } from "../../../game/stats/service";
import { updateQuestProgress } from "../../../game/quests/service";
import { checkAchievements } from "../../../game/achievements/service";
import {
buildDisplay,
dividerBlock,
textBlock,
} from "../../../core/lib/componentsV2";
import { buildAreaMetadataBlocks } from "./_helpers";
const MINING_ACCENT = 0xC27C0E; const MINING_ACCENT = 0xc27c0e;
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'mina', name: "mina",
type: 'message', type: "message",
aliases: ['minar'], aliases: ["minar"],
cooldown: 5, cooldown: 5,
description: 'Ir a la mina (usa pico si está disponible) y obtener recompensas según el nivel.', description:
usage: 'mina [nivel] [toolKey] [area:clave] (ej: mina 2 tool.pickaxe.basic)', "Ir a la mina (usa pico si está disponible) y obtener recompensas según el nivel.",
usage: "mina [nivel] [toolKey] [area:clave] (ej: mina 2 tool.pickaxe.basic)",
run: async (message, args, _client: Amayo) => { run: async (message, args, _client: Amayo) => {
const userId = message.author.id; const userId = message.author.id;
const guildId = message.guild!.id; const guildId = message.guild!.id;
@@ -23,30 +38,41 @@ export const command: CommandMessage = {
const areaInfo = areaOverride const areaInfo = areaOverride
? await resolveGuildAreaWithFallback(guildId, areaOverride) ? await resolveGuildAreaWithFallback(guildId, areaOverride)
: await resolveAreaByType(guildId, 'MINE'); : await resolveAreaByType(guildId, "MINE");
if (!areaInfo.area) { if (!areaInfo.area) {
if (areaOverride) { if (areaOverride) {
await message.reply(`⚠️ No existe un área con key \`${areaOverride}\` para este servidor.`); await message.reply(
`⚠️ No existe un área con key \`${areaOverride}\` para este servidor.`
);
} else { } else {
await message.reply('⚠️ No hay un área de tipo **MINE** configurada. Crea una con `!area-crear` o especifica `area:<key>`.'); await message.reply(
"⚠️ No hay un área de tipo **MINE** configurada. Crea una con `!area-crear` o especifica `area:<key>`."
);
} }
return; return;
} }
const { area, source } = areaInfo; const { area, source } = areaInfo;
const globalNotice = source === 'global' const globalNotice =
source === "global"
? ` Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **MINE** para personalizarla en este servidor.` ? ` Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **MINE** para personalizarla en este servidor.`
: null; : null;
const level = levelArg ?? await getDefaultLevel(userId, guildId, area.id); const level = levelArg ?? (await getDefaultLevel(userId, guildId, area.id));
const toolKey = providedTool ?? await findBestToolKey(userId, guildId, 'pickaxe'); const toolKey =
providedTool ?? (await findBestToolKey(userId, guildId, "pickaxe"));
try { try {
const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); const result = await runMinigame(userId, guildId, area.key, level, {
toolKey: toolKey ?? undefined,
});
const rewardKeys = result.rewards const rewardKeys = result.rewards
.filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey)) .filter(
(r): r is { type: "item"; itemKey: string; qty?: number } =>
r.type === "item" && Boolean(r.itemKey)
)
.map((r) => r.itemKey!); .map((r) => r.itemKey!);
if (result.tool?.key) rewardKeys.push(result.tool.key); if (result.tool?.key) rewardKeys.push(result.tool.key);
const rewardItems = await fetchItemBasics(guildId, rewardKeys); const rewardItems = await fetchItemBasics(guildId, rewardKeys);
@@ -55,27 +81,46 @@ export const command: CommandMessage = {
await updateStats(userId, guildId, { minesCompleted: 1 }); await updateStats(userId, guildId, { minesCompleted: 1 });
// Actualizar progreso de misiones // Actualizar progreso de misiones
await updateQuestProgress(userId, guildId, 'mine_count', 1); await updateQuestProgress(userId, guildId, "mine_count", 1);
// Verificar logros // Verificar logros
const newAchievements = await checkAchievements(userId, guildId, 'mine_count'); const newAchievements = await checkAchievements(
userId,
guildId,
"mine_count"
);
const rewardLines = result.rewards.length const rewardLines = result.rewards.length
? result.rewards.map((r) => { ? result.rewards
if (r.type === 'coins') return `• 🪙 +${r.amount}`; .map((r) => {
if (r.type === "coins") return `• 🪙 +${r.amount}`;
const info = rewardItems.get(r.itemKey!); const info = rewardItems.get(r.itemKey!);
const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); const label = formatItemLabel(
info ?? { key: r.itemKey!, name: null, icon: null }
);
return `${label} x${r.qty ?? 1}`; return `${label} x${r.qty ?? 1}`;
}).join('\n') })
: '• —'; .join("\n")
: "• —";
const mobsLines = result.mobs.length const mobsLines = result.mobs.length
? result.mobs.map(m => `${m}`).join('\n') ? result.mobs.map((m) => `${m}`).join("\n")
: '• —'; : "• —";
const toolInfo = result.tool?.key const toolInfo = result.tool?.key
? `${formatItemLabel(rewardItems.get(result.tool.key) ?? { key: result.tool.key, name: null, icon: null }, { fallbackIcon: '🔧' })}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta ?? 0} dur.)`}` ? `${formatItemLabel(
: '—'; rewardItems.get(result.tool.key) ?? {
key: result.tool.key,
name: null,
icon: null,
},
{ fallbackIcon: "🔧" }
)}${
result.tool.broken
? " (rota)"
: ` (-${result.tool.durabilityDelta ?? 0} dur.)`
}`
: "—";
const blocks = [textBlock('# ⛏️ Mina')]; const blocks = [textBlock("# ⛏️ Mina")];
if (globalNotice) { if (globalNotice) {
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
@@ -83,16 +128,32 @@ export const command: CommandMessage = {
} }
blocks.push(dividerBlock()); blocks.push(dividerBlock());
const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; const areaScope =
blocks.push(textBlock(`**Área:** \`${area.key}\`${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`)); source === "global"
? "🌐 Configuración global"
: "📍 Configuración local";
blocks.push(
textBlock(
`**Área:** \`${area.key}\`${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`
)
);
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(textBlock(`**Recompensas**\n${rewardLines}`));
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
blocks.push(textBlock(`**Mobs**\n${mobsLines}`)); blocks.push(textBlock(`**Mobs**\n${mobsLines}`));
// Añadir metadata del área (imagen/descripcion) si existe
const metaBlocks = buildAreaMetadataBlocks(area);
if (metaBlocks.length) {
blocks.push(dividerBlock());
blocks.push(...metaBlocks);
}
if (newAchievements.length > 0) { if (newAchievements.length > 0) {
blocks.push(dividerBlock({ divider: false, spacing: 2 })); blocks.push(dividerBlock({ divider: false, spacing: 2 }));
const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n'); const achLines = newAchievements
.map((ach) => `✨ **${ach.name}** — ${ach.description}`)
.join("\n");
blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`)); blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`));
} }
@@ -101,6 +162,5 @@ export const command: CommandMessage = {
} catch (e: any) { } catch (e: any) {
await message.reply(`❌ No se pudo minar: ${e?.message ?? e}`); await message.reply(`❌ No se pudo minar: ${e?.message ?? e}`);
} }
} },
}; };

View File

@@ -1,21 +1,36 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from '../../../core/client'; import type Amayo from "../../../core/client";
import { runMinigame } from '../../../game/minigames/service'; import { runMinigame } from "../../../game/minigames/service";
import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; import {
import { updateStats } from '../../../game/stats/service'; getDefaultLevel,
import { updateQuestProgress } from '../../../game/quests/service'; findBestToolKey,
import { checkAchievements } from '../../../game/achievements/service'; parseGameArgs,
import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; resolveGuildAreaWithFallback,
resolveAreaByType,
sendDisplayReply,
fetchItemBasics,
formatItemLabel,
} from "./_helpers";
import { updateStats } from "../../../game/stats/service";
import { updateQuestProgress } from "../../../game/quests/service";
import { checkAchievements } from "../../../game/achievements/service";
import {
buildDisplay,
dividerBlock,
textBlock,
} from "../../../core/lib/componentsV2";
import { buildAreaMetadataBlocks } from "./_helpers";
const FIGHT_ACCENT = 0x992D22; const FIGHT_ACCENT = 0x992d22;
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'pelear', name: "pelear",
type: 'message', type: "message",
aliases: ['fight','arena'], aliases: ["fight", "arena"],
cooldown: 8, cooldown: 8,
description: 'Entra a la arena y pelea (usa espada si está disponible).', description: "Entra a la arena y pelea (usa espada si está disponible).",
usage: 'pelear [nivel] [toolKey] [area:clave] (ej: pelear 1 weapon.sword.iron)', usage:
"pelear [nivel] [toolKey] [area:clave] (ej: pelear 1 weapon.sword.iron)",
run: async (message, args, _client: Amayo) => { run: async (message, args, _client: Amayo) => {
const userId = message.author.id; const userId = message.author.id;
const guildId = message.guild!.id; const guildId = message.guild!.id;
@@ -23,63 +38,98 @@ export const command: CommandMessage = {
const areaInfo = areaOverride const areaInfo = areaOverride
? await resolveGuildAreaWithFallback(guildId, areaOverride) ? await resolveGuildAreaWithFallback(guildId, areaOverride)
: await resolveAreaByType(guildId, 'FIGHT'); : await resolveAreaByType(guildId, "FIGHT");
if (!areaInfo.area) { if (!areaInfo.area) {
if (areaOverride) { if (areaOverride) {
await message.reply(`⚠️ No existe un área con key \`${areaOverride}\` para este servidor.`); await message.reply(
`⚠️ No existe un área con key \`${areaOverride}\` para este servidor.`
);
} else { } else {
await message.reply('⚠️ No hay un área de tipo **FIGHT** configurada. Crea una con `!area-crear` o especifica `area:<key>`.'); await message.reply(
"⚠️ No hay un área de tipo **FIGHT** configurada. Crea una con `!area-crear` o especifica `area:<key>`."
);
} }
return; return;
} }
const { area, source } = areaInfo; const { area, source } = areaInfo;
const globalNotice = source === 'global' const globalNotice =
source === "global"
? ` Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **FIGHT** para personalizarla en este servidor.` ? ` Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **FIGHT** para personalizarla en este servidor.`
: null; : null;
const level = levelArg ?? await getDefaultLevel(userId, guildId, area.id); const level = levelArg ?? (await getDefaultLevel(userId, guildId, area.id));
const toolKey = providedTool ?? await findBestToolKey(userId, guildId, 'sword'); const toolKey =
providedTool ?? (await findBestToolKey(userId, guildId, "sword"));
try { try {
const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); const result = await runMinigame(userId, guildId, area.key, level, {
toolKey: toolKey ?? undefined,
});
const rewardKeys = result.rewards const rewardKeys = result.rewards
.filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey)) .filter(
(r): r is { type: "item"; itemKey: string; qty?: number } =>
r.type === "item" && Boolean(r.itemKey)
)
.map((r) => r.itemKey!); .map((r) => r.itemKey!);
if (result.tool?.key) rewardKeys.push(result.tool.key); if (result.tool?.key) rewardKeys.push(result.tool.key);
const rewardItems = await fetchItemBasics(guildId, rewardKeys); const rewardItems = await fetchItemBasics(guildId, rewardKeys);
// Actualizar stats y misiones // Actualizar stats y misiones
await updateStats(userId, guildId, { fightsCompleted: 1 }); await updateStats(userId, guildId, { fightsCompleted: 1 });
await updateQuestProgress(userId, guildId, 'fight_count', 1); await updateQuestProgress(userId, guildId, "fight_count", 1);
// Contar mobs derrotados // Contar mobs derrotados
const mobsCount = result.mobs.length; const mobsCount = result.mobs.length;
if (mobsCount > 0) { if (mobsCount > 0) {
await updateStats(userId, guildId, { mobsDefeated: mobsCount }); await updateStats(userId, guildId, { mobsDefeated: mobsCount });
await updateQuestProgress(userId, guildId, 'mob_defeat_count', mobsCount); await updateQuestProgress(
userId,
guildId,
"mob_defeat_count",
mobsCount
);
} }
const newAchievements = await checkAchievements(userId, guildId, 'fight_count'); const newAchievements = await checkAchievements(
userId,
guildId,
"fight_count"
);
const rewardLines = result.rewards.length const rewardLines = result.rewards.length
? result.rewards.map((r) => { ? result.rewards
if (r.type === 'coins') return `• 🪙 +${r.amount}`; .map((r) => {
if (r.type === "coins") return `• 🪙 +${r.amount}`;
const info = rewardItems.get(r.itemKey!); const info = rewardItems.get(r.itemKey!);
const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); const label = formatItemLabel(
info ?? { key: r.itemKey!, name: null, icon: null }
);
return `${label} x${r.qty ?? 1}`; return `${label} x${r.qty ?? 1}`;
}).join('\n') })
: '• —'; .join("\n")
: "• —";
const mobsLines = result.mobs.length const mobsLines = result.mobs.length
? result.mobs.map(m => `${m}`).join('\n') ? result.mobs.map((m) => `${m}`).join("\n")
: '• —'; : "• —";
const toolInfo = result.tool?.key const toolInfo = result.tool?.key
? `${formatItemLabel(rewardItems.get(result.tool.key) ?? { key: result.tool.key, name: null, icon: null }, { fallbackIcon: '🗡️' })}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta ?? 0} dur.)`}` ? `${formatItemLabel(
: '—'; rewardItems.get(result.tool.key) ?? {
key: result.tool.key,
name: null,
icon: null,
},
{ fallbackIcon: "🗡️" }
)}${
result.tool.broken
? " (rota)"
: ` (-${result.tool.durabilityDelta ?? 0} dur.)`
}`
: "—";
const blocks = [textBlock('# ⚔️ Arena')]; const blocks = [textBlock("# ⚔️ Arena")];
if (globalNotice) { if (globalNotice) {
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
@@ -87,16 +137,32 @@ export const command: CommandMessage = {
} }
blocks.push(dividerBlock()); blocks.push(dividerBlock());
const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; const areaScope =
blocks.push(textBlock(`**Área:** \`${area.key}\`${areaScope}\n**Nivel:** ${level}\n**Arma:** ${toolInfo}`)); source === "global"
? "🌐 Configuración global"
: "📍 Configuración local";
blocks.push(
textBlock(
`**Área:** \`${area.key}\`${areaScope}\n**Nivel:** ${level}\n**Arma:** ${toolInfo}`
)
);
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(textBlock(`**Recompensas**\n${rewardLines}`));
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
blocks.push(textBlock(`**Enemigos**\n${mobsLines}`)); blocks.push(textBlock(`**Enemigos**\n${mobsLines}`));
// Añadir metadata del área
const metaBlocks = buildAreaMetadataBlocks(area);
if (metaBlocks.length) {
blocks.push(dividerBlock());
blocks.push(...metaBlocks);
}
if (newAchievements.length > 0) { if (newAchievements.length > 0) {
blocks.push(dividerBlock({ divider: false, spacing: 2 })); blocks.push(dividerBlock({ divider: false, spacing: 2 }));
const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n'); const achLines = newAchievements
.map((ach) => `✨ **${ach.name}** — ${ach.description}`)
.join("\n");
blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`)); blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`));
} }
@@ -105,6 +171,5 @@ export const command: CommandMessage = {
} catch (e: any) { } catch (e: any) {
await message.reply(`❌ No se pudo pelear: ${e?.message ?? e}`); await message.reply(`❌ No se pudo pelear: ${e?.message ?? e}`);
} }
} },
}; };

View File

@@ -1,21 +1,36 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from '../../../core/client'; import type Amayo from "../../../core/client";
import { runMinigame } from '../../../game/minigames/service'; import { runMinigame } from "../../../game/minigames/service";
import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; import {
import { updateStats } from '../../../game/stats/service'; getDefaultLevel,
import { updateQuestProgress } from '../../../game/quests/service'; findBestToolKey,
import { checkAchievements } from '../../../game/achievements/service'; parseGameArgs,
import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; resolveGuildAreaWithFallback,
resolveAreaByType,
sendDisplayReply,
fetchItemBasics,
formatItemLabel,
} from "./_helpers";
import { updateStats } from "../../../game/stats/service";
import { updateQuestProgress } from "../../../game/quests/service";
import { checkAchievements } from "../../../game/achievements/service";
import {
buildDisplay,
dividerBlock,
textBlock,
} from "../../../core/lib/componentsV2";
import { buildAreaMetadataBlocks } from "./_helpers";
const FISHING_ACCENT = 0x1ABC9C; const FISHING_ACCENT = 0x1abc9c;
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'pescar', name: "pescar",
type: 'message', type: "message",
aliases: ['fish'], aliases: ["fish"],
cooldown: 5, cooldown: 5,
description: 'Pesca en la laguna (usa caña si está disponible) y obtén recompensas.', description:
usage: 'pescar [nivel] [toolKey] [area:clave] (ej: pescar 1 tool.rod.basic)', "Pesca en la laguna (usa caña si está disponible) y obtén recompensas.",
usage: "pescar [nivel] [toolKey] [area:clave] (ej: pescar 1 tool.rod.basic)",
run: async (message, args, _client: Amayo) => { run: async (message, args, _client: Amayo) => {
const userId = message.author.id; const userId = message.author.id;
const guildId = message.guild!.id; const guildId = message.guild!.id;
@@ -23,55 +38,85 @@ export const command: CommandMessage = {
const areaInfo = areaOverride const areaInfo = areaOverride
? await resolveGuildAreaWithFallback(guildId, areaOverride) ? await resolveGuildAreaWithFallback(guildId, areaOverride)
: await resolveAreaByType(guildId, 'LAGOON'); : await resolveAreaByType(guildId, "LAGOON");
if (!areaInfo.area) { if (!areaInfo.area) {
if (areaOverride) { if (areaOverride) {
await message.reply(`⚠️ No existe un área con key \`${areaOverride}\` para este servidor.`); await message.reply(
`⚠️ No existe un área con key \`${areaOverride}\` para este servidor.`
);
} else { } else {
await message.reply('⚠️ No hay un área de tipo **LAGOON** configurada. Crea una con `!area-crear` o especifica `area:<key>`.'); await message.reply(
"⚠️ No hay un área de tipo **LAGOON** configurada. Crea una con `!area-crear` o especifica `area:<key>`."
);
} }
return; return;
} }
const { area, source } = areaInfo; const { area, source } = areaInfo;
const globalNotice = source === 'global' const globalNotice =
source === "global"
? ` Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **LAGOON** para personalizarla en este servidor.` ? ` Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **LAGOON** para personalizarla en este servidor.`
: null; : null;
const level = levelArg ?? await getDefaultLevel(userId, guildId, area.id); const level = levelArg ?? (await getDefaultLevel(userId, guildId, area.id));
const toolKey = providedTool ?? await findBestToolKey(userId, guildId, 'rod'); const toolKey =
providedTool ?? (await findBestToolKey(userId, guildId, "rod"));
try { try {
const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); const result = await runMinigame(userId, guildId, area.key, level, {
toolKey: toolKey ?? undefined,
});
const rewardKeys = result.rewards const rewardKeys = result.rewards
.filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey)) .filter(
(r): r is { type: "item"; itemKey: string; qty?: number } =>
r.type === "item" && Boolean(r.itemKey)
)
.map((r) => r.itemKey!); .map((r) => r.itemKey!);
if (result.tool?.key) rewardKeys.push(result.tool.key); if (result.tool?.key) rewardKeys.push(result.tool.key);
const rewardItems = await fetchItemBasics(guildId, rewardKeys); const rewardItems = await fetchItemBasics(guildId, rewardKeys);
// Actualizar stats y misiones // Actualizar stats y misiones
await updateStats(userId, guildId, { fishingCompleted: 1 }); await updateStats(userId, guildId, { fishingCompleted: 1 });
await updateQuestProgress(userId, guildId, 'fish_count', 1); await updateQuestProgress(userId, guildId, "fish_count", 1);
const newAchievements = await checkAchievements(userId, guildId, 'fish_count'); const newAchievements = await checkAchievements(
userId,
guildId,
"fish_count"
);
const rewardLines = result.rewards.length const rewardLines = result.rewards.length
? result.rewards.map((r) => { ? result.rewards
if (r.type === 'coins') return `• 🪙 +${r.amount}`; .map((r) => {
if (r.type === "coins") return `• 🪙 +${r.amount}`;
const info = rewardItems.get(r.itemKey!); const info = rewardItems.get(r.itemKey!);
const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); const label = formatItemLabel(
info ?? { key: r.itemKey!, name: null, icon: null }
);
return `${label} x${r.qty ?? 1}`; return `${label} x${r.qty ?? 1}`;
}).join('\n') })
: '• —'; .join("\n")
: "• —";
const mobsLines = result.mobs.length const mobsLines = result.mobs.length
? result.mobs.map(m => `${m}`).join('\n') ? result.mobs.map((m) => `${m}`).join("\n")
: '• —'; : "• —";
const toolInfo = result.tool?.key const toolInfo = result.tool?.key
? `${formatItemLabel(rewardItems.get(result.tool.key) ?? { key: result.tool.key, name: null, icon: null }, { fallbackIcon: '🎣' })}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta ?? 0} dur.)`}` ? `${formatItemLabel(
: '—'; rewardItems.get(result.tool.key) ?? {
key: result.tool.key,
name: null,
icon: null,
},
{ fallbackIcon: "🎣" }
)}${
result.tool.broken
? " (rota)"
: ` (-${result.tool.durabilityDelta ?? 0} dur.)`
}`
: "—";
const blocks = [textBlock('# 🎣 Pesca')]; const blocks = [textBlock("# 🎣 Pesca")];
if (globalNotice) { if (globalNotice) {
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
@@ -79,16 +124,32 @@ export const command: CommandMessage = {
} }
blocks.push(dividerBlock()); blocks.push(dividerBlock());
const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; const areaScope =
blocks.push(textBlock(`**Área:** \`${area.key}\`${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`)); source === "global"
? "🌐 Configuración global"
: "📍 Configuración local";
blocks.push(
textBlock(
`**Área:** \`${area.key}\`${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`
)
);
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(textBlock(`**Recompensas**\n${rewardLines}`));
blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
blocks.push(textBlock(`**Mobs**\n${mobsLines}`)); blocks.push(textBlock(`**Mobs**\n${mobsLines}`));
// Añadir metadata del área
const metaBlocks = buildAreaMetadataBlocks(area);
if (metaBlocks.length) {
blocks.push(dividerBlock());
blocks.push(...metaBlocks);
}
if (newAchievements.length > 0) { if (newAchievements.length > 0) {
blocks.push(dividerBlock({ divider: false, spacing: 2 })); blocks.push(dividerBlock({ divider: false, spacing: 2 }));
const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n'); const achLines = newAchievements
.map((ach) => `✨ **${ach.name}** — ${ach.description}`)
.join("\n");
blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`)); blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`));
} }
@@ -97,6 +158,5 @@ export const command: CommandMessage = {
} catch (e: any) { } catch (e: any) {
await message.reply(`❌ No se pudo pescar: ${e?.message ?? e}`); await message.reply(`❌ No se pudo pescar: ${e?.message ?? e}`);
} }
} },
}; };

View File

@@ -1,71 +1,119 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from '../../../core/client'; import type Amayo from "../../../core/client";
import { runMinigame } from '../../../game/minigames/service'; import { runMinigame } from "../../../game/minigames/service";
import { resolveArea, getDefaultLevel, findBestToolKey, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; import {
import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; resolveArea,
getDefaultLevel,
findBestToolKey,
sendDisplayReply,
fetchItemBasics,
formatItemLabel,
} from "./_helpers";
import {
buildDisplay,
dividerBlock,
textBlock,
} from "../../../core/lib/componentsV2";
import { buildAreaMetadataBlocks } from "./_helpers";
const FARM_ACCENT = 0x2ECC71; const FARM_ACCENT = 0x2ecc71;
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'plantar', name: "plantar",
type: 'message', type: "message",
aliases: ['farm'], aliases: ["farm"],
cooldown: 5, cooldown: 5,
description: 'Planta/cosecha en el campo (usa azada si está disponible).', description: "Planta/cosecha en el campo (usa azada si está disponible).",
usage: 'plantar [nivel] [toolKey] (ej: plantar 1 tool.hoe.basic)', usage: "plantar [nivel] [toolKey] (ej: plantar 1 tool.hoe.basic)",
run: async (message, args, _client: Amayo) => { run: async (message, args, _client: Amayo) => {
const userId = message.author.id; const userId = message.author.id;
const guildId = message.guild!.id; const guildId = message.guild!.id;
const areaKey = 'farm.field'; const areaKey = "farm.field";
const area = await resolveArea(guildId, areaKey); const area = await resolveArea(guildId, areaKey);
if (!area) { await message.reply('⚠️ Área de cultivo no configurada. Crea `gameArea` con key `farm.field`.'); return; } if (!area) {
await message.reply(
"⚠️ Área de cultivo no configurada. Crea `gameArea` con key `farm.field`."
);
return;
}
const levelArg = args[0] && /^\d+$/.test(args[0]) ? parseInt(args[0], 10) : null; const levelArg =
args[0] && /^\d+$/.test(args[0]) ? parseInt(args[0], 10) : null;
const providedTool = args.find((a) => a && !/^\d+$/.test(a)); const providedTool = args.find((a) => a && !/^\d+$/.test(a));
const level = levelArg ?? await getDefaultLevel(userId, guildId, area.id); const level = levelArg ?? (await getDefaultLevel(userId, guildId, area.id));
const toolKey = providedTool ?? await findBestToolKey(userId, guildId, 'hoe'); const toolKey =
providedTool ?? (await findBestToolKey(userId, guildId, "hoe"));
try { try {
const result = await runMinigame(userId, guildId, areaKey, level, { toolKey: toolKey ?? undefined }); const result = await runMinigame(userId, guildId, areaKey, level, {
toolKey: toolKey ?? undefined,
});
const rewardKeys = result.rewards const rewardKeys = result.rewards
.filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey)) .filter(
(r): r is { type: "item"; itemKey: string; qty?: number } =>
r.type === "item" && Boolean(r.itemKey)
)
.map((r) => r.itemKey!); .map((r) => r.itemKey!);
if (result.tool?.key) rewardKeys.push(result.tool.key); if (result.tool?.key) rewardKeys.push(result.tool.key);
const rewardItems = await fetchItemBasics(guildId, rewardKeys); const rewardItems = await fetchItemBasics(guildId, rewardKeys);
const rewardLines = result.rewards.length const rewardLines = result.rewards.length
? result.rewards.map((r) => { ? result.rewards
if (r.type === 'coins') return `• 🪙 +${r.amount}`; .map((r) => {
if (r.type === "coins") return `• 🪙 +${r.amount}`;
const info = rewardItems.get(r.itemKey!); const info = rewardItems.get(r.itemKey!);
const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); const label = formatItemLabel(
info ?? { key: r.itemKey!, name: null, icon: null }
);
return `${label} x${r.qty ?? 1}`; return `${label} x${r.qty ?? 1}`;
}).join('\n') })
: '• —'; .join("\n")
: "• —";
const mobsLines = result.mobs.length const mobsLines = result.mobs.length
? result.mobs.map(m => `${m}`).join('\n') ? result.mobs.map((m) => `${m}`).join("\n")
: '• —'; : "• —";
const toolInfo = result.tool?.key const toolInfo = result.tool?.key
? `${formatItemLabel(rewardItems.get(result.tool.key) ?? { key: result.tool.key, name: null, icon: null }, { fallbackIcon: '🪓' })}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta ?? 0} dur.)`}` ? `${formatItemLabel(
: '—'; rewardItems.get(result.tool.key) ?? {
key: result.tool.key,
name: null,
icon: null,
},
{ fallbackIcon: "🪓" }
)}${
result.tool.broken
? " (rota)"
: ` (-${result.tool.durabilityDelta ?? 0} dur.)`
}`
: "—";
const blocks = [ const blocks = [
textBlock('# 🌱 Campo'), textBlock("# 🌱 Campo"),
dividerBlock(), dividerBlock(),
textBlock(`**Área:** \`${area.key}\`\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`), textBlock(
`**Área:** \`${area.key}\`\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`
),
dividerBlock({ divider: false, spacing: 1 }), dividerBlock({ divider: false, spacing: 1 }),
textBlock(`**Recompensas**\n${rewardLines}`), textBlock(`**Recompensas**\n${rewardLines}`),
dividerBlock({ divider: false, spacing: 1 }), dividerBlock({ divider: false, spacing: 1 }),
textBlock(`**Eventos**\n${mobsLines}`), textBlock(`**Eventos**\n${mobsLines}`),
]; ];
// Añadir metadata del área
const metaBlocks = buildAreaMetadataBlocks(area);
if (metaBlocks.length) {
blocks.push(dividerBlock());
// @ts-ignore: extended block type allowed at runtime
blocks.push(...metaBlocks);
}
const display = buildDisplay(FARM_ACCENT, blocks); const display = buildDisplay(FARM_ACCENT, blocks);
await sendDisplayReply(message, display); await sendDisplayReply(message, display);
} catch (e: any) { } catch (e: any) {
await message.reply(`❌ No se pudo plantar: ${e?.message ?? e}`); await message.reply(`❌ No se pudo plantar: ${e?.message ?? e}`);
} }
} },
}; };

View File

@@ -1,6 +1,6 @@
// Simple Appwrite client wrapper // Simple Appwrite client wrapper
// @ts-ignore // @ts-ignore
import { Client, Databases } from "node-appwrite"; import { Client, Databases, Storage } from "node-appwrite";
const endpoint = process.env.APPWRITE_ENDPOINT || ""; const endpoint = process.env.APPWRITE_ENDPOINT || "";
const projectId = process.env.APPWRITE_PROJECT_ID || ""; const projectId = process.env.APPWRITE_PROJECT_ID || "";
@@ -14,8 +14,21 @@ export const APPWRITE_COLLECTION_AI_CONVERSATIONS_ID =
export const APPWRITE_COLLECTION_GUILD_CACHE_ID = export const APPWRITE_COLLECTION_GUILD_CACHE_ID =
process.env.APPWRITE_COLLECTION_GUILD_CACHE_ID || ""; process.env.APPWRITE_COLLECTION_GUILD_CACHE_ID || "";
// Optional: collections for game realtime mirrors
export const APPWRITE_COLLECTION_QUESTS_ID =
process.env.APPWRITE_COLLECTION_QUESTS_ID || "";
export const APPWRITE_COLLECTION_QUEST_PROGRESS_ID =
process.env.APPWRITE_COLLECTION_QUEST_PROGRESS_ID || "";
export const APPWRITE_COLLECTION_SCHEDULED_ATTACKS_ID =
process.env.APPWRITE_COLLECTION_SCHEDULED_ATTACKS_ID || "";
// Optional: bucket for images (areas/levels)
export const APPWRITE_BUCKET_IMAGES_ID =
process.env.APPWRITE_BUCKET_IMAGES_ID || "";
let client: Client | null = null; let client: Client | null = null;
let databases: Databases | null = null; let databases: Databases | null = null;
let storage: Storage | null = null;
function ensureClient() { function ensureClient() {
if (!endpoint || !projectId || !apiKey) return null; if (!endpoint || !projectId || !apiKey) return null;
@@ -25,6 +38,7 @@ function ensureClient() {
.setProject(projectId) .setProject(projectId)
.setKey(apiKey); .setKey(apiKey);
databases = new Databases(client); databases = new Databases(client);
storage = new Storage(client);
return client; return client;
} }
@@ -32,6 +46,10 @@ export function getDatabases(): Databases | null {
return ensureClient() ? (databases as Databases) : null; return ensureClient() ? (databases as Databases) : null;
} }
export function getStorage(): Storage | null {
return ensureClient() ? (storage as Storage) : null;
}
export function isAppwriteConfigured(): boolean { export function isAppwriteConfigured(): boolean {
return Boolean( return Boolean(
endpoint && endpoint &&
@@ -61,3 +79,20 @@ export function isGuildCacheConfigured(): boolean {
APPWRITE_COLLECTION_GUILD_CACHE_ID APPWRITE_COLLECTION_GUILD_CACHE_ID
); );
} }
export function isAppwriteStorageConfigured(): boolean {
return Boolean(endpoint && projectId && apiKey && APPWRITE_BUCKET_IMAGES_ID);
}
export function isGameRealtimeConfigured(): boolean {
// minimal check for quests/progress and scheduled attacks mirrors
return Boolean(
endpoint &&
projectId &&
apiKey &&
APPWRITE_DATABASE_ID &&
(APPWRITE_COLLECTION_QUESTS_ID ||
APPWRITE_COLLECTION_QUEST_PROGRESS_ID ||
APPWRITE_COLLECTION_SCHEDULED_ATTACKS_ID)
);
}

View File

@@ -1,11 +1,17 @@
export type DisplayBlock = export type DisplayBlock =
| { kind: 'text'; content: string } | { kind: "text"; content: string }
| { kind: 'divider'; divider?: boolean; spacing?: number }; | { kind: "divider"; divider?: boolean; spacing?: number }
| { kind: "image"; url: string };
export const textBlock = (content: string): DisplayBlock => ({ kind: 'text', content }); export const textBlock = (content: string): DisplayBlock => ({
kind: "text",
content,
});
export const dividerBlock = (options: { divider?: boolean; spacing?: number } = {}): DisplayBlock => ({ export const dividerBlock = (
kind: 'divider', options: { divider?: boolean; spacing?: number } = {}
): DisplayBlock => ({
kind: "divider",
divider: options.divider, divider: options.divider,
spacing: options.spacing, spacing: options.spacing,
}); });
@@ -15,10 +21,15 @@ export function buildDisplay(accentColor: number, blocks: DisplayBlock[]) {
type: 17 as const, type: 17 as const,
accent_color: accentColor, accent_color: accentColor,
components: blocks.map((block) => { components: blocks.map((block) => {
if (block.kind === 'text') { if (block.kind === "text") {
return { type: 10 as const, content: block.content }; return { type: 10 as const, content: block.content };
} }
if (block.kind === "image") {
// This component type will be translated by the renderer to an embed image
return { type: 12 as const, url: block.url } as any;
}
return { return {
type: 14 as const, type: 14 as const,
divider: block.divider ?? true, divider: block.divider ?? true,